wellnua-lite/VOICE_DEBUG_GUIDE.md
Sergei 81a0c59060 Fix Android voice bug - STT restart after TTS
PROBLEM:
startListening() triggers spurious AppState change to "background" on Android,
causing voice session to stop immediately after Julia responds.

ROOT CAUSE:
React Native AppState bug - requesting audio focus triggers false background event.

SOLUTION:
- Added sttStartingIgnoreAppStateRef flag
- Ignore AppState changes for 200ms after startListening() call
- Protects against false session termination during STT initialization

CHANGES:
- app/(tabs)/_layout.tsx: Added Android workaround with 200ms protection window
- VOICE_DEBUG_GUIDE.md: Documented bug, workaround, and expected logs

RESULT:
Voice session now continues correctly after Julia's response on Android.
STT successfully restarts and user can speak again without manual restart.
2026-01-29 09:41:16 -08:00

14 KiB
Raw Permalink Blame History

Voice Recognition Debug Guide

Как работает голосовая система на Android vs iOS

Основная проблема: Audio Focus на Android

Android: STT (Speech-to-Text) и TTS (Text-to-Speech) НЕ МОГУТ работать одновременно из-за конфликта Audio Focus. Когда TTS начинает говорить, она захватывает аудио-фокус, и STT автоматически останавливается системой.

iOS: STT и TTS МОГУТ работать одновременно. STT может слушать даже во время воспроизведения TTS (что позволяет прерывать Julia голосом).

КРИТИЧЕСКАЯ проблема Android: startListening() → AppState background

На Android обнаружен баг React Native:

  • При вызове startListening() AppState кратковременно переходит в "background" или "inactive"
  • Это происходит из-за запроса аудио-фокуса и системных разрешений
  • НЕ связано с действиями пользователя (блокировка экрана, переключение приложений)

Workaround (реализован в коде):

  • Добавлен флаг sttStartingIgnoreAppStateRef
  • Игнорируем AppState changes на 200ms после вызова startListening()
  • Защищает от ложной остановки сессии во время запуска STT

Логи при срабатывании workaround:

LOG  [Android] [TabLayout] 🛡️ Ignoring AppState changes for 200ms (STT start workaround)
LOG  [Android] [TabLayout] ▶️ STARTING STT... (status: listening)
LOG  [Android] [TabLayout] 📱 AppState changed to: "background"
LOG  [Android] [TabLayout] 🛡️ IGNORING AppState change (STT start protection active)
LOG  [Android] [TabLayout] ✅ STT started successfully
LOG  [Android] [TabLayout] ✅ AppState monitoring resumed

Нормальный Flow (Должен работать так)

1 Юзер нажимает кнопку "Start"

Android:

[Android] [TabLayout] 🎯 FAB PRESSED - isSessionActive: false
[Android] [TabLayout] ▶️ STARTING session (FAB start)
[Android] [VoiceContext] 🎤 STARTING voice session
[Android] [VoiceContext] → Session initialized, status=listening
[Android] [TabLayout] 🎤 Voice session STARTED - starting STT
[Android] [TabLayout] ▶️ STARTING STT... (status: listening)
[Android] [TabLayout] ✅ STT started successfully

iOS:

[iOS] [TabLayout] 🎯 FAB PRESSED - isSessionActive: false
[iOS] [TabLayout] ▶️ STARTING session (FAB start)
[iOS] [VoiceContext] 🎤 STARTING voice session
[iOS] [VoiceContext] → Session initialized, status=listening
[iOS] [TabLayout] 🎤 Voice session STARTED - starting STT
[iOS] [TabLayout] ▶️ STARTING STT... (status: listening)
[iOS] [TabLayout] ✅ STT started successfully

2 Юзер говорит что-то

Android:

[Android] [TabLayout] handleSpeechResult - isFinal: false, status: listening, transcript: "hello..."
[Android] [TabLayout] → Updating PARTIAL transcript
[Android] [TabLayout] Partial changed: "" → "hello"
[Android] [TabLayout] → Started 2s silence timer

iOS: (то же самое)

3 2 секунды тишины → auto-stop

Android:

[Android] [TabLayout] 🤖 AUTO-STOP: 2s silence - stopping STT
[Android] [TabLayout] handleSTTEnd - sessionActive: true, status: listening
[Android] [TabLayout] → shouldRestartSTT set to TRUE
[Android] [TabLayout] handleSpeechResult - isFinal: true, status: listening, transcript: "hello"
[Android] [TabLayout] → Processing FINAL transcript, sending to API

iOS: (то же самое, но с префиксом [iOS])

4 API запрос

Android:

