@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.
- package/CHANGELOG.md +129 -0
- package/README.md +52 -20
- package/package.json +8 -4
- 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 +57 -11
- 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
|
@@ -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
|
+
}
|