@alia.onl/sdk 13.2.0 → 15.3.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 +139 -0
- package/README.md +53 -21
- package/package.json +9 -5
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +4 -7
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +1 -1
- package/src/components/AliaChatSheet.tsx +15 -4
- package/src/components/AliaComposer.tsx +62 -16
- 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/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -736
- package/src/index.ts +9 -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/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 +3 -0
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -97
- package/src/lib/speech-synthesis.ts +5 -1
- 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/types.ts +49 -4
- package/src/voice.ts +31 -12
- 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
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
-
import { create } from 'zustand';
|
|
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 {
|
|
12
|
-
speechFailureCode,
|
|
13
|
-
VOICE_ERROR_MESSAGES,
|
|
14
|
-
type VoiceErrorCode,
|
|
15
|
-
} from '../lib/speech-messages';
|
|
16
|
-
import { defaultSpeechLanguage } from '../lib/speech-language';
|
|
17
|
-
|
|
18
|
-
// ============== OPTIONS ==============
|
|
19
|
-
|
|
20
|
-
export interface UseSTTOptions {
|
|
21
|
-
/**
|
|
22
|
-
* Language to recognize, as a BCP-47 tag (`es-ES`). Defaults to the
|
|
23
|
-
* platform's own language. The Alia app passes its UI locale.
|
|
24
|
-
*/
|
|
25
|
-
lang?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ============== INLINE STT STORE ==============
|
|
29
|
-
|
|
30
|
-
interface STTStoreState {
|
|
31
|
-
isRecording: boolean;
|
|
32
|
-
metering: number;
|
|
33
|
-
setRecording: (v: boolean) => void;
|
|
34
|
-
setMetering: (v: number) => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const useSTTStore = create<STTStoreState>((set) => ({
|
|
38
|
-
isRecording: false,
|
|
39
|
-
metering: 0,
|
|
40
|
-
setRecording: (isRecording) => set({ isRecording }),
|
|
41
|
-
setMetering: (metering) => set({ metering }),
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
// ============== HOOK ==============
|
|
45
|
-
|
|
46
|
-
type STTState = 'idle' | 'recording' | 'transcribing';
|
|
47
|
-
|
|
48
|
-
/** How long `stopAndTranscribe` waits for the engine's final words. */
|
|
49
|
-
const FINAL_RESULT_TIMEOUT_MS = 2_000;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* How many times one dictation may quietly re-open the recognizer.
|
|
53
|
-
*
|
|
54
|
-
* Browsers end a continuous session on their own — Chrome after a stretch of
|
|
55
|
-
* silence or about a minute of audio — and the person is still looking at a
|
|
56
|
-
* dictation bar that says it is listening. Re-opening keeps what was already
|
|
57
|
-
* heard and carries on. The cap is what stops an engine that ends the instant
|
|
58
|
-
* it starts from spinning.
|
|
59
|
-
*/
|
|
60
|
-
const MAX_SILENT_RESTARTS = 20;
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Dictation, recognized on the device.
|
|
64
|
-
*
|
|
65
|
-
* The public shape is the one the recorder-and-upload version had —
|
|
66
|
-
* `startRecording`, `stopAndTranscribe`, `cancel`, the three states and
|
|
67
|
-
* `useSTTStore`'s metering — so the composer and the dictation bar did not
|
|
68
|
-
* change. What changed underneath: words arrive while the person speaks
|
|
69
|
-
* (`speech-recognition.ts` on web, `.native.ts` on iOS/Android), so
|
|
70
|
-
* `transcribing` is now the moment between "stop" and the engine's final
|
|
71
|
-
* result rather than an upload, and there is no transcription endpoint.
|
|
72
|
-
*/
|
|
73
|
-
export function useSpeechToText(options: UseSTTOptions = {}) {
|
|
74
|
-
const lang = options.lang ?? defaultSpeechLanguage();
|
|
75
|
-
|
|
76
|
-
const [state, setState] = useState<STTState>('idle');
|
|
77
|
-
const [errorCode, setErrorCode] = useState<VoiceErrorCode | null>(null);
|
|
78
|
-
const [isSupported] = useState(isSpeechRecognitionAvailable);
|
|
79
|
-
|
|
80
|
-
const stateRef = useRef<STTState>('idle');
|
|
81
|
-
const sessionRef = useRef<SpeechRecognitionSession | null>(null);
|
|
82
|
-
/** Text from sessions that already ended during this dictation. */
|
|
83
|
-
const earlierTextRef = useRef('');
|
|
84
|
-
/** Text from the live session. */
|
|
85
|
-
const currentTextRef = useRef('');
|
|
86
|
-
const restartsRef = useRef(0);
|
|
87
|
-
/** The recognizer and tag this dictation uses, chosen once when it starts. */
|
|
88
|
-
const recognizerRef = useRef<{ lang: string; service?: string; silent: boolean }>({
|
|
89
|
-
lang,
|
|
90
|
-
silent: false,
|
|
91
|
-
});
|
|
92
|
-
const endWaitersRef = useRef<Array<() => void>>([]);
|
|
93
|
-
const lastMeteringRef = useRef(0);
|
|
94
|
-
const mountedRef = useRef(true);
|
|
95
|
-
|
|
96
|
-
/** What went wrong, as a code; `null` clears it. */
|
|
97
|
-
const report = useCallback((code: VoiceErrorCode | null) => {
|
|
98
|
-
if (mountedRef.current) setErrorCode(code);
|
|
99
|
-
}, []);
|
|
100
|
-
|
|
101
|
-
const setPhase = useCallback((next: STTState) => {
|
|
102
|
-
stateRef.current = next;
|
|
103
|
-
if (mountedRef.current) setState(next);
|
|
104
|
-
}, []);
|
|
105
|
-
|
|
106
|
-
// Sync recording state to store + reset metering on stop
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
useSTTStore.getState().setRecording(state === 'recording');
|
|
109
|
-
if (state !== 'recording') {
|
|
110
|
-
lastMeteringRef.current = 0;
|
|
111
|
-
useSTTStore.getState().setMetering(0);
|
|
112
|
-
}
|
|
113
|
-
}, [state]);
|
|
114
|
-
|
|
115
|
-
const fullText = (): string =>
|
|
116
|
-
`${earlierTextRef.current} ${currentTextRef.current}`.replace(/\s+/g, ' ').trim();
|
|
117
|
-
|
|
118
|
-
const openSession = useCallback(() => {
|
|
119
|
-
currentTextRef.current = '';
|
|
120
|
-
let failed = false;
|
|
121
|
-
const recognizer = recognizerRef.current;
|
|
122
|
-
sessionRef.current = startSpeechRecognition(
|
|
123
|
-
{ lang: recognizer.lang, service: recognizer.service },
|
|
124
|
-
{
|
|
125
|
-
onResult: ({ transcript }) => {
|
|
126
|
-
currentTextRef.current = transcript;
|
|
127
|
-
},
|
|
128
|
-
onLevel: (level) => {
|
|
129
|
-
if (stateRef.current !== 'recording') return;
|
|
130
|
-
// Epsilon guard: quiet should not thrash the store's subscribers.
|
|
131
|
-
if (Math.abs(level - lastMeteringRef.current) < 0.02) return;
|
|
132
|
-
lastMeteringRef.current = level;
|
|
133
|
-
useSTTStore.getState().setMetering(level);
|
|
134
|
-
},
|
|
135
|
-
onError: (failure) => {
|
|
136
|
-
const code = speechFailureCode(failure);
|
|
137
|
-
if (code === null) return;
|
|
138
|
-
failed = true;
|
|
139
|
-
// Every recognizer on the device named no language, and the
|
|
140
|
-
// default refused this one: it is the device that cannot recognize
|
|
141
|
-
// speech, not the language that is wrong.
|
|
142
|
-
report(code === 'speech-language' && recognizer.silent ? 'speech-unsupported' : code);
|
|
143
|
-
},
|
|
144
|
-
onEnd: () => {
|
|
145
|
-
sessionRef.current = null;
|
|
146
|
-
earlierTextRef.current = fullText();
|
|
147
|
-
currentTextRef.current = '';
|
|
148
|
-
const waiters = endWaitersRef.current;
|
|
149
|
-
endWaitersRef.current = [];
|
|
150
|
-
for (const resolve of waiters) resolve();
|
|
151
|
-
if (waiters.length > 0) return;
|
|
152
|
-
|
|
153
|
-
// The engine ended on its own while the person is still dictating.
|
|
154
|
-
if (stateRef.current !== 'recording') return;
|
|
155
|
-
if (failed || restartsRef.current >= MAX_SILENT_RESTARTS) {
|
|
156
|
-
setPhase('idle');
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
restartsRef.current += 1;
|
|
160
|
-
openSession();
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
);
|
|
164
|
-
}, [report, setPhase]);
|
|
165
|
-
|
|
166
|
-
const startRecording = useCallback(async () => {
|
|
167
|
-
if (stateRef.current !== 'idle') return;
|
|
168
|
-
report(null);
|
|
169
|
-
|
|
170
|
-
if (!isSpeechRecognitionAvailable()) {
|
|
171
|
-
report('speech-unsupported');
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Claim the state before the first await so a double tap cannot open two.
|
|
176
|
-
setPhase('recording');
|
|
177
|
-
const refusal = await requestSpeechRecognitionPermission();
|
|
178
|
-
if ((stateRef.current as STTState) !== 'recording') return; // cancelled while asking
|
|
179
|
-
if (refusal !== null) {
|
|
180
|
-
report(speechFailureCode(refusal));
|
|
181
|
-
setPhase('idle');
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const choice: SpeechRecognizerChoice = await chooseSpeechRecognizer(lang);
|
|
186
|
-
if ((stateRef.current as STTState) !== 'recording') return; // cancelled while choosing
|
|
187
|
-
if ('failure' in choice) {
|
|
188
|
-
report(speechFailureCode(choice.failure));
|
|
189
|
-
setPhase('idle');
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
recognizerRef.current = choice;
|
|
193
|
-
|
|
194
|
-
earlierTextRef.current = '';
|
|
195
|
-
currentTextRef.current = '';
|
|
196
|
-
restartsRef.current = 0;
|
|
197
|
-
openSession();
|
|
198
|
-
}, [lang, openSession, report, setPhase]);
|
|
199
|
-
|
|
200
|
-
const waitForEnd = useCallback((): Promise<void> => {
|
|
201
|
-
if (sessionRef.current === null) return Promise.resolve();
|
|
202
|
-
return new Promise<void>((resolve) => {
|
|
203
|
-
const timer = setTimeout(() => {
|
|
204
|
-
sessionRef.current?.abort();
|
|
205
|
-
resolve();
|
|
206
|
-
}, FINAL_RESULT_TIMEOUT_MS);
|
|
207
|
-
endWaitersRef.current.push(() => {
|
|
208
|
-
clearTimeout(timer);
|
|
209
|
-
resolve();
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
}, []);
|
|
213
|
-
|
|
214
|
-
const stopAndTranscribe = useCallback(async (): Promise<string | null> => {
|
|
215
|
-
if (stateRef.current !== 'recording') return null;
|
|
216
|
-
setPhase('transcribing');
|
|
217
|
-
const ended = waitForEnd();
|
|
218
|
-
sessionRef.current?.stop();
|
|
219
|
-
await ended;
|
|
220
|
-
const text = fullText();
|
|
221
|
-
earlierTextRef.current = '';
|
|
222
|
-
currentTextRef.current = '';
|
|
223
|
-
setPhase('idle');
|
|
224
|
-
return text === '' ? null : text;
|
|
225
|
-
}, [setPhase, waitForEnd]);
|
|
226
|
-
|
|
227
|
-
const cancel = useCallback(() => {
|
|
228
|
-
const session = sessionRef.current;
|
|
229
|
-
setPhase('idle');
|
|
230
|
-
earlierTextRef.current = '';
|
|
231
|
-
currentTextRef.current = '';
|
|
232
|
-
session?.abort();
|
|
233
|
-
report(null);
|
|
234
|
-
}, [report, setPhase]);
|
|
235
|
-
|
|
236
|
-
useEffect(() => {
|
|
237
|
-
mountedRef.current = true;
|
|
238
|
-
return () => {
|
|
239
|
-
mountedRef.current = false;
|
|
240
|
-
const wasListening = stateRef.current !== 'idle';
|
|
241
|
-
stateRef.current = 'idle';
|
|
242
|
-
sessionRef.current?.abort();
|
|
243
|
-
// The effect that mirrors the phase into the store will not run again,
|
|
244
|
-
// and the store outlives this hook: a composer unmounted mid-dictation —
|
|
245
|
-
// the call controls replacing it — would otherwise leave the ambient
|
|
246
|
-
// field and the dictation bar reading "recording" for the rest of the call.
|
|
247
|
-
if (wasListening) {
|
|
248
|
-
useSTTStore.getState().setRecording(false);
|
|
249
|
-
useSTTStore.getState().setMetering(0);
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
}, []);
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
state,
|
|
256
|
-
/** What went wrong, in English. */
|
|
257
|
-
error: errorCode === null ? null : VOICE_ERROR_MESSAGES[errorCode],
|
|
258
|
-
/** The same, as a code an app can translate. */
|
|
259
|
-
errorCode,
|
|
260
|
-
/** False where the platform cannot recognize speech at all (Firefox). */
|
|
261
|
-
isSupported,
|
|
262
|
-
startRecording,
|
|
263
|
-
stopAndTranscribe,
|
|
264
|
-
cancel,
|
|
265
|
-
isRecording: state === 'recording',
|
|
266
|
-
isTranscribing: state === 'transcribing',
|
|
267
|
-
};
|
|
268
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The one shape on-device speech recognition has in this package.
|
|
3
|
-
*
|
|
4
|
-
* Two engines sit behind it and neither leaks through: the browser's Web
|
|
5
|
-
* Speech API (`speech-recognition.ts`, picked by web bundlers and by tests) and
|
|
6
|
-
* `expo-speech-recognition` on iOS and Android (`speech-recognition.native.ts`,
|
|
7
|
-
* picked by Metro's platform resolution). Dictation and the voice loop are
|
|
8
|
-
* written against this file only, so the audio never leaves the device through
|
|
9
|
-
* Alia — there is no transcription endpoint, and nothing here posts audio
|
|
10
|
-
* anywhere. Whether the ENGINE uses a network service is the platform's
|
|
11
|
-
* business and is stated in `docs/voice.mdx`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** Why a recognition session failed, in the vocabulary both engines share. */
|
|
15
|
-
export type SpeechRecognitionFailureCode =
|
|
16
|
-
/** Microphone or speech-recognition permission was refused. */
|
|
17
|
-
| 'not-allowed'
|
|
18
|
-
/** No microphone, or it could not be opened. */
|
|
19
|
-
| 'audio-capture'
|
|
20
|
-
/** The engine needed the network and could not reach it. */
|
|
21
|
-
| 'network'
|
|
22
|
-
/** The engine does not recognize this language. */
|
|
23
|
-
| 'language-not-supported'
|
|
24
|
-
/** Nothing was said before the engine gave up. Recoverable: start again. */
|
|
25
|
-
| 'no-speech'
|
|
26
|
-
/** The session was aborted on purpose. Never an error to report. */
|
|
27
|
-
| 'aborted'
|
|
28
|
-
/** Recognition is not available on this device or in this browser. */
|
|
29
|
-
| 'unsupported'
|
|
30
|
-
/** Anything else — the engine was busy, interrupted, or said nothing useful. */
|
|
31
|
-
| 'other';
|
|
32
|
-
|
|
33
|
-
export interface SpeechRecognitionFailure {
|
|
34
|
-
readonly code: SpeechRecognitionFailureCode;
|
|
35
|
-
/** The engine's own words, for logs. Never shown as-is. */
|
|
36
|
-
readonly detail?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface SpeechRecognitionHandlers {
|
|
40
|
-
/**
|
|
41
|
-
* The whole of what this session has heard so far, and whether the engine
|
|
42
|
-
* considers it settled. It is the FULL transcript each time, never a delta:
|
|
43
|
-
* both engines revise earlier words as more audio arrives.
|
|
44
|
-
*/
|
|
45
|
-
onResult(result: { transcript: string; isFinal: boolean }): void;
|
|
46
|
-
/** Input level, 0..1, on the same scale as dictation's metering. */
|
|
47
|
-
onLevel?(level: number): void;
|
|
48
|
-
onError(failure: SpeechRecognitionFailure): void;
|
|
49
|
-
/** The session is over, whichever way it ended. Always the last call. */
|
|
50
|
-
onEnd(): void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface SpeechRecognitionOptions {
|
|
54
|
-
/** BCP-47 tag, e.g. `es-ES`. */
|
|
55
|
-
readonly lang: string;
|
|
56
|
-
/**
|
|
57
|
-
* Android: the recognition service to use, by package, instead of the
|
|
58
|
-
* system's default. `chooseSpeechRecognizer` picks it.
|
|
59
|
-
*/
|
|
60
|
-
readonly service?: string;
|
|
61
|
-
/**
|
|
62
|
-
* Remove the device's own playback from what the microphone hears, where the
|
|
63
|
-
* platform can (iOS voice processing). Voice mode listens while it speaks,
|
|
64
|
-
* so without this it can hear itself.
|
|
65
|
-
*/
|
|
66
|
-
readonly echoCancellation?: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface SpeechRecognitionSession {
|
|
70
|
-
/** Stop listening and deliver a final result, then `onEnd`. */
|
|
71
|
-
stop(): void;
|
|
72
|
-
/** Stop listening and discard; `onEnd` still follows. */
|
|
73
|
-
abort(): void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Map a raw 0..1-ish reading to the field's level, clamped. */
|
|
77
|
-
export function clampLevel(level: number): number {
|
|
78
|
-
if (!Number.isFinite(level)) return 0;
|
|
79
|
-
return Math.min(1, Math.max(0, level));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* What to start a session with, decided before the first one opens.
|
|
84
|
-
*
|
|
85
|
-
* `silent` is set when the device has recognition services and every one of
|
|
86
|
-
* them, asked, named no language at all. A `language-not-supported` from such
|
|
87
|
-
* a device is not about the language: nothing on it recognizes speech (a Pixel
|
|
88
|
-
* without Google's speech services, whose remaining service has no language
|
|
89
|
-
* pack). Where nobody could be asked — the web, iOS — it is `false` and a
|
|
90
|
-
* language error stays one.
|
|
91
|
-
*/
|
|
92
|
-
export type SpeechRecognizerChoice =
|
|
93
|
-
| { readonly lang: string; readonly service?: string; readonly silent: boolean }
|
|
94
|
-
| { readonly failure: SpeechRecognitionFailure };
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The tag in `offered` that recognizes `lang`: the same tag (`en-US`, however
|
|
98
|
-
* the service spells it — `en_US`, `en-us`), else another region of the same
|
|
99
|
-
* language (`es-US` for `es-ES`), else `null`.
|
|
100
|
-
*/
|
|
101
|
-
export function matchSpeechLocale(lang: string, offered: readonly string[]): string | null {
|
|
102
|
-
const norm = (tag: string) => tag.replace(/_/g, '-').toLowerCase();
|
|
103
|
-
const wanted = norm(lang);
|
|
104
|
-
const exact = offered.find((tag) => norm(tag) === wanted);
|
|
105
|
-
if (exact !== undefined) return exact;
|
|
106
|
-
const language = wanted.split('-')[0];
|
|
107
|
-
return offered.find((tag) => norm(tag).split('-')[0] === language) ?? null;
|
|
108
|
-
}
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* On-device speech recognition for iOS and Android, through
|
|
3
|
-
* `expo-speech-recognition` (Apple's Speech framework, Android's
|
|
4
|
-
* `SpeechRecognizer`). Metro picks this file over `speech-recognition.ts` on
|
|
5
|
-
* native; the contract is `speech-recognition-types.ts`.
|
|
6
|
-
*
|
|
7
|
-
* `expo-speech-recognition` is a peer of the SDK and a native module: an app
|
|
8
|
-
* must depend on it (the root entry's dictation reaches this file) and list its
|
|
9
|
-
* config plugin (which writes `NSSpeechRecognitionUsageDescription`,
|
|
10
|
-
* `NSMicrophoneUsageDescription` and Android's `RECORD_AUDIO` plus the
|
|
11
|
-
* recognizer-package visibility query), then ship a new native build.
|
|
12
|
-
*
|
|
13
|
-
* The module is a process-wide singleton — one recognition at a time — so the
|
|
14
|
-
* hooks never start a session before the previous one has reported `onEnd`. A
|
|
15
|
-
* late `end` from an aborted session would otherwise land on its successor's
|
|
16
|
-
* listeners.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type {
|
|
20
|
-
ExpoSpeechRecognitionErrorCode,
|
|
21
|
-
ExpoSpeechRecognitionModule as NativeModule,
|
|
22
|
-
} from 'expo-speech-recognition';
|
|
23
|
-
import {
|
|
24
|
-
clampLevel,
|
|
25
|
-
matchSpeechLocale,
|
|
26
|
-
type SpeechRecognizerChoice,
|
|
27
|
-
type SpeechRecognitionFailure,
|
|
28
|
-
type SpeechRecognitionFailureCode,
|
|
29
|
-
type SpeechRecognitionHandlers,
|
|
30
|
-
type SpeechRecognitionOptions,
|
|
31
|
-
type SpeechRecognitionSession,
|
|
32
|
-
} from './speech-recognition-types';
|
|
33
|
-
|
|
34
|
-
type SpeechModule = typeof NativeModule;
|
|
35
|
-
|
|
36
|
-
let loaded: SpeechModule | null | undefined;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The native module, loaded on first use — never at import.
|
|
40
|
-
*
|
|
41
|
-
* `expo-speech-recognition` calls `requireNativeModule` when it is imported,
|
|
42
|
-
* which THROWS in a binary built before the module was added (an older dev
|
|
43
|
-
* client, Expo Go). This file is reachable from the SDK's root entry through
|
|
44
|
-
* dictation, so a top-level import would take the whole app down in such a
|
|
45
|
-
* build over a feature nobody had touched yet. Loaded here, the same build
|
|
46
|
-
* answers "not available" and everything else keeps working.
|
|
47
|
-
*/
|
|
48
|
-
function speechModule(): SpeechModule | null {
|
|
49
|
-
if (loaded !== undefined) return loaded;
|
|
50
|
-
try {
|
|
51
|
-
loaded = (require('expo-speech-recognition') as typeof import('expo-speech-recognition'))
|
|
52
|
-
.ExpoSpeechRecognitionModule;
|
|
53
|
-
} catch {
|
|
54
|
-
loaded = null;
|
|
55
|
-
}
|
|
56
|
-
return loaded;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function isSpeechRecognitionAvailable(): boolean {
|
|
60
|
-
try {
|
|
61
|
-
return speechModule()?.isRecognitionAvailable() ?? false;
|
|
62
|
-
} catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function requestSpeechRecognitionPermission(): Promise<SpeechRecognitionFailure | null> {
|
|
68
|
-
const module = speechModule();
|
|
69
|
-
if (module === null || !isSpeechRecognitionAvailable()) return { code: 'unsupported' };
|
|
70
|
-
try {
|
|
71
|
-
const permission = await module.requestPermissionsAsync();
|
|
72
|
-
return permission.granted ? null : { code: 'not-allowed', detail: permission.status };
|
|
73
|
-
} catch (error: unknown) {
|
|
74
|
-
return {
|
|
75
|
-
code: 'other',
|
|
76
|
-
detail: error instanceof Error ? error.message : 'permission request failed',
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function failureOf(error: ExpoSpeechRecognitionErrorCode): SpeechRecognitionFailureCode {
|
|
82
|
-
switch (error) {
|
|
83
|
-
case 'not-allowed':
|
|
84
|
-
case 'service-not-allowed':
|
|
85
|
-
return 'not-allowed';
|
|
86
|
-
case 'audio-capture':
|
|
87
|
-
return 'audio-capture';
|
|
88
|
-
case 'network':
|
|
89
|
-
return 'network';
|
|
90
|
-
case 'language-not-supported':
|
|
91
|
-
return 'language-not-supported';
|
|
92
|
-
case 'no-speech':
|
|
93
|
-
case 'speech-timeout':
|
|
94
|
-
return 'no-speech';
|
|
95
|
-
case 'aborted':
|
|
96
|
-
return 'aborted';
|
|
97
|
-
default:
|
|
98
|
-
return 'other';
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** `volumechange` is -2..10, and anything below 0 is inaudible. */
|
|
103
|
-
const VOLUME_CEILING = 10;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Which recognizer to use for `lang`, and the tag it knows the language by.
|
|
107
|
-
*
|
|
108
|
-
* Android leaves recognition to whichever service the device has, and
|
|
109
|
-
* `SpeechRecognizer` answers a language it cannot do only by failing:
|
|
110
|
-
* `language-not-supported`, which dictation showed as "does not support this
|
|
111
|
-
* language" whatever the real reason. On a Pixel the default service is often
|
|
112
|
-
* Android System Intelligence, which recognizes only the languages whose
|
|
113
|
-
* packs are downloaded, while another service on the same phone (Google's
|
|
114
|
-
* speech services) could have done it. So each service is asked what it
|
|
115
|
-
* recognizes — the default first — and the first that has the language wins,
|
|
116
|
-
* under the spelling it uses; a service offering only another region of the
|
|
117
|
-
* language (`es-US` for `es-ES`) is taken over failing.
|
|
118
|
-
*
|
|
119
|
-
* A service that reports nothing (Android 12 and older, a service that does
|
|
120
|
-
* not implement the query) proves nothing, so when none reports, the default
|
|
121
|
-
* is tried as before, with `silent` set if there were services to ask. iOS has
|
|
122
|
-
* none to list and goes straight to Apple's recognizer.
|
|
123
|
-
*/
|
|
124
|
-
export async function chooseSpeechRecognizer(lang: string): Promise<SpeechRecognizerChoice> {
|
|
125
|
-
const module = speechModule();
|
|
126
|
-
if (module === null) return { failure: { code: 'unsupported' } };
|
|
127
|
-
const attempt = <T>(read: () => T, fallback: T): T => {
|
|
128
|
-
try {
|
|
129
|
-
return read();
|
|
130
|
-
} catch {
|
|
131
|
-
return fallback;
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
const preferred = attempt(() => module.getDefaultRecognitionService().packageName, '');
|
|
135
|
-
const services = attempt(() => module.getSpeechRecognitionServices(), [] as string[]);
|
|
136
|
-
const order = [...new Set([preferred, ...services].filter((name) => name !== ''))];
|
|
137
|
-
|
|
138
|
-
let reported = false;
|
|
139
|
-
let asked = 0;
|
|
140
|
-
for (const service of order) {
|
|
141
|
-
let offered: string[];
|
|
142
|
-
try {
|
|
143
|
-
offered = (await module.getSupportedLocales({ androidRecognitionServicePackage: service }))
|
|
144
|
-
.locales;
|
|
145
|
-
} catch {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
asked += 1;
|
|
149
|
-
if (offered.length === 0) continue;
|
|
150
|
-
reported = true;
|
|
151
|
-
const tag = matchSpeechLocale(lang, offered);
|
|
152
|
-
if (tag !== null) return { lang: tag, service, silent: false };
|
|
153
|
-
}
|
|
154
|
-
if (reported) return { failure: { code: 'language-not-supported' } };
|
|
155
|
-
return { lang, silent: asked > 0 };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function startSpeechRecognition(
|
|
159
|
-
options: SpeechRecognitionOptions,
|
|
160
|
-
handlers: SpeechRecognitionHandlers,
|
|
161
|
-
): SpeechRecognitionSession {
|
|
162
|
-
const module = speechModule();
|
|
163
|
-
if (module === null) {
|
|
164
|
-
handlers.onError({ code: 'unsupported' });
|
|
165
|
-
handlers.onEnd();
|
|
166
|
-
return { stop: () => undefined, abort: () => undefined };
|
|
167
|
-
}
|
|
168
|
-
const ExpoSpeechRecognitionModule = module;
|
|
169
|
-
let ended = false;
|
|
170
|
-
/**
|
|
171
|
-
* Finals already delivered. Android's continuous mode starts a new segment
|
|
172
|
-
* after every final result, so the utterance is the settled segments plus
|
|
173
|
-
* whatever is still in flight; iOS only finalizes on stop and always sends
|
|
174
|
-
* the whole transcript, so this stays empty there.
|
|
175
|
-
*/
|
|
176
|
-
let settled = '';
|
|
177
|
-
|
|
178
|
-
const subscriptions = [
|
|
179
|
-
ExpoSpeechRecognitionModule.addListener('result', (event) => {
|
|
180
|
-
if (ended) return;
|
|
181
|
-
const current = event.results[0]?.transcript ?? '';
|
|
182
|
-
const transcript = `${settled} ${current}`.replace(/\s+/g, ' ').trim();
|
|
183
|
-
if (event.isFinal) settled = transcript;
|
|
184
|
-
handlers.onResult({ transcript, isFinal: event.isFinal });
|
|
185
|
-
}),
|
|
186
|
-
ExpoSpeechRecognitionModule.addListener('error', (event) => {
|
|
187
|
-
if (ended) return;
|
|
188
|
-
const code = failureOf(event.error);
|
|
189
|
-
if (code === 'aborted') return;
|
|
190
|
-
handlers.onError({ code, detail: event.message });
|
|
191
|
-
}),
|
|
192
|
-
ExpoSpeechRecognitionModule.addListener('end', () => finish()),
|
|
193
|
-
ExpoSpeechRecognitionModule.addListener('volumechange', (event) => {
|
|
194
|
-
if (ended) return;
|
|
195
|
-
handlers.onLevel?.(clampLevel(event.value / VOLUME_CEILING));
|
|
196
|
-
}),
|
|
197
|
-
];
|
|
198
|
-
|
|
199
|
-
function finish(): void {
|
|
200
|
-
if (ended) return;
|
|
201
|
-
ended = true;
|
|
202
|
-
for (const subscription of subscriptions) subscription.remove();
|
|
203
|
-
handlers.onLevel?.(0);
|
|
204
|
-
handlers.onEnd();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
ExpoSpeechRecognitionModule.start({
|
|
209
|
-
lang: options.lang,
|
|
210
|
-
...(options.service ? { androidRecognitionServicePackage: options.service } : {}),
|
|
211
|
-
interimResults: true,
|
|
212
|
-
continuous: true,
|
|
213
|
-
addsPunctuation: true,
|
|
214
|
-
maxAlternatives: 1,
|
|
215
|
-
volumeChangeEventOptions: { enabled: handlers.onLevel !== undefined, intervalMillis: 100 },
|
|
216
|
-
...(options.echoCancellation ? { iosVoiceProcessingEnabled: true } : {}),
|
|
217
|
-
});
|
|
218
|
-
} catch (error: unknown) {
|
|
219
|
-
if (!ended)
|
|
220
|
-
handlers.onError({
|
|
221
|
-
code: 'other',
|
|
222
|
-
detail: error instanceof Error ? error.message : 'start failed',
|
|
223
|
-
});
|
|
224
|
-
finish();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
stop() {
|
|
229
|
-
if (ended) return;
|
|
230
|
-
ExpoSpeechRecognitionModule.stop();
|
|
231
|
-
},
|
|
232
|
-
abort() {
|
|
233
|
-
if (ended) return;
|
|
234
|
-
ExpoSpeechRecognitionModule.abort();
|
|
235
|
-
},
|
|
236
|
-
};
|
|
237
|
-
}
|