@alia.onl/sdk 13.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +52 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +4 -7
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +1 -1
  8. package/src/components/AliaChatSheet.tsx +15 -4
  9. package/src/components/AliaComposer.tsx +57 -11
  10. package/src/components/voice/VoiceControls.tsx +28 -4
  11. package/src/components/voice/VoiceSession.tsx +23 -7
  12. package/src/hooks/useAliaActivity.ts +31 -0
  13. package/src/hooks/useAliaChat.ts +14 -3
  14. package/src/hooks/useAmbientWave.ts +3 -3
  15. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  16. package/src/hooks/useDictation.ts +295 -0
  17. package/src/hooks/useTTS.ts +24 -63
  18. package/src/hooks/useVoiceRoom.ts +148 -736
  19. package/src/index.ts +9 -3
  20. package/src/lib/activity-socket.ts +102 -0
  21. package/src/lib/activity-store.ts +53 -0
  22. package/src/lib/api-url.ts +2 -0
  23. package/src/lib/audio-level.ts +2 -2
  24. package/src/lib/dictation-recorder-types.ts +37 -0
  25. package/src/lib/dictation-recorder.native.ts +90 -0
  26. package/src/lib/dictation-recorder.ts +131 -0
  27. package/src/lib/dictation.ts +208 -0
  28. package/src/lib/labels.tsx +3 -0
  29. package/src/lib/pcm.ts +57 -0
  30. package/src/lib/speech-language.ts +1 -1
  31. package/src/lib/speech-messages.ts +43 -97
  32. package/src/lib/speech-synthesis.ts +5 -1
  33. package/src/lib/turn-state.ts +15 -0
  34. package/src/lib/voice-audio-engine-types.ts +59 -0
  35. package/src/lib/voice-audio-engine.native.ts +232 -0
  36. package/src/lib/voice-audio-engine.ts +350 -0
  37. package/src/lib/voice-call.ts +539 -0
  38. package/src/lib/voice-levels.ts +7 -3
  39. package/src/lib/voice-protocol.ts +309 -0
  40. package/src/types.ts +49 -4
  41. package/src/voice.ts +31 -12
  42. package/tsconfig.json +2 -0
  43. package/src/hooks/useSpeechToText.ts +0 -268
  44. package/src/lib/speech-recognition-types.ts +0 -108
  45. package/src/lib/speech-recognition.native.ts +0 -237
  46. package/src/lib/speech-recognition.ts +0 -308
  47. package/src/lib/voice-text.ts +0 -198
  48. package/src/lib/voice-turn.ts +0 -89
