@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.
@@ -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 { levelFromDbfs } from '../lib/audio-level';
13
-
14
- const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
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
- apiUrl?: string;
20
- accessToken?: string;
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
- const METERING_PRESET = { ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true };
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 apiUrl = options.apiUrl || API_URL;
70
+ const lang = options.lang ?? defaultSpeechLanguage();
47
71
 
48
72
  const [state, setState] = useState<STTState>('idle');
49
- const [error, setError] = useState<string | null>(null);
50
- const isRecordingRef = useRef(false);
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 sttStore = useSTTStore;
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
- // ============== AUTH ==============
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 getToken = useCallback((): string | null => {
61
- if (options.accessToken) return options.accessToken;
62
- return oxyServices.httpService.getAccessToken();
63
- }, [options.accessToken, oxyServices]);
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
- sttStore.getState().setRecording(state === 'recording');
101
+ useSTTStore.getState().setRecording(state === 'recording');
88
102
  if (state !== 'recording') {
89
103
  lastMeteringRef.current = 0;
90
- sttStore.getState().setMetering(0);
104
+ useSTTStore.getState().setMetering(0);
91
105
  }
92
106
  }, [state]);
93
107
 
94
- const startRecording = useCallback(async () => {
95
- try {
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
- setState('recording');
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
- await setAudioModeAsync({
112
- allowsRecording: true,
113
- playsInSilentMode: true,
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
- await audioRecorder.prepareToRecordAsync();
117
- audioRecorder.record();
118
- isRecordingRef.current = true;
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
- const stopAndTranscribe = useCallback(async (): Promise<string | null> => {
136
- if (!isRecordingRef.current) return null;
163
+ if (!isSpeechRecognitionAvailable()) {
164
+ report('speech-unsupported');
165
+ return;
166
+ }
137
167
 
138
- try {
139
- setState('transcribing');
140
- isRecordingRef.current = false;
141
- await audioRecorder.stop();
142
- const uri = audioRecorder.uri;
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
- if (!uri) {
145
- setState('idle');
146
- return null;
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
- // Read audio file as base64
150
- const response = await fetch(uri);
151
- const blob = await response.blob();
152
- // Detect actual MIME type (web records webm, native records m4a)
153
- const detectedFormat = blob.type?.split(';')[0] || 'audio/m4a';
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
- // Send to transcription API
165
- const token = getToken();
166
- const transcribeResponse = await fetch(`${apiUrl}/v1/voice/transcribe`, {
167
- method: 'POST',
168
- headers: {
169
- 'Content-Type': 'application/json',
170
- Authorization: `Bearer ${token}`,
171
- },
172
- body: JSON.stringify({
173
- audio: base64,
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
- if (!transcribeResponse.ok) {
179
- const errorData = await transcribeResponse.json().catch(() => ({}));
180
- throw new Error(errorData.error || 'Transcription failed');
181
- }
182
-
183
- const result = await transcribeResponse.json() as { text: string };
184
- setState('idle');
185
- return result.text || null;
186
- } catch (e: unknown) {
187
- console.error('[STT] Transcription error:', e);
188
- setError(errorMessage(e, 'Transcription failed'));
189
- setState('idle');
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
- if (isRecordingRef.current) {
196
- isRecordingRef.current = false;
197
- Promise.resolve(audioRecorder.stop()).catch(() => {});
198
- }
199
- setState('idle');
200
- setError(null);
201
- }, [audioRecorder]);
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
- error,
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,
@@ -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 { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
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. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
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 ?? PREFERRED_VOICE_MODEL_ID;
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.httpService.getAccessToken();
110
+ return oxyServices.session.accessToken;
114
111
  }, [options.accessToken, oxyServices]);
115
112
 
116
113
  const getTTSVoice = useCallback(() => {
117
- return voicePref === 'male' ? 'echo' : 'nova';
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 response = await fetch(`${apiUrl}/v1/audio/speech`, {
278
- method: 'POST',
279
- headers: {
280
- 'Content-Type': 'application/json',
281
- Authorization: `Bearer ${token}`,
282
- },
283
- body: JSON.stringify({
284
- model: voiceModel,
285
- input: text,
286
- voice: getTTSVoice(),
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) => setError(errorMessage(e, 'Failed to read aloud')));
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
  }, []);