From 8c0e36cae3c3f76fa4598e437bfce3b977ec4ab8 Mon Sep 17 00:00:00 2001 From: Sergei Date: Wed, 28 Jan 2026 20:43:42 -0800 Subject: [PATCH] Fix Android voice bugs - STT restart and token retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical Android fixes: BUG 1 - STT not restarting after TTS: - Problem: isSpeaking delay (300ms iOS visual) blocked Android STT - Android audio focus conflict: STT cannot start while isSpeaking=true - Fix: Platform-specific isSpeaking timing - iOS: 300ms delay (smooth visual indicator) - Android: immediate (allows STT to restart) BUG 2 - Session expired loop: - Problem: 401 error → token reset → no retry → user hears error - Fix: Automatic token refresh and retry on 401 - Flow: 401 → clear token → get new token → retry request - User never hears "Session expired" unless retry also fails contexts/VoiceContext.tsx:12-23,387-360 --- contexts/VoiceContext.tsx | 49 ++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/contexts/VoiceContext.tsx b/contexts/VoiceContext.tsx index ddbce9f..397b398 100644 --- a/contexts/VoiceContext.tsx +++ b/contexts/VoiceContext.tsx @@ -17,6 +17,7 @@ import React, { useRef, ReactNode, } from 'react'; +import { Platform } from 'react-native'; import * as Speech from 'expo-speech'; import { api } from '@/services/api'; import { useVoiceTranscript } from './VoiceTranscriptContext'; @@ -322,10 +323,42 @@ export function VoiceProvider({ children }: { children: ReactNode }) { return responseText; } else { - // Token might be expired + // Token might be expired - retry with new token if (data.status === '401 Unauthorized') { + console.log('[VoiceContext] Token expired, retrying with new token...'); apiTokenRef.current = null; - throw new Error('Session expired, please try again'); + + // Get new token and retry request + const newToken = await getWellNuoToken(); + + const retryRequestParams: Record = { + function: voiceApiType, + clientId: 'MA_001', + user_name: WELLNUO_USER, + token: newToken, + question: normalizedQuestion, + deployment_id: deploymentId, + }; + + const retryResponse = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(retryRequestParams).toString(), + signal: abortController.signal, + }); + + const retryData = await retryResponse.json(); + + if (retryData.ok && retryData.response?.body) { + const responseText = retryData.response.body; + console.log('[VoiceContext] Retry succeeded:', responseText.slice(0, 100) + '...'); + setLastResponse(responseText); + addTranscriptEntry('assistant', responseText); + await speak(responseText); + return responseText; + } else { + throw new Error(retryData.message || 'Could not get response after retry'); + } } throw new Error(data.message || 'Could not get response'); } @@ -400,11 +433,15 @@ export function VoiceProvider({ children }: { children: ReactNode }) { }, onDone: () => { console.log('[VoiceContext] TTS completed'); - // Delay turning off green indicator to match STT restart delay (300ms) - // This keeps the visual indicator on during the transition period - setTimeout(() => { + // 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') { + setTimeout(() => { + setIsSpeaking(false); + }, 300); + } else { setIsSpeaking(false); - }, 300); + } // Return to listening state after speaking (if session wasn't stopped) if (!sessionStoppedRef.current) { setStatus('listening');