@assistant-ui/react 0.15.32 → 0.15.33

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,880 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ WORKLET_SOURCE,
4
+ createPcmPlayer,
5
+ createResampler,
6
+ decodePcm16,
7
+ encodePcm16,
8
+ openMicrophone,
9
+ readLevel,
10
+ } from "./audio";
11
+
12
+ class FakeTrack {
13
+ enabled = true;
14
+ stop = vi.fn();
15
+ }
16
+
17
+ class FakeSource {
18
+ buffer: unknown = null;
19
+ onended: (() => void) | null = null;
20
+ connect = vi.fn();
21
+ start = vi.fn();
22
+ stop = vi.fn();
23
+ }
24
+
25
+ class FakeWorklet {
26
+ static instances: FakeWorklet[] = [];
27
+ port: { onmessage: ((event: { data: unknown }) => void) | null } = {
28
+ onmessage: null,
29
+ };
30
+ connect = vi.fn();
31
+ disconnect = vi.fn();
32
+ options: unknown;
33
+ constructor(_context: unknown, _name: string, options?: unknown) {
34
+ this.options = options;
35
+ FakeWorklet.instances.push(this);
36
+ }
37
+ }
38
+
39
+ class FakeContext {
40
+ static instances: FakeContext[] = [];
41
+ static workletSupported = true;
42
+ currentTime = 0;
43
+ state = "running";
44
+ sampleRate: number;
45
+ sources: FakeSource[] = [];
46
+ destination = {};
47
+ static addModule: (url: string) => Promise<void> = async () => {};
48
+ audioWorklet = FakeContext.workletSupported
49
+ ? { addModule: vi.fn((url: string) => FakeContext.addModule(url)) }
50
+ : undefined;
51
+ mediaSource = { connect: vi.fn(), disconnect: vi.fn() };
52
+ processor = {
53
+ connect: vi.fn(),
54
+ disconnect: vi.fn(),
55
+ onaudioprocess: null as ((event: unknown) => void) | null,
56
+ };
57
+ analyser = { fftSize: 0, connect: vi.fn(), getByteTimeDomainData: vi.fn() };
58
+ constructor(options?: { sampleRate: number }) {
59
+ this.sampleRate = options?.sampleRate ?? 48000;
60
+ FakeContext.instances.push(this);
61
+ }
62
+ createAnalyser = vi.fn(() => this.analyser);
63
+ createMediaStreamSource = vi.fn(() => this.mediaSource);
64
+ createScriptProcessor = vi.fn(
65
+ (_bufferSize: number, _inputChannels: number, _outputChannels: number) =>
66
+ this.processor,
67
+ );
68
+ createBuffer = vi.fn((_channels: number, length: number, rate: number) => ({
69
+ duration: length / rate,
70
+ copyToChannel: vi.fn(),
71
+ }));
72
+ createBufferSource = vi.fn(() => {
73
+ const source = new FakeSource();
74
+ this.sources.push(source);
75
+ return source;
76
+ });
77
+ resume: () => Promise<void> = vi.fn(async () => {});
78
+ close = vi.fn(async () => {
79
+ if (this.state === "closed")
80
+ throw new DOMException(
81
+ "AudioContext is already closed",
82
+ "InvalidStateError",
83
+ );
84
+ this.state = "closed";
85
+ });
86
+ }
87
+
88
+ const installBrowser = () => {
89
+ const track = new FakeTrack();
90
+ const getUserMedia = vi.fn(async () => ({
91
+ getTracks: () => [track],
92
+ getAudioTracks: () => [track],
93
+ }));
94
+ const createObjectURL = vi.fn(() => "blob:worklet");
95
+ const revokeObjectURL = vi.fn();
96
+ vi.stubGlobal("AudioContext", FakeContext);
97
+ vi.stubGlobal("AudioWorkletNode", FakeWorklet);
98
+ vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
99
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
100
+ return { track, getUserMedia, createObjectURL, revokeObjectURL };
101
+ };
102
+
103
+ const chunk = (length: number) =>
104
+ encodePcm16(new Float32Array(length).fill(0.1));
105
+
106
+ describe("pcm", () => {
107
+ it("round-trips samples through little-endian int16", () => {
108
+ const samples = Float32Array.from({ length: 1000 }, (_, i) =>
109
+ Math.sin(i / 10),
110
+ );
111
+ const decoded = decodePcm16(encodePcm16(samples));
112
+ expect(decoded.length).toBe(1000);
113
+ for (let i = 0; i < samples.length; i++) {
114
+ expect(Math.abs(decoded[i]! - samples[i]!)).toBeLessThan(2 / 32768);
115
+ }
116
+ });
117
+ it("clamps out-of-range samples", () => {
118
+ const decoded = decodePcm16(encodePcm16(Float32Array.from([2, -2])));
119
+ expect(decoded[0]).toBeCloseTo(1, 3);
120
+ expect(decoded[1]).toBe(-1);
121
+ });
122
+ it("round-trips an empty buffer", () => {
123
+ expect(encodePcm16(new Float32Array(0))).toBe("");
124
+ expect(decodePcm16("").length).toBe(0);
125
+ });
126
+ it("encodes buffers longer than one fromCharCode chunk", () => {
127
+ const samples = new Float32Array(40000).fill(0.5);
128
+ expect(decodePcm16(encodePcm16(samples)).length).toBe(40000);
129
+ });
130
+ });
131
+ describe("level", () => {
132
+ const analyser = (fill: (i: number) => number) =>
133
+ ({
134
+ fftSize: 256,
135
+ getByteTimeDomainData: (data: Uint8Array) => {
136
+ for (let i = 0; i < data.length; i++) data[i] = fill(i);
137
+ },
138
+ }) as unknown as AnalyserNode;
139
+ it("is 0 for silence", () => {
140
+ expect(readLevel(analyser(() => 128))).toBe(0);
141
+ });
142
+ it("is 1 for a full-swing square wave", () => {
143
+ expect(readLevel(analyser((i) => (i % 2 ? 255 : 0)))).toBe(1);
144
+ });
145
+ it("never exceeds 1", () => {
146
+ expect(readLevel(analyser(() => 255))).toBeLessThanOrEqual(1);
147
+ });
148
+ });
149
+
150
+ const ramp = (start: number, length: number, scale = 1) =>
151
+ Float32Array.from({ length }, (_, i) => (start + i) * scale);
152
+
153
+ const tone = (
154
+ frequency: number,
155
+ sampleRate: number,
156
+ start: number,
157
+ length: number,
158
+ ) =>
159
+ Float32Array.from({ length }, (_, i) =>
160
+ Math.sin((2 * Math.PI * frequency * (start + i)) / sampleRate),
161
+ );
162
+
163
+ const rms = (samples: number[]) =>
164
+ Math.sqrt(
165
+ samples.reduce((sum, sample) => sum + sample * sample, 0) / samples.length,
166
+ );
167
+
168
+ const loadWorklet = (sampleRate: number, targetRate: number) => {
169
+ const posted: Float32Array[] = [];
170
+ class AudioWorkletProcessor {
171
+ port = { postMessage: (data: Float32Array) => posted.push(data) };
172
+ }
173
+ let Processor:
174
+ | (new (options: { processorOptions: { targetRate: number } }) => {
175
+ process: (inputs: Float32Array[][]) => boolean;
176
+ })
177
+ | undefined;
178
+ new Function(
179
+ "AudioWorkletProcessor",
180
+ "sampleRate",
181
+ "registerProcessor",
182
+ WORKLET_SOURCE,
183
+ )(AudioWorkletProcessor, sampleRate, (_: string, cls: typeof Processor) => {
184
+ Processor = cls;
185
+ });
186
+ const processor = new Processor!({ processorOptions: { targetRate } });
187
+ return {
188
+ process: (...channels: Float32Array[]) => processor.process([channels]),
189
+ posted,
190
+ };
191
+ };
192
+
193
+ const resampleWithWorklet = (frequency: number, sampleRate = 48000) => {
194
+ const worklet = loadWorklet(sampleRate, 16000);
195
+ const input = tone(frequency, sampleRate, 0, sampleRate);
196
+ for (let start = 0; start < input.length; start += 128)
197
+ worklet.process(input.subarray(start, start + 128));
198
+ return worklet.posted.flatMap((data) => [...data]);
199
+ };
200
+
201
+ describe("resampler", () => {
202
+ it.each([
203
+ [48000, 1365],
204
+ [44100, 1486],
205
+ ])(
206
+ "keeps phase and filter state across 2048-frame buffers at %i Hz",
207
+ (rate, count) => {
208
+ const ratio = rate / 16000;
209
+ const input = ramp(0, 4096);
210
+ const expected = [...createResampler(ratio)(input)];
211
+ const resample = createResampler(ratio);
212
+ const out = [...resample(ramp(0, 2048)), ...resample(ramp(2048, 2048))];
213
+ expect(out).toHaveLength(count);
214
+ expect(out).toEqual(expected);
215
+ },
216
+ );
217
+
218
+ it("interpolates across the boundary between buffers", () => {
219
+ const input = ramp(0, 6);
220
+ const expected = [...createResampler(2.5)(input)];
221
+ const resample = createResampler(2.5);
222
+ expect([...resample(ramp(0, 3)), ...resample(ramp(3, 3))]).toEqual(
223
+ expected,
224
+ );
225
+ });
226
+
227
+ it("ignores empty buffers", () => {
228
+ const resample = createResampler(3);
229
+ expect(resample(new Float32Array(0))).toHaveLength(0);
230
+ const out = [...resample(ramp(0, 5))];
231
+ expect(out).toHaveLength(2);
232
+ expect(out).toEqual([...createResampler(3)(ramp(0, 5))]);
233
+ });
234
+
235
+ it("attenuates tones above Nyquist while retaining speech-band tones in both paths", () => {
236
+ for (const sampleRate of [48000, 44100]) {
237
+ const ratio = sampleRate / 16000;
238
+ const scriptHigh = [
239
+ ...createResampler(ratio)(tone(12000, sampleRate, 0, sampleRate)),
240
+ ];
241
+ const scriptSpeech = [
242
+ ...createResampler(ratio)(tone(4000, sampleRate, 0, sampleRate)),
243
+ ];
244
+ const workletHigh = resampleWithWorklet(12000, sampleRate);
245
+ const workletSpeech = resampleWithWorklet(4000, sampleRate);
246
+
247
+ for (const { high, speech } of [
248
+ { high: scriptHigh, speech: scriptSpeech },
249
+ { high: workletHigh, speech: workletSpeech },
250
+ ]) {
251
+ expect(rms(high.slice(100))).toBeLessThan(0.05);
252
+ expect(rms(speech.slice(100))).toBeGreaterThan(0.65);
253
+ }
254
+ }
255
+ });
256
+
257
+ it("produces the same output in the worklet", () => {
258
+ const worklet = loadWorklet(44100, 16000);
259
+ const resample = createResampler(44100 / 16000);
260
+ const expected: number[] = [];
261
+ for (let block = 0; block < 32; block++) {
262
+ const input = ramp(block * 128, 128, 1 / 4096);
263
+ worklet.process(input);
264
+ expected.push(...resample(input));
265
+ }
266
+ const out = worklet.posted.flatMap((data) => [...data]);
267
+ expect(expected).toHaveLength(1486);
268
+ expect(out.length).toBeGreaterThan(1486 - 320);
269
+ expect(out.length).toBeLessThanOrEqual(1486);
270
+ expect(out).toEqual(expected.slice(0, out.length));
271
+ });
272
+
273
+ it("posts the worklet output in 20 ms chunks", () => {
274
+ const worklet = loadWorklet(48000, 16000);
275
+ for (let block = 0; block < 16; block++) worklet.process(ramp(0, 128));
276
+ expect(worklet.posted).toHaveLength(2);
277
+ for (const data of worklet.posted)
278
+ expect(data.length).toBeGreaterThanOrEqual(320);
279
+ });
280
+
281
+ it("downmixes every input channel in the worklet", () => {
282
+ const worklet = loadWorklet(16000, 16000);
283
+ for (let block = 0; block < 3; block++)
284
+ worklet.process(new Float32Array(128), new Float32Array(128).fill(0.5));
285
+ const out = worklet.posted.flatMap((data) => [...data]);
286
+ expect(out.length).toBeGreaterThan(0);
287
+ for (const value of out) expect(value).toBeCloseTo(0.25, 6);
288
+ });
289
+ });
290
+
291
+ describe("microphone", () => {
292
+ afterEach(() => {
293
+ vi.unstubAllGlobals();
294
+ FakeContext.instances = [];
295
+ FakeContext.workletSupported = true;
296
+ FakeContext.addModule = async () => {};
297
+ FakeWorklet.instances = [];
298
+ });
299
+
300
+ it("requests the microphone and opens a context at the device rate", async () => {
301
+ const browser = installBrowser();
302
+ await openMicrophone({ sampleRate: 16000, onChunk: () => {} });
303
+ expect(browser.getUserMedia).toHaveBeenCalledWith({ audio: true });
304
+ expect(FakeContext.instances[0]?.sampleRate).toBe(48000);
305
+ });
306
+
307
+ it("resumes a suspended capture context", async () => {
308
+ installBrowser();
309
+ vi.stubGlobal(
310
+ "AudioContext",
311
+ class extends FakeContext {
312
+ override state = "suspended";
313
+ },
314
+ );
315
+ await openMicrophone({ sampleRate: 16000, onChunk: () => {} });
316
+ expect(FakeContext.instances[0]?.resume).toHaveBeenCalled();
317
+ });
318
+
319
+ it("passes the target rate to the worklet", async () => {
320
+ installBrowser();
321
+ await openMicrophone({ sampleRate: 16000, onChunk: () => {} });
322
+ expect(FakeWorklet.instances[0]?.options).toEqual({
323
+ processorOptions: { targetRate: 16000 },
324
+ });
325
+ });
326
+
327
+ it("loads the worklet from a blob url and revokes it", async () => {
328
+ const browser = installBrowser();
329
+ await openMicrophone({ sampleRate: 16000, onChunk: () => {} });
330
+ expect(
331
+ FakeContext.instances[0]?.audioWorklet?.addModule,
332
+ ).toHaveBeenCalledWith("blob:worklet");
333
+ expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet");
334
+ });
335
+
336
+ it("forwards worklet chunks as base64", async () => {
337
+ installBrowser();
338
+ const onChunk = vi.fn();
339
+ await openMicrophone({ sampleRate: 16000, onChunk });
340
+ const samples = Float32Array.from([0.5, -0.5]);
341
+ FakeWorklet.instances[0]?.port.onmessage?.({ data: samples });
342
+ expect(onChunk).toHaveBeenCalledWith(encodePcm16(samples));
343
+ });
344
+
345
+ it("falls back to a script processor without worklet support", async () => {
346
+ FakeContext.workletSupported = false;
347
+ installBrowser();
348
+ const onChunk = vi.fn();
349
+ await openMicrophone({ sampleRate: 16000, onChunk });
350
+ const context = FakeContext.instances[0]!;
351
+ expect(context.createScriptProcessor).toHaveBeenCalledWith(1024, 1, 1);
352
+ expect(context.mediaSource.connect).toHaveBeenCalledWith(context.analyser);
353
+ expect(context.analyser.connect).toHaveBeenCalledWith(context.processor);
354
+ expect(context.processor.connect).toHaveBeenCalledWith(context.destination);
355
+ const samples = Float32Array.from([0, 0.3, 0.6, 0.9, 1.2, 1.5]);
356
+ context.processor.onaudioprocess?.({
357
+ inputBuffer: { getChannelData: () => samples },
358
+ });
359
+ expect(decodePcm16(onChunk.mock.calls[0]![0] as string)).toHaveLength(2);
360
+ expect(FakeWorklet.instances).toHaveLength(0);
361
+ });
362
+
363
+ it.each([
364
+ [48000, 3, 683],
365
+ [44100, 2.75625, 743],
366
+ ])(
367
+ "keeps fallback chunks below 25 ms at %i Hz without losing samples",
368
+ async (rate, ratio, count) => {
369
+ FakeContext.workletSupported = false;
370
+ installBrowser();
371
+ vi.stubGlobal(
372
+ "AudioContext",
373
+ class extends FakeContext {
374
+ constructor() {
375
+ super({ sampleRate: rate });
376
+ }
377
+ },
378
+ );
379
+ const onChunk = vi.fn();
380
+ await openMicrophone({ sampleRate: 16000, onChunk });
381
+ const context = FakeContext.instances[0]!;
382
+ const size = context.createScriptProcessor.mock.calls[0]![0];
383
+ expect(size / rate).toBeLessThan(0.025);
384
+ const inputs = [0, size].map((start) => ramp(start, size, 1 / 8192));
385
+ const resample = createResampler(ratio);
386
+ const expected = inputs.flatMap((input) => [...resample(input)]);
387
+ for (const input of inputs)
388
+ context.processor.onaudioprocess?.({
389
+ inputBuffer: { getChannelData: () => input },
390
+ });
391
+ const out = onChunk.mock.calls.flatMap(([base64]) => [
392
+ ...decodePcm16(base64 as string),
393
+ ]);
394
+ expect(out).toHaveLength(count);
395
+ for (const [k, value] of out.entries())
396
+ expect(value).toBeCloseTo(expected[k]!, 4);
397
+ },
398
+ );
399
+
400
+ it("captures with the fallback when the worklet module is blocked", async () => {
401
+ FakeContext.addModule = async () => {
402
+ throw new DOMException("Blocked by CSP", "SecurityError");
403
+ };
404
+ const browser = installBrowser();
405
+ const onChunk = vi.fn();
406
+ const microphone = await openMicrophone({ sampleRate: 16000, onChunk });
407
+ const context = FakeContext.instances[0]!;
408
+ const handler = context.processor.onaudioprocess!;
409
+ const samples = Float32Array.from([0, 0, 0, 0.5, 0, 0]);
410
+ const event = {
411
+ inputBuffer: {
412
+ getChannelData: () => samples,
413
+ },
414
+ };
415
+ handler(event);
416
+ expect(onChunk).toHaveBeenCalledWith(
417
+ encodePcm16(createResampler(3)(samples)),
418
+ );
419
+ expect(browser.track.stop).not.toHaveBeenCalled();
420
+ expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet");
421
+ expect(FakeWorklet.instances).toHaveLength(0);
422
+ microphone.setMuted(true);
423
+ handler(event);
424
+ expect(onChunk).toHaveBeenCalledTimes(1);
425
+ microphone.setMuted(false);
426
+ await microphone.close();
427
+ handler(event);
428
+ expect(onChunk).toHaveBeenCalledTimes(1);
429
+ expect(context.processor.onaudioprocess).toBeNull();
430
+ expect(browser.track.stop).toHaveBeenCalled();
431
+ expect(context.close).toHaveBeenCalled();
432
+ });
433
+
434
+ it("stops the tracks and closes the context when both capture paths fail", async () => {
435
+ FakeContext.addModule = async () => {
436
+ throw new Error("bad module");
437
+ };
438
+ const browser = installBrowser();
439
+ vi.stubGlobal(
440
+ "AudioContext",
441
+ class extends FakeContext {
442
+ override createScriptProcessor = vi.fn(() => {
443
+ throw new Error("processor unavailable");
444
+ });
445
+ },
446
+ );
447
+ await expect(
448
+ openMicrophone({ sampleRate: 16000, onChunk: () => {} }),
449
+ ).rejects.toThrow("processor unavailable");
450
+ expect(browser.track.stop).toHaveBeenCalled();
451
+ expect(FakeContext.instances[0]?.close).toHaveBeenCalled();
452
+ expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet");
453
+ expect(FakeWorklet.instances).toHaveLength(0);
454
+ });
455
+
456
+ it.each([false, true])(
457
+ "cleans up an aborted setup when the module rejects: %s",
458
+ async (rejectModule) => {
459
+ let finishModule!: () => void;
460
+ FakeContext.addModule = () =>
461
+ new Promise<void>((resolve, reject) => {
462
+ finishModule = rejectModule
463
+ ? () => reject(new Error("blocked module"))
464
+ : resolve;
465
+ });
466
+ const browser = installBrowser();
467
+ const controller = new AbortController();
468
+ const opening = openMicrophone({
469
+ sampleRate: 16000,
470
+ onChunk: () => {},
471
+ signal: controller.signal,
472
+ });
473
+ await vi.waitFor(() =>
474
+ expect(
475
+ FakeContext.instances[0]?.audioWorklet?.addModule,
476
+ ).toHaveBeenCalled(),
477
+ );
478
+ controller.abort(new Error("cancelled"));
479
+ finishModule();
480
+ await expect(opening).rejects.toThrow("cancelled");
481
+ expect(browser.track.stop).toHaveBeenCalled();
482
+ expect(FakeContext.instances[0]?.close).toHaveBeenCalled();
483
+ expect(
484
+ FakeContext.instances[0]?.createScriptProcessor,
485
+ ).not.toHaveBeenCalled();
486
+ expect(FakeWorklet.instances).toHaveLength(0);
487
+ },
488
+ );
489
+
490
+ it("mutes by disabling the track and dropping chunks", async () => {
491
+ const browser = installBrowser();
492
+ const onChunk = vi.fn();
493
+ const microphone = await openMicrophone({ sampleRate: 16000, onChunk });
494
+ const samples = Float32Array.from([0.5]);
495
+ microphone.setMuted(true);
496
+ expect(browser.track.enabled).toBe(false);
497
+ FakeWorklet.instances[0]?.port.onmessage?.({ data: samples });
498
+ expect(onChunk).not.toHaveBeenCalled();
499
+ microphone.setMuted(false);
500
+ expect(browser.track.enabled).toBe(true);
501
+ FakeWorklet.instances[0]?.port.onmessage?.({ data: samples });
502
+ expect(onChunk).toHaveBeenCalledTimes(1);
503
+ });
504
+
505
+ it("drops worklet messages that arrive after close", async () => {
506
+ installBrowser();
507
+ const onChunk = vi.fn();
508
+ const microphone = await openMicrophone({ sampleRate: 16000, onChunk });
509
+ const worklet = FakeWorklet.instances[0]!;
510
+ const handler = worklet.port.onmessage;
511
+ await microphone.close();
512
+ expect(worklet.port.onmessage).toBeNull();
513
+ handler?.({ data: Float32Array.from([0.5]) });
514
+ expect(onChunk).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("closes the graph, the tracks, and the context", async () => {
518
+ const browser = installBrowser();
519
+ const microphone = await openMicrophone({
520
+ sampleRate: 16000,
521
+ onChunk: () => {},
522
+ });
523
+ await microphone.close();
524
+ const context = FakeContext.instances[0]!;
525
+ expect(FakeWorklet.instances[0]?.disconnect).toHaveBeenCalled();
526
+ expect(context.mediaSource.disconnect).toHaveBeenCalled();
527
+ expect(browser.track.stop).toHaveBeenCalled();
528
+ expect(context.close).toHaveBeenCalled();
529
+ });
530
+
531
+ it("rejects without requesting permission when already aborted", async () => {
532
+ const browser = installBrowser();
533
+ const controller = new AbortController();
534
+ controller.abort(new Error("cancelled"));
535
+ await expect(
536
+ openMicrophone({
537
+ sampleRate: 16000,
538
+ onChunk: () => {},
539
+ signal: controller.signal,
540
+ }),
541
+ ).rejects.toThrow("cancelled");
542
+ expect(browser.getUserMedia).not.toHaveBeenCalled();
543
+ expect(FakeContext.instances).toHaveLength(0);
544
+ });
545
+
546
+ it.each(["granted", "denied"])(
547
+ "cancels a pending permission request before it is %s",
548
+ async (permission) => {
549
+ const browser = installBrowser();
550
+ let grant!: (
551
+ stream: Awaited<ReturnType<typeof browser.getUserMedia>>,
552
+ ) => void;
553
+ let deny!: (reason: unknown) => void;
554
+ browser.getUserMedia.mockReturnValue(
555
+ new Promise((resolve, reject) => {
556
+ grant = resolve;
557
+ deny = reject;
558
+ }),
559
+ );
560
+ const controller = new AbortController();
561
+ const onError = vi.fn();
562
+ const opening = openMicrophone({
563
+ sampleRate: 16000,
564
+ onChunk: () => {},
565
+ signal: controller.signal,
566
+ }).catch(onError);
567
+ const reason = new Error("cancelled");
568
+ controller.abort(reason);
569
+ try {
570
+ await new Promise<void>((resolve) => setImmediate(resolve));
571
+ expect(onError).toHaveBeenCalledWith(reason);
572
+ expect(FakeContext.instances).toHaveLength(0);
573
+ } finally {
574
+ if (permission === "granted")
575
+ grant({
576
+ getTracks: () => [browser.track],
577
+ getAudioTracks: () => [browser.track],
578
+ });
579
+ else deny(new DOMException("Permission denied", "NotAllowedError"));
580
+ await opening;
581
+ await new Promise<void>((resolve) => setImmediate(resolve));
582
+ }
583
+ expect(onError).toHaveBeenCalledTimes(1);
584
+ expect(browser.track.stop).toHaveBeenCalledTimes(
585
+ permission === "granted" ? 1 : 0,
586
+ );
587
+ expect(FakeContext.instances).toHaveLength(0);
588
+ },
589
+ );
590
+
591
+ it("shares pending microphone cleanup across repeated close calls", async () => {
592
+ const browser = installBrowser();
593
+ const microphone = await openMicrophone({
594
+ sampleRate: 16000,
595
+ onChunk: () => {},
596
+ });
597
+ const context = FakeContext.instances[0]!;
598
+ let finishClose!: () => void;
599
+ context.close.mockImplementation(() => {
600
+ if (context.state === "closed")
601
+ return Promise.reject(
602
+ new DOMException("Already closed", "InvalidStateError"),
603
+ );
604
+ context.state = "closed";
605
+ return new Promise<void>((resolve) => {
606
+ finishClose = resolve;
607
+ });
608
+ });
609
+ const settled = vi.fn();
610
+ const first = microphone.close().then(settled);
611
+ const second = microphone.close().then(settled);
612
+ const both = Promise.allSettled([first, second]);
613
+ await Promise.resolve();
614
+ expect(settled).not.toHaveBeenCalled();
615
+ finishClose();
616
+ expect(await both).toEqual([
617
+ { status: "fulfilled", value: undefined },
618
+ { status: "fulfilled", value: undefined },
619
+ ]);
620
+ await expect(microphone.close()).resolves.toBeUndefined();
621
+ expect(context.close).toHaveBeenCalledTimes(1);
622
+ expect(browser.track.stop).toHaveBeenCalledTimes(1);
623
+ });
624
+
625
+ it("releases a stream when cancellation wins just after permission resolves", async () => {
626
+ const browser = installBrowser();
627
+ let grant!: (
628
+ stream: Awaited<ReturnType<typeof browser.getUserMedia>>,
629
+ ) => void;
630
+ browser.getUserMedia.mockReturnValue(
631
+ new Promise((resolve) => {
632
+ grant = resolve;
633
+ }),
634
+ );
635
+ const controller = new AbortController();
636
+ const opening = openMicrophone({
637
+ sampleRate: 16000,
638
+ onChunk: () => {},
639
+ signal: controller.signal,
640
+ });
641
+ const reason = new Error("cancelled");
642
+ grant({
643
+ getTracks: () => [browser.track],
644
+ getAudioTracks: () => [browser.track],
645
+ });
646
+ queueMicrotask(() => controller.abort(reason));
647
+ await expect(opening).rejects.toBe(reason);
648
+ expect(browser.track.stop).toHaveBeenCalledTimes(1);
649
+ });
650
+
651
+ it.each(["resume", "worklet"])(
652
+ "releases the microphone without waiting for pending %s",
653
+ async (stage) => {
654
+ const browser = installBrowser();
655
+ let finish!: () => void;
656
+ const pending = new Promise<void>((resolve) => {
657
+ finish = resolve;
658
+ });
659
+ if (stage === "worklet") FakeContext.addModule = () => pending;
660
+ else
661
+ vi.stubGlobal(
662
+ "AudioContext",
663
+ class extends FakeContext {
664
+ override state = "suspended";
665
+ override resume = () => pending;
666
+ },
667
+ );
668
+ const controller = new AbortController();
669
+ const onError = vi.fn();
670
+ const opening = openMicrophone({
671
+ sampleRate: 16000,
672
+ onChunk: () => {},
673
+ signal: controller.signal,
674
+ }).catch(onError);
675
+ await new Promise<void>((resolve) => setImmediate(resolve));
676
+ const context = FakeContext.instances[0]!;
677
+ const reason = new Error("cancelled");
678
+ controller.abort(reason);
679
+ try {
680
+ await new Promise<void>((resolve) => setImmediate(resolve));
681
+ expect(onError).toHaveBeenCalledWith(reason);
682
+ expect(browser.track.stop).toHaveBeenCalledTimes(1);
683
+ expect(context.state).toBe("closed");
684
+ } finally {
685
+ finish();
686
+ await opening;
687
+ await new Promise<void>((resolve) => setImmediate(resolve));
688
+ }
689
+ expect(FakeWorklet.instances).toHaveLength(0);
690
+ expect(context.createScriptProcessor).not.toHaveBeenCalled();
691
+ expect(context.close).toHaveBeenCalledTimes(1);
692
+ if (stage === "worklet")
693
+ expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet");
694
+ },
695
+ );
696
+ });
697
+
698
+ describe("playback", () => {
699
+ afterEach(() => {
700
+ vi.useRealTimers();
701
+ vi.unstubAllGlobals();
702
+ FakeContext.instances = [];
703
+ });
704
+
705
+ const setup = (onIdle?: () => void) => {
706
+ vi.stubGlobal("AudioContext", FakeContext);
707
+ const player = createPcmPlayer(
708
+ onIdle ? { sampleRate: 24000, onIdle } : { sampleRate: 24000 },
709
+ );
710
+ return { player, context: FakeContext.instances[0]! };
711
+ };
712
+
713
+ it("copies the decoded samples into the buffer", () => {
714
+ const { player, context } = setup();
715
+ const samples = Float32Array.from([0.5, -0.25]);
716
+ player.enqueue(encodePcm16(samples));
717
+ const buffer = context.createBuffer.mock.results[0]!.value;
718
+ expect(context.createBuffer).toHaveBeenCalledWith(1, 2, 24000);
719
+ const copied = buffer.copyToChannel.mock.calls[0]![0] as Float32Array;
720
+ expect(copied[0]).toBeCloseTo(0.5, 3);
721
+ expect(copied[1]).toBeCloseTo(-0.25, 3);
722
+ expect(buffer.copyToChannel).toHaveBeenCalledWith(copied, 0);
723
+ });
724
+
725
+ it("schedules chunks back to back from the current time", () => {
726
+ const { player, context } = setup();
727
+ context.currentTime = 1;
728
+ player.enqueue(chunk(2400));
729
+ player.enqueue(chunk(2400));
730
+ expect(context.sources[0]?.start).toHaveBeenCalledWith(1);
731
+ expect(context.sources[1]?.start).toHaveBeenCalledWith(1.1);
732
+ });
733
+
734
+ it("never schedules in the past", () => {
735
+ const { player, context } = setup();
736
+ player.enqueue(chunk(2400));
737
+ context.currentTime = 5;
738
+ player.enqueue(chunk(2400));
739
+ expect(context.sources[1]?.start).toHaveBeenCalledWith(5);
740
+ });
741
+
742
+ it("reports playing until the last source ends", () => {
743
+ const { player, context } = setup();
744
+ player.enqueue(chunk(2400));
745
+ expect(player.isPlaying()).toBe(true);
746
+ context.sources[0]?.onended?.();
747
+ expect(player.isPlaying()).toBe(false);
748
+ });
749
+
750
+ it("calls onIdle once after the queue drains", () => {
751
+ vi.useFakeTimers();
752
+ const onIdle = vi.fn();
753
+ const { player, context } = setup(onIdle);
754
+ player.enqueue(chunk(2400));
755
+ player.enqueue(chunk(2400));
756
+ context.sources[0]?.onended?.();
757
+ vi.advanceTimersByTime(200);
758
+ expect(onIdle).not.toHaveBeenCalled();
759
+ context.sources[1]?.onended?.();
760
+ vi.advanceTimersByTime(200);
761
+ expect(onIdle).toHaveBeenCalledTimes(1);
762
+ });
763
+
764
+ it("does not report idle across a short gap between chunks", () => {
765
+ vi.useFakeTimers();
766
+ const onIdle = vi.fn();
767
+ const { player, context } = setup(onIdle);
768
+ player.enqueue(chunk(2400));
769
+ context.sources[0]?.onended?.();
770
+ vi.advanceTimersByTime(50);
771
+ player.enqueue(chunk(2400));
772
+ vi.advanceTimersByTime(200);
773
+ expect(onIdle).not.toHaveBeenCalled();
774
+ });
775
+
776
+ it("clear stops every source and resets the cursor", () => {
777
+ vi.useFakeTimers();
778
+ const onIdle = vi.fn();
779
+ const { player, context } = setup(onIdle);
780
+ player.enqueue(chunk(2400));
781
+ player.enqueue(chunk(2400));
782
+ player.clear();
783
+ expect(context.sources[0]?.stop).toHaveBeenCalled();
784
+ expect(context.sources[1]?.stop).toHaveBeenCalled();
785
+ expect(player.isPlaying()).toBe(false);
786
+ vi.advanceTimersByTime(200);
787
+ expect(onIdle).not.toHaveBeenCalled();
788
+ context.currentTime = 0.05;
789
+ player.enqueue(chunk(2400));
790
+ expect(context.sources[2]?.start).toHaveBeenCalledWith(0.05);
791
+ });
792
+
793
+ it("skips empty chunks", () => {
794
+ const { player, context } = setup();
795
+ player.enqueue("");
796
+ expect(context.createBufferSource).not.toHaveBeenCalled();
797
+ expect(player.isPlaying()).toBe(false);
798
+ });
799
+
800
+ it("resumes a suspended context on enqueue", () => {
801
+ const { player, context } = setup();
802
+ context.state = "suspended";
803
+ player.enqueue(chunk(2400));
804
+ expect(context.resume).toHaveBeenCalled();
805
+ });
806
+
807
+ it("close stops sources and closes the context", async () => {
808
+ const { player, context } = setup();
809
+ player.enqueue(chunk(2400));
810
+ await player.close();
811
+ expect(context.sources[0]?.stop).toHaveBeenCalled();
812
+ expect(context.close).toHaveBeenCalled();
813
+ });
814
+
815
+ it("shares pending playback cleanup across repeated close calls", async () => {
816
+ const { player, context } = setup();
817
+ player.enqueue(chunk(2400));
818
+ let finishClose!: () => void;
819
+ context.close.mockImplementation(() => {
820
+ if (context.state === "closed")
821
+ return Promise.reject(
822
+ new DOMException("Already closed", "InvalidStateError"),
823
+ );
824
+ context.state = "closed";
825
+ return new Promise<void>((resolve) => {
826
+ finishClose = resolve;
827
+ });
828
+ });
829
+ const settled = vi.fn();
830
+ const first = player.close().then(settled);
831
+ const second = player.close().then(settled);
832
+ const both = Promise.allSettled([first, second]);
833
+ await Promise.resolve();
834
+ expect(settled).not.toHaveBeenCalled();
835
+ finishClose();
836
+ expect(await both).toEqual([
837
+ { status: "fulfilled", value: undefined },
838
+ { status: "fulfilled", value: undefined },
839
+ ]);
840
+ await expect(player.close()).resolves.toBeUndefined();
841
+ expect(context.close).toHaveBeenCalledTimes(1);
842
+ expect(context.sources[0]?.stop).toHaveBeenCalledTimes(1);
843
+ });
844
+
845
+ it("handles a pending resume rejected when playback closes", async () => {
846
+ vi.useFakeTimers();
847
+ const onIdle = vi.fn();
848
+ const { player, context } = setup(onIdle);
849
+ let rejectResume!: (reason: unknown) => void;
850
+ const resume = new Promise<void>((_resolve, reject) => {
851
+ rejectResume = reject;
852
+ });
853
+ context.state = "suspended";
854
+ context.resume = () => resume;
855
+ context.close.mockImplementation(async () => {
856
+ context.state = "closed";
857
+ rejectResume(
858
+ new DOMException("AudioContext closed", "InvalidStateError"),
859
+ );
860
+ });
861
+ player.enqueue(chunk(2400));
862
+ await player.close();
863
+ await vi.advanceTimersByTimeAsync(1000);
864
+ expect(context.state).toBe("closed");
865
+ expect(player.isPlaying()).toBe(false);
866
+ expect(onIdle).not.toHaveBeenCalled();
867
+ });
868
+
869
+ it("does not report idle for a source that ends after close", async () => {
870
+ vi.useFakeTimers();
871
+ const onIdle = vi.fn();
872
+ const { player, context } = setup(onIdle);
873
+ player.enqueue(chunk(2400));
874
+ await player.close();
875
+ expect(context.sources[0]?.onended).toBeNull();
876
+ await vi.advanceTimersByTimeAsync(1000);
877
+ expect(onIdle).not.toHaveBeenCalled();
878
+ expect(vi.getTimerCount()).toBe(0);
879
+ });
880
+ });