import assert from "node:assert/strict"; import { test } from "node:test"; import { PCMPlayer } from "../../src/voice_assistant/audio_gateway/web/pcm-player.js"; class FakeAudioSource { constructor(context) { this.context = context; this.buffer = null; this.onended = null; this.startedAt = null; this.stopped = false; } connect() {} disconnect() {} start(time) { this.startedAt = time; this.context.sources.push(this); } stop() { this.stopped = true; } end() { this.onended?.(); } } class FakeAudioContext { constructor() { this.currentTime = 0; this.destination = {}; this.sources = []; } async resume() {} createAnalyser() { return { fftSize: 1024, smoothingTimeConstant: 0.65, connect() {}, disconnect() {}, getFloatTimeDomainData(array) { array.fill(0); }, getByteFrequencyData(array) { array.fill(0); }, }; } createBuffer(channels, length, sampleRate) { return { duration: length / sampleRate, copyToChannel(samples) { this.samples = samples; }, }; } createBufferSource() { return new FakeAudioSource(this); } } function pcm(values) { const buffer = new ArrayBuffer(values.length * 2); const view = new DataView(buffer); values.forEach((value, index) => { view.setInt16(index * 2, value, true); }); return buffer; } test("decodes signed PCM16 correctly", () => { const player = new PCMPlayer({ audioContext: new FakeAudioContext(), }); const samples = player.decodePCM( pcm([-32768, 0, 16384, 32767]) ); assert.equal(samples[0], -1); assert.equal(samples[1], 0); assert.equal(samples[2], 0.5); assert.ok(samples[3] > 0.99); }); test("schedules chunks in order", async () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, }); await player.enqueue(pcm([100, 200, 300, 400])); await player.enqueue(pcm([500, 600, 700, 800])); assert.equal(context.sources.length, 2); const first = context.sources[0]; const second = context.sources[1]; assert.ok(second.startedAt >= first.startedAt + first.buffer.duration); }); test("waits for actual playback completion", async () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, }); await player.enqueue(pcm([100, 200])); player.finish(); let completed = false; const waiting = player.waitForCompletion().then(() => { completed = true; }); await Promise.resolve(); assert.equal(completed, false); context.sources[0].end(); await waiting; assert.equal(completed, true); }); test("stop interrupts scheduled playback", async () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, }); await player.enqueue(pcm([100, 200])); player.stop(); assert.equal(context.sources[0].stopped, true); assert.equal(player.sources.size, 0); assert.equal(player.playing, false); }); test("reset allows a new playback turn", async () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, }); await player.enqueue(pcm([100, 200])); player.finish(); context.sources[0].end(); await player.waitForCompletion(); player.reset(); await player.enqueue(pcm([300, 400])); assert.equal(context.sources.length, 2); assert.equal(player.finished, false); }); test("rejects invalid PCM buffers", () => { const player = new PCMPlayer({ audioContext: new FakeAudioContext(), }); assert.throws( () => player.decodePCM(new ArrayBuffer(3)), /Invalid PCM byte length/ ); }); test("exposes playback analyser", () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, }); assert.equal( player.getAnalyser(), player.analyser ); }); test("late PCM after stop is ignored", async () => { const context = new FakeAudioContext(); const player = new PCMPlayer({ audioContext: context, sampleRate: 48000, }); player.stop(); const pcm = new ArrayBuffer(8); await assert.doesNotReject( player.enqueue(pcm) ); assert.equal(player.playing, false); assert.equal(player.sources.size, 0); });