diff --git a/apps/mobile/src/components/AudioPlayer.tsx b/apps/mobile/src/components/AudioPlayer.tsx
new file mode 100644
index 0000000..2dd733c
--- /dev/null
+++ b/apps/mobile/src/components/AudioPlayer.tsx
@@ -0,0 +1,286 @@
+import React, { useState, useEffect } from 'react';
+import { View, Text, StyleSheet, Alert } from 'react-native';
+import {
+ useAudioPlayer,
+ useAudioPlayerStatus,
+ AudioModule,
+ setAudioModeAsync,
+} from 'expo-audio';
+import { colors } from '../constants/Colors';
+import { typography } from '../constants/Typography';
+import { spacing, borderRadius } from '../constants/Layout';
+import { Button } from './ui/Button';
+
+export interface AudioPlayerProps {
+ audioUrl: string;
+ audioText?: string;
+ audioMetadata?: {
+ duration: number;
+ fileSize: number;
+ format: string;
+ processingTime: number;
+ };
+ style?: object;
+}
+
+export function AudioPlayer({
+ audioUrl,
+ audioText,
+ audioMetadata,
+ style,
+}: AudioPlayerProps) {
+ const [hasPermission, setHasPermission] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Construct full URL if needed
+ const fullUrl = audioUrl.startsWith('http')
+ ? audioUrl
+ : `http://10.141.39.175:4000${audioUrl}`;
+
+ // Create audio player with the URL - this is the correct expo-audio API
+ const player = useAudioPlayer({ uri: fullUrl });
+ const status = useAudioPlayerStatus(player);
+
+ useEffect(() => {
+ // Request audio permissions and set audio mode for better volume
+ requestPermissions();
+ }, []);
+
+ useEffect(() => {
+ // Set player volume to maximum for normal audio level
+ if (player) {
+ try {
+ player.volume = 1.0; // Maximum volume (0.0 to 1.0)
+ } catch (error) {
+ // Volume control not supported
+ }
+ }
+ }, [player]);
+
+ const requestPermissions = async () => {
+ try {
+ const { granted } = await AudioModule.requestRecordingPermissionsAsync();
+ setHasPermission(granted);
+
+ // Set audio mode for better playback volume
+ await setAudioModeAsync({
+ playsInSilentMode: true,
+ allowsRecording: false, // We're only playing, not recording
+ });
+ } catch (error) {
+ // Failed to request audio permissions
+ setHasPermission(false);
+ }
+ };
+
+ const playPauseAudio = async () => {
+ if (!hasPermission) {
+ Alert.alert(
+ 'Permission Required',
+ 'Audio playback permission is required to play coaching audio.'
+ );
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ // Check if audio is stuck at the end
+ if (duration > 0 && currentTime >= duration - 0.5 && !status.playing) {
+ // Audio finished, restart from beginning
+ player.seekTo(0);
+ await new Promise(resolve => setTimeout(resolve, 100)); // Small delay
+ player.play();
+ } else if (status.playing) {
+ // Pause audio using expo-audio API
+ player.pause();
+ } else {
+ // Play audio using expo-audio API
+ player.play();
+ }
+ } catch (error) {
+ // Failed to play/pause audio
+ // Try to recover from stuck state
+ try {
+ forceReset();
+ } catch (resetError) {
+ Alert.alert(
+ 'Playback Error',
+ 'Audio player needs to be reloaded. Please try again.'
+ );
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const stopAudio = () => {
+ try {
+ // Reset to beginning and pause - expo-audio API
+ player.seekTo(0);
+ player.pause();
+ } catch (error) {
+ // Failed to stop audio
+ // Force reset if stuck
+ forceReset();
+ }
+ };
+
+ const forceReset = () => {
+ try {
+ // Force player to reset when stuck
+ player.seekTo(0);
+ setIsLoading(false);
+ } catch (error) {
+ // Failed to force reset
+ }
+ };
+
+ const formatTime = (seconds: number) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ // Get current time and duration from status
+ const currentTime = status.currentTime || 0;
+ const duration =
+ status.duration || (audioMetadata ? audioMetadata.duration : 0);
+ const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
+
+ return (
+
+
+ 🎵 Personalized Coaching Audio
+ {audioMetadata && (
+
+ {formatTime(audioMetadata.duration)} •{' '}
+ {audioMetadata.format.toUpperCase()}
+
+ )}
+
+
+ {audioText && (
+
+ "{audioText}"
+
+ )}
+
+
+
+
+
+
+ {formatTime(currentTime)}
+
+ {duration > 0
+ ? formatTime(duration)
+ : audioMetadata
+ ? formatTime(audioMetadata.duration)
+ : '--:--'}
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ audioText: {
+ ...typography.body,
+ color: colors.textSecondary,
+ fontStyle: 'italic',
+ textAlign: 'center',
+ },
+ container: {
+ backgroundColor: colors.surfaceLight,
+ borderColor: colors.primary,
+ borderLeftWidth: 4,
+ borderRadius: borderRadius.md,
+ padding: spacing.lg,
+ },
+ controls: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ gap: spacing.md,
+ justifyContent: 'center',
+ marginTop: spacing.md,
+ },
+ header: {
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ },
+ metadata: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ playButton: {
+ minWidth: 120,
+ },
+ progressBar: {
+ backgroundColor: colors.border,
+ borderRadius: borderRadius.sm,
+ height: 4,
+ width: '100%',
+ },
+ progressContainer: {
+ marginBottom: spacing.md,
+ },
+ progressFill: {
+ backgroundColor: colors.primary,
+ borderRadius: borderRadius.sm,
+ height: '100%',
+ },
+ stopButton: {
+ minWidth: 80,
+ },
+ textContainer: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.sm,
+ marginBottom: spacing.md,
+ padding: spacing.md,
+ },
+ timeContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: spacing.xs,
+ },
+ timeText: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ },
+ title: {
+ ...typography.h4,
+ color: colors.text,
+ textAlign: 'center',
+ },
+});
diff --git a/apps/mobile/src/context/AppContext.tsx b/apps/mobile/src/context/AppContext.tsx
index 79373da..c68051d 100644
--- a/apps/mobile/src/context/AppContext.tsx
+++ b/apps/mobile/src/context/AppContext.tsx
@@ -27,6 +27,13 @@ export interface AppState {
} | null;
coaching: CheckinResponse['coaching'] | null;
audioUrl: string | null;
+ audioText: string | null;
+ audioMetadata: {
+ duration: number;
+ fileSize: number;
+ format: string;
+ processingTime: number;
+ } | null;
};
apiHealth: {
isHealthy: boolean;
@@ -46,6 +53,7 @@ export interface AppState {
error: string | null;
processingTime: number | null;
coachingMode: 'fast' | 'optimized';
+ ttsEnabled: boolean;
}
export type AppAction =
@@ -83,7 +91,8 @@ export type AppAction =
};
}
| { type: 'UPLOAD_ERROR'; payload: string }
- | { type: 'SET_COACHING_MODE'; payload: 'fast' | 'optimized' };
+ | { type: 'SET_COACHING_MODE'; payload: 'fast' | 'optimized' }
+ | { type: 'SET_TTS_ENABLED'; payload: boolean };
const initialState: AppState = {
currentScreen: 'home',
@@ -99,6 +108,8 @@ const initialState: AppState = {
sentiment: null,
coaching: null,
audioUrl: null,
+ audioText: null,
+ audioMetadata: null,
},
apiHealth: {
isHealthy: false,
@@ -113,6 +124,7 @@ const initialState: AppState = {
error: null,
processingTime: null,
coachingMode: 'fast', // Default to fast mode for demos
+ ttsEnabled: false, // Default TTS disabled
};
function appReducer(state: AppState, action: AppAction): AppState {
@@ -162,6 +174,8 @@ function appReducer(state: AppState, action: AppAction): AppState {
sentiment: null,
coaching: null,
audioUrl: null,
+ audioText: null,
+ audioMetadata: null,
},
error: null,
processingTime: null,
@@ -220,7 +234,9 @@ function appReducer(state: AppState, action: AppAction): AppState {
action.payload.data.coaching,
action.payload.data.sentiment?.score
),
- audioUrl: action.payload.data.audioUrl,
+ audioUrl: action.payload.data.audioUrl || null,
+ audioText: action.payload.data.audioText || null,
+ audioMetadata: action.payload.data.audioMetadata || null,
},
processingTime: action.payload.processingTime || null,
error: null,
@@ -248,7 +264,9 @@ function appReducer(state: AppState, action: AppAction): AppState {
action.payload.coaching,
action.payload.sentiment?.score
),
- audioUrl: action.payload.audioUrl,
+ audioUrl: action.payload.audioUrl || null,
+ audioText: action.payload.audioText || null,
+ audioMetadata: action.payload.audioMetadata || null,
},
loading: {
...state.loading,
@@ -262,6 +280,12 @@ function appReducer(state: AppState, action: AppAction): AppState {
coachingMode: action.payload,
};
+ case 'SET_TTS_ENABLED':
+ return {
+ ...state,
+ ttsEnabled: action.payload,
+ };
+
default:
return state;
}
@@ -276,6 +300,7 @@ interface AppContextType {
uploadRecordingWithMode: (mode?: 'fast' | 'optimized') => Promise;
resetApp: () => void;
setCoachingMode: (mode: 'fast' | 'optimized') => void;
+ setTTSEnabled: (enabled: boolean) => void;
};
}
@@ -354,7 +379,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'START_UPLOAD' });
try {
- const result = await apiService.submitCheckin(uriToUse);
+ const result = await apiService.submitCheckin(uriToUse, {
+ enableTTS: state.ttsEnabled,
+ mode: state.coachingMode,
+ });
if (result.success && result.data) {
// Extract processing time from success message
@@ -388,7 +416,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
});
}
},
- [state.recordingData.uri]
+ [state.recordingData.uri, state.ttsEnabled, state.coachingMode]
);
const uploadRecordingWithMode = useCallback(
@@ -407,7 +435,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const result = await apiService.submitCheckinWithMode(
state.recordingData.uri,
- selectedMode
+ selectedMode,
+ state.ttsEnabled
);
if (result.success && result.data) {
@@ -445,13 +474,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
});
}
},
- [state.recordingData.uri, state.coachingMode]
+ [state.recordingData.uri, state.coachingMode, state.ttsEnabled]
);
const setCoachingMode = useCallback((mode: 'fast' | 'optimized') => {
dispatch({ type: 'SET_COACHING_MODE', payload: mode });
}, []);
+ const setTTSEnabled = useCallback((enabled: boolean) => {
+ dispatch({ type: 'SET_TTS_ENABLED', payload: enabled });
+ }, []);
+
const resetApp = useCallback(() => {
dispatch({ type: 'RESET_RECORDING' });
dispatch({ type: 'NAVIGATE_TO', payload: 'home' });
@@ -464,6 +497,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
uploadRecordingWithMode,
resetApp,
setCoachingMode,
+ setTTSEnabled,
}),
[
checkApiHealth,
@@ -471,6 +505,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
uploadRecordingWithMode,
resetApp,
setCoachingMode,
+ setTTSEnabled,
]
);
diff --git a/apps/mobile/src/screens/RecordingScreen.tsx b/apps/mobile/src/screens/RecordingScreen.tsx
index 97a91f9..b2fd355 100644
--- a/apps/mobile/src/screens/RecordingScreen.tsx
+++ b/apps/mobile/src/screens/RecordingScreen.tsx
@@ -20,7 +20,7 @@ import { ScrollView } from 'react-native';
const MAX_RECORDING_DURATION = 60; // 60 seconds
export function RecordingScreen() {
- const { dispatch, actions } = useAppContext();
+ const { state, dispatch, actions } = useAppContext();
const audioRecording = useAudioRecording();
const [timeRemaining, setTimeRemaining] = useState(MAX_RECORDING_DURATION);
const [pulseAnimation] = useState(new Animated.Value(1));
@@ -324,6 +324,25 @@ export function RecordingScreen() {
)}
+ {/* TTS Settings */}
+ {!audioRecording.isRecording && (
+
+
+ 🎵 Audio Coaching
+
+
+ Get personalized audio coaching in addition to text feedback
+
+
+ )}
+
{/* Tips - positioned at bottom */}
{!audioRecording.isRecording && (
@@ -448,6 +467,27 @@ const styles = StyleSheet.create({
justifyContent: 'space-evenly',
paddingVertical: spacing.lg,
},
+ settingsCard: {
+ backgroundColor: colors.surfaceLight,
+ borderLeftColor: colors.secondary,
+ borderLeftWidth: 4,
+ marginBottom: spacing.lg,
+ },
+ settingsHeader: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: spacing.sm,
+ },
+ settingsText: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ },
+ settingsTitle: {
+ ...typography.bodySmall,
+ color: colors.text,
+ fontWeight: '600',
+ },
timerContainer: {
alignItems: 'center',
marginBottom: spacing.xl,
@@ -482,4 +522,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginBottom: spacing.sm,
},
+ ttsToggle: {
+ minWidth: 50,
+ },
});
diff --git a/apps/mobile/src/screens/ResultsScreen.tsx b/apps/mobile/src/screens/ResultsScreen.tsx
index 10f517d..5f9eceb 100644
--- a/apps/mobile/src/screens/ResultsScreen.tsx
+++ b/apps/mobile/src/screens/ResultsScreen.tsx
@@ -15,9 +15,9 @@ import {
SentimentSkeleton,
CoachingSkeleton,
} from '../components/SkeletonLoader';
-import { TranscriptDisplay } from '../components/TranscriptDisplay';
import { SimpleSentimentBar } from '../components/SentimentMeter';
import { CoachingCards } from '../components/CoachingCards';
+import { AudioPlayer } from '../components/AudioPlayer';
import { useAppContext } from '../context/AppContext';
export function ResultsScreen() {
@@ -257,14 +257,13 @@ export function ResultsScreen() {
showsVerticalScrollIndicator={false}
>
- {/* Transcript */}
- {checkinData.transcript && (
-
)}