@alia.onl/sdk 7.2.9 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -6
- package/README.md +242 -0
- package/package.json +8 -12
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- package/src/components/ResearchProgressCard.tsx +3 -0
- package/src/components/ui/prompt-input/mic-button.tsx +2 -3
- package/src/components/ui/prompt-input/prompt-input.tsx +1 -4
- package/src/components/voice/VoiceControls.tsx +48 -63
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +24 -10
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +196 -147
- package/src/hooks/useTTS.ts +33 -38
- package/src/hooks/useVoiceRoom.ts +581 -416
- package/src/index.ts +6 -19
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +14 -2
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +76 -0
- package/src/lib/speech-recognition-types.ts +108 -0
- package/src/lib/speech-recognition.native.ts +226 -0
- package/src/lib/speech-recognition.ts +288 -0
- package/src/lib/speech-synthesis.ts +122 -0
- package/src/lib/voice-levels.ts +60 -0
- package/src/lib/voice-text.ts +192 -0
- package/src/lib/voice-turn.ts +86 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +11 -3
- package/src/lib/config.ts +0 -36
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
-
import { errorMessage } from '../lib/utils';
|
|
3
|
-
import {
|
|
4
|
-
useAudioRecorder,
|
|
5
|
-
useAudioRecorderState,
|
|
6
|
-
RecordingPresets,
|
|
7
|
-
getRecordingPermissionsAsync,
|
|
8
|
-
setAudioModeAsync,
|
|
9
|
-
} from 'expo-audio';
|
|
10
|
-
import { useOxy } from '@oxy.so/services';
|
|
11
2
|
import { create } from 'zustand';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
import {
|
|
4
|
+
chooseSpeechRecognizer,
|
|
5
|
+
isSpeechRecognitionAvailable,
|
|
6
|
+
requestSpeechRecognitionPermission,
|
|
7
|
+
startSpeechRecognition,
|
|
8
|
+
} from '../lib/speech-recognition';
|
|
9
|
+
import type { SpeechRecognitionSession } from '../lib/speech-recognition-types';
|
|
10
|
+
import type { SpeechRecognizerChoice } from '../lib/speech-recognition-types';
|
|
11
|
+
import { speechFailureCode, VOICE_ERROR_MESSAGES, type VoiceErrorCode } from '../lib/speech-messages';
|
|
12
|
+
import { defaultSpeechLanguage } from '../lib/speech-language';
|
|
15
13
|
|
|
16
14
|
// ============== OPTIONS ==============
|
|
17
15
|
|
|
18
16
|
export interface UseSTTOptions {
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Language to recognize, as a BCP-47 tag (`es-ES`). Defaults to the
|
|
19
|
+
* platform's own language. The Alia app passes its UI locale.
|
|
20
|
+
*/
|
|
21
|
+
lang?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// ============== INLINE STT STORE ==============
|
|
@@ -40,169 +41,217 @@ export const useSTTStore = create<STTStoreState>((set) => ({
|
|
|
40
41
|
|
|
41
42
|
type STTState = 'idle' | 'recording' | 'transcribing';
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/** How long `stopAndTranscribe` waits for the engine's final words. */
|
|
45
|
+
const FINAL_RESULT_TIMEOUT_MS = 2_000;
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* How many times one dictation may quietly re-open the recognizer.
|
|
49
|
+
*
|
|
50
|
+
* Browsers end a continuous session on their own — Chrome after a stretch of
|
|
51
|
+
* silence or about a minute of audio — and the person is still looking at a
|
|
52
|
+
* dictation bar that says it is listening. Re-opening keeps what was already
|
|
53
|
+
* heard and carries on. The cap is what stops an engine that ends the instant
|
|
54
|
+
* it starts from spinning.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_SILENT_RESTARTS = 20;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dictation, recognized on the device.
|
|
60
|
+
*
|
|
61
|
+
* The public shape is the one the recorder-and-upload version had —
|
|
62
|
+
* `startRecording`, `stopAndTranscribe`, `cancel`, the three states and
|
|
63
|
+
* `useSTTStore`'s metering — so the composer and the dictation bar did not
|
|
64
|
+
* change. What changed underneath: words arrive while the person speaks
|
|
65
|
+
* (`speech-recognition.ts` on web, `.native.ts` on iOS/Android), so
|
|
66
|
+
* `transcribing` is now the moment between "stop" and the engine's final
|
|
67
|
+
* result rather than an upload, and there is no transcription endpoint.
|
|
68
|
+
*/
|
|
45
69
|
export function useSpeechToText(options: UseSTTOptions = {}) {
|
|
46
|
-
const
|
|
70
|
+
const lang = options.lang ?? defaultSpeechLanguage();
|
|
47
71
|
|
|
48
72
|
const [state, setState] = useState<STTState>('idle');
|
|
49
|
-
const [
|
|
50
|
-
const
|
|
51
|
-
const { oxyServices } = useOxy();
|
|
52
|
-
|
|
53
|
-
const audioRecorder = useAudioRecorder(METERING_PRESET);
|
|
54
|
-
const recorderState = useAudioRecorderState(audioRecorder, 100);
|
|
73
|
+
const [errorCode, setErrorCode] = useState<VoiceErrorCode | null>(null);
|
|
74
|
+
const [isSupported] = useState(isSpeechRecognitionAvailable);
|
|
55
75
|
|
|
56
|
-
const
|
|
76
|
+
const stateRef = useRef<STTState>('idle');
|
|
77
|
+
const sessionRef = useRef<SpeechRecognitionSession | null>(null);
|
|
78
|
+
/** Text from sessions that already ended during this dictation. */
|
|
79
|
+
const earlierTextRef = useRef('');
|
|
80
|
+
/** Text from the live session. */
|
|
81
|
+
const currentTextRef = useRef('');
|
|
82
|
+
const restartsRef = useRef(0);
|
|
83
|
+
/** The recognizer and tag this dictation uses, chosen once when it starts. */
|
|
84
|
+
const recognizerRef = useRef<{ lang: string; service?: string; silent: boolean }>({ lang, silent: false });
|
|
85
|
+
const endWaitersRef = useRef<Array<() => void>>([]);
|
|
86
|
+
const lastMeteringRef = useRef(0);
|
|
87
|
+
const mountedRef = useRef(true);
|
|
57
88
|
|
|
58
|
-
|
|
89
|
+
/** What went wrong, as a code; `null` clears it. */
|
|
90
|
+
const report = useCallback((code: VoiceErrorCode | null) => {
|
|
91
|
+
if (mountedRef.current) setErrorCode(code);
|
|
92
|
+
}, []);
|
|
59
93
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}, [
|
|
64
|
-
|
|
65
|
-
// Push metering to store while recording (with epsilon guard to avoid thrashing subscribers)
|
|
66
|
-
const lastMeteringRef = useRef(0);
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (state === 'recording') {
|
|
69
|
-
let target: number;
|
|
70
|
-
if (recorderState.metering != null) {
|
|
71
|
-
// The same curve read-aloud playback uses, so one background answers
|
|
72
|
-
// both the same way — see `lib/audio-level.ts`.
|
|
73
|
-
target = levelFromDbfs(recorderState.metering);
|
|
74
|
-
} else {
|
|
75
|
-
// Metering unavailable — simulate gentle activity
|
|
76
|
-
target = 0.12 + Math.random() * 0.18;
|
|
77
|
-
}
|
|
78
|
-
if (Math.abs(target - lastMeteringRef.current) >= 0.02) {
|
|
79
|
-
lastMeteringRef.current = target;
|
|
80
|
-
sttStore.getState().setMetering(target);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}, [state, recorderState.metering, recorderState.durationMillis]);
|
|
94
|
+
const setPhase = useCallback((next: STTState) => {
|
|
95
|
+
stateRef.current = next;
|
|
96
|
+
if (mountedRef.current) setState(next);
|
|
97
|
+
}, []);
|
|
84
98
|
|
|
85
99
|
// Sync recording state to store + reset metering on stop
|
|
86
100
|
useEffect(() => {
|
|
87
|
-
|
|
101
|
+
useSTTStore.getState().setRecording(state === 'recording');
|
|
88
102
|
if (state !== 'recording') {
|
|
89
103
|
lastMeteringRef.current = 0;
|
|
90
|
-
|
|
104
|
+
useSTTStore.getState().setMetering(0);
|
|
91
105
|
}
|
|
92
106
|
}, [state]);
|
|
93
107
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
setError(null);
|
|
97
|
-
|
|
98
|
-
// getRecordingPermissionsAsync consults the Permissions API first (and
|
|
99
|
-
// only falls back to a real getUserMedia prompt when undetermined), so an
|
|
100
|
-
// already-granted permission never gets misreported as denied when the
|
|
101
|
-
// device itself is missing/busy — that failure surfaces from
|
|
102
|
-
// prepareToRecordAsync below with an accurate message.
|
|
103
|
-
const permStatus = await getRecordingPermissionsAsync();
|
|
104
|
-
if (!permStatus.granted) {
|
|
105
|
-
setError('Microphone permission required');
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
+
const fullText = (): string =>
|
|
109
|
+
`${earlierTextRef.current} ${currentTextRef.current}`.replace(/\s+/g, ' ').trim();
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
const openSession = useCallback(() => {
|
|
112
|
+
currentTextRef.current = '';
|
|
113
|
+
let failed = false;
|
|
114
|
+
const recognizer = recognizerRef.current;
|
|
115
|
+
sessionRef.current = startSpeechRecognition(
|
|
116
|
+
{ lang: recognizer.lang, service: recognizer.service },
|
|
117
|
+
{
|
|
118
|
+
onResult: ({ transcript }) => {
|
|
119
|
+
currentTextRef.current = transcript;
|
|
120
|
+
},
|
|
121
|
+
onLevel: (level) => {
|
|
122
|
+
if (stateRef.current !== 'recording') return;
|
|
123
|
+
// Epsilon guard: quiet should not thrash the store's subscribers.
|
|
124
|
+
if (Math.abs(level - lastMeteringRef.current) < 0.02) return;
|
|
125
|
+
lastMeteringRef.current = level;
|
|
126
|
+
useSTTStore.getState().setMetering(level);
|
|
127
|
+
},
|
|
128
|
+
onError: (failure) => {
|
|
129
|
+
const code = speechFailureCode(failure);
|
|
130
|
+
if (code === null) return;
|
|
131
|
+
failed = true;
|
|
132
|
+
// Every recognizer on the device named no language, and the
|
|
133
|
+
// default refused this one: it is the device that cannot recognize
|
|
134
|
+
// speech, not the language that is wrong.
|
|
135
|
+
report(code === 'speech-language' && recognizer.silent ? 'speech-unsupported' : code);
|
|
136
|
+
},
|
|
137
|
+
onEnd: () => {
|
|
138
|
+
sessionRef.current = null;
|
|
139
|
+
earlierTextRef.current = fullText();
|
|
140
|
+
currentTextRef.current = '';
|
|
141
|
+
const waiters = endWaitersRef.current;
|
|
142
|
+
endWaitersRef.current = [];
|
|
143
|
+
for (const resolve of waiters) resolve();
|
|
144
|
+
if (waiters.length > 0) return;
|
|
110
145
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
146
|
+
// The engine ended on its own while the person is still dictating.
|
|
147
|
+
if (stateRef.current !== 'recording') return;
|
|
148
|
+
if (failed || restartsRef.current >= MAX_SILENT_RESTARTS) {
|
|
149
|
+
setPhase('idle');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
restartsRef.current += 1;
|
|
153
|
+
openSession();
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}, [report, setPhase]);
|
|
115
158
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} catch (e: unknown) {
|
|
120
|
-
const name = e instanceof Error ? e.name : '';
|
|
121
|
-
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
|
122
|
-
setError('No microphone found — check your input devices');
|
|
123
|
-
} else if (name === 'NotAllowedError') {
|
|
124
|
-
setError('Microphone permission required');
|
|
125
|
-
} else if (name === 'NotReadableError') {
|
|
126
|
-
setError('Microphone is in use by another application');
|
|
127
|
-
} else {
|
|
128
|
-
setError('Failed to start recording');
|
|
129
|
-
}
|
|
130
|
-
setState('idle');
|
|
131
|
-
isRecordingRef.current = false;
|
|
132
|
-
}
|
|
133
|
-
}, [audioRecorder]);
|
|
159
|
+
const startRecording = useCallback(async () => {
|
|
160
|
+
if (stateRef.current !== 'idle') return;
|
|
161
|
+
report(null);
|
|
134
162
|
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
if (!isSpeechRecognitionAvailable()) {
|
|
164
|
+
report('speech-unsupported');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
// Claim the state before the first await so a double tap cannot open two.
|
|
169
|
+
setPhase('recording');
|
|
170
|
+
const refusal = await requestSpeechRecognitionPermission();
|
|
171
|
+
if ((stateRef.current as STTState) !== 'recording') return; // cancelled while asking
|
|
172
|
+
if (refusal !== null) {
|
|
173
|
+
report(speechFailureCode(refusal));
|
|
174
|
+
setPhase('idle');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
143
177
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
const choice: SpeechRecognizerChoice = await chooseSpeechRecognizer(lang);
|
|
179
|
+
if ((stateRef.current as STTState) !== 'recording') return; // cancelled while choosing
|
|
180
|
+
if ('failure' in choice) {
|
|
181
|
+
report(speechFailureCode(choice.failure));
|
|
182
|
+
setPhase('idle');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
recognizerRef.current = choice;
|
|
148
186
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const base64 = await new Promise<string>((resolve, reject) => {
|
|
155
|
-
const reader = new FileReader();
|
|
156
|
-
reader.onload = () => {
|
|
157
|
-
const result = reader.result as string;
|
|
158
|
-
resolve(result.split(',')[1]);
|
|
159
|
-
};
|
|
160
|
-
reader.onerror = reject;
|
|
161
|
-
reader.readAsDataURL(blob);
|
|
162
|
-
});
|
|
187
|
+
earlierTextRef.current = '';
|
|
188
|
+
currentTextRef.current = '';
|
|
189
|
+
restartsRef.current = 0;
|
|
190
|
+
openSession();
|
|
191
|
+
}, [lang, openSession, report, setPhase]);
|
|
163
192
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
format: detectedFormat,
|
|
175
|
-
}),
|
|
193
|
+
const waitForEnd = useCallback((): Promise<void> => {
|
|
194
|
+
if (sessionRef.current === null) return Promise.resolve();
|
|
195
|
+
return new Promise<void>((resolve) => {
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
sessionRef.current?.abort();
|
|
198
|
+
resolve();
|
|
199
|
+
}, FINAL_RESULT_TIMEOUT_MS);
|
|
200
|
+
endWaitersRef.current.push(() => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve();
|
|
176
203
|
});
|
|
204
|
+
});
|
|
205
|
+
}, []);
|
|
177
206
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
}, [audioRecorder, getToken, apiUrl]);
|
|
207
|
+
const stopAndTranscribe = useCallback(async (): Promise<string | null> => {
|
|
208
|
+
if (stateRef.current !== 'recording') return null;
|
|
209
|
+
setPhase('transcribing');
|
|
210
|
+
const ended = waitForEnd();
|
|
211
|
+
sessionRef.current?.stop();
|
|
212
|
+
await ended;
|
|
213
|
+
const text = fullText();
|
|
214
|
+
earlierTextRef.current = '';
|
|
215
|
+
currentTextRef.current = '';
|
|
216
|
+
setPhase('idle');
|
|
217
|
+
return text === '' ? null : text;
|
|
218
|
+
}, [setPhase, waitForEnd]);
|
|
193
219
|
|
|
194
220
|
const cancel = useCallback(() => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}, [
|
|
221
|
+
const session = sessionRef.current;
|
|
222
|
+
setPhase('idle');
|
|
223
|
+
earlierTextRef.current = '';
|
|
224
|
+
currentTextRef.current = '';
|
|
225
|
+
session?.abort();
|
|
226
|
+
report(null);
|
|
227
|
+
}, [report, setPhase]);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
mountedRef.current = true;
|
|
231
|
+
return () => {
|
|
232
|
+
mountedRef.current = false;
|
|
233
|
+
const wasListening = stateRef.current !== 'idle';
|
|
234
|
+
stateRef.current = 'idle';
|
|
235
|
+
sessionRef.current?.abort();
|
|
236
|
+
// The effect that mirrors the phase into the store will not run again,
|
|
237
|
+
// and the store outlives this hook: a composer unmounted mid-dictation —
|
|
238
|
+
// the call controls replacing it — would otherwise leave the ambient
|
|
239
|
+
// field and the dictation bar reading "recording" for the rest of the call.
|
|
240
|
+
if (wasListening) {
|
|
241
|
+
useSTTStore.getState().setRecording(false);
|
|
242
|
+
useSTTStore.getState().setMetering(0);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}, []);
|
|
202
246
|
|
|
203
247
|
return {
|
|
204
248
|
state,
|
|
205
|
-
|
|
249
|
+
/** What went wrong, in English. */
|
|
250
|
+
error: errorCode === null ? null : VOICE_ERROR_MESSAGES[errorCode],
|
|
251
|
+
/** The same, as a code an app can translate. */
|
|
252
|
+
errorCode,
|
|
253
|
+
/** False where the platform cannot recognize speech at all (Firefox). */
|
|
254
|
+
isSupported,
|
|
206
255
|
startRecording,
|
|
207
256
|
stopAndTranscribe,
|
|
208
257
|
cancel,
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { useOxy } from '@oxy.so/services';
|
|
|
5
5
|
import { errorMessage } from '../lib/utils';
|
|
6
6
|
import { create } from 'zustand';
|
|
7
7
|
import { createAudioLevelMeter } from '../lib/audio-level';
|
|
8
|
-
import {
|
|
8
|
+
import { requestSpeechClip } from '../lib/speech-synthesis';
|
|
9
9
|
|
|
10
10
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
11
11
|
|
|
@@ -17,12 +17,8 @@ export interface UseTTSOptions {
|
|
|
17
17
|
voice?: 'male' | 'female';
|
|
18
18
|
tone?: 'brief' | 'chill' | 'default';
|
|
19
19
|
/**
|
|
20
|
-
* Speech model.
|
|
21
|
-
*
|
|
22
|
-
* Not checked against `GET /catalogue`: that surface describes what a chat
|
|
23
|
-
* picker may offer and its resolver filters to `chat_visible` entries, so
|
|
24
|
-
* passing a voice identifier through it would substitute a chat model that
|
|
25
|
-
* cannot speak. See `lib/config.ts`.
|
|
20
|
+
* Speech model. Omitted, the request carries no `model` and the server's
|
|
21
|
+
* own speech model answers.
|
|
26
22
|
*/
|
|
27
23
|
model?: string;
|
|
28
24
|
}
|
|
@@ -89,7 +85,7 @@ useTTSStore.subscribe((state, previous) => {
|
|
|
89
85
|
export function useTTS(options: UseTTSOptions = {}) {
|
|
90
86
|
const apiUrl = options.apiUrl || API_URL;
|
|
91
87
|
const voicePref = options.voice ?? 'female';
|
|
92
|
-
const voiceModel = options.model
|
|
88
|
+
const voiceModel = options.model;
|
|
93
89
|
const tonePref = options.tone ?? 'default';
|
|
94
90
|
|
|
95
91
|
const { oxyServices } = useOxy();
|
|
@@ -105,16 +101,17 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
105
101
|
const reset = useTTSStore((s) => s.reset);
|
|
106
102
|
|
|
107
103
|
const playerRef = useRef<AudioPlayer | null>(null);
|
|
104
|
+
const requestRef = useRef<AbortController | null>(null);
|
|
108
105
|
|
|
109
106
|
// ============== AUTH ==============
|
|
110
107
|
|
|
111
108
|
const getToken = useCallback((): string | null => {
|
|
112
109
|
if (options.accessToken) return options.accessToken;
|
|
113
|
-
return oxyServices.
|
|
110
|
+
return oxyServices.session.accessToken;
|
|
114
111
|
}, [options.accessToken, oxyServices]);
|
|
115
112
|
|
|
116
113
|
const getTTSVoice = useCallback(() => {
|
|
117
|
-
return voicePref === 'male' ? '
|
|
114
|
+
return voicePref === 'male' ? 'male' : 'female';
|
|
118
115
|
}, [voicePref]);
|
|
119
116
|
|
|
120
117
|
const getTTSSpeed = useCallback(() => {
|
|
@@ -133,6 +130,8 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
133
130
|
}, []);
|
|
134
131
|
|
|
135
132
|
const stop = useCallback(() => {
|
|
133
|
+
requestRef.current?.abort();
|
|
134
|
+
requestRef.current = null;
|
|
136
135
|
releasePlayer();
|
|
137
136
|
reset();
|
|
138
137
|
}, [reset, releasePlayer]);
|
|
@@ -150,6 +149,7 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
150
149
|
const playFromUrl = useCallback((
|
|
151
150
|
audioUrl: string,
|
|
152
151
|
_messageId: string,
|
|
152
|
+
signal: AbortSignal,
|
|
153
153
|
onUnplayable?: () => void,
|
|
154
154
|
) => {
|
|
155
155
|
releasePlayer();
|
|
@@ -157,6 +157,7 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
157
157
|
(async () => {
|
|
158
158
|
try {
|
|
159
159
|
const { createAudioPlayer } = await import('expo-audio');
|
|
160
|
+
if (signal.aborted) return;
|
|
160
161
|
/**
|
|
161
162
|
* `crossOrigin` is web-only and ignored elsewhere, and it is what makes
|
|
162
163
|
* the waveform readable there: a browser will not let an `AnalyserNode`
|
|
@@ -199,6 +200,7 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
199
200
|
});
|
|
200
201
|
|
|
201
202
|
player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
|
|
203
|
+
if (signal.aborted || playerRef.current !== player) return;
|
|
202
204
|
if (status.error) {
|
|
203
205
|
releasePlayer();
|
|
204
206
|
if (onUnplayable) {
|
|
@@ -217,6 +219,7 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
217
219
|
player.play();
|
|
218
220
|
setPlaybackState('playing');
|
|
219
221
|
} catch {
|
|
222
|
+
if (signal.aborted) return;
|
|
220
223
|
if (onUnplayable) {
|
|
221
224
|
onUnplayable();
|
|
222
225
|
return;
|
|
@@ -256,6 +259,8 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
256
259
|
stop();
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
requestRef.current = controller;
|
|
259
264
|
try {
|
|
260
265
|
setActiveMessage(messageId);
|
|
261
266
|
setPlaybackState('loading');
|
|
@@ -274,32 +279,18 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
274
279
|
throw new Error('Not authenticated');
|
|
275
280
|
}
|
|
276
281
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
speed: getTTSSpeed(),
|
|
288
|
-
conversationId,
|
|
289
|
-
messageId,
|
|
290
|
-
}),
|
|
282
|
+
const audioUrl = await requestSpeechClip({
|
|
283
|
+
apiUrl,
|
|
284
|
+
token,
|
|
285
|
+
model: voiceModel,
|
|
286
|
+
input: text,
|
|
287
|
+
voice: getTTSVoice(),
|
|
288
|
+
speed: getTTSSpeed(),
|
|
289
|
+
conversationId,
|
|
290
|
+
messageId,
|
|
291
|
+
signal: controller.signal,
|
|
291
292
|
});
|
|
292
|
-
|
|
293
|
-
if (!response.ok) {
|
|
294
|
-
if (response.status === 504) {
|
|
295
|
-
throw new Error('Request timed out — please try again');
|
|
296
|
-
}
|
|
297
|
-
const errData = await response.json().catch(() => ({}));
|
|
298
|
-
throw new Error(errData.error?.message || errData.error || 'TTS failed');
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const data = await response.json();
|
|
302
|
-
playFromUrl(data.audioUrl, messageId);
|
|
293
|
+
playFromUrl(audioUrl, messageId, controller.signal);
|
|
303
294
|
};
|
|
304
295
|
|
|
305
296
|
/**
|
|
@@ -311,22 +302,26 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
311
302
|
* forever, and the person notices nothing.
|
|
312
303
|
*/
|
|
313
304
|
if (audioUrl) {
|
|
314
|
-
playFromUrl(audioUrl, messageId, () => {
|
|
315
|
-
void synthesize().catch((e: unknown) =>
|
|
305
|
+
playFromUrl(audioUrl, messageId, controller.signal, () => {
|
|
306
|
+
void synthesize().catch((e: unknown) => {
|
|
307
|
+
if (!controller.signal.aborted) setError(errorMessage(e, 'Failed to read aloud'));
|
|
308
|
+
});
|
|
316
309
|
});
|
|
317
310
|
return;
|
|
318
311
|
}
|
|
319
312
|
|
|
320
313
|
await synthesize();
|
|
321
314
|
} catch (e: unknown) {
|
|
315
|
+
if (controller.signal.aborted) return;
|
|
322
316
|
console.error('[TTS] Error:', e);
|
|
323
317
|
setError(errorMessage(e, 'Failed to read aloud'));
|
|
324
318
|
}
|
|
325
|
-
}, [getToken, apiUrl, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
|
|
319
|
+
}, [getToken, apiUrl, voiceModel, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
|
|
326
320
|
|
|
327
321
|
// Cleanup on unmount
|
|
328
322
|
useEffect(() => {
|
|
329
323
|
return () => {
|
|
324
|
+
requestRef.current?.abort();
|
|
330
325
|
releasePlayer();
|
|
331
326
|
};
|
|
332
327
|
}, []);
|