@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.
@@ -0,0 +1,226 @@
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 { ExpoSpeechRecognitionErrorCode, ExpoSpeechRecognitionModule as NativeModule } from 'expo-speech-recognition';
20
+ import {
21
+ clampLevel,
22
+ matchSpeechLocale,
23
+ type SpeechRecognizerChoice,
24
+ type SpeechRecognitionFailure,
25
+ type SpeechRecognitionFailureCode,
26
+ type SpeechRecognitionHandlers,
27
+ type SpeechRecognitionOptions,
28
+ type SpeechRecognitionSession,
29
+ } from './speech-recognition-types';
30
+
31
+ type SpeechModule = typeof NativeModule;
32
+
33
+ let loaded: SpeechModule | null | undefined;
34
+
35
+ /**
36
+ * The native module, loaded on first use — never at import.
37
+ *
38
+ * `expo-speech-recognition` calls `requireNativeModule` when it is imported,
39
+ * which THROWS in a binary built before the module was added (an older dev
40
+ * client, Expo Go). This file is reachable from the SDK's root entry through
41
+ * dictation, so a top-level import would take the whole app down in such a
42
+ * build over a feature nobody had touched yet. Loaded here, the same build
43
+ * answers "not available" and everything else keeps working.
44
+ */
45
+ function speechModule(): SpeechModule | null {
46
+ if (loaded !== undefined) return loaded;
47
+ try {
48
+ loaded = (require('expo-speech-recognition') as typeof import('expo-speech-recognition'))
49
+ .ExpoSpeechRecognitionModule;
50
+ } catch {
51
+ loaded = null;
52
+ }
53
+ return loaded;
54
+ }
55
+
56
+ export function isSpeechRecognitionAvailable(): boolean {
57
+ try {
58
+ return speechModule()?.isRecognitionAvailable() ?? false;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export async function requestSpeechRecognitionPermission(): Promise<SpeechRecognitionFailure | null> {
65
+ const module = speechModule();
66
+ if (module === null || !isSpeechRecognitionAvailable()) return { code: 'unsupported' };
67
+ try {
68
+ const permission = await module.requestPermissionsAsync();
69
+ return permission.granted ? null : { code: 'not-allowed', detail: permission.status };
70
+ } catch (error: unknown) {
71
+ return { code: 'other', detail: error instanceof Error ? error.message : 'permission request failed' };
72
+ }
73
+ }
74
+
75
+ function failureOf(error: ExpoSpeechRecognitionErrorCode): SpeechRecognitionFailureCode {
76
+ switch (error) {
77
+ case 'not-allowed':
78
+ case 'service-not-allowed':
79
+ return 'not-allowed';
80
+ case 'audio-capture':
81
+ return 'audio-capture';
82
+ case 'network':
83
+ return 'network';
84
+ case 'language-not-supported':
85
+ return 'language-not-supported';
86
+ case 'no-speech':
87
+ case 'speech-timeout':
88
+ return 'no-speech';
89
+ case 'aborted':
90
+ return 'aborted';
91
+ default:
92
+ return 'other';
93
+ }
94
+ }
95
+
96
+ /** `volumechange` is -2..10, and anything below 0 is inaudible. */
97
+ const VOLUME_CEILING = 10;
98
+
99
+ /**
100
+ * Which recognizer to use for `lang`, and the tag it knows the language by.
101
+ *
102
+ * Android leaves recognition to whichever service the device has, and
103
+ * `SpeechRecognizer` answers a language it cannot do only by failing:
104
+ * `language-not-supported`, which dictation showed as "does not support this
105
+ * language" whatever the real reason. On a Pixel the default service is often
106
+ * Android System Intelligence, which recognizes only the languages whose
107
+ * packs are downloaded, while another service on the same phone (Google's
108
+ * speech services) could have done it. So each service is asked what it
109
+ * recognizes — the default first — and the first that has the language wins,
110
+ * under the spelling it uses; a service offering only another region of the
111
+ * language (`es-US` for `es-ES`) is taken over failing.
112
+ *
113
+ * A service that reports nothing (Android 12 and older, a service that does
114
+ * not implement the query) proves nothing, so when none reports, the default
115
+ * is tried as before, with `silent` set if there were services to ask. iOS has
116
+ * none to list and goes straight to Apple's recognizer.
117
+ */
118
+ export async function chooseSpeechRecognizer(lang: string): Promise<SpeechRecognizerChoice> {
119
+ const module = speechModule();
120
+ if (module === null) return { failure: { code: 'unsupported' } };
121
+ const attempt = <T>(read: () => T, fallback: T): T => {
122
+ try {
123
+ return read();
124
+ } catch {
125
+ return fallback;
126
+ }
127
+ };
128
+ const preferred = attempt(() => module.getDefaultRecognitionService().packageName, '');
129
+ const services = attempt(() => module.getSpeechRecognitionServices(), [] as string[]);
130
+ const order = [...new Set([preferred, ...services].filter((name) => name !== ''))];
131
+
132
+ let reported = false;
133
+ let asked = 0;
134
+ for (const service of order) {
135
+ let offered: string[];
136
+ try {
137
+ offered = (await module.getSupportedLocales({ androidRecognitionServicePackage: service })).locales;
138
+ } catch {
139
+ continue;
140
+ }
141
+ asked += 1;
142
+ if (offered.length === 0) continue;
143
+ reported = true;
144
+ const tag = matchSpeechLocale(lang, offered);
145
+ if (tag !== null) return { lang: tag, service, silent: false };
146
+ }
147
+ if (reported) return { failure: { code: 'language-not-supported' } };
148
+ return { lang, silent: asked > 0 };
149
+ }
150
+
151
+ export function startSpeechRecognition(
152
+ options: SpeechRecognitionOptions,
153
+ handlers: SpeechRecognitionHandlers,
154
+ ): SpeechRecognitionSession {
155
+ const module = speechModule();
156
+ if (module === null) {
157
+ handlers.onError({ code: 'unsupported' });
158
+ handlers.onEnd();
159
+ return { stop: () => undefined, abort: () => undefined };
160
+ }
161
+ const ExpoSpeechRecognitionModule = module;
162
+ let ended = false;
163
+ /**
164
+ * Finals already delivered. Android's continuous mode starts a new segment
165
+ * after every final result, so the utterance is the settled segments plus
166
+ * whatever is still in flight; iOS only finalizes on stop and always sends
167
+ * the whole transcript, so this stays empty there.
168
+ */
169
+ let settled = '';
170
+
171
+ const subscriptions = [
172
+ ExpoSpeechRecognitionModule.addListener('result', (event) => {
173
+ if (ended) return;
174
+ const current = event.results[0]?.transcript ?? '';
175
+ const transcript = `${settled} ${current}`.replace(/\s+/g, ' ').trim();
176
+ if (event.isFinal) settled = transcript;
177
+ handlers.onResult({ transcript, isFinal: event.isFinal });
178
+ }),
179
+ ExpoSpeechRecognitionModule.addListener('error', (event) => {
180
+ if (ended) return;
181
+ const code = failureOf(event.error);
182
+ if (code === 'aborted') return;
183
+ handlers.onError({ code, detail: event.message });
184
+ }),
185
+ ExpoSpeechRecognitionModule.addListener('end', () => finish()),
186
+ ExpoSpeechRecognitionModule.addListener('volumechange', (event) => {
187
+ if (ended) return;
188
+ handlers.onLevel?.(clampLevel(event.value / VOLUME_CEILING));
189
+ }),
190
+ ];
191
+
192
+ function finish(): void {
193
+ if (ended) return;
194
+ ended = true;
195
+ for (const subscription of subscriptions) subscription.remove();
196
+ handlers.onLevel?.(0);
197
+ handlers.onEnd();
198
+ }
199
+
200
+ try {
201
+ ExpoSpeechRecognitionModule.start({
202
+ lang: options.lang,
203
+ ...(options.service ? { androidRecognitionServicePackage: options.service } : {}),
204
+ interimResults: true,
205
+ continuous: true,
206
+ addsPunctuation: true,
207
+ maxAlternatives: 1,
208
+ volumeChangeEventOptions: { enabled: handlers.onLevel !== undefined, intervalMillis: 100 },
209
+ ...(options.echoCancellation ? { iosVoiceProcessingEnabled: true } : {}),
210
+ });
211
+ } catch (error: unknown) {
212
+ if (!ended) handlers.onError({ code: 'other', detail: error instanceof Error ? error.message : 'start failed' });
213
+ finish();
214
+ }
215
+
216
+ return {
217
+ stop() {
218
+ if (ended) return;
219
+ ExpoSpeechRecognitionModule.stop();
220
+ },
221
+ abort() {
222
+ if (ended) return;
223
+ ExpoSpeechRecognitionModule.abort();
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * On-device speech recognition for the web: the browser's Web Speech API.
3
+ *
4
+ * Metro resolves `speech-recognition.native.ts` on iOS and Android, so this
5
+ * file is what a web bundle — and every test — gets. See
6
+ * `speech-recognition-types.ts` for the contract both implement.
7
+ *
8
+ * ## Why a microphone stream is opened beside the recognizer
9
+ *
10
+ * The Web Speech API reports words, not sound: there is no level event, and a
11
+ * refused or missing microphone surfaces as a bare `not-allowed` or
12
+ * `audio-capture` with nothing to tell "blocked" from "busy". Opening the
13
+ * microphone first with `getUserMedia` answers both. It is where the browser
14
+ * asks for permission (once — the recognizer shares the origin's grant), its
15
+ * DOMException names say exactly what is wrong, and an `AnalyserNode` on the
16
+ * stream is the honest level the dictation trace and the voice field move to.
17
+ * The stream is never recorded or sent anywhere.
18
+ *
19
+ * ## Support
20
+ *
21
+ * Chromium browsers and Safari implement it (Chrome as `webkitSpeechRecognition`
22
+ * and through a Google speech service; Safari through Apple's). Firefox does
23
+ * not, and `isSpeechRecognitionAvailable()` says so before anything asks for a
24
+ * microphone.
25
+ */
26
+
27
+ import { levelFromDbfs } from './audio-level';
28
+ import type {
29
+ SpeechRecognitionFailure,
30
+ SpeechRecognitionFailureCode,
31
+ SpeechRecognitionHandlers,
32
+ SpeechRecognitionOptions,
33
+ SpeechRecognitionSession,
34
+ SpeechRecognizerChoice,
35
+ } from './speech-recognition-types';
36
+
37
+ // ── The slice of the Web Speech API this uses ──
38
+ // Declared here rather than taken from lib.dom: TypeScript's DOM library does
39
+ // not ship the prefixed constructor, and the unprefixed one is not everywhere.
40
+
41
+ interface WebSpeechAlternative { readonly transcript: string }
42
+ interface WebSpeechResult { readonly isFinal: boolean; readonly length: number; readonly [index: number]: WebSpeechAlternative }
43
+ interface WebSpeechResultList { readonly length: number; readonly [index: number]: WebSpeechResult }
44
+ interface WebSpeechResultEvent { readonly results: WebSpeechResultList }
45
+ interface WebSpeechErrorEvent { readonly error: string; readonly message?: string }
46
+
47
+ interface WebSpeechRecognition {
48
+ lang: string;
49
+ continuous: boolean;
50
+ interimResults: boolean;
51
+ maxAlternatives: number;
52
+ onresult: ((event: WebSpeechResultEvent) => void) | null;
53
+ onerror: ((event: WebSpeechErrorEvent) => void) | null;
54
+ onend: (() => void) | null;
55
+ start(): void;
56
+ stop(): void;
57
+ abort(): void;
58
+ }
59
+
60
+ type WebSpeechRecognitionConstructor = new () => WebSpeechRecognition;
61
+
62
+ function recognitionConstructor(): WebSpeechRecognitionConstructor | null {
63
+ const scope = globalThis as unknown as {
64
+ SpeechRecognition?: WebSpeechRecognitionConstructor;
65
+ webkitSpeechRecognition?: WebSpeechRecognitionConstructor;
66
+ };
67
+ return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
68
+ }
69
+
70
+ /** Whether this browser can recognize speech at all. Firefox cannot. */
71
+ export function isSpeechRecognitionAvailable(): boolean {
72
+ return recognitionConstructor() !== null;
73
+ }
74
+
75
+ /**
76
+ * Ask for the microphone before a session needs it.
77
+ *
78
+ * The browser asks once per origin; `startSpeechRecognition` opens the stream
79
+ * itself, so this is only for callers that want the answer up front (voice mode
80
+ * does, so a refusal ends the call before it starts rather than on the first
81
+ * word).
82
+ */
83
+ export async function requestSpeechRecognitionPermission(): Promise<SpeechRecognitionFailure | null> {
84
+ if (!isSpeechRecognitionAvailable()) return { code: 'unsupported' };
85
+ const media = globalThis.navigator?.mediaDevices;
86
+ if (media?.getUserMedia === undefined) return null;
87
+ try {
88
+ const stream = await media.getUserMedia({ audio: true });
89
+ for (const track of stream.getTracks()) track.stop();
90
+ return null;
91
+ } catch (error: unknown) {
92
+ return mediaFailure(error);
93
+ }
94
+ }
95
+
96
+ function domErrorName(error: unknown): string {
97
+ // A DOMException is not always `instanceof Error`, so read `.name` off the object.
98
+ if (typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string') {
99
+ return error.name;
100
+ }
101
+ return '';
102
+ }
103
+
104
+ function mediaFailure(error: unknown): SpeechRecognitionFailure {
105
+ const name = domErrorName(error);
106
+ switch (name) {
107
+ case 'NotAllowedError':
108
+ case 'PermissionDeniedError':
109
+ case 'SecurityError':
110
+ return { code: 'not-allowed', detail: name };
111
+ case 'NotFoundError':
112
+ case 'DevicesNotFoundError':
113
+ case 'OverconstrainedError':
114
+ case 'NotReadableError':
115
+ case 'TrackStartError':
116
+ return { code: 'audio-capture', detail: name };
117
+ default:
118
+ return { code: 'other', detail: name || 'getUserMedia failed' };
119
+ }
120
+ }
121
+
122
+ function recognitionFailure(event: WebSpeechErrorEvent): SpeechRecognitionFailure {
123
+ const map: Record<string, SpeechRecognitionFailureCode> = {
124
+ 'not-allowed': 'not-allowed',
125
+ 'service-not-allowed': 'not-allowed',
126
+ 'audio-capture': 'audio-capture',
127
+ network: 'network',
128
+ 'language-not-supported': 'language-not-supported',
129
+ 'no-speech': 'no-speech',
130
+ aborted: 'aborted',
131
+ };
132
+ return { code: map[event.error] ?? 'other', detail: event.error };
133
+ }
134
+
135
+ /** Everything heard so far, as one string: Chrome segments carry their own leading spaces. */
136
+ function transcriptOf(results: WebSpeechResultList): { transcript: string; isFinal: boolean } {
137
+ let transcript = '';
138
+ let isFinal = results.length > 0;
139
+ for (let index = 0; index < results.length; index += 1) {
140
+ const result = results[index];
141
+ if (result === undefined) continue;
142
+ transcript += result[0]?.transcript ?? '';
143
+ if (!result.isFinal) isFinal = false;
144
+ }
145
+ return { transcript: transcript.replace(/\s+/g, ' ').trim(), isFinal };
146
+ }
147
+
148
+ const LEVEL_INTERVAL_MS = 100;
149
+
150
+ /** An analyser on the microphone, polled on a clock. Returns its teardown. */
151
+ function meterStream(stream: MediaStream, onLevel: (level: number) => void): () => void {
152
+ const AudioContextCtor = (globalThis as unknown as { AudioContext?: typeof AudioContext }).AudioContext;
153
+ if (AudioContextCtor === undefined) return () => undefined;
154
+ let context: AudioContext;
155
+ try {
156
+ context = new AudioContextCtor();
157
+ } catch {
158
+ return () => undefined;
159
+ }
160
+ if (context.state === 'suspended') context.resume().catch(() => undefined);
161
+ const analyser = context.createAnalyser();
162
+ analyser.fftSize = 512;
163
+ context.createMediaStreamSource(stream).connect(analyser);
164
+ const buffer = new Float32Array(analyser.fftSize);
165
+ const timer = setInterval(() => {
166
+ analyser.getFloatTimeDomainData(buffer);
167
+ let sum = 0;
168
+ for (let index = 0; index < buffer.length; index += 1) sum += buffer[index] * buffer[index];
169
+ const rms = Math.sqrt(sum / buffer.length);
170
+ onLevel(rms > 0 ? levelFromDbfs(20 * Math.log10(rms)) : 0);
171
+ }, LEVEL_INTERVAL_MS);
172
+ return () => {
173
+ clearInterval(timer);
174
+ context.close().catch(() => undefined);
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Listen until stopped.
180
+ *
181
+ * Continuous with interim results: the caller decides when an utterance is
182
+ * over (voice mode on a silence timer, dictation on the stop button), because
183
+ * the browser's own end-of-speech detection cuts people off mid-thought.
184
+ */
185
+ /**
186
+ * The browser has one recognizer and no way to ask it which languages it
187
+ * knows: the language goes as given (see the native file for Android's).
188
+ */
189
+ export async function chooseSpeechRecognizer(lang: string): Promise<SpeechRecognizerChoice> {
190
+ return { lang, silent: false };
191
+ }
192
+
193
+ export function startSpeechRecognition(
194
+ options: SpeechRecognitionOptions,
195
+ handlers: SpeechRecognitionHandlers,
196
+ ): SpeechRecognitionSession {
197
+ let recognition: WebSpeechRecognition | null = null;
198
+ let stream: MediaStream | null = null;
199
+ let stopMeter: (() => void) | null = null;
200
+ let ended = false;
201
+ let stopRequested: 'stop' | 'abort' | null = null;
202
+
203
+ const release = (): void => {
204
+ stopMeter?.();
205
+ stopMeter = null;
206
+ if (stream !== null) for (const track of stream.getTracks()) track.stop();
207
+ stream = null;
208
+ };
209
+
210
+ const finish = (): void => {
211
+ if (ended) return;
212
+ ended = true;
213
+ release();
214
+ handlers.onLevel?.(0);
215
+ handlers.onEnd();
216
+ };
217
+
218
+ const fail = (failure: SpeechRecognitionFailure): void => {
219
+ if (ended) return;
220
+ handlers.onError(failure);
221
+ finish();
222
+ };
223
+
224
+ void (async () => {
225
+ const Recognition = recognitionConstructor();
226
+ if (Recognition === null) {
227
+ fail({ code: 'unsupported' });
228
+ return;
229
+ }
230
+
231
+ const media = globalThis.navigator?.mediaDevices;
232
+ if (media?.getUserMedia !== undefined) {
233
+ try {
234
+ stream = await media.getUserMedia({
235
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
236
+ });
237
+ } catch (error: unknown) {
238
+ fail(mediaFailure(error));
239
+ return;
240
+ }
241
+ if (stopRequested !== null) {
242
+ finish();
243
+ return;
244
+ }
245
+ if (handlers.onLevel !== undefined) stopMeter = meterStream(stream, handlers.onLevel);
246
+ }
247
+
248
+ const instance = new Recognition();
249
+ recognition = instance;
250
+ instance.lang = options.lang;
251
+ instance.continuous = true;
252
+ instance.interimResults = true;
253
+ instance.maxAlternatives = 1;
254
+ instance.onresult = (event) => {
255
+ if (ended) return;
256
+ handlers.onResult(transcriptOf(event.results));
257
+ };
258
+ instance.onerror = (event) => {
259
+ if (ended) return;
260
+ const failure = recognitionFailure(event);
261
+ if (failure.code === 'aborted') return;
262
+ handlers.onError(failure);
263
+ };
264
+ instance.onend = finish;
265
+ try {
266
+ instance.start();
267
+ } catch (error: unknown) {
268
+ fail({ code: 'other', detail: domErrorName(error) || 'start failed' });
269
+ return;
270
+ }
271
+ if (stopRequested === 'stop') instance.stop();
272
+ if (stopRequested === 'abort') instance.abort();
273
+ })();
274
+
275
+ return {
276
+ stop() {
277
+ if (ended) return;
278
+ stopRequested = 'stop';
279
+ recognition?.stop();
280
+ },
281
+ abort() {
282
+ if (ended) return;
283
+ stopRequested = 'abort';
284
+ if (recognition === null) return;
285
+ recognition.abort();
286
+ },
287
+ };
288
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Speech out: ask `POST /v1/audio/speech` for a clip, and play one.
3
+ *
4
+ * Shared by read-aloud (`useTTS`) and voice mode (`useVoiceRoom`) so both speak
5
+ * through the one route Alia has for it — Alia → Oxy → Kaana, billed by Oxy per
6
+ * call — with the same request body and the same error copy. Voice names are
7
+ * the PRODUCT's (`male` / `female`); the route maps them, and no upstream voice
8
+ * name ever appears on this side.
9
+ */
10
+
11
+ import { createAudioLevelMeter } from './audio-level';
12
+
13
+ export type ProductVoice = 'male' | 'female';
14
+
15
+ export interface SpeechClipRequest {
16
+ readonly apiUrl: string;
17
+ readonly token: string;
18
+ /** A speech model; omitted, the server's own speech model answers. */
19
+ readonly model?: string;
20
+ readonly input: string;
21
+ readonly voice: ProductVoice;
22
+ readonly speed?: number;
23
+ /** Both or neither: the route stores the clip on that message when given. */
24
+ readonly conversationId?: string;
25
+ readonly messageId?: string;
26
+ readonly signal: AbortSignal;
27
+ }
28
+
29
+ /** Synthesize `input` and return the private link the route answers with. */
30
+ export async function requestSpeechClip(request: SpeechClipRequest): Promise<string> {
31
+ const response = await fetch(`${request.apiUrl}/v1/audio/speech`, {
32
+ method: 'POST',
33
+ signal: request.signal,
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ Authorization: `Bearer ${request.token}`,
37
+ },
38
+ body: JSON.stringify({
39
+ model: request.model,
40
+ input: request.input,
41
+ voice: request.voice,
42
+ speed: request.speed,
43
+ conversationId: request.conversationId,
44
+ messageId: request.messageId,
45
+ }),
46
+ });
47
+
48
+ if (!response.ok) {
49
+ if (response.status === 504) {
50
+ throw new Error('Request timed out — please try again');
51
+ }
52
+ const errData = await response.json().catch(() => ({}));
53
+ throw new Error(errData.error?.message || errData.error || 'TTS failed');
54
+ }
55
+
56
+ const data = (await response.json()) as { audioUrl?: unknown };
57
+ request.signal.throwIfAborted();
58
+ if (typeof data.audioUrl !== 'string' || data.audioUrl === '') throw new Error('TTS failed');
59
+ return data.audioUrl;
60
+ }
61
+
62
+ export interface PlayClipOptions {
63
+ readonly signal: AbortSignal;
64
+ /** The clip's live level, 0..1, where the platform lets playback be sampled. */
65
+ readonly onLevel?: (level: number) => void;
66
+ }
67
+
68
+ /**
69
+ * Play one clip to its end.
70
+ *
71
+ * Resolves when it finishes or when `signal` aborts (the player is released
72
+ * either way — an interrupted answer stops at once), and rejects if the player
73
+ * reports an error. `crossOrigin` and sampling are set as `useTTS` sets them;
74
+ * its comments explain why.
75
+ */
76
+ export async function playSpeechClip(uri: string, options: PlayClipOptions): Promise<void> {
77
+ const { signal, onLevel } = options;
78
+ if (signal.aborted) return;
79
+ const { createAudioPlayer } = await import('expo-audio');
80
+ if (signal.aborted) return;
81
+
82
+ const player = createAudioPlayer({ uri }, { crossOrigin: 'anonymous' });
83
+ const release = (): void => {
84
+ try {
85
+ player.remove();
86
+ } catch {
87
+ // Already removed.
88
+ }
89
+ };
90
+
91
+ try {
92
+ await new Promise<void>((resolve, reject) => {
93
+ const onAbort = (): void => resolve();
94
+ signal.addEventListener('abort', onAbort, { once: true });
95
+ const settle = (settleWith: () => void): void => {
96
+ signal.removeEventListener('abort', onAbort);
97
+ settleWith();
98
+ };
99
+
100
+ if (onLevel !== undefined) {
101
+ player.setAudioSamplingEnabled(true);
102
+ const meter = createAudioLevelMeter();
103
+ player.addListener('audioSampleUpdate', (sample) => {
104
+ const frames = sample.channels[0]?.frames;
105
+ if (frames === undefined) return;
106
+ onLevel(meter.push(frames, Date.now()));
107
+ });
108
+ }
109
+ player.addListener('playbackStatusUpdate', (status) => {
110
+ if (status.error) {
111
+ settle(() => reject(new Error(String(status.error))));
112
+ return;
113
+ }
114
+ if (status.didJustFinish) settle(resolve);
115
+ });
116
+ player.play();
117
+ });
118
+ } finally {
119
+ release();
120
+ onLevel?.(0);
121
+ }
122
+ }
@@ -0,0 +1,60 @@
1
+ import { clampLevel } from './speech-recognition-types';
2
+
3
+ /**
4
+ * The two live levels of a voice call — what the microphone hears and what the
5
+ * call is saying — for whatever draws them.
6
+ *
7
+ * It replaced the LiveKit `Room` that `useAudioLevelMonitor` used to take and
8
+ * put analysers on. The voice loop writes here from the recognizer's level
9
+ * events and the clip player's samples; the monitor subscribes and hands React
10
+ * a throttled copy. Writes are cheap and never render anything by themselves.
11
+ */
12
+ export interface VoiceLevelSource {
13
+ readonly capture: number;
14
+ readonly playback: number;
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+
18
+ export class VoiceLevelChannel implements VoiceLevelSource {
19
+ private captureLevel = 0;
20
+ private playbackLevel = 0;
21
+ private readonly listeners = new Set<() => void>();
22
+
23
+ get capture(): number {
24
+ return this.captureLevel;
25
+ }
26
+
27
+ get playback(): number {
28
+ return this.playbackLevel;
29
+ }
30
+
31
+ setCapture(level: number): void {
32
+ const next = clampLevel(level);
33
+ if (next === this.captureLevel) return;
34
+ this.captureLevel = next;
35
+ this.emit();
36
+ }
37
+
38
+ setPlayback(level: number): void {
39
+ const next = clampLevel(level);
40
+ if (next === this.playbackLevel) return;
41
+ this.playbackLevel = next;
42
+ this.emit();
43
+ }
44
+
45
+ reset(): void {
46
+ this.setCapture(0);
47
+ this.setPlayback(0);
48
+ }
49
+
50
+ subscribe(listener: () => void): () => void {
51
+ this.listeners.add(listener);
52
+ return () => {
53
+ this.listeners.delete(listener);
54
+ };
55
+ }
56
+
57
+ private emit(): void {
58
+ for (const listener of this.listeners) listener();
59
+ }
60
+ }