Improve TTS voice quality - faster rate, higher pitch, iOS premium voice

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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-29 09:46:38 -08:00
parent 81a0c59060
commit f4a239ff43

View File

@ -234,19 +234,21 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
*/
const sendTranscript = useCallback(
async (text: string): Promise<string | null> => {
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<string, string> = {
@ -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<string, string> = {
@ -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<void> => {
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