From 3731206546d0048f7f6ad276600d72b52dc04a9d Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 29 Jan 2026 10:05:10 -0800 Subject: [PATCH] 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. --- PRD.md | 220 ------------------------------------- app/(tabs)/_layout.tsx | 7 +- app/(tabs)/voice-debug.tsx | 33 +++++- contexts/VoiceContext.tsx | 100 ++++------------- 4 files changed, 55 insertions(+), 305 deletions(-) delete mode 100644 PRD.md diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 1f45821..0000000 --- a/PRD.md +++ /dev/null @@ -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; - stopListening: () => void; - hasPermission: boolean; - requestPermission: () => Promise; - } - ``` - - Язык: `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; - 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`: - - Обернуть `` в `` + `` - - 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/) — анимации diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 688292f..9ee37af 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -433,14 +433,11 @@ export default function TabLayout() { ), }} /> - {/* Voice Debug - VISIBLE for debugging */} + {/* Voice Debug - hidden in production */} ( - - ), + href: null, }} /> {/* Header */} - - Voice Debug - + + + Voice Debug + + + {platformBadge} + + @@ -268,7 +278,7 @@ export default function VoiceDebugScreen() { {sttIsListening && status !== 'processing' && status !== 'speaking' && ( - Silence Timer (iOS auto-stop at 2.0s) + Silence Timer ({Platform.OS === 'ios' ? 'iOS' : 'Android'} auto-stop at 2.0s) => { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; const trimmedText = text.trim(); - if (!trimmedText) { - console.log(`${platformPrefix} [VoiceContext] Empty transcript, skipping API call`); + console.log('[VoiceContext] Empty transcript, skipping API call'); return null; } // Don't send if session was stopped if (sessionStoppedRef.current) { - console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping API call`); + console.log('[VoiceContext] Session stopped, skipping API call'); return null; } - console.log(`${platformPrefix} [VoiceContext] 📤 Sending transcript to API (${voiceApiType}): "${trimmedText}"`); + console.log(`[VoiceContext] Sending transcript to API (${voiceApiType}):`, trimmedText); setStatus('processing'); setError(null); @@ -263,28 +261,23 @@ export function VoiceProvider({ children }: { children: ReactNode }) { abortControllerRef.current = abortController; try { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - // Get API token - console.log(`${platformPrefix} [VoiceContext] 🔑 Getting API token...`); const token = await getWellNuoToken(); - console.log(`${platformPrefix} [VoiceContext] ✅ Token obtained`); // Check if aborted 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; } // Normalize question const normalizedQuestion = normalizeQuestion(trimmedText); - console.log(`${platformPrefix} [VoiceContext] 📝 Normalized question: "${normalizedQuestion}"`); // Get deployment ID const deploymentId = deploymentIdRef.current || '21'; // 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 const requestParams: Record = { @@ -302,7 +295,6 @@ export function VoiceProvider({ children }: { children: ReactNode }) { // Currently single deployment mode only } - console.log(`${platformPrefix} [VoiceContext] 🌐 Sending API request...`); const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -310,37 +302,33 @@ export function VoiceProvider({ children }: { children: ReactNode }) { signal: abortController.signal, }); - console.log(`${platformPrefix} [VoiceContext] 📥 API response received, parsing...`); const data = await response.json(); // Check if session was stopped while waiting for response 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; } if (data.ok && 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); // Add Julia's response to transcript for chat display addTranscriptEntry('assistant', responseText); - console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS for response...`); // Speak the response (will be skipped if session stopped) await speak(responseText); - console.log(`${platformPrefix} [VoiceContext] ✅ TTS completed`); return responseText; } else { // Token might be expired - retry with new token 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; // Get new token and retry request - console.log(`${platformPrefix} [VoiceContext] 🔑 Getting new token for retry...`); const newToken = await getWellNuoToken(); const retryRequestParams: Record = { @@ -363,31 +351,27 @@ export function VoiceProvider({ children }: { children: ReactNode }) { if (retryData.ok && 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); addTranscriptEntry('assistant', responseText); await speak(responseText); return responseText; } else { - console.error(`${platformPrefix} [VoiceContext] ❌ Retry FAILED:`, retryData.message); 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'); } } catch (err) { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - // Ignore abort errors if (err instanceof Error && err.name === 'AbortError') { - console.log(`${platformPrefix} [VoiceContext] ⚠️ API request aborted`); + console.log('[VoiceContext] API request aborted'); return null; } // Handle API errors gracefully with voice feedback 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 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 */ const interruptIfSpeaking = useCallback(() => { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - if (isSpeaking) { - console.log(`${platformPrefix} [VoiceContext] ⚠️ User INTERRUPTED - stopping TTS`); + console.log('[VoiceContext] User interrupted - stopping TTS'); Speech.stop(); setIsSpeaking(false); setStatus('listening'); - console.log(`${platformPrefix} [VoiceContext] → TTS stopped, status=listening`); return true; - } else { - console.log(`${platformPrefix} [VoiceContext] interruptIfSpeaking called but NOT speaking`); - return false; } + return false; }, [isSpeaking]); /** * Speak text using TTS */ const speak = useCallback(async (text: string): Promise => { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - - if (!text.trim()) { - console.log(`${platformPrefix} [VoiceContext] Empty text, skipping TTS`); - return; - } + if (!text.trim()) return; // Don't speak if session was stopped if (sessionStoppedRef.current) { - console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping TTS`); + console.log('[VoiceContext] Session stopped, skipping TTS'); return; } - console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS: "${text.slice(0, 50)}..."`); + console.log('[VoiceContext] Speaking:', text.slice(0, 50) + '...'); setStatus('speaking'); setIsSpeaking(true); return new Promise((resolve) => { Speech.speak(text, { language: 'en-US', - rate: 1.1, // Faster, more natural (was 0.9) - pitch: 1.15, // Slightly higher, less robotic (was 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, + rate: 0.9, + pitch: 1.0, onStart: () => { - console.log(`${platformPrefix} [VoiceContext] ▶️ TTS playback STARTED`); + console.log('[VoiceContext] TTS started'); }, 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 Android: Turn off immediately (audio focus conflict with STT) if (Platform.OS === 'ios') { - console.log('[iOS] [VoiceContext] ⏱️ Delaying isSpeaking=false by 300ms (match STT restart)'); setTimeout(() => { - console.log('[iOS] [VoiceContext] → isSpeaking = false (after 300ms delay)'); setIsSpeaking(false); }, 300); } else { - console.log('[Android] [VoiceContext] → isSpeaking = false (immediate - audio focus release)'); setIsSpeaking(false); } - // Return to listening state after speaking (if session wasn't stopped) if (!sessionStoppedRef.current) { - console.log(`${platformPrefix} [VoiceContext] → status = listening (ready for next input)`); setStatus('listening'); - } else { - console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`); } resolve(); }, onError: (error) => { - console.error(`${platformPrefix} [VoiceContext] ❌ TTS ERROR:`, error); + console.warn('[VoiceContext] TTS error:', error); // On error, turn off indicator immediately (no delay) setIsSpeaking(false); if (!sessionStoppedRef.current) { @@ -495,15 +458,12 @@ export function VoiceProvider({ children }: { children: ReactNode }) { resolve(); }, onStopped: () => { - console.log(`${platformPrefix} [VoiceContext] ⏹️ TTS STOPPED (interrupted by user)`); + console.log('[VoiceContext] TTS stopped (interrupted)'); // When interrupted by user, turn off indicator immediately setIsSpeaking(false); // Don't set status to listening if session was stopped by user if (!sessionStoppedRef.current) { - console.log(`${platformPrefix} [VoiceContext] → status = listening (after interruption)`); setStatus('listening'); - } else { - console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`); } resolve(); }, @@ -523,46 +483,34 @@ export function VoiceProvider({ children }: { children: ReactNode }) { * Start voice session */ const startSession = useCallback(() => { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - console.log(`${platformPrefix} [VoiceContext] 🎤 STARTING voice session`); + console.log('[VoiceContext] Starting voice session'); sessionStoppedRef.current = false; setStatus('listening'); setIsListening(true); setError(null); setTranscript(''); setPartialTranscript(''); - console.log(`${platformPrefix} [VoiceContext] → Session initialized, status=listening`); }, []); /** * Stop voice session */ const stopSession = useCallback(() => { - const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - console.log(`${platformPrefix} [VoiceContext] 🛑 STOPPING voice session`); - + console.log('[VoiceContext] Stopping voice session'); // Mark session as stopped FIRST to prevent any pending callbacks sessionStoppedRef.current = true; - console.log(`${platformPrefix} [VoiceContext] → sessionStopped flag set to TRUE`); - // Abort any in-flight API requests if (abortControllerRef.current) { - console.log(`${platformPrefix} [VoiceContext] → Aborting in-flight API request`); abortControllerRef.current.abort(); abortControllerRef.current = null; } - // Stop TTS - console.log(`${platformPrefix} [VoiceContext] → Stopping TTS`); Speech.stop(); - // Reset all state - console.log(`${platformPrefix} [VoiceContext] → Resetting all state to idle`); setStatus('idle'); setIsListening(false); setIsSpeaking(false); setError(null); - console.log(`${platformPrefix} [VoiceContext] ✅ Voice session stopped`); }, []); // Computed values