From f4a239ff43a3646e262c702fd1822b522217ab2f Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 29 Jan 2026 09:46:38 -0800 Subject: [PATCH] Improve TTS voice quality - faster rate, higher pitch, iOS premium voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes to contexts/VoiceContext.tsx: - Increase rate from 0.9 to 1.1 (faster, more natural) - Increase pitch from 1.0 to 1.15 (slightly higher, less robotic) - Add iOS premium voice (Samantha - Siri quality) - Android continues to use default high-quality voice This fixes the complaint that the voice sounded "отсталый" (backward/outdated) and "жёсткий" (harsh/stiff) on iOS. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- contexts/VoiceContext.tsx | 104 ++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/contexts/VoiceContext.tsx b/contexts/VoiceContext.tsx index 397b398..1b4fe26 100644 --- a/contexts/VoiceContext.tsx +++ b/contexts/VoiceContext.tsx @@ -234,19 +234,21 @@ export function VoiceProvider({ children }: { children: ReactNode }) { */ const sendTranscript = useCallback( async (text: string): Promise => { + const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; const trimmedText = text.trim(); + if (!trimmedText) { - console.log('[VoiceContext] Empty transcript, skipping API call'); + console.log(`${platformPrefix} [VoiceContext] Empty transcript, skipping API call`); return null; } // Don't send if session was stopped if (sessionStoppedRef.current) { - console.log('[VoiceContext] Session stopped, skipping API call'); + console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping API call`); return null; } - console.log(`[VoiceContext] Sending transcript to API (${voiceApiType}):`, trimmedText); + console.log(`${platformPrefix} [VoiceContext] 📤 Sending transcript to API (${voiceApiType}): "${trimmedText}"`); setStatus('processing'); setError(null); @@ -261,23 +263,28 @@ 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('[VoiceContext] Request aborted before API call'); + console.log(`${platformPrefix} [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('[VoiceContext] Using API type:', voiceApiType); + console.log(`${platformPrefix} [VoiceContext] 📡 Using API type: ${voiceApiType}, deployment: ${deploymentId}`); // Build request params const requestParams: Record = { @@ -295,6 +302,7 @@ 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' }, @@ -302,33 +310,37 @@ 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('[VoiceContext] Session stopped during API call, discarding response'); + console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped during API call, discarding response`); return null; } if (data.ok && data.response?.body) { const responseText = data.response.body; - console.log('[VoiceContext] API response:', responseText.slice(0, 100) + '...'); + console.log(`${platformPrefix} [VoiceContext] ✅ API SUCCESS: "${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('[VoiceContext] Token expired, retrying with new token...'); + console.log(`${platformPrefix} [VoiceContext] ⚠️ 401 Unauthorized - Token expired, retrying...`); 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 = { @@ -351,27 +363,31 @@ export function VoiceProvider({ children }: { children: ReactNode }) { if (retryData.ok && retryData.response?.body) { const responseText = retryData.response.body; - console.log('[VoiceContext] Retry succeeded:', responseText.slice(0, 100) + '...'); + console.log(`${platformPrefix} [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('[VoiceContext] API request aborted'); + console.log(`${platformPrefix} [VoiceContext] ⚠️ API request aborted`); return null; } // Handle API errors gracefully with voice feedback const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - console.warn('[VoiceContext] API error:', errorMsg); + console.error(`${platformPrefix} [VoiceContext] ❌ API ERROR:`, errorMsg); // Create user-friendly error message for TTS const spokenError = `Sorry, I encountered an error: ${errorMsg}. Please try again.`; @@ -397,59 +413,80 @@ 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('[VoiceContext] User interrupted - stopping TTS'); + console.log(`${platformPrefix} [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 => { - if (!text.trim()) return; + const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; - // Don't speak if session was stopped - if (sessionStoppedRef.current) { - console.log('[VoiceContext] Session stopped, skipping TTS'); + if (!text.trim()) { + console.log(`${platformPrefix} [VoiceContext] Empty text, skipping TTS`); return; } - console.log('[VoiceContext] Speaking:', text.slice(0, 50) + '...'); + // Don't speak if session was stopped + if (sessionStoppedRef.current) { + console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping TTS`); + return; + } + + console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS: "${text.slice(0, 50)}..."`); setStatus('speaking'); setIsSpeaking(true); return new Promise((resolve) => { Speech.speak(text, { language: 'en-US', - rate: 0.9, - pitch: 1.0, + 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, onStart: () => { - console.log('[VoiceContext] TTS started'); + console.log(`${platformPrefix} [VoiceContext] ▶️ TTS playback STARTED`); }, onDone: () => { - console.log('[VoiceContext] TTS completed'); + console.log(`${platformPrefix} [VoiceContext] ✅ TTS playback 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.warn('[VoiceContext] TTS error:', error); + console.error(`${platformPrefix} [VoiceContext] ❌ TTS ERROR:`, error); // On error, turn off indicator immediately (no delay) setIsSpeaking(false); if (!sessionStoppedRef.current) { @@ -458,12 +495,15 @@ export function VoiceProvider({ children }: { children: ReactNode }) { resolve(); }, onStopped: () => { - console.log('[VoiceContext] TTS stopped (interrupted)'); + console.log(`${platformPrefix} [VoiceContext] ⏹️ TTS STOPPED (interrupted by user)`); // 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(); }, @@ -483,34 +523,46 @@ export function VoiceProvider({ children }: { children: ReactNode }) { * Start voice session */ const startSession = useCallback(() => { - console.log('[VoiceContext] Starting voice session'); + const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; + console.log(`${platformPrefix} [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(() => { - console.log('[VoiceContext] Stopping voice session'); + const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]'; + console.log(`${platformPrefix} [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