@@ -0,0 +1,539 @@
1
+ /**
2
+ * One voice call over Alia's `/v1/voice/session` socket (ADR 0017 §3–§4),
3
+ * without React: the hook (`useVoiceRoom`) owns one and renders its snapshots.
4
+ *
5
+ * Alia holds the realtime session with Kaana; turn detection and barge-in are
6
+ * the model's, on the server. The device only:
7
+ *
8
+ * - captures the microphone and sends it as PCM16 frames (nothing while muted);
9
+ * - plays the answer's PCM16 frames from a jitter-buffered queue;
10
+ * - when the server says the person started speaking, drops what it has not
11
+ * played and tells Alia how much of the answer was HEARD
12
+ * (`playback.interrupted`), so the model remembers what was said, not what it
13
+ * generated;
14
+ * - shows a state it can prove (`VoiceCallState`). "Listening" needs the
15
+ * session ready upstream, a first captured buffer and Alia's acknowledgement
16
+ * of the first appended audio; "speaking" needs the first answer frame
17
+ * actually playing.
18
+ *
19
+ * A socket that drops without `session.closed` is resumed within the session's
20
+ * resume window (`session.resume { afterSeq }`), and the call reads
21
+ * `reconnecting` meanwhile — never `listening`.
22
+ */
23
+
24
+ import type { VoiceCallState, VoiceMessage } from '../types';
25
+ import type { VoiceErrorCode } from './speech-messages';
26
+ import type { VoiceLevelChannel } from './voice-levels';
27
+ import {
28
+ VoiceAudioEngineError,
29
+ type VoiceAudioEngine,
30
+ type VoiceAudioEngineHandlers,
31
+ } from './voice-audio-engine-types';
32
+ import {
33
+ decodeAssistantAudioFrame,
34
+ parseVoiceServerEvent,
35
+ VOICE_PROTOCOL_VERSION,
36
+ voiceSessionUrl,
37
+ type ProductVoice,
38
+ type ReceivedVoiceEvent,
39
+ type VoiceClientEvent,
40
+ type VoiceCloseReason,
41
+ } from './voice-protocol';
42
+
43
+ /** The part of a WebSocket a call uses: the browser's and React Native's both fit. */
44
+ export interface VoiceSocket {
45
+ binaryType: string;
46
+ readonly readyState: number;
47
+ onopen: ((event: unknown) => void) | null;
48
+ onmessage: ((event: { data: unknown }) => void) | null;
49
+ onclose: ((event: unknown) => void) | null;
50
+ onerror: ((event: unknown) => void) | null;
51
+ send(data: string | ArrayBufferLike | ArrayBufferView): void;
52
+ close(code?: number, reason?: string): void;
53
+ }
54
+
55
+ export type VoiceSocketFactory = (url: string) => VoiceSocket;
56
+
57
+ const OPEN = 1;
58
+
59
+ /** How often the Oxy token is re-read, and re-sent when it changed. */
60
+ const TOKEN_REFRESH_MS = 4 * 60_000;
61
+ /** Between two attempts to resume a dropped socket. */
62
+ const RESUME_RETRY_MS = 1_000;
63
+ /** Until the server says otherwise in `session.ready`. */
64
+ const DEFAULT_RESUME_WINDOW_MS = 30_000;
65
+
66
+ export interface VoiceCallSnapshot {
67
+ readonly state: VoiceCallState;
68
+ readonly messages: readonly VoiceMessage[];
69
+ readonly isMuted: boolean;
70
+ /** Milliseconds left when the server warned that the call is about to end. */
71
+ readonly endingSoonMs: number | null;
72
+ /** The model serving the call, once the session is ready. */
73
+ readonly model: { id: string; displayName: string; publisher: string } | null;
74
+ }
75
+
76
+ export interface VoiceCallOptions {
77
+ readonly apiUrl: string;
78
+ /** The person's Oxy session token, read at connect and every few minutes. */
79
+ readonly getToken: () => string | null;
80
+ readonly conversationId: string;
81
+ readonly agentId?: string;
82
+ readonly voice: ProductVoice;
83
+ /** BCP-47. */
84
+ readonly locale: string;
85
+ readonly createEngine: () => VoiceAudioEngine;
86
+ readonly openSocket: VoiceSocketFactory;
87
+ readonly levels: VoiceLevelChannel;
88
+ readonly onChange: (snapshot: VoiceCallSnapshot) => void;
89
+ /** Alia stored messages of the call in the conversation. */
90
+ readonly onMessagesSaved?: (conversationId: string, messageIds: readonly string[]) => void;
91
+ /** A tool filed an action for the person's OK; the card is in the conversation. */
92
+ readonly onApprovalRequested?: (toolName: string) => void;
93
+ readonly tokenRefreshMs?: number;
94
+ readonly resumeRetryMs?: number;
95
+ }
96
+
97
+ const LIVE: ReadonlySet<VoiceCallState['kind']> = new Set([
98
+ 'listening',
99
+ 'user-speaking',
100
+ 'thinking',
101
+ 'using-tool',
102
+ 'speaking',
103
+ ]);
104
+
105
+ function asBytes(data: unknown): Uint8Array | null {
106
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
107
+ if (ArrayBuffer.isView(data))
108
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
109
+ return null;
110
+ }
111
+
112
+ export class VoiceCall {
113
+ private readonly options: VoiceCallOptions;
114
+ private state: VoiceCallState = { kind: 'idle' };
115
+ private messages: VoiceMessage[] = [];
116
+ private muted = false;
117
+ private endingSoonMs: number | null = null;
118
+ private model: VoiceCallSnapshot['model'] = null;
119
+
120
+ private engine: VoiceAudioEngine | null = null;
121
+ private socket: VoiceSocket | null = null;
122
+ private authenticated = false;
123
+ private lastToken: string | null = null;
124
+ private voiceSessionId: string | null = null;
125
+ private lastSeq = -1;
126
+ private resumeWindowMs = DEFAULT_RESUME_WINDOW_MS;
127
+ private droppedAt: number | null = null;
128
+ private resumeTimer: ReturnType<typeof setTimeout> | null = null;
129
+ private tokenTimer: ReturnType<typeof setInterval> | null = null;
130
+ private finished = false;
131
+
132
+ /** The three facts "listening" needs. */
133
+ private ready = false;
134
+ private captured = false;
135
+ private inputReady = false;
136
+
137
+ /** Assistant items the person talked over: late frames of them are dropped. */
138
+ private readonly interrupted = new Set<string>();
139
+ private lastReceivedItem: string | null = null;
140
+ private playingItem: string | null = null;
141
+ private readonly activeTools = new Map<string, string>();
142
+ /** A tool ran since the last response ended: another response follows it. */
143
+ private toolRan = false;
144
+ /** The error Alia sent before closing, which the close then ends the call with. */
145
+ private serverError: VoiceErrorCode | null = null;
146
+
147
+ constructor(options: VoiceCallOptions) {
148
+ this.options = options;
149
+ }
150
+
151
+ snapshot(): VoiceCallSnapshot {
152
+ return {
153
+ state: this.state,
154
+ messages: this.messages,
155
+ isMuted: this.muted,
156
+ endingSoonMs: this.endingSoonMs,
157
+ model: this.model,
158
+ };
159
+ }
160
+
161
+ /** Ask for the microphone, then open the socket and the session. */
162
+ async start(): Promise<void> {
163
+ if (this.state.kind !== 'idle') return;
164
+ this.setState({ kind: 'connecting' });
165
+ const token = this.options.getToken();
166
+ if (token === null || token === '') {
167
+ this.fail('not-authenticated');
168
+ return;
169
+ }
170
+ const engine = this.options.createEngine();
171
+ this.engine = engine;
172
+ try {
173
+ await engine.start(this.engineHandlers());
174
+ } catch (caught: unknown) {
175
+ if (this.finished) return;
176
+ this.fail(caught instanceof VoiceAudioEngineError ? caught.code : 'voice-unavailable');
177
+ return;
178
+ }
179
+ if (this.finished) return;
180
+ this.openSocket(false);
181
+ this.tokenTimer = setInterval(
182
+ () => this.refreshToken(),
183
+ this.options.tokenRefreshMs ?? TOKEN_REFRESH_MS,
184
+ );
185
+ }
186
+
187
+ /** The person hung up, or the screen holding the call went away. */
188
+ hangUp(): void {
189
+ if (this.finished) return;
190
+ this.send({ type: 'session.end' });
191
+ this.finish({ kind: 'ended', reason: 'hangup' });
192
+ }
193
+
194
+ setMuted(muted: boolean): void {
195
+ if (this.muted === muted) return;
196
+ this.muted = muted;
197
+ if (muted) this.options.levels.setCapture(0);
198
+ this.emit();
199
+ }
200
+
201
+ /* ------------------------------------------------------------------------ */
202
+
203
+ private engineHandlers(): VoiceAudioEngineHandlers {
204
+ return {
205
+ onCaptureFrame: (pcm) => {
206
+ if (this.finished) return;
207
+ if (!this.captured) {
208
+ this.captured = true;
209
+ this.maybeListening();
210
+ }
211
+ if (this.muted || !this.ready || !this.authenticated) return;
212
+ if (this.socket?.readyState !== OPEN) return;
213
+ this.socket.send(pcm);
214
+ },
215
+ onCaptureLevel: (level) => this.options.levels.setCapture(this.muted ? 0 : level),
216
+ onPlaybackStarted: (itemId) => {
217
+ if (this.finished || this.interrupted.has(itemId)) return;
218
+ this.playingItem = itemId;
219
+ if (LIVE.has(this.state.kind)) this.setState({ kind: 'speaking' });
220
+ },
221
+ onPlaybackLevel: (level) => this.options.levels.setPlayback(level),
222
+ onPlaybackIdle: () => {
223
+ this.playingItem = null;
224
+ if (this.state.kind === 'speaking') this.afterSpeech();
225
+ },
226
+ onError: (error) => this.fail(error.code),
227
+ };
228
+ }
229
+
230
+ private openSocket(resuming: boolean): void {
231
+ this.authenticated = false;
232
+ let socket: VoiceSocket;
233
+ try {
234
+ socket = this.options.openSocket(voiceSessionUrl(this.options.apiUrl));
235
+ } catch {
236
+ if (resuming) this.scheduleResume();
237
+ else this.fail('connection-lost');
238
+ return;
239
+ }
240
+ socket.binaryType = 'arraybuffer';
241
+ this.socket = socket;
242
+ socket.onopen = () => {
243
+ const token = this.options.getToken();
244
+ if (token === null || token === '') {
245
+ this.fail('not-authenticated');
246
+ return;
247
+ }
248
+ this.lastToken = token;
249
+ socket.send(JSON.stringify({ type: 'auth', token } satisfies VoiceClientEvent));
250
+ };
251
+ socket.onmessage = (event) => {
252
+ if (this.socket !== socket || this.finished) return;
253
+ if (typeof event.data === 'string') {
254
+ const parsed = parseVoiceServerEvent(event.data);
255
+ if (parsed !== null) this.handle(parsed, resuming);
256
+ return;
257
+ }
258
+ const bytes = asBytes(event.data);
259
+ if (bytes !== null) this.handleAudio(bytes);
260
+ };
261
+ socket.onerror = () => undefined;
262
+ socket.onclose = () => {
263
+ if (this.socket !== socket || this.finished) return;
264
+ this.socket = null;
265
+ this.authenticated = false;
266
+ if (this.serverError !== null) {
267
+ this.fail(this.serverError);
268
+ return;
269
+ }
270
+ if (this.voiceSessionId === null) {
271
+ this.fail('connection-lost');
272
+ return;
273
+ }
274
+ this.droppedAt ??= Date.now();
275
+ this.setState({ kind: 'reconnecting' });
276
+ this.scheduleResume();
277
+ };
278
+ }
279
+
280
+ private scheduleResume(): void {
281
+ if (this.finished) return;
282
+ const droppedAt = this.droppedAt ?? Date.now();
283
+ this.droppedAt = droppedAt;
284
+ if (Date.now() - droppedAt >= this.resumeWindowMs) {
285
+ this.fail('connection-lost');
286
+ return;
287
+ }
288
+ if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
289
+ this.resumeTimer = setTimeout(() => {
290
+ this.resumeTimer = null;
291
+ if (!this.finished) this.openSocket(true);
292
+ }, this.options.resumeRetryMs ?? RESUME_RETRY_MS);
293
+ }
294
+
295
+ private refreshToken(): void {
296
+ if (!this.authenticated || this.socket?.readyState !== OPEN) return;
297
+ const token = this.options.getToken();
298
+ if (token === null || token === '' || token === this.lastToken) return;
299
+ this.lastToken = token;
300
+ this.send({ type: 'auth', token });
301
+ }
302
+
303
+ private send(event: VoiceClientEvent): void {
304
+ if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(event));
305
+ }
306
+
307
+ private handle(event: ReceivedVoiceEvent, resuming: boolean): void {
308
+ if (event.seq !== undefined) {
309
+ if (event.seq <= this.lastSeq) return;
310
+ this.lastSeq = event.seq;
311
+ }
312
+ switch (event.type) {
313
+ case 'auth.ok':
314
+ if (this.authenticated) return;
315
+ this.authenticated = true;
316
+ if (resuming && this.voiceSessionId !== null) {
317
+ this.send({
318
+ type: 'session.resume',
319
+ protocol: VOICE_PROTOCOL_VERSION,
320
+ voiceSessionId: this.voiceSessionId,
321
+ afterSeq: this.lastSeq,
322
+ });
323
+ } else {
324
+ this.send({
325
+ type: 'session.start',
326
+ protocol: VOICE_PROTOCOL_VERSION,
327
+ conversationId: this.options.conversationId,
328
+ ...(this.options.agentId === undefined ? {} : { agentId: this.options.agentId }),
329
+ voice: this.options.voice,
330
+ locale: this.options.locale,
331
+ });
332
+ }
333
+ return;
334
+ case 'session.accepted':
335
+ this.voiceSessionId = event.voiceSessionId;
336
+ return;
337
+ case 'session.ready':
338
+ this.ready = true;
339
+ this.model = event.model;
340
+ this.resumeWindowMs = event.resumeWindowMs;
341
+ if (this.state.kind === 'reconnecting') this.setState({ kind: 'listening' });
342
+ else this.maybeListening();
343
+ this.emit();
344
+ return;
345
+ case 'input.ready':
346
+ this.inputReady = true;
347
+ this.maybeListening();
348
+ return;
349
+ case 'session.resumed':
350
+ this.droppedAt = null;
351
+ if (this.state.kind === 'reconnecting') this.setState({ kind: 'listening' });
352
+ return;
353
+ case 'session.reconnecting':
354
+ this.setState({ kind: 'reconnecting' });
355
+ return;
356
+ case 'user.speech_started':
357
+ this.bargeIn();
358
+ if (LIVE.has(this.state.kind)) this.setState({ kind: 'user-speaking' });
359
+ return;
360
+ case 'user.speech_stopped':
361
+ if (this.state.kind === 'user-speaking' || this.state.kind === 'listening')
362
+ this.setState({ kind: 'thinking' });
363
+ return;
364
+ case 'transcript.delta':
365
+ this.upsertMessage(event.role, event.itemId, (previous) => previous + event.text, true);
366
+ return;
367
+ case 'transcript.done':
368
+ this.upsertMessage(event.role, event.itemId, () => event.text, false);
369
+ return;
370
+ case 'assistant.audio_done':
371
+ return;
372
+ case 'assistant.done':
373
+ this.settleStreaming(event.itemId);
374
+ if (this.toolRan) {
375
+ // A response that called tools is followed by the one that answers.
376
+ this.toolRan = false;
377
+ return;
378
+ }
379
+ if (this.state.kind === 'thinking' && this.playingItem === null) this.afterSpeech();
380
+ return;
381
+ case 'tool.started':
382
+ this.activeTools.set(event.callId, event.name);
383
+ this.toolRan = true;
384
+ if (LIVE.has(this.state.kind)) this.setState({ kind: 'using-tool', tool: event.name });
385
+ return;
386
+ case 'tool.finished': {
387
+ this.activeTools.delete(event.callId);
388
+ if (this.state.kind !== 'using-tool') return;
389
+ const still = [...this.activeTools.values()].at(-1);
390
+ this.setState(
391
+ still === undefined ? { kind: 'thinking' } : { kind: 'using-tool', tool: still },
392
+ );
393
+ return;
394
+ }
395
+ case 'approval.requested':
396
+ this.options.onApprovalRequested?.(event.toolName);
397
+ return;
398
+ case 'messages.saved':
399
+ this.options.onMessagesSaved?.(event.conversationId, event.messageIds);
400
+ return;
401
+ case 'session.warning':
402
+ this.endingSoonMs = event.remainingMs;
403
+ this.emit();
404
+ return;
405
+ case 'error':
406
+ this.serverError = event.code;
407
+ return;
408
+ case 'session.closed':
409
+ this.closedByServer(event.reason);
410
+ return;
411
+ }
412
+ }
413
+
414
+ private handleAudio(frame: Uint8Array): void {
415
+ const decoded = decodeAssistantAudioFrame(frame);
416
+ if (decoded === null || this.interrupted.has(decoded.itemId)) return;
417
+ this.lastReceivedItem = decoded.itemId;
418
+ // Copied: the socket's buffer is not the engine's to keep.
419
+ this.engine?.enqueue(decoded.itemId, decoded.pcm.slice());
420
+ }
421
+
422
+ /** The person spoke: stop the answer now and report what of it was heard. */
423
+ private bargeIn(): void {
424
+ const received = this.lastReceivedItem;
425
+ if (received !== null) this.interrupted.add(received);
426
+ const engine = this.engine;
427
+ if (engine === null) return;
428
+ this.playingItem = null;
429
+ void engine.flush().then((heard) => {
430
+ if (heard === null || this.finished) return;
431
+ this.interrupted.add(heard.itemId);
432
+ this.send({ type: 'playback.interrupted', itemId: heard.itemId, playedMs: heard.playedMs });
433
+ });
434
+ }
435
+
436
+ /** Back to listening once the answer has been said, or nothing more is coming. */
437
+ private afterSpeech(): void {
438
+ if (this.activeTools.size > 0) {
439
+ this.setState({ kind: 'using-tool', tool: [...this.activeTools.values()].at(-1)! });
440
+ return;
441
+ }
442
+ this.setState({ kind: 'listening' });
443
+ }
444
+
445
+ private maybeListening(): void {
446
+ if (this.state.kind !== 'connecting') return;
447
+ if (this.ready && this.captured && this.inputReady) this.setState({ kind: 'listening' });
448
+ }
449
+
450
+ private closedByServer(reason: VoiceCloseReason): void {
451
+ if (reason === 'error') {
452
+ this.fail(this.serverError ?? 'realtime-failed');
453
+ return;
454
+ }
455
+ this.finish({ kind: 'ended', reason });
456
+ }
457
+
458
+ private upsertMessage(
459
+ role: 'user' | 'assistant',
460
+ itemId: string,
461
+ content: (previous: string) => string,
462
+ isStreaming: boolean,
463
+ ): void {
464
+ const id = `${role}-${itemId}`;
465
+ const index = this.messages.findIndex((message) => message.id === id);
466
+ if (index === -1) {
467
+ this.messages = [
468
+ ...this.messages,
469
+ {
470
+ id,
471
+ role,
472
+ ...(role === 'assistant' ? { speaker: 'primary' as const } : {}),
473
+ content: content(''),
474
+ timestamp: Date.now(),
475
+ isStreaming,
476
+ },
477
+ ];
478
+ } else {
479
+ const previous = this.messages[index]!;
480
+ const next = [...this.messages];
481
+ next[index] = { ...previous, content: content(previous.content), isStreaming };
482
+ this.messages = next;
483
+ }
484
+ this.emit();
485
+ }
486
+
487
+ private settleStreaming(itemId: string | null): void {
488
+ if (itemId === null) return;
489
+ const id = `assistant-${itemId}`;
490
+ if (!this.messages.some((message) => message.id === id && message.isStreaming)) return;
491
+ this.messages = this.messages.map((message) =>
492
+ message.id === id ? { ...message, isStreaming: false } : message,
493
+ );
494
+ this.emit();
495
+ }
496
+
497
+ private fail(code: VoiceErrorCode): void {
498
+ this.finish({ kind: 'error', code });
499
+ }
500
+
501
+ private finish(state: VoiceCallState): void {
502
+ if (this.finished) return;
503
+ this.finished = true;
504
+ if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
505
+ if (this.tokenTimer !== null) clearInterval(this.tokenTimer);
506
+ this.resumeTimer = null;
507
+ this.tokenTimer = null;
508
+ this.engine?.stop();
509
+ this.engine = null;
510
+ const socket = this.socket;
511
+ this.socket = null;
512
+ if (socket !== null) {
513
+ socket.onopen = null;
514
+ socket.onmessage = null;
515
+ socket.onclose = null;
516
+ socket.onerror = null;
517
+ try {
518
+ socket.close(1000, 'call ended');
519
+ } catch {
520
+ // Already closed.
521
+ }
522
+ }
523
+ this.options.levels.reset();
524
+ this.messages = this.messages.map((message) =>
525
+ message.isStreaming ? { ...message, isStreaming: false } : message,
526
+ );
527
+ this.setState(state);
528
+ }
529
+
530
+ private setState(state: VoiceCallState): void {
531
+ if (this.finished && state.kind !== 'ended' && state.kind !== 'error') return;
532
+ this.state = state;
533
+ this.emit();
534
+ }
535
+
536
+ private emit(): void {
537
+ this.options.onChange(this.snapshot());
538
+ }
539
+ }
@@ -1,12 +1,16 @@
1
- import { clampLevel } from './speech-recognition-types';
1
+ /** A level reading clamped to 0..1; anything not a finite number is 0. */
2
+ export function clampLevel(level: number): number {
3
+ if (!Number.isFinite(level)) return 0;
4
+ return Math.min(1, Math.max(0, level));
5
+ }
2
6
 
3
7
  /**
4
8
  * The two live levels of a voice call — what the microphone hears and what the
5
9
  * call is saying — for whatever draws them.
6
10
  *
7
11
  * 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
12
+ * put analysers on. The voice call writes here from its audio engine's
13
+ * captured and played buffers; the monitor subscribes and hands React
10
14
  * a throttled copy. Writes are cheap and never render anything by themselves.
11
15
  */
12
16
  export interface VoiceLevelSource {