[Android] [VoiceContext] 📤 Sending transcript to API (ask_wellnuo_ai): "hello"
[Android] [VoiceContext] 🔑 Getting API token...
[Android] [VoiceContext] ✅ Token obtained
[Android] [VoiceContext] 📝 Normalized question: "how is dad doing"
[Android] [VoiceContext] 📡 Using API type: ask_wellnuo_ai, deployment: 21
[Android] [VoiceContext] 🌐 Sending API request...
[Android] [TabLayout] Status transition: listening → processing, sttIsListening: true
[Android] [TabLayout] ⏸️ Stopping STT during processing (echo prevention)

Важно на Android: STT останавливается во время processing, чтобы не слышать эхо от TTS.

5 API ответ получен

Android:

[Android] [VoiceContext] 📥 API response received, parsing...
[Android] [VoiceContext] ✅ API SUCCESS: "Your dad Ferdinand is doing well. He spent most of his time..."
[Android] [VoiceContext] 🔊 Starting TTS for response...
[Android] [VoiceContext] 🔊 Starting TTS: "Your dad Ferdinand is doing well..."
[Android] [VoiceContext] ▶️ TTS playback STARTED
[Android] [TabLayout] Status transition: processing → speaking, sttIsListening: false

iOS: (то же самое)

6 TTS заканчивает говорить

Android:

[Android] [VoiceContext] ✅ TTS playback COMPLETED
[Android] [VoiceContext] → isSpeaking = false (immediate - audio focus release)
[Android] [VoiceContext] → status = listening (ready for next input)
[Android] [VoiceContext] ✅ TTS completed
[Android] [TabLayout] Status transition: speaking → listening, sttIsListening: false
[Android] [TabLayout] 🔄 TTS FINISHED - preparing to restart STT
[Android] [TabLayout] ⏱️ Waiting 50ms before restarting STT (audio focus release)

iOS:

[iOS] [VoiceContext] ✅ TTS playback COMPLETED
[iOS] [VoiceContext] ⏱️ Delaying isSpeaking=false by 300ms (match STT restart)
[iOS] [VoiceContext] → status = listening (ready for next input)
[iOS] [VoiceContext] ✅ TTS completed
[iOS] [TabLayout] Status transition: speaking → listening, sttIsListening: false
[iOS] [TabLayout] 🔄 TTS FINISHED - preparing to restart STT
[iOS] [TabLayout] ⏱️ Waiting 300ms before restarting STT (audio focus release)
[iOS] [VoiceContext] → isSpeaking = false (after 300ms delay)

Ключевая разница:

  • Android: isSpeaking = false сразу (50ms delay для STT restart)
  • iOS: isSpeaking = false через 300ms (плавный переход, matches STT restart)

7 STT рестартует после TTS

Android:

[Android] [TabLayout] ⏰ Delay complete - restarting STT now
[Android] [TabLayout] ▶️ STARTING STT... (status: listening)
[Android] [TabLayout] ✅ STT started successfully

iOS: (то же самое)

8 Юзер может говорить снова → повтор с шага 2


Что НЕ ДОЛЖНО происходить (баги)

БАГ 1: STT не рестартует после TTS (Android)

Симптомы:

[Android] [VoiceContext] ✅ TTS playback COMPLETED
[Android] [TabLayout] Status transition: speaking → listening, sttIsListening: false
[Android] [TabLayout] 🔄 TTS FINISHED - preparing to restart STT
[Android] [TabLayout] ⏱️ Waiting 50ms before restarting STT (audio focus release)
[Android] [TabLayout] ⏰ Delay complete - restarting STT now
[Android] [TabLayout] safeStartSTT - already listening or starting, skipping  ❌ ПРОБЛЕМА!

Причина: sttIsListening или sttStartingRef.current остались true, хотя STT на самом деле не работает.

Решение: Убедиться что stopListening() вызывается корректно и флаги сбрасываются.

БАГ 2: STT не останавливается во время TTS (эхо)

Симптомы:

[Android] [TabLayout] Status transition: processing → speaking, sttIsListening: true  ❌ ПРОБЛЕМА!

Причина: STT не остановилось при переходе в speaking, и будет слышать Julia как эхо.

Решение: Проверить что stopListening() вызывается при status === 'speaking'.

БАГ 3: isSpeaking не сбрасывается

Симптомы (Android):

[Android] [VoiceContext] ✅ TTS playback COMPLETED
[Android] [VoiceContext] → isSpeaking = false (immediate - audio focus release)
... но FAB остаётся зелёным (isSpeaking === true в UI)

Причина: setIsSpeaking(false) не отрабатывает или есть race condition.

Решение: Проверить что setIsSpeaking(false) вызывается в onDone TTS callback.


Как читать логи

Префиксы платформ:

  • [iOS] — событие происходит на iOS
  • [Android] — событие происходит на Android

