@alia.onl/sdk 13.1.0 → 15.2.1
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 +190 -0
- package/README.md +83 -20
- package/package.json +8 -4
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +57 -107
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +28 -25
- package/src/components/AliaChatSheet.tsx +30 -8
- package/src/components/AliaComposer.tsx +382 -0
- package/src/components/ComposerDropZone.tsx +235 -0
- package/src/components/voice/VoiceControls.tsx +28 -4
- package/src/components/voice/VoiceSession.tsx +23 -7
- package/src/hooks/useAliaActivity.ts +31 -0
- package/src/hooks/useAliaChat.ts +14 -3
- package/src/hooks/useAmbientWave.ts +3 -3
- package/src/hooks/useAttachmentIntake.ts +401 -0
- package/src/hooks/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useFocusWhenPresented.ts +41 -0
- package/src/hooks/usePendingQuestions.ts +19 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -671
- package/src/index.ts +19 -3
- package/src/lib/activity-socket.ts +102 -0
- package/src/lib/activity-store.ts +53 -0
- package/src/lib/api-url.ts +2 -0
- package/src/lib/attachment-intake.ts +154 -0
- package/src/lib/attachment-tiles.ts +74 -0
- package/src/lib/audio-level.ts +2 -2
- package/src/lib/dictation-recorder-types.ts +37 -0
- package/src/lib/dictation-recorder.native.ts +90 -0
- package/src/lib/dictation-recorder.ts +131 -0
- package/src/lib/dictation.ts +208 -0
- package/src/lib/labels.tsx +50 -1
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -65
- package/src/lib/speech-synthesis.ts +104 -9
- package/src/lib/turn-state.ts +15 -0
- package/src/lib/voice-audio-engine-types.ts +59 -0
- package/src/lib/voice-audio-engine.native.ts +232 -0
- package/src/lib/voice-audio-engine.ts +350 -0
- package/src/lib/voice-call.ts +539 -0
- package/src/lib/voice-levels.ts +7 -3
- package/src/lib/voice-protocol.ts +309 -0
- package/src/tools.ts +1 -0
- package/src/types.ts +63 -4
- package/src/voice.ts +31 -10
- package/tsconfig.json +2 -0
- package/src/hooks/useSpeechToText.ts +0 -268
- package/src/lib/speech-recognition-types.ts +0 -108
- package/src/lib/speech-recognition.native.ts +0 -237
- package/src/lib/speech-recognition.ts +0 -308
- package/src/lib/voice-text.ts +0 -198
- package/src/lib/voice-turn.ts +0 -89
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's microphone: record a clip, then transcribe it with a Kaana
|
|
3
|
+
* transcription model through Alia (`POST /v1/audio/transcriptions`, ADR 0017
|
|
4
|
+
* §7). Free on every plan, five minutes per clip at most.
|
|
5
|
+
*
|
|
6
|
+
* idle → requesting-permission → recording → transcribing → idle (text)
|
|
7
|
+
* ↘ error { code }
|
|
8
|
+
*
|
|
9
|
+
* - `recording` is entered only once the recorder reports it started; the
|
|
10
|
+
* level meter is the real recorder's.
|
|
11
|
+
* - The text arrives after the person stops — `onText` — and never partially:
|
|
12
|
+
* a failed transcription inserts nothing and keeps the clip for ONE `retry()`.
|
|
13
|
+
* - At five minutes the recording stops by itself and is transcribed; the
|
|
14
|
+
* remaining time is exposed for the last 30 seconds.
|
|
15
|
+
*
|
|
16
|
+
* Nothing is recognized on the device.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
20
|
+
import { create } from 'zustand';
|
|
21
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
22
|
+
import {
|
|
23
|
+
dictationFailureCode,
|
|
24
|
+
MAX_DICTATION_MS,
|
|
25
|
+
requestTranscription,
|
|
26
|
+
type DictationClip,
|
|
27
|
+
} from '../lib/dictation';
|
|
28
|
+
import { DictationRecorderError, useDictationRecorder } from '../lib/dictation-recorder';
|
|
29
|
+
import { defaultSpeechLanguage } from '../lib/speech-language';
|
|
30
|
+
import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from '../lib/speech-messages';
|
|
31
|
+
|
|
32
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
33
|
+
|
|
34
|
+
/** The remaining time is shown from here on. */
|
|
35
|
+
const COUNTDOWN_FROM_MS = 30_000;
|
|
36
|
+
const CLOCK_MS = 250;
|
|
37
|
+
|
|
38
|
+
export type DictationState =
|
|
39
|
+
| 'idle'
|
|
40
|
+
| 'requesting-permission'
|
|
41
|
+
| 'recording'
|
|
42
|
+
| 'transcribing'
|
|
43
|
+
| 'error';
|
|
44
|
+
|
|
45
|
+
export interface UseDictationOptions {
|
|
46
|
+
apiUrl?: string;
|
|
47
|
+
/** Bearer; defaults to the surrounding Oxy session's. */
|
|
48
|
+
accessToken?: string;
|
|
49
|
+
/** Language spoken (BCP-47); defaults to the platform's. The Alia app passes its own. */
|
|
50
|
+
lang?: string;
|
|
51
|
+
/** The transcribed text, once — after a stop, the five-minute stop, or a retry. */
|
|
52
|
+
onText?: (text: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DictationStoreState {
|
|
56
|
+
isRecording: boolean;
|
|
57
|
+
metering: number;
|
|
58
|
+
setRecording: (isRecording: boolean) => void;
|
|
59
|
+
setMetering: (metering: number) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the composer is recording, and its level: what the ambient field and
|
|
64
|
+
* the app's turn actions read without holding the hook.
|
|
65
|
+
*/
|
|
66
|
+
export const useDictationStore = create<DictationStoreState>((set) => ({
|
|
67
|
+
isRecording: false,
|
|
68
|
+
metering: 0,
|
|
69
|
+
setRecording: (isRecording) => set({ isRecording }),
|
|
70
|
+
setMetering: (metering) => set({ metering }),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
export function useDictation(options: UseDictationOptions = {}) {
|
|
74
|
+
const { oxyServices } = useOxy();
|
|
75
|
+
const recorder = useDictationRecorder();
|
|
76
|
+
const [state, setStateValue] = useState<DictationState>('idle');
|
|
77
|
+
const [errorCode, setErrorCode] = useState<VoiceErrorCode | null>(null);
|
|
78
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
79
|
+
const [canRetry, setCanRetry] = useState(false);
|
|
80
|
+
|
|
81
|
+
const stateRef = useRef<DictationState>('idle');
|
|
82
|
+
const mountedRef = useRef(true);
|
|
83
|
+
const startedAtRef = useRef(0);
|
|
84
|
+
const clockRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
85
|
+
const capRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
86
|
+
const failedClipRef = useRef<DictationClip | null>(null);
|
|
87
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
88
|
+
const lastMeteringRef = useRef(0);
|
|
89
|
+
const optionsRef = useRef(options);
|
|
90
|
+
optionsRef.current = options;
|
|
91
|
+
|
|
92
|
+
const setState = useCallback((next: DictationState) => {
|
|
93
|
+
stateRef.current = next;
|
|
94
|
+
if (mountedRef.current) setStateValue(next);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const report = useCallback(
|
|
98
|
+
(code: VoiceErrorCode | null) => {
|
|
99
|
+
if (mountedRef.current) setErrorCode(code);
|
|
100
|
+
if (code !== null) setState('error');
|
|
101
|
+
},
|
|
102
|
+
[setState],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const store = useDictationStore.getState();
|
|
107
|
+
store.setRecording(state === 'recording');
|
|
108
|
+
if (state !== 'recording') {
|
|
109
|
+
lastMeteringRef.current = 0;
|
|
110
|
+
store.setMetering(0);
|
|
111
|
+
}
|
|
112
|
+
}, [state]);
|
|
113
|
+
|
|
114
|
+
const clearClock = useCallback((): void => {
|
|
115
|
+
if (clockRef.current !== null) clearInterval(clockRef.current);
|
|
116
|
+
if (capRef.current !== null) clearTimeout(capRef.current);
|
|
117
|
+
clockRef.current = null;
|
|
118
|
+
capRef.current = null;
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const transcribe = useCallback(
|
|
122
|
+
async (clip: DictationClip): Promise<string | null> => {
|
|
123
|
+
const token = optionsRef.current.accessToken || oxyServices.session.accessToken || '';
|
|
124
|
+
if (token === '') {
|
|
125
|
+
failedClipRef.current = null;
|
|
126
|
+
if (mountedRef.current) setCanRetry(false);
|
|
127
|
+
report('not-authenticated');
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
setState('transcribing');
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
abortRef.current = controller;
|
|
133
|
+
try {
|
|
134
|
+
const result = await requestTranscription({
|
|
135
|
+
apiUrl: optionsRef.current.apiUrl || API_URL,
|
|
136
|
+
token,
|
|
137
|
+
clip,
|
|
138
|
+
language: optionsRef.current.lang ?? defaultSpeechLanguage(),
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
if (controller.signal.aborted) return null;
|
|
142
|
+
failedClipRef.current = null;
|
|
143
|
+
if (mountedRef.current) setCanRetry(false);
|
|
144
|
+
setState('idle');
|
|
145
|
+
const text = result.text.trim();
|
|
146
|
+
if (text === '') return null;
|
|
147
|
+
optionsRef.current.onText?.(text);
|
|
148
|
+
return text;
|
|
149
|
+
} catch (caught: unknown) {
|
|
150
|
+
if (controller.signal.aborted) return null;
|
|
151
|
+
const code = dictationFailureCode(caught);
|
|
152
|
+
// Kept for one retry, unless retrying cannot change the answer.
|
|
153
|
+
const keep = code !== 'too-long' && code !== 'not-authenticated';
|
|
154
|
+
failedClipRef.current = keep ? clip : null;
|
|
155
|
+
if (mountedRef.current) setCanRetry(keep);
|
|
156
|
+
report(code);
|
|
157
|
+
return null;
|
|
158
|
+
} finally {
|
|
159
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[oxyServices, report, setState],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const stop = useCallback(async (): Promise<string | null> => {
|
|
166
|
+
if (stateRef.current !== 'recording') return null;
|
|
167
|
+
clearClock();
|
|
168
|
+
setState('transcribing');
|
|
169
|
+
let clip: DictationClip | null;
|
|
170
|
+
try {
|
|
171
|
+
clip = await recorder.stop();
|
|
172
|
+
} catch {
|
|
173
|
+
report('mic-unavailable');
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (clip === null) {
|
|
177
|
+
setState('idle');
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (clip.durationMs > MAX_DICTATION_MS + 1_000) {
|
|
181
|
+
report('too-long');
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return transcribe(clip);
|
|
185
|
+
}, [clearClock, recorder, report, setState, transcribe]);
|
|
186
|
+
|
|
187
|
+
const stopRef = useRef(stop);
|
|
188
|
+
stopRef.current = stop;
|
|
189
|
+
|
|
190
|
+
const start = useCallback(async (): Promise<void> => {
|
|
191
|
+
if (stateRef.current !== 'idle' && stateRef.current !== 'error') return;
|
|
192
|
+
if (mountedRef.current) {
|
|
193
|
+
setErrorCode(null);
|
|
194
|
+
setCanRetry(false);
|
|
195
|
+
setElapsedMs(0);
|
|
196
|
+
}
|
|
197
|
+
failedClipRef.current = null;
|
|
198
|
+
setState('requesting-permission');
|
|
199
|
+
try {
|
|
200
|
+
await recorder.start((level) => {
|
|
201
|
+
if (stateRef.current !== 'recording') return;
|
|
202
|
+
// Quiet should not thrash the store's subscribers.
|
|
203
|
+
if (Math.abs(level - lastMeteringRef.current) < 0.02) return;
|
|
204
|
+
lastMeteringRef.current = level;
|
|
205
|
+
useDictationStore.getState().setMetering(level);
|
|
206
|
+
});
|
|
207
|
+
} catch (caught: unknown) {
|
|
208
|
+
if ((stateRef.current as DictationState) !== 'requesting-permission') return;
|
|
209
|
+
report(caught instanceof DictationRecorderError ? caught.code : 'mic-unavailable');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if ((stateRef.current as DictationState) !== 'requesting-permission') {
|
|
213
|
+
// Cancelled while the permission was asked.
|
|
214
|
+
recorder.cancel();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
startedAtRef.current = Date.now();
|
|
218
|
+
setState('recording');
|
|
219
|
+
clockRef.current = setInterval(() => {
|
|
220
|
+
if (mountedRef.current) setElapsedMs(Date.now() - startedAtRef.current);
|
|
221
|
+
}, CLOCK_MS);
|
|
222
|
+
capRef.current = setTimeout(() => {
|
|
223
|
+
void stopRef.current();
|
|
224
|
+
}, MAX_DICTATION_MS);
|
|
225
|
+
}, [recorder, report, setState]);
|
|
226
|
+
|
|
227
|
+
const retry = useCallback(async (): Promise<string | null> => {
|
|
228
|
+
const clip = failedClipRef.current;
|
|
229
|
+
if (clip === null || stateRef.current === 'recording' || stateRef.current === 'transcribing')
|
|
230
|
+
return null;
|
|
231
|
+
// One retry: whatever it answers, the clip is not kept for another.
|
|
232
|
+
failedClipRef.current = null;
|
|
233
|
+
if (mountedRef.current) {
|
|
234
|
+
setCanRetry(false);
|
|
235
|
+
setErrorCode(null);
|
|
236
|
+
}
|
|
237
|
+
const text = await transcribe(clip);
|
|
238
|
+
failedClipRef.current = null;
|
|
239
|
+
if (mountedRef.current) setCanRetry(false);
|
|
240
|
+
return text;
|
|
241
|
+
}, [transcribe]);
|
|
242
|
+
|
|
243
|
+
const cancel = useCallback(() => {
|
|
244
|
+
clearClock();
|
|
245
|
+
abortRef.current?.abort();
|
|
246
|
+
abortRef.current = null;
|
|
247
|
+
if (stateRef.current === 'recording' || stateRef.current === 'requesting-permission')
|
|
248
|
+
recorder.cancel();
|
|
249
|
+
failedClipRef.current = null;
|
|
250
|
+
if (mountedRef.current) {
|
|
251
|
+
setErrorCode(null);
|
|
252
|
+
setCanRetry(false);
|
|
253
|
+
}
|
|
254
|
+
setState('idle');
|
|
255
|
+
}, [clearClock, recorder, setState]);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
mountedRef.current = true;
|
|
259
|
+
return () => {
|
|
260
|
+
mountedRef.current = false;
|
|
261
|
+
clearClock();
|
|
262
|
+
abortRef.current?.abort();
|
|
263
|
+
const wasRecording =
|
|
264
|
+
stateRef.current === 'recording' || stateRef.current === 'requesting-permission';
|
|
265
|
+
stateRef.current = 'idle';
|
|
266
|
+
if (wasRecording) recorder.cancel();
|
|
267
|
+
// The store outlives this hook: a composer unmounted mid-dictation (the
|
|
268
|
+
// call bar replacing it) must not leave the field reading "recording".
|
|
269
|
+
useDictationStore.getState().setRecording(false);
|
|
270
|
+
useDictationStore.getState().setMetering(0);
|
|
271
|
+
};
|
|
272
|
+
}, [clearClock, recorder]);
|
|
273
|
+
|
|
274
|
+
const remaining = MAX_DICTATION_MS - elapsedMs;
|
|
275
|
+
return {
|
|
276
|
+
state,
|
|
277
|
+
/** What went wrong, in English. */
|
|
278
|
+
error: errorCode === null ? null : VOICE_ERROR_MESSAGES[errorCode],
|
|
279
|
+
/** The same, as a code an app can translate. */
|
|
280
|
+
errorCode,
|
|
281
|
+
/** Since the recording started. */
|
|
282
|
+
elapsedMs,
|
|
283
|
+
/** Left before the five-minute stop, in the last 30 seconds; else `null`. */
|
|
284
|
+
remainingMs:
|
|
285
|
+
state === 'recording' && remaining <= COUNTDOWN_FROM_MS ? Math.max(0, remaining) : null,
|
|
286
|
+
/** A failed transcription kept its clip for one more try. */
|
|
287
|
+
canRetry,
|
|
288
|
+
start,
|
|
289
|
+
stop,
|
|
290
|
+
retry,
|
|
291
|
+
cancel,
|
|
292
|
+
isRecording: state === 'recording',
|
|
293
|
+
isTranscribing: state === 'transcribing',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
runOnJS,
|
|
4
|
+
type SharedValue,
|
|
5
|
+
useAnimatedReaction,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
} from 'react-native-reanimated';
|
|
8
|
+
|
|
9
|
+
/** Bloom's sheet reports `1` once fully presented; a hair under it absorbs float rounding. */
|
|
10
|
+
const PRESENTED = 0.999;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calls `focus` once each time a sheet finishes presenting.
|
|
14
|
+
*
|
|
15
|
+
* `progress` is the `animatedProgress` of Bloom's `BottomSheet`: `0` closed, `1` fully
|
|
16
|
+
* presented, every value between while it animates or is dragged. The sheet is armed while it
|
|
17
|
+
* is closed and fires on reaching `1`, so dragging it part-way and letting go does not take the
|
|
18
|
+
* focus again; only a new `present()` does. Reduced motion settles immediately, which is the
|
|
19
|
+
* same crossing. Omitted, nothing happens — the surface is not a sheet.
|
|
20
|
+
*/
|
|
21
|
+
export function useFocusWhenPresented(
|
|
22
|
+
progress: SharedValue<number> | undefined,
|
|
23
|
+
focus: () => void,
|
|
24
|
+
): void {
|
|
25
|
+
const latest = useRef(focus);
|
|
26
|
+
latest.current = focus;
|
|
27
|
+
const run = useCallback(() => latest.current(), []);
|
|
28
|
+
const armed = useSharedValue(true);
|
|
29
|
+
useAnimatedReaction(
|
|
30
|
+
() => progress?.value ?? 0,
|
|
31
|
+
(value) => {
|
|
32
|
+
if (value <= 0) {
|
|
33
|
+
armed.value = true;
|
|
34
|
+
} else if (value >= PRESENTED && armed.value) {
|
|
35
|
+
armed.value = false;
|
|
36
|
+
runOnJS(run)();
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
[progress, run],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { type AskedQuestion, pendingQuestions } from '../lib/questions';
|
|
3
|
+
import type { ChatMessage } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What Alia asked in her last turn and the person has not answered: asked above the composer,
|
|
7
|
+
* not in the thread. Any later message, typed or sent from the card, is the answer; `dismiss`
|
|
8
|
+
* puts the card away until she asks again.
|
|
9
|
+
*/
|
|
10
|
+
export function usePendingQuestions(
|
|
11
|
+
messages: readonly Pick<ChatMessage, 'id' | 'role' | 'toolInvocations'>[],
|
|
12
|
+
isStreaming: boolean,
|
|
13
|
+
): { questions: AskedQuestion[]; dismiss: () => void } {
|
|
14
|
+
const asked = useMemo(() => pendingQuestions(messages, isStreaming), [messages, isStreaming]);
|
|
15
|
+
const lastMessageId = messages.at(-1)?.id ?? null;
|
|
16
|
+
const [dismissedOf, setDismissedOf] = useState<string | null>(null);
|
|
17
|
+
const dismiss = useCallback(() => setDismissedOf(lastMessageId), [lastMessageId]);
|
|
18
|
+
return { questions: dismissedOf === lastMessageId ? [] : asked, dismiss };
|
|
19
|
+
}
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -136,18 +136,9 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
136
136
|
reset();
|
|
137
137
|
}, [reset, releasePlayer]);
|
|
138
138
|
|
|
139
|
-
/**
|
|
140
|
-
* Play a stored clip.
|
|
141
|
-
*
|
|
142
|
-
* `onUnplayable` is how a caller says "I can make this again". Stored audio
|
|
143
|
-
* is not permanent — `tts/` objects age out — and a link to something that is
|
|
144
|
-
* gone fails in the player, not at the request, so the only place to notice
|
|
145
|
-
* is here. Without a fallback the failure is reported; with one, the caller
|
|
146
|
-
* gets to rebuild rather than the person getting an error about a button they
|
|
147
|
-
* pressed twice.
|
|
148
|
-
*/
|
|
139
|
+
/** Play the clip the route answered with. A clip that will not play is a failure, reported. */
|
|
149
140
|
const playFromUrl = useCallback(
|
|
150
|
-
(audioUrl: string,
|
|
141
|
+
(audioUrl: string, signal: AbortSignal) => {
|
|
151
142
|
releasePlayer();
|
|
152
143
|
|
|
153
144
|
(async () => {
|
|
@@ -199,10 +190,6 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
199
190
|
if (signal.aborted || playerRef.current !== player) return;
|
|
200
191
|
if (status.error) {
|
|
201
192
|
releasePlayer();
|
|
202
|
-
if (onUnplayable) {
|
|
203
|
-
onUnplayable();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
193
|
setError(status.error);
|
|
207
194
|
return;
|
|
208
195
|
}
|
|
@@ -216,10 +203,6 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
216
203
|
setPlaybackState('playing');
|
|
217
204
|
} catch {
|
|
218
205
|
if (signal.aborted) return;
|
|
219
|
-
if (onUnplayable) {
|
|
220
|
-
onUnplayable();
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
206
|
setError('Audio playback not available');
|
|
224
207
|
}
|
|
225
208
|
})();
|
|
@@ -228,7 +211,7 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
228
211
|
);
|
|
229
212
|
|
|
230
213
|
const readAloud = useCallback(
|
|
231
|
-
async (messageId: string, text: string, conversationId?: string
|
|
214
|
+
async (messageId: string, text: string, conversationId?: string) => {
|
|
232
215
|
// Read volatile playback state at call time (not via closure) so this
|
|
233
216
|
// callback's identity survives every playback transition. It is passed to
|
|
234
217
|
// memoized message rows — an unstable identity re-renders all of them.
|
|
@@ -260,51 +243,29 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
260
243
|
setPlaybackState('loading');
|
|
261
244
|
|
|
262
245
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
246
|
+
* Always ask the route: it answers a clip this person already had made —
|
|
247
|
+
* same text, voice, speed and model — from storage, without
|
|
248
|
+
* synthesizing it again, and makes it only when there is none. So a
|
|
249
|
+
* second press, here or after a reload, costs nothing and plays at once.
|
|
250
|
+
* `conversationId` (with the message id) files the clip under that
|
|
251
|
+
* conversation, so deleting the conversation deletes it.
|
|
269
252
|
*/
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
throw new Error('Not authenticated');
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const audioUrl = await requestSpeechClip({
|
|
277
|
-
apiUrl,
|
|
278
|
-
token,
|
|
279
|
-
model: voiceModel,
|
|
280
|
-
input: text,
|
|
281
|
-
voice: getTTSVoice(),
|
|
282
|
-
speed: getTTSSpeed(),
|
|
283
|
-
conversationId,
|
|
284
|
-
messageId,
|
|
285
|
-
signal: controller.signal,
|
|
286
|
-
});
|
|
287
|
-
playFromUrl(audioUrl, messageId, controller.signal);
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* A stored clip plays straight away, and is remade if it has gone.
|
|
292
|
-
*
|
|
293
|
-
* The row keeps its key long after a `tts/` object ages out of storage,
|
|
294
|
-
* so the link is signed, valid, and points at nothing — a failure only
|
|
295
|
-
* the player ever sees. Remaking it is cheaper than keeping every clip
|
|
296
|
-
* forever, and the person notices nothing.
|
|
297
|
-
*/
|
|
298
|
-
if (audioUrl) {
|
|
299
|
-
playFromUrl(audioUrl, messageId, controller.signal, () => {
|
|
300
|
-
void synthesize().catch((e: unknown) => {
|
|
301
|
-
if (!controller.signal.aborted) setError(errorMessage(e, 'Failed to read aloud'));
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
return;
|
|
253
|
+
const token = getToken();
|
|
254
|
+
if (!token) {
|
|
255
|
+
throw new Error('Not authenticated');
|
|
305
256
|
}
|
|
306
|
-
|
|
307
|
-
|
|
257
|
+
const audioUrl = await requestSpeechClip({
|
|
258
|
+
apiUrl,
|
|
259
|
+
token,
|
|
260
|
+
model: voiceModel,
|
|
261
|
+
input: text,
|
|
262
|
+
voice: getTTSVoice(),
|
|
263
|
+
speed: getTTSSpeed(),
|
|
264
|
+
conversationId,
|
|
265
|
+
messageId,
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
});
|
|
268
|
+
playFromUrl(audioUrl, controller.signal);
|
|
308
269
|
} catch (e: unknown) {
|
|
309
270
|
if (controller.signal.aborted) return;
|
|
310
271
|
console.error('[TTS] Error:', e);
|