@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,350 @@
1
+ /**
2
+ * The web voice-call engine: one `AudioContext`, the microphone through
3
+ * `getUserMedia` (echo cancellation, noise suppression, automatic gain), an
4
+ * `AudioWorklet` that turns it into 24 kHz PCM16 frames, and a second
5
+ * `AudioWorklet` that plays the answer from a jitter-buffered queue.
6
+ *
7
+ * Both worklets run in the same context, so the page's audio graph is one
8
+ * stack. Whether Chrome's echo canceller removes Web Audio playback (it is
9
+ * proven for audio played through an `RTCPeerConnection`) is a release gate of
10
+ * ADR 0017 §4, measured on real hardware; the fallback it names (routing
11
+ * playback through a local `RTCPeerConnection` loopback) is not built until the
12
+ * measurement says it is needed.
13
+ *
14
+ * The worklets are inline source turned into a Blob URL: the SDK ships raw
15
+ * source and a host bundler would not emit a separate worklet file.
16
+ */
17
+
18
+ import { levelOfPcm16, pcm16ToFloat } from './pcm';
19
+ import { VOICE_FRAME_SAMPLES, VOICE_SAMPLE_RATE } from './voice-protocol';
20
+ import {
21
+ VoiceAudioEngineError,
22
+ type VoiceAudioEngine,
23
+ type VoiceAudioEngineHandlers,
24
+ } from './voice-audio-engine-types';
25
+
26
+ export * from './voice-audio-engine-types';
27
+
28
+ /** Played before the first sample of a burst, so network jitter does not stutter it. */
29
+ const PREBUFFER_MS = 80;
30
+ /** A burst shorter than the prebuffer still plays once nothing more arrives for this long. */
31
+ const PREBUFFER_IDLE_MS = 100;
32
+
33
+ const CAPTURE_PROCESSOR = `
34
+ class AliaVoiceCapture extends AudioWorkletProcessor {
35
+ constructor() {
36
+ super();
37
+ this.ratio = sampleRate / ${VOICE_SAMPLE_RATE};
38
+ this.t = 0;
39
+ this.prev = 0;
40
+ this.frame = new Int16Array(${VOICE_FRAME_SAMPLES});
41
+ this.filled = 0;
42
+ }
43
+ process(inputs) {
44
+ const input = inputs[0] && inputs[0][0];
45
+ if (!input || input.length === 0) return true;
46
+ const n = input.length;
47
+ while (this.t < n - 1) {
48
+ const i = Math.floor(this.t);
49
+ const f = this.t - i;
50
+ const a = i < 0 ? this.prev : input[i];
51
+ const b = input[i + 1];
52
+ let s = a + (b - a) * f;
53
+ s = s < -1 ? -1 : s > 1 ? 1 : s;
54
+ this.frame[this.filled++] = s < 0 ? s * 0x8000 : s * 0x7fff;
55
+ if (this.filled === this.frame.length) {
56
+ const out = this.frame;
57
+ this.port.postMessage(out.buffer, [out.buffer]);
58
+ this.frame = new Int16Array(${VOICE_FRAME_SAMPLES});
59
+ this.filled = 0;
60
+ }
61
+ this.t += this.ratio;
62
+ }
63
+ this.t -= n;
64
+ this.prev = input[n - 1];
65
+ return true;
66
+ }
67
+ }
68
+ registerProcessor('alia-voice-capture', AliaVoiceCapture);
69
+ `;
70
+
71
+ const PLAYBACK_PROCESSOR = `
72
+ class AliaVoicePlayback extends AudioWorkletProcessor {
73
+ constructor() {
74
+ super();
75
+ this.step = ${VOICE_SAMPLE_RATE} / sampleRate;
76
+ this.queue = [];
77
+ this.queued = 0;
78
+ this.pos = 0;
79
+ this.buffering = true;
80
+ this.lastChunkAt = 0;
81
+ this.item = null;
82
+ this.played = 0;
83
+ this.sinceReport = 0;
84
+ this.sumSquares = 0;
85
+ this.count = 0;
86
+ this.port.onmessage = (event) => {
87
+ const message = event.data;
88
+ if (message.type === 'chunk') {
89
+ this.queue.push({ itemId: message.itemId, samples: message.samples });
90
+ this.queued += message.samples.length;
91
+ this.lastChunkAt = currentTime;
92
+ } else if (message.type === 'flush') {
93
+ const playedMs = this.item === null ? null : (this.played / ${VOICE_SAMPLE_RATE}) * 1000;
94
+ this.port.postMessage({ type: 'flushed', itemId: this.item, playedMs });
95
+ this.queue = [];
96
+ this.queued = 0;
97
+ this.pos = 0;
98
+ this.buffering = true;
99
+ this.item = null;
100
+ this.played = 0;
101
+ }
102
+ };
103
+ }
104
+ report(force) {
105
+ if (!force && this.sinceReport < sampleRate / 20) return;
106
+ const rms = this.count === 0 ? 0 : Math.sqrt(this.sumSquares / this.count);
107
+ this.port.postMessage({
108
+ type: 'progress',
109
+ itemId: this.item,
110
+ playedMs: (this.played / ${VOICE_SAMPLE_RATE}) * 1000,
111
+ rms,
112
+ });
113
+ this.sinceReport = 0;
114
+ this.sumSquares = 0;
115
+ this.count = 0;
116
+ }
117
+ process(_inputs, outputs) {
118
+ const out = outputs[0][0];
119
+ if (this.buffering) {
120
+ const ready =
121
+ this.queued >= ${(VOICE_SAMPLE_RATE * PREBUFFER_MS) / 1000} ||
122
+ (this.queued > 0 && currentTime - this.lastChunkAt > ${PREBUFFER_IDLE_MS / 1000});
123
+ if (!ready) {
124
+ out.fill(0);
125
+ for (let c = 1; c < outputs[0].length; c++) outputs[0][c].fill(0);
126
+ return true;
127
+ }
128
+ this.buffering = false;
129
+ }
130
+ for (let k = 0; k < out.length; k++) {
131
+ const head = this.queue[0];
132
+ if (!head) {
133
+ out[k] = 0;
134
+ if (!this.buffering) {
135
+ this.buffering = true;
136
+ this.report(true);
137
+ this.port.postMessage({ type: 'idle' });
138
+ }
139
+ continue;
140
+ }
141
+ if (head.itemId !== this.item) {
142
+ this.item = head.itemId;
143
+ this.played = 0;
144
+ this.port.postMessage({ type: 'started', itemId: head.itemId });
145
+ }
146
+ const i = Math.floor(this.pos);
147
+ const f = this.pos - i;
148
+ const a = head.samples[i];
149
+ const b = i + 1 < head.samples.length ? head.samples[i + 1] : a;
150
+ const s = a + (b - a) * f;
151
+ out[k] = s;
152
+ this.sumSquares += s * s;
153
+ this.count++;
154
+ this.pos += this.step;
155
+ const consumed = Math.floor(this.pos);
156
+ if (consumed > 0) {
157
+ const taken = Math.min(consumed, head.samples.length);
158
+ this.played += taken;
159
+ this.queued -= taken;
160
+ if (consumed >= head.samples.length) {
161
+ this.queue.shift();
162
+ this.pos = 0;
163
+ } else {
164
+ head.samples = head.samples.subarray(consumed);
165
+ this.pos -= consumed;
166
+ }
167
+ }
168
+ }
169
+ for (let c = 1; c < outputs[0].length; c++) outputs[0][c].set(out);
170
+ this.sinceReport += out.length;
171
+ this.report(false);
172
+ return true;
173
+ }
174
+ }
175
+ registerProcessor('alia-voice-playback', AliaVoicePlayback);
176
+ `;
177
+
178
+ function moduleUrl(source: string): string {
179
+ return URL.createObjectURL(new Blob([source], { type: 'application/javascript' }));
180
+ }
181
+
182
+ function deviceError(caught: unknown): VoiceAudioEngineError {
183
+ const name = (caught as { name?: unknown } | null)?.name;
184
+ if (name === 'NotAllowedError' || name === 'SecurityError' || name === 'PermissionDeniedError')
185
+ return new VoiceAudioEngineError('mic-denied');
186
+ return new VoiceAudioEngineError(
187
+ 'mic-unavailable',
188
+ typeof name === 'string' ? name : 'The microphone could not be opened',
189
+ );
190
+ }
191
+
192
+ /** Level of the RMS a worklet reported, on the dictation meter's dBFS scale. */
193
+ function levelOfRms(rms: number): number {
194
+ if (rms <= 0) return 0;
195
+ return Math.min(1, Math.max(0, (20 * Math.log10(rms) + 60) / 60));
196
+ }
197
+
198
+ export function createVoiceAudioEngine(): VoiceAudioEngine {
199
+ let context: AudioContext | null = null;
200
+ let stream: MediaStream | null = null;
201
+ let capture: AudioWorkletNode | null = null;
202
+ let playback: AudioWorkletNode | null = null;
203
+ let stopped = false;
204
+ let pendingFlush: ((value: { itemId: string; playedMs: number } | null) => void) | null = null;
205
+ let lastProgress: { itemId: string; playedMs: number } | null = null;
206
+
207
+ return {
208
+ async start(handlers: VoiceAudioEngineHandlers): Promise<void> {
209
+ const media = globalThis.navigator?.mediaDevices;
210
+ const AudioContextCtor = globalThis.AudioContext;
211
+ if (!media?.getUserMedia || !AudioContextCtor || !('AudioWorkletNode' in globalThis)) {
212
+ throw new VoiceAudioEngineError('voice-unavailable');
213
+ }
214
+ try {
215
+ stream = await media.getUserMedia({
216
+ audio: {
217
+ echoCancellation: true,
218
+ noiseSuppression: true,
219
+ autoGainControl: true,
220
+ channelCount: 1,
221
+ },
222
+ });
223
+ } catch (caught: unknown) {
224
+ throw deviceError(caught);
225
+ }
226
+ if (stopped) {
227
+ for (const track of stream.getTracks()) track.stop();
228
+ return;
229
+ }
230
+
231
+ context = new AudioContextCtor({ latencyHint: 'interactive' });
232
+ const captureUrl = moduleUrl(CAPTURE_PROCESSOR);
233
+ const playbackUrl = moduleUrl(PLAYBACK_PROCESSOR);
234
+ try {
235
+ await context.audioWorklet.addModule(captureUrl);
236
+ await context.audioWorklet.addModule(playbackUrl);
237
+ } catch {
238
+ throw new VoiceAudioEngineError('voice-unavailable');
239
+ } finally {
240
+ URL.revokeObjectURL(captureUrl);
241
+ URL.revokeObjectURL(playbackUrl);
242
+ }
243
+ await context.resume().catch(() => undefined);
244
+
245
+ const source = context.createMediaStreamSource(stream);
246
+ capture = new AudioWorkletNode(context, 'alia-voice-capture', {
247
+ numberOfInputs: 1,
248
+ numberOfOutputs: 0,
249
+ channelCount: 1,
250
+ });
251
+ capture.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
252
+ if (stopped) return;
253
+ const pcm = new Uint8Array(event.data);
254
+ handlers.onCaptureLevel(levelOfPcm16(pcm));
255
+ handlers.onCaptureFrame(pcm);
256
+ };
257
+ source.connect(capture);
258
+
259
+ playback = new AudioWorkletNode(context, 'alia-voice-playback', {
260
+ numberOfInputs: 0,
261
+ numberOfOutputs: 1,
262
+ outputChannelCount: [context.destination.channelCount || 2],
263
+ });
264
+ playback.port.onmessage = (event: MessageEvent) => {
265
+ const message = event.data as
266
+ | { type: 'started'; itemId: string }
267
+ | { type: 'progress'; itemId: string | null; playedMs: number; rms: number }
268
+ | { type: 'idle' }
269
+ | { type: 'flushed'; itemId: string | null; playedMs: number | null };
270
+ if (stopped) return;
271
+ switch (message.type) {
272
+ case 'started':
273
+ handlers.onPlaybackStarted(message.itemId);
274
+ return;
275
+ case 'progress':
276
+ lastProgress =
277
+ message.itemId === null
278
+ ? null
279
+ : { itemId: message.itemId, playedMs: message.playedMs };
280
+ handlers.onPlaybackLevel(levelOfRms(message.rms));
281
+ return;
282
+ case 'idle':
283
+ handlers.onPlaybackLevel(0);
284
+ handlers.onPlaybackIdle();
285
+ return;
286
+ case 'flushed': {
287
+ const resolve = pendingFlush;
288
+ pendingFlush = null;
289
+ handlers.onPlaybackLevel(0);
290
+ resolve?.(
291
+ message.itemId === null || message.playedMs === null
292
+ ? null
293
+ : { itemId: message.itemId, playedMs: Math.round(message.playedMs) },
294
+ );
295
+ }
296
+ }
297
+ };
298
+ playback.connect(context.destination);
299
+
300
+ for (const track of stream.getAudioTracks()) {
301
+ track.addEventListener('ended', () => {
302
+ if (!stopped) handlers.onError(new VoiceAudioEngineError('mic-unavailable'));
303
+ });
304
+ }
305
+ },
306
+
307
+ enqueue(itemId: string, pcm16: Uint8Array): void {
308
+ if (stopped || playback === null) return;
309
+ const samples = pcm16ToFloat(pcm16);
310
+ playback.port.postMessage({ type: 'chunk', itemId, samples }, [samples.buffer]);
311
+ },
312
+
313
+ flush(): Promise<{ itemId: string; playedMs: number } | null> {
314
+ if (stopped || playback === null) return Promise.resolve(null);
315
+ const fallback = lastProgress;
316
+ return new Promise((resolve) => {
317
+ // The worklet answers within a render quantum; if the context is
318
+ // suspended it never will, and the last progress report is the answer.
319
+ const timer = setTimeout(() => {
320
+ pendingFlush = null;
321
+ resolve(
322
+ fallback === null ? null : { ...fallback, playedMs: Math.round(fallback.playedMs) },
323
+ );
324
+ }, 250);
325
+ pendingFlush = (value) => {
326
+ clearTimeout(timer);
327
+ resolve(value);
328
+ };
329
+ playback?.port.postMessage({ type: 'flush' });
330
+ });
331
+ },
332
+
333
+ stop(): void {
334
+ if (stopped) return;
335
+ stopped = true;
336
+ pendingFlush?.(null);
337
+ pendingFlush = null;
338
+ capture?.port.close();
339
+ playback?.port.close();
340
+ capture?.disconnect();
341
+ playback?.disconnect();
342
+ for (const track of stream?.getTracks() ?? []) track.stop();
343
+ void context?.close().catch(() => undefined);
344
+ capture = null;
345
+ playback = null;
346
+ stream = null;
347
+ context = null;
348
+ },
349
+ };
350
+ }