Префиксы модулей:

  • [TabLayout] — события из app/(tabs)/_layout.tsx (STT orchestration)
  • [VoiceContext] — события из contexts/VoiceContext.tsx (API + TTS)

Эмодзи:

  • 🎯 — FAB нажат
  • 🎤 — Voice session start/stop
  • ▶️ — STT start
  • ⏸️ — STT stop
  • 📤 — Sending to API
  • 📥 — API response
  • 🔊 — TTS start/playback
  • — Success
  • — Error
  • ⚠️ — Warning
  • 🔄 — Restart/retry
  • ⏱️ — Timer/delay
  • — Timer triggered
  • 🛑 — Force stop

Как тестировать

Тест 1: Базовый цикл (1 вопрос → 1 ответ → 1 вопрос)

  1. Нажать FAB (зелёная кнопка)
  2. Сказать: "Hello"
  3. Подождать 2 секунды (auto-stop)
  4. Проверить: Julia отвечает
  5. Проверить: После ответа Julia, STT рестартует автоматически
  6. Сказать: "How is dad?"
  7. Проверить: Julia отвечает снова

Ожидаемые логи (Android):

[Android] [TabLayout] 🎯 FAB PRESSED - isSessionActive: false
[Android] [TabLayout] ▶️ STARTING session (FAB start)
[Android] [TabLayout] ▶️ STARTING STT... (status: listening)
[Android] [TabLayout] ✅ STT started successfully
... пользователь говорит ...
[Android] [TabLayout] 🤖 AUTO-STOP: 2s silence - stopping STT
[Android] [TabLayout] → Processing FINAL transcript, sending to API
[Android] [VoiceContext] 📤 Sending transcript to API
[Android] [TabLayout] ⏸️ Stopping STT during processing (echo prevention)
[Android] [VoiceContext] ✅ API SUCCESS
[Android] [VoiceContext] 🔊 Starting TTS for response...
[Android] [VoiceContext] ▶️ TTS playback STARTED
... Julia говорит ...
[Android] [VoiceContext] ✅ TTS playback COMPLETED
[Android] [TabLayout] 🔄 TTS FINISHED - preparing to restart STT
[Android] [TabLayout] ⏱️ Waiting 50ms before restarting STT
[Android] [TabLayout] ⏰ Delay complete - restarting STT now
[Android] [TabLayout] ▶️ STARTING STT... (status: listening)
[Android] [TabLayout] ✅ STT started successfully  ← КРИТИЧНО! Если нет этой строки — БАГ!

Тест 2: Прерывание Julia голосом (только iOS)

На iOS:

  1. Нажать FAB
  2. Сказать: "Tell me a long story"
  3. Пока Julia говорит, сказать: "Stop"
  4. Проверить: Julia прервалась
  5. Проверить: "Stop" отправилось как новый запрос

На Android: Прерывание голосом НЕ РАБОТАЕТ (STT выключен во время TTS). Прервать можно только кнопкой FAB.


Checklist для отладки

Если STT не рестартует после TTS на Android:

  • Проверь что stopListening() вызывается при status === 'speaking'
  • Проверь что sttIsListening === false после stopListening()
  • Проверь что safeStartSTT() вызывается после задержки 50ms
  • Проверь что sttStartingRef.current === false перед вызовом safeStartSTT()
  • Проверь что sessionActiveRef.current === true (сессия активна)
  • Проверь логи на наличие [Android] [TabLayout] ✅ STT started successfully
  • Проверь что нет логов [Android] [TabLayout] ⚠️ SKIPPING STT start - TTS is playing

Audio Focus на Android (детально)

Как работает Audio Focus:

  1. TTS запрашивает Audio Focus:

    • Когда Speech.speak() вызывается, Android TTS запрашивает AUDIOFOCUS_GAIN_TRANSIENT
    • Это автоматически останавливает все другие аудио-источники, включая STT
  2. STT теряет Audio Focus:

    • Expo Speech Recognition получает событие onAudioFocusLoss
    • STT автоматически останавливается (не нужно вручную вызывать stopListening())
  3. TTS освобождает Audio Focus:

    • Когда TTS заканчивает (onDone callback), Audio Focus освобождается
    • Но STT НЕ рестартует автоматически — нужно вручную вызвать startListening()

Почему 50ms delay на Android?

  • TTS может освободить Audio Focus с небольшой задержкой
  • Если попытаться запустить STT слишком рано, STT может не получить Audio Focus
  • 50ms — достаточно для освобождения, но незаметно для юзера

Почему 300ms delay на iOS?

  • iOS не имеет проблем с Audio Focus
  • 300ms delay нужен для плавного перехода анимаций (зелёный индикатор)
  • Также даёт время для TTS полностью завершиться и освободить аудио-драйвер