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.
This commit is contained in:
parent
f4a239ff43
commit
3731206546
220
PRD.md
220
PRD.md
@ -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<void>;
|
||||
stopListening: () => void;
|
||||
hasPermission: boolean;
|
||||
requestPermission: () => Promise<boolean>;
|
||||
}
|
||||
```
|
||||
- Язык: `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<void>;
|
||||
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`:
|
||||
- Обернуть `<Tabs>` в `<View style={{flex:1}}>` + `<VoiceFAB />`
|
||||
- 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/) — анимации
|
||||
@ -433,14 +433,11 @@ export default function TabLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/* Voice Debug - VISIBLE for debugging */}
|
||||
{/* Voice Debug - hidden in production */}
|
||||
<Tabs.Screen
|
||||
name="voice-debug"
|
||||
options={{
|
||||
title: 'Debug',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="activity" size={22} color={color} />
|
||||
),
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
@ -222,13 +223,22 @@ export default function VoiceDebugScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// Platform badge
|
||||
const platformBadge = Platform.OS === 'ios' ? '🍎 iOS' : '🤖 Android';
|
||||
const platformColor = Platform.OS === 'ios' ? '#007AFF' : '#3DDC84';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
Voice Debug
|
||||
</Text>
|
||||
<View style={[styles.platformBadge, { backgroundColor: platformColor }]}>
|
||||
<Text style={styles.platformBadgeText}>{platformBadge}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={clearLogs} style={styles.clearButton}>
|
||||
<Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
|
||||
</TouchableOpacity>
|
||||
@ -268,7 +278,7 @@ export default function VoiceDebugScreen() {
|
||||
{sttIsListening && status !== 'processing' && status !== 'speaking' && (
|
||||
<View style={styles.timerContainer}>
|
||||
<Text style={[styles.timerLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
|
||||
Silence Timer (iOS auto-stop at 2.0s)
|
||||
Silence Timer ({Platform.OS === 'ios' ? 'iOS' : 'Android'} auto-stop at 2.0s)
|
||||
</Text>
|
||||
<View style={styles.timerRow}>
|
||||
<Text style={[styles.timerText, {
|
||||
@ -382,10 +392,25 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
platformBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
platformBadgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
clearButton: {
|
||||
padding: 8,
|
||||
},
|
||||
|
||||
@ -234,21 +234,19 @@ 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(`${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<string, string> = {
|
||||
@ -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<string, string> = {
|
||||
@ -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<void> => {
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user