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
|
<Tabs.Screen
|
||||||
name="voice-debug"
|
name="voice-debug"
|
||||||
options={{
|
options={{
|
||||||
title: 'Debug',
|
href: null,
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Feather name="activity" size={22} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Feather } from '@expo/vector-icons';
|
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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}>
|
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
|
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
|
||||||
<Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
<View style={styles.headerLeft}>
|
||||||
Voice Debug
|
<Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||||
</Text>
|
Voice Debug
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.platformBadge, { backgroundColor: platformColor }]}>
|
||||||
|
<Text style={styles.platformBadgeText}>{platformBadge}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<TouchableOpacity onPress={clearLogs} style={styles.clearButton}>
|
<TouchableOpacity onPress={clearLogs} style={styles.clearButton}>
|
||||||
<Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
|
<Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -268,7 +278,7 @@ export default function VoiceDebugScreen() {
|
|||||||
{sttIsListening && status !== 'processing' && status !== 'speaking' && (
|
{sttIsListening && status !== 'processing' && status !== 'speaking' && (
|
||||||
<View style={styles.timerContainer}>
|
<View style={styles.timerContainer}>
|
||||||
<Text style={[styles.timerLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
|
<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>
|
</Text>
|
||||||
<View style={styles.timerRow}>
|
<View style={styles.timerRow}>
|
||||||
<Text style={[styles.timerText, {
|
<Text style={[styles.timerText, {
|
||||||
@ -382,10 +392,25 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
},
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
platformBadge: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
platformBadgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
padding: 8,
|
padding: 8,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -234,21 +234,19 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
const sendTranscript = useCallback(
|
const sendTranscript = useCallback(
|
||||||
async (text: string): Promise<string | null> => {
|
async (text: string): Promise<string | null> => {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
|
||||||
const trimmedText = text.trim();
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
if (!trimmedText) {
|
if (!trimmedText) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] Empty transcript, skipping API call`);
|
console.log('[VoiceContext] Empty transcript, skipping API call');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't send if session was stopped
|
// Don't send if session was stopped
|
||||||
if (sessionStoppedRef.current) {
|
if (sessionStoppedRef.current) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping API call`);
|
console.log('[VoiceContext] Session stopped, skipping API call');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] 📤 Sending transcript to API (${voiceApiType}): "${trimmedText}"`);
|
console.log(`[VoiceContext] Sending transcript to API (${voiceApiType}):`, trimmedText);
|
||||||
setStatus('processing');
|
setStatus('processing');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@ -263,28 +261,23 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
|
||||||
|
|
||||||
// Get API token
|
// Get API token
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🔑 Getting API token...`);
|
|
||||||
const token = await getWellNuoToken();
|
const token = await getWellNuoToken();
|
||||||
console.log(`${platformPrefix} [VoiceContext] ✅ Token obtained`);
|
|
||||||
|
|
||||||
// Check if aborted
|
// Check if aborted
|
||||||
if (abortController.signal.aborted || sessionStoppedRef.current) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize question
|
// Normalize question
|
||||||
const normalizedQuestion = normalizeQuestion(trimmedText);
|
const normalizedQuestion = normalizeQuestion(trimmedText);
|
||||||
console.log(`${platformPrefix} [VoiceContext] 📝 Normalized question: "${normalizedQuestion}"`);
|
|
||||||
|
|
||||||
// Get deployment ID
|
// Get deployment ID
|
||||||
const deploymentId = deploymentIdRef.current || '21';
|
const deploymentId = deploymentIdRef.current || '21';
|
||||||
|
|
||||||
// Log which API type we're using
|
// 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
|
// Build request params
|
||||||
const requestParams: Record<string, string> = {
|
const requestParams: Record<string, string> = {
|
||||||
@ -302,7 +295,6 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
// Currently single deployment mode only
|
// Currently single deployment mode only
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🌐 Sending API request...`);
|
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@ -310,37 +302,33 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] 📥 API response received, parsing...`);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Check if session was stopped while waiting for response
|
// Check if session was stopped while waiting for response
|
||||||
if (sessionStoppedRef.current) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.ok && data.response?.body) {
|
if (data.ok && data.response?.body) {
|
||||||
const responseText = 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);
|
setLastResponse(responseText);
|
||||||
|
|
||||||
// Add Julia's response to transcript for chat display
|
// Add Julia's response to transcript for chat display
|
||||||
addTranscriptEntry('assistant', responseText);
|
addTranscriptEntry('assistant', responseText);
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS for response...`);
|
|
||||||
// Speak the response (will be skipped if session stopped)
|
// Speak the response (will be skipped if session stopped)
|
||||||
await speak(responseText);
|
await speak(responseText);
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] ✅ TTS completed`);
|
|
||||||
return responseText;
|
return responseText;
|
||||||
} else {
|
} else {
|
||||||
// Token might be expired - retry with new token
|
// Token might be expired - retry with new token
|
||||||
if (data.status === '401 Unauthorized') {
|
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;
|
apiTokenRef.current = null;
|
||||||
|
|
||||||
// Get new token and retry request
|
// Get new token and retry request
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🔑 Getting new token for retry...`);
|
|
||||||
const newToken = await getWellNuoToken();
|
const newToken = await getWellNuoToken();
|
||||||
|
|
||||||
const retryRequestParams: Record<string, string> = {
|
const retryRequestParams: Record<string, string> = {
|
||||||
@ -363,31 +351,27 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (retryData.ok && retryData.response?.body) {
|
if (retryData.ok && retryData.response?.body) {
|
||||||
const responseText = 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);
|
setLastResponse(responseText);
|
||||||
addTranscriptEntry('assistant', responseText);
|
addTranscriptEntry('assistant', responseText);
|
||||||
await speak(responseText);
|
await speak(responseText);
|
||||||
return responseText;
|
return responseText;
|
||||||
} else {
|
} else {
|
||||||
console.error(`${platformPrefix} [VoiceContext] ❌ Retry FAILED:`, retryData.message);
|
|
||||||
throw new Error(retryData.message || 'Could not get response after retry');
|
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');
|
throw new Error(data.message || 'Could not get response');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
|
||||||
|
|
||||||
// Ignore abort errors
|
// Ignore abort errors
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ API request aborted`);
|
console.log('[VoiceContext] API request aborted');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle API errors gracefully with voice feedback
|
// Handle API errors gracefully with voice feedback
|
||||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
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
|
// Create user-friendly error message for TTS
|
||||||
const spokenError = `Sorry, I encountered an error: ${errorMsg}. Please try again.`;
|
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
|
* Call this from the STT hook when voice activity is detected
|
||||||
*/
|
*/
|
||||||
const interruptIfSpeaking = useCallback(() => {
|
const interruptIfSpeaking = useCallback(() => {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
|
||||||
|
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ User INTERRUPTED - stopping TTS`);
|
console.log('[VoiceContext] User interrupted - stopping TTS');
|
||||||
Speech.stop();
|
Speech.stop();
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
setStatus('listening');
|
setStatus('listening');
|
||||||
console.log(`${platformPrefix} [VoiceContext] → TTS stopped, status=listening`);
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] interruptIfSpeaking called but NOT speaking`);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}, [isSpeaking]);
|
}, [isSpeaking]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speak text using TTS
|
* Speak text using TTS
|
||||||
*/
|
*/
|
||||||
const speak = useCallback(async (text: string): Promise<void> => {
|
const speak = useCallback(async (text: string): Promise<void> => {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
if (!text.trim()) return;
|
||||||
|
|
||||||
if (!text.trim()) {
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] Empty text, skipping TTS`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't speak if session was stopped
|
// Don't speak if session was stopped
|
||||||
if (sessionStoppedRef.current) {
|
if (sessionStoppedRef.current) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, skipping TTS`);
|
console.log('[VoiceContext] Session stopped, skipping TTS');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🔊 Starting TTS: "${text.slice(0, 50)}..."`);
|
console.log('[VoiceContext] Speaking:', text.slice(0, 50) + '...');
|
||||||
setStatus('speaking');
|
setStatus('speaking');
|
||||||
setIsSpeaking(true);
|
setIsSpeaking(true);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
Speech.speak(text, {
|
Speech.speak(text, {
|
||||||
language: 'en-US',
|
language: 'en-US',
|
||||||
rate: 1.1, // Faster, more natural (was 0.9)
|
rate: 0.9,
|
||||||
pitch: 1.15, // Slightly higher, less robotic (was 1.0)
|
pitch: 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: () => {
|
onStart: () => {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ▶️ TTS playback STARTED`);
|
console.log('[VoiceContext] TTS started');
|
||||||
},
|
},
|
||||||
onDone: () => {
|
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 iOS: Delay turning off green indicator to match STT restart delay (300ms)
|
||||||
// On Android: Turn off immediately (audio focus conflict with STT)
|
// On Android: Turn off immediately (audio focus conflict with STT)
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
console.log('[iOS] [VoiceContext] ⏱️ Delaying isSpeaking=false by 300ms (match STT restart)');
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('[iOS] [VoiceContext] → isSpeaking = false (after 300ms delay)');
|
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
console.log('[Android] [VoiceContext] → isSpeaking = false (immediate - audio focus release)');
|
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return to listening state after speaking (if session wasn't stopped)
|
// Return to listening state after speaking (if session wasn't stopped)
|
||||||
if (!sessionStoppedRef.current) {
|
if (!sessionStoppedRef.current) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] → status = listening (ready for next input)`);
|
|
||||||
setStatus('listening');
|
setStatus('listening');
|
||||||
} else {
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`);
|
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(`${platformPrefix} [VoiceContext] ❌ TTS ERROR:`, error);
|
console.warn('[VoiceContext] TTS error:', error);
|
||||||
// On error, turn off indicator immediately (no delay)
|
// On error, turn off indicator immediately (no delay)
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
if (!sessionStoppedRef.current) {
|
if (!sessionStoppedRef.current) {
|
||||||
@ -495,15 +458,12 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
onStopped: () => {
|
onStopped: () => {
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⏹️ TTS STOPPED (interrupted by user)`);
|
console.log('[VoiceContext] TTS stopped (interrupted)');
|
||||||
// When interrupted by user, turn off indicator immediately
|
// When interrupted by user, turn off indicator immediately
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
// Don't set status to listening if session was stopped by user
|
// Don't set status to listening if session was stopped by user
|
||||||
if (!sessionStoppedRef.current) {
|
if (!sessionStoppedRef.current) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] → status = listening (after interruption)`);
|
|
||||||
setStatus('listening');
|
setStatus('listening');
|
||||||
} else {
|
|
||||||
console.log(`${platformPrefix} [VoiceContext] ⚠️ Session stopped, NOT returning to listening`);
|
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
@ -523,46 +483,34 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
|||||||
* Start voice session
|
* Start voice session
|
||||||
*/
|
*/
|
||||||
const startSession = useCallback(() => {
|
const startSession = useCallback(() => {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
console.log('[VoiceContext] Starting voice session');
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🎤 STARTING voice session`);
|
|
||||||
sessionStoppedRef.current = false;
|
sessionStoppedRef.current = false;
|
||||||
setStatus('listening');
|
setStatus('listening');
|
||||||
setIsListening(true);
|
setIsListening(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setTranscript('');
|
setTranscript('');
|
||||||
setPartialTranscript('');
|
setPartialTranscript('');
|
||||||
console.log(`${platformPrefix} [VoiceContext] → Session initialized, status=listening`);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop voice session
|
* Stop voice session
|
||||||
*/
|
*/
|
||||||
const stopSession = useCallback(() => {
|
const stopSession = useCallback(() => {
|
||||||
const platformPrefix = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
console.log('[VoiceContext] Stopping voice session');
|
||||||
console.log(`${platformPrefix} [VoiceContext] 🛑 STOPPING voice session`);
|
|
||||||
|
|
||||||
// Mark session as stopped FIRST to prevent any pending callbacks
|
// Mark session as stopped FIRST to prevent any pending callbacks
|
||||||
sessionStoppedRef.current = true;
|
sessionStoppedRef.current = true;
|
||||||
console.log(`${platformPrefix} [VoiceContext] → sessionStopped flag set to TRUE`);
|
|
||||||
|
|
||||||
// Abort any in-flight API requests
|
// Abort any in-flight API requests
|
||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
console.log(`${platformPrefix} [VoiceContext] → Aborting in-flight API request`);
|
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop TTS
|
// Stop TTS
|
||||||
console.log(`${platformPrefix} [VoiceContext] → Stopping TTS`);
|
|
||||||
Speech.stop();
|
Speech.stop();
|
||||||
|
|
||||||
// Reset all state
|
// Reset all state
|
||||||
console.log(`${platformPrefix} [VoiceContext] → Resetting all state to idle`);
|
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setIsListening(false);
|
setIsListening(false);
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
console.log(`${platformPrefix} [VoiceContext] ✅ Voice session stopped`);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user