Hide Debug tab and revert TTS voice changes

- Hide voice-debug tab in production (href: null)
- Revert VoiceContext to stable version (no Samantha voice)
- Add platform badge to Debug screen (iOS/Android indicator)
- Remove PRD.md (moved to Ralphy workflow)

TTS voice quality improvements caused 'speak function failed' error
on iOS Simulator (Samantha voice unavailable). Reverted to working
baseline: rate 0.9, pitch 1.0, default system voice.
This commit is contained in:
Sergei 2026-01-29 10:05:10 -08:00
parent f4a239ff43
commit 3731206546
4 changed files with 55 additions and 305 deletions

220
PRD.md
View File

@ -1,220 +0,0 @@
# PRD — Voice FAB с локальным STT/TTS (замена LiveKit)
## 📝 Изначальное требование
> Нам нужно вырезать полностью LiveKit передачу сигнала. Нужно вывести по центру такую иконку — когда мы нажимаем, она светится/пульсирует и воспринимает звук. Я могу с LLM-кой поговорить, которая будет использовать Speech-to-Text и Text-to-Speech. Помимо этого я вижу историю переписки в чате.
>
> FAB — плавающая кнопка поверх таб-бара (как в TikTok). Она должна пульсировать когда мы с ней разговариваем.
>
> Локально использовать Speech-to-Text, Text-to-Speech. Ответы отправляем в ту же LLM (WellNuo API ask_wellnuo_ai), к которой уже обращаемся в текстовом чате.
>
> Чат остаётся чатом — голос просто говорит через динамики. Отдельного интерфейса голосового нет.
### Уточнения от пользователя:
- **Continuous listening mode**: нажал один раз = начал слушать, нажал ещё раз = остановил. Слушает постоянно пока активен.
- **FAB виден на всех табах**: можно переходить между вкладками, FAB продолжает пульсировать и слушать
- **Язык STT**: только английский (en-US)
- **Прерывание TTS**: если пользователь начинает говорить пока Julia озвучивает ответ — TTS немедленно останавливается и STT начинает слушать пользователя
---
## Цель
Заменить LiveKit на локальное распознавание речи (STT) и синтез речи (TTS). Добавить пульсирующую FAB-кнопку по центру таб-бара для голосового взаимодействия с Julia AI. FAB работает в режиме continuous listening — виден и активен на всех табах.
---
## Контекст проекта
- **Тип:** Expo / React Native (SDK 54)
- **Стек:** TypeScript, Expo Router, React Native Reanimated
- **Текущий LLM:** WellNuo API `ask_wellnuo_ai` (уже работает в текстовом чате)
- **Удаляем:** LiveKit (@livekit/react-native, livekit-client)
### Ключевые файлы:
| Файл | Действие |
|------|----------|
| `app/(tabs)/_layout.tsx` | Добавить FAB компонент |
| `app/(tabs)/chat.tsx` | Убрать LiveKit код, интегрировать STT/TTS |
| `services/livekitService.ts` | УДАЛИТЬ |
| `hooks/useLiveKitRoom.ts` | УДАЛИТЬ |
| `contexts/VoiceCallContext.tsx` | Переделать в VoiceContext для STT/TTS |
| `package.json` | Удалить livekit deps, добавить STT |
---
## Архитектура
### STT (Speech-to-Text) — локально
**Библиотека:** `@jamsch/expo-speech-recognition`
- Нативный iOS SFSpeechRecognizer / Android SpeechRecognizer
- Язык: `en-US` (фиксированный)
- Требует dev build (не работает в Expo Go)
### TTS (Text-to-Speech) — локально
**Библиотека:** `expo-speech`
- Встроено в Expo SDK
- Системный голос устройства
### LLM — без изменений
- Существующий `ask_wellnuo_ai` API
- Функция `sendTextMessage()` в chat.tsx
---
## User Flow
| # | Действие | UI состояние FAB | Что происходит |
|---|----------|------------------|----------------|
| 1 | Видит FAB на любом табе | `idle` (микрофон, белый) | Готов к активации |
| 2 | Нажимает FAB | `listening` (красный, пульсирует) | STT начинает слушать |
| 3 | Говорит | `listening` | STT распознаёт в реальном времени |
| 4 | Пауза в речи | `processing` (синий) | Отправка в API |
| 5 | Ответ получен | `speaking` (зелёный) | TTS озвучивает |
| 6 | TTS закончил | `listening` (красный) | Снова слушает |
| 7 | Нажимает FAB повторно | `idle` (белый) | Остановка сессии |
| 8 | **Прерывание**: говорит во время `speaking` | `listening` (красный) | TTS останавливается, STT слушает |
**Важно:**
- FAB виден и активен на ВСЕХ табах. Переход между табами НЕ прерывает сессию.
- Пользователь может перебить Julia в любой момент — TTS немедленно замолкает.
---
## Задачи
### @worker1 — Backend/Cleanup (файлы: package.json, services/, contexts/, chat.tsx)
- [x] @worker1 Удалить из `package.json`: `@livekit/react-native`, `@livekit/react-native-expo-plugin`, `livekit-client`, `react-native-webrtc`, `@config-plugins/react-native-webrtc`
- [x] @worker1 Добавить в `package.json`: `@jamsch/expo-speech-recognition`, `expo-speech`
- [x] @worker1 Удалить файл `services/livekitService.ts`
- [x] @worker1 Удалить файл `hooks/useLiveKitRoom.ts` (если существует)
- [x] @worker1 Удалить из `chat.tsx`: все LiveKit импорты, `registerGlobals()`, `LiveKitRoom` компонент, `VoiceCallTranscriptHandler`, `getToken()`, состояния `callState/isCallActive`
- [x] @worker1 Переделать `contexts/VoiceCallContext.tsx``contexts/VoiceContext.tsx` с интерфейсом:
```typescript
interface VoiceContextValue {
isActive: boolean; // сессия активна
isListening: boolean; // STT слушает
isSpeaking: boolean; // TTS говорит
transcript: string; // текущий текст STT
startSession: () => void; // начать сессию
stopSession: () => void; // остановить сессию
}
```
- [x] @worker1 Интегрировать отправку в API: transcript → `sendTextMessage()` → response → TTS
- [x] @worker1 Добавить функцию `interruptIfSpeaking()` в VoiceContext — останавливает TTS если говорит, возвращает true/false
- [x] @worker1 Экспортировать `interruptIfSpeaking` в VoiceContextValue интерфейс
### @worker2 — STT/TTS хуки (файлы: hooks/, app.json)
- [x] @worker2 Добавить в `app.json` plugins секцию:
```json
["@jamsch/expo-speech-recognition"]
```
- [x] @worker2 Создать `hooks/useSpeechRecognition.ts`:
```typescript
interface UseSpeechRecognitionReturn {
isListening: boolean;
transcript: string;
partialTranscript: string;
error: string | null;
startListening: () => Promise<void>;
stopListening: () => void;
hasPermission: boolean;
requestPermission: () => Promise<boolean>;
}
```
- Язык: `en-US` (фиксированный)
- Continuous mode: true
- Partial results: true (для live preview)
- Автоотправка при паузе >2 сек
- [x] @worker2 Создать `hooks/useTextToSpeech.ts`:
```typescript
interface UseTextToSpeechReturn {
isSpeaking: boolean;
speak: (text: string) => Promise<void>;
stop: () => void;
}
```
- Язык: `en-US`
- Автостоп при начале новой записи
- [x] @worker2 Добавить permissions в `app.json`:
- iOS: `NSMicrophoneUsageDescription`, `NSSpeechRecognitionUsageDescription`
- Android: `RECORD_AUDIO`
### @worker3 — UI/FAB (файлы: components/, _layout.tsx)
- [x] @worker3 Создать `components/VoiceFAB.tsx`:
- Позиция: по центру таб-бара, выступает на 20px вверх
- Размер: 64x64px
- Состояния:
- `idle`: белый фон, иконка микрофона (#007AFF)
- `listening`: красный фон (#FF3B30), белая иконка, пульсация (scale 1.0↔1.15, 600ms loop)
- `processing`: синий фон (#007AFF), ActivityIndicator
- `speaking`: зелёный фон (#34C759), иконка звука
- Анимации: Reanimated для пульсации, withTiming для переходов цвета
- Shadow: iOS shadow + Android elevation
- Z-index: 1000 (над всем)
- [x] @worker3 Интегрировать FAB в `app/(tabs)/_layout.tsx`:
- Обернуть `<Tabs>` в `<View style={{flex:1}}>` + `<VoiceFAB />`
- FAB должен быть абсолютно позиционирован
- Подключить к VoiceContext
- [x] @worker3 Добавить haptic feedback при нажатии (expo-haptics)
- [x] @worker3 Интегрировать прерывание в VoiceFAB: вызывать `interruptIfSpeaking()` при обнаружении голоса во время `speaking` состояния
- [x] @worker3 STT должен продолжать слушать даже во время TTS playback (для детекции прерывания)
---
## Error Handling
| Ситуация | Действие |
|----------|----------|
| Permissions denied | Toast + ссылка на Settings |
| STT не распознал речь | Игнорировать пустой результат |
| API ошибка | Toast "Ошибка соединения", retry через 3 сек |
| TTS ошибка | Показать только текст в чате |
| Нет интернета | Toast, fallback на текстовый ввод |
---
## Dev Build Requirements
**ВАЖНО:** STT требует development build!
```bash
# Создать dev build
npx expo prebuild
npx expo run:ios # или run:android
# В Expo Go показать:
Alert.alert("Требуется Dev Build", "Голосовые функции недоступны в Expo Go")
```
---
## Критерии готовности
- [x] LiveKit полностью удалён (нет в package.json, нет импортов)
- [x] FAB отображается на всех табах по центру
- [x] Tap на FAB = toggle listening mode
- [x] FAB пульсирует красным во время listening
- [x] STT распознаёт английскую речь
- [x] Распознанный текст отправляется в ask_wellnuo_ai
- [x] Ответ Julia отображается в чате
- [x] TTS озвучивает ответ
- [x] После TTS автоматически продолжает слушать
- [x] Переход между табами НЕ прерывает сессию
- [x] Пользователь может перебить Julia голосом — TTS останавливается, STT слушает
- [x] TypeScript компилируется без ошибок
---
## Технические ресурсы
- [expo-speech-recognition](https://github.com/jamsch/expo-speech-recognition) — STT
- [expo-speech](https://docs.expo.dev/versions/latest/sdk/speech/) — TTS
- [Reanimated](https://docs.swmansion.com/react-native-reanimated/) — анимации

View File

@ -433,14 +433,11 @@ export default function TabLayout() {
), ),
}} }}
/> />
{/* Voice Debug - VISIBLE for debugging */} {/* Voice Debug - hidden in production */}
<Tabs.Screen <Tabs.Screen
name="voice-debug" name="voice-debug"
options={{ options={{
title: 'Debug', href: null,
tabBarIcon: ({ color, size }) => (
<Feather name="activity" size={22} color={color} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen

View File

@ -12,6 +12,7 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Platform,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
@ -222,13 +223,22 @@ export default function VoiceDebugScreen() {
} }
}; };
// Platform badge
const platformBadge = Platform.OS === 'ios' ? '🍎 iOS' : '🤖 Android';
const platformColor = Platform.OS === 'ios' ? '#007AFF' : '#3DDC84';
return ( return (
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}> <View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}>
{/* Header */} {/* Header */}
<View style={[styles.header, { paddingTop: insets.top + 16 }]}> <View style={[styles.header, { paddingTop: insets.top + 16 }]}>
<View style={styles.headerLeft}>
<Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}> <Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
Voice Debug Voice Debug
</Text> </Text>
<View style={[styles.platformBadge, { backgroundColor: platformColor }]}>
<Text style={styles.platformBadgeText}>{platformBadge}</Text>
</View>
</View>
<TouchableOpacity onPress={clearLogs} style={styles.clearButton}> <TouchableOpacity onPress={clearLogs} style={styles.clearButton}>
<Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} /> <Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
</TouchableOpacity> </TouchableOpacity>
@ -268,7 +278,7 @@ export default function VoiceDebugScreen() {
{sttIsListening && status !== 'processing' && status !== 'speaking' && ( {sttIsListening && status !== 'processing' && status !== 'speaking' && (
<View style={styles.timerContainer}> <View style={styles.timerContainer}>
<Text style={[styles.timerLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}> <Text style={[styles.timerLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
Silence Timer (iOS auto-stop at 2.0s) Silence Timer ({Platform.OS === 'ios' ? 'iOS' : 'Android'} auto-stop at 2.0s)
</Text> </Text>
<View style={styles.timerRow}> <View style={styles.timerRow}>
<Text style={[styles.timerText, { <Text style={[styles.timerText, {
@ -382,10 +392,25 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 16, paddingBottom: 16,
}, },
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
headerTitle: { headerTitle: {
fontSize: 28, fontSize: 28,
fontWeight: '700', fontWeight: '700',
}, },
platformBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
platformBadgeText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '700',
},
clearButton: { clearButton: {
padding: 8, padding: 8,
}, },

View File

@ -234,21 +234,19 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
*/ */
const sendTranscript = useCallback( const sendTranscript = useCallback(
async (text: string): Promise<string | null> => { async (text: string): Promise<string | null> => {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
const trimmedText = text.trim(); const trimmedText = text.trim();
if (!trimmedText) { if (!trimmedText) {
console.log(`${platformPrefix} [VoiceContext] Empty transcript, skipping API call`); console.log('[VoiceContext] Empty transcript, skipping API call');
return null; return null;
} }
// Don't send if session was stopped // Don't send if session was stopped
if (sessionStoppedRef.current) { if (sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping API call`); console.log('[VoiceContext] Session stopped, skipping API call');
return null; return null;
} }
console.log(`${platformPrefix} [VoiceContext] 📤 Sending transcript to API (${voiceApiType}): "${trimmedText}"`); console.log(`[VoiceContext] Sending transcript to API (${voiceApiType}):`, trimmedText);
setStatus('processing'); setStatus('processing');
setError(null); setError(null);
@ -263,28 +261,23 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
try { try {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
// Get API token // Get API token
console.log(`${platformPrefix} [VoiceContext] 🔑 Getting API token...`);
const token = await getWellNuoToken(); const token = await getWellNuoToken();
console.log(`${platformPrefix} [VoiceContext] ✅ Token obtained`);
// Check if aborted // Check if aborted
if (abortController.signal.aborted || sessionStoppedRef.current) { if (abortController.signal.aborted || sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Request aborted before API call`); console.log('[VoiceContext] Request aborted before API call');
return null; return null;
} }
// Normalize question // Normalize question
const normalizedQuestion = normalizeQuestion(trimmedText); const normalizedQuestion = normalizeQuestion(trimmedText);
console.log(`${platformPrefix} [VoiceContext] 📝 Normalized question: "${normalizedQuestion}"`);
// Get deployment ID // Get deployment ID
const deploymentId = deploymentIdRef.current || '21'; const deploymentId = deploymentIdRef.current || '21';
// Log which API type we're using // Log which API type we're using
console.log(`${platformPrefix} [VoiceContext] 📡 Using API type: ${voiceApiType}, deployment: ${deploymentId}`); console.log('[VoiceContext] Using API type:', voiceApiType);
// Build request params // Build request params
const requestParams: Record<string, string> = { const requestParams: Record<string, string> = {
@ -302,7 +295,6 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
// Currently single deployment mode only // Currently single deployment mode only
} }
console.log(`${platformPrefix} [VoiceContext] 🌐 Sending API request...`);
const response = await fetch(API_URL, { const response = await fetch(API_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@ -310,37 +302,33 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
signal: abortController.signal, signal: abortController.signal,
}); });
console.log(`${platformPrefix} [VoiceContext] 📥 API response received, parsing...`);
const data = await response.json(); const data = await response.json();
// Check if session was stopped while waiting for response // Check if session was stopped while waiting for response
if (sessionStoppedRef.current) { if (sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped during API call, discarding response`); console.log('[VoiceContext] Session stopped during API call, discarding response');
return null; return null;
} }
if (data.ok && data.response?.body) { if (data.ok && data.response?.body) {
const responseText = data.response.body; const responseText = data.response.body;
console.log(`${platformPrefix} [VoiceContext] ✅ API SUCCESS: "${responseText.slice(0, 100)}..."`); console.log('[VoiceContext] API response:', responseText.slice(0, 100) + '...');
setLastResponse(responseText); setLastResponse(responseText);
// Add Julia's response to transcript for chat display // Add Julia's response to transcript for chat display
addTranscriptEntry('assistant', responseText); addTranscriptEntry('assistant', responseText);
console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS for response...`);
// Speak the response (will be skipped if session stopped) // Speak the response (will be skipped if session stopped)
await speak(responseText); await speak(responseText);
console.log(`${platformPrefix} [VoiceContext] ✅ TTS completed`);
return responseText; return responseText;
} else { } else {
// Token might be expired - retry with new token // Token might be expired - retry with new token
if (data.status === '401 Unauthorized') { if (data.status === '401 Unauthorized') {
console.log(`${platformPrefix} [VoiceContext] ⚠️ 401 Unauthorized - Token expired, retrying...`); console.log('[VoiceContext] Token expired, retrying with new token...');
apiTokenRef.current = null; apiTokenRef.current = null;
// Get new token and retry request // Get new token and retry request
console.log(`${platformPrefix} [VoiceContext] 🔑 Getting new token for retry...`);
const newToken = await getWellNuoToken(); const newToken = await getWellNuoToken();
const retryRequestParams: Record<string, string> = { const retryRequestParams: Record<string, string> = {
@ -363,31 +351,27 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
if (retryData.ok && retryData.response?.body) { if (retryData.ok && retryData.response?.body) {
const responseText = retryData.response.body; const responseText = retryData.response.body;
console.log(`${platformPrefix} [VoiceContext] ✅ Retry SUCCEEDED: "${responseText.slice(0, 100)}..."`); console.log('[VoiceContext] Retry succeeded:', responseText.slice(0, 100) + '...');
setLastResponse(responseText); setLastResponse(responseText);
addTranscriptEntry('assistant', responseText); addTranscriptEntry('assistant', responseText);
await speak(responseText); await speak(responseText);
return responseText; return responseText;
} else { } else {
console.error(`${platformPrefix} [VoiceContext] ❌ Retry FAILED:`, retryData.message);
throw new Error(retryData.message || 'Could not get response after retry'); throw new Error(retryData.message || 'Could not get response after retry');
} }
} }
console.error(`${platformPrefix} [VoiceContext] ❌ API error:`, data.message || data.status);
throw new Error(data.message || 'Could not get response'); throw new Error(data.message || 'Could not get response');
} }
} catch (err) { } catch (err) {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
// Ignore abort errors // Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') { if (err instanceof Error && err.name === 'AbortError') {
console.log(`${platformPrefix} [VoiceContext] ⚠️ API request aborted`); console.log('[VoiceContext] API request aborted');
return null; return null;
} }
// Handle API errors gracefully with voice feedback // Handle API errors gracefully with voice feedback
const errorMsg = err instanceof Error ? err.message : 'Unknown error'; const errorMsg = err instanceof Error ? err.message : 'Unknown error';
console.error(`${platformPrefix} [VoiceContext] ❌ API ERROR:`, errorMsg); console.warn('[VoiceContext] API error:', errorMsg);
// Create user-friendly error message for TTS // Create user-friendly error message for TTS
const spokenError = `Sorry, I encountered an error: ${errorMsg}. Please try again.`; const spokenError = `Sorry, I encountered an error: ${errorMsg}. Please try again.`;
@ -413,80 +397,59 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
* Call this from the STT hook when voice activity is detected * Call this from the STT hook when voice activity is detected
*/ */
const interruptIfSpeaking = useCallback(() => { const interruptIfSpeaking = useCallback(() => {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
if (isSpeaking) { if (isSpeaking) {
console.log(`${platformPrefix} [VoiceContext] ⚠️ User INTERRUPTED - stopping TTS`); console.log('[VoiceContext] User interrupted - stopping TTS');
Speech.stop(); Speech.stop();
setIsSpeaking(false); setIsSpeaking(false);
setStatus('listening'); setStatus('listening');
console.log(`${platformPrefix} [VoiceContext] → TTS stopped, status=listening`);
return true; return true;
} else {
console.log(`${platformPrefix} [VoiceContext] interruptIfSpeaking called but NOT speaking`);
return false;
} }
return false;
}, [isSpeaking]); }, [isSpeaking]);
/** /**
* Speak text using TTS * Speak text using TTS
*/ */
const speak = useCallback(async (text: string): Promise<void> => { const speak = useCallback(async (text: string): Promise<void> => {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; if (!text.trim()) return;
if (!text.trim()) {
console.log(`${platformPrefix} [VoiceContext] Empty text, skipping TTS`);
return;
}
// Don't speak if session was stopped // Don't speak if session was stopped
if (sessionStoppedRef.current) { if (sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping TTS`); console.log('[VoiceContext] Session stopped, skipping TTS');
return; return;
} }
console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS: "${text.slice(0, 50)}..."`); console.log('[VoiceContext] Speaking:', text.slice(0, 50) + '...');
setStatus('speaking'); setStatus('speaking');
setIsSpeaking(true); setIsSpeaking(true);
return new Promise((resolve) => { return new Promise((resolve) => {
Speech.speak(text, { Speech.speak(text, {
language: 'en-US', language: 'en-US',
rate: 1.1, // Faster, more natural (was 0.9) rate: 0.9,
pitch: 1.15, // Slightly higher, less robotic (was 1.0) pitch: 1.0,
// iOS Premium voice (Siri-quality, female)
// Android will use default high-quality voice
voice: Platform.OS === 'ios' ? 'com.apple.voice.premium.en-US.Samantha' : undefined,
onStart: () => { onStart: () => {
console.log(`${platformPrefix} [VoiceContext] ▶️ TTS playback STARTED`); console.log('[VoiceContext] TTS started');
}, },
onDone: () => { onDone: () => {
console.log(`${platformPrefix} [VoiceContext] ✅ TTS playback COMPLETED`); console.log('[VoiceContext] TTS completed');
// On iOS: Delay turning off green indicator to match STT restart delay (300ms) // On iOS: Delay turning off green indicator to match STT restart delay (300ms)
// On Android: Turn off immediately (audio focus conflict with STT) // On Android: Turn off immediately (audio focus conflict with STT)
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
console.log('[iOS] [VoiceContext] ⏱️ Delaying isSpeaking=false by 300ms (match STT restart)');
setTimeout(() => { setTimeout(() => {
console.log('[iOS] [VoiceContext] → isSpeaking = false (after 300ms delay)');
setIsSpeaking(false); setIsSpeaking(false);
}, 300); }, 300);
} else { } else {
console.log('[Android] [VoiceContext] → isSpeaking = false (immediate - audio focus release)');
setIsSpeaking(false); setIsSpeaking(false);
} }
// Return to listening state after speaking (if session wasn't stopped) // Return to listening state after speaking (if session wasn't stopped)
if (!sessionStoppedRef.current) { if (!sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] → status = listening (ready for next input)`);
setStatus('listening'); setStatus('listening');
} else {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`);
} }
resolve(); resolve();
}, },
onError: (error) => { onError: (error) => {
console.error(`${platformPrefix} [VoiceContext] ❌ TTS ERROR:`, error); console.warn('[VoiceContext] TTS error:', error);
// On error, turn off indicator immediately (no delay) // On error, turn off indicator immediately (no delay)
setIsSpeaking(false); setIsSpeaking(false);
if (!sessionStoppedRef.current) { if (!sessionStoppedRef.current) {
@ -495,15 +458,12 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
resolve(); resolve();
}, },
onStopped: () => { onStopped: () => {
console.log(`${platformPrefix} [VoiceContext] ⏹️ TTS STOPPED (interrupted by user)`); console.log('[VoiceContext] TTS stopped (interrupted)');
// When interrupted by user, turn off indicator immediately // When interrupted by user, turn off indicator immediately
setIsSpeaking(false); setIsSpeaking(false);
// Don't set status to listening if session was stopped by user // Don't set status to listening if session was stopped by user
if (!sessionStoppedRef.current) { if (!sessionStoppedRef.current) {
console.log(`${platformPrefix} [VoiceContext] → status = listening (after interruption)`);
setStatus('listening'); setStatus('listening');
} else {
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`);
} }
resolve(); resolve();
}, },
@ -523,46 +483,34 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
* Start voice session * Start voice session
*/ */
const startSession = useCallback(() => { const startSession = useCallback(() => {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; console.log('[VoiceContext] Starting voice session');
console.log(`${platformPrefix} [VoiceContext] 🎤 STARTING voice session`);
sessionStoppedRef.current = false; sessionStoppedRef.current = false;
setStatus('listening'); setStatus('listening');
setIsListening(true); setIsListening(true);
setError(null); setError(null);
setTranscript(''); setTranscript('');
setPartialTranscript(''); setPartialTranscript('');
console.log(`${platformPrefix} [VoiceContext] → Session initialized, status=listening`);
}, []); }, []);
/** /**
* Stop voice session * Stop voice session
*/ */
const stopSession = useCallback(() => { const stopSession = useCallback(() => {
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; console.log('[VoiceContext] Stopping voice session');
console.log(`${platformPrefix} [VoiceContext] 🛑 STOPPING voice session`);
// Mark session as stopped FIRST to prevent any pending callbacks // Mark session as stopped FIRST to prevent any pending callbacks
sessionStoppedRef.current = true; sessionStoppedRef.current = true;
console.log(`${platformPrefix} [VoiceContext] → sessionStopped flag set to TRUE`);
// Abort any in-flight API requests // Abort any in-flight API requests
if (abortControllerRef.current) { if (abortControllerRef.current) {
console.log(`${platformPrefix} [VoiceContext] → Aborting in-flight API request`);
abortControllerRef.current.abort(); abortControllerRef.current.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
} }
// Stop TTS // Stop TTS
console.log(`${platformPrefix} [VoiceContext] → Stopping TTS`);
Speech.stop(); Speech.stop();
// Reset all state // Reset all state
console.log(`${platformPrefix} [VoiceContext] → Resetting all state to idle`);
setStatus('idle'); setStatus('idle');
setIsListening(false); setIsListening(false);
setIsSpeaking(false); setIsSpeaking(false);
setError(null); setError(null);
console.log(`${platformPrefix} [VoiceContext] ✅ Voice session stopped`);
}, []); }, []);
// Computed values // Computed values