import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; import { VoiceSessionController } from "../../src/voice_assistant/audio_gateway/web/voice-session.js"; const originalWebSocket = globalThis.WebSocket; class FakeWebSocket { static OPEN = 1; static CLOSED = 3; constructor() { this.readyState = FakeWebSocket.OPEN; this.sent = []; this.closed = false; } send(data) { this.sent.push(data); } close() { this.closed = true; this.readyState = FakeWebSocket.CLOSED; } } globalThis.WebSocket = FakeWebSocket; globalThis.cancelAnimationFrame = () => {}; afterEach(() => { // Restore only state modified by individual tests. }); function makeController() { const states = []; const errors = []; const workletMessages = []; const controller = new VoiceSessionController({ onStateChange: (state) => states.push(state), onError: (error) => errors.push(error), }); controller.ws = new FakeWebSocket(); controller.worklet = { port: { postMessage: (data) => workletMessages.push(data), onmessage: null, }, disconnect() {}, }; controller.source = { disconnect() {} }; controller.silentGain = { disconnect() {} }; return { controller, states, errors, workletMessages, }; } test("speech detection requests a new turn", () => { const { controller } = makeController(); controller.setState("listening"); controller.beginTurn(); assert.equal(controller.state, "starting"); assert.deepEqual( JSON.parse(controller.ws.sent[0]), { type: "audio.start" } ); }); test("PCM is buffered until turn.started", () => { const { controller } = makeController(); controller.setState("listening"); controller.handlePCM(new ArrayBuffer(4)); assert.equal(controller.prebuffer.length, 1); assert.equal(controller.ws.sent.length, 0); controller.beginTurn(); controller.handlePCM(new ArrayBuffer(6)); assert.equal(controller.pending.length, 2); assert.equal(controller.ws.sent.length, 1); controller.handleGatewayEvent({ type: "turn.started", turn_id: 1, }); assert.equal(controller.state, "recording"); assert.equal(controller.pending.length, 0); assert.equal(controller.sequence, 2); assert.equal(controller.ws.sent.length, 5); }); test("PCM metadata precedes each binary frame", () => { const { controller } = makeController(); controller.turnId = 7; controller.setState("recording"); const pcm = new ArrayBuffer(8); controller.sendPCM(pcm); assert.deepEqual( JSON.parse(controller.ws.sent[0]), { type: "audio.chunk", turn_id: 7, sequence_number: 0, } ); assert.equal(controller.ws.sent[1], pcm); assert.equal(controller.sequence, 1); }); test("speech end requests worklet flush", () => { const { controller, workletMessages } = makeController(); controller.turnId = 1; controller.setState("recording"); controller.finishSpeech(); assert.equal(controller.state, "flushing"); assert.deepEqual( workletMessages.at(-1), { type: "flush" } ); assert.equal(controller.ws.sent.length, 0); }); test("audio.stop is sent after flushed", () => { const { controller } = makeController(); controller.turnId = 1; controller.setState("flushing"); controller.handlePCM(new ArrayBuffer(4)); controller.handleFlushed(); assert.equal(controller.state, "processing"); const lastMessage = JSON.parse(controller.ws.sent.at(-1)); assert.deepEqual(lastMessage, { type: "audio.stop", turn_id: 1, }); assert.equal(controller.ws.sent.length, 3); }); test("speech ending before turn.started is handled", () => { const { controller, workletMessages } = makeController(); controller.setState("listening"); controller.beginTurn(); controller.finishSpeech(); assert.equal(controller.stopRequested, true); assert.equal(controller.state, "starting"); controller.handleGatewayEvent({ type: "turn.started", turn_id: 3, }); assert.equal(controller.state, "flushing"); assert.deepEqual( workletMessages.at(-1), { type: "flush" } ); }); test("closing a session releases resources", () => { const { controller, workletMessages } = makeController(); const socket = controller.ws; controller.setState("listening"); controller.close(); assert.equal(controller.state, "closed"); assert.equal(socket.closed, true); assert.equal(controller.ws, null); assert.deepEqual( workletMessages.at(-1), { type: "stop" } ); }); test("gateway errors close the session", () => { const { controller, errors } = makeController(); controller.setState("processing"); controller.handleGatewayEvent({ type: "error", code: "STT_START_FAILED", message: "Provider unavailable", }); assert.equal(controller.state, "closed"); assert.equal(errors.length, 1); assert.match( errors[0].message, /STT_START_FAILED/ ); }); test("turn.ended waits for playback before listening", async () => { const { controller } = makeController(); let resolvePlayback; controller.player = { finish() {}, reset() {}, stop() {}, waitForCompletion() { return new Promise((resolve) => { resolvePlayback = resolve; }); }, }; controller.turnId = 1; controller.setState("processing"); let resumed = false; controller.startListening = () => { resumed = true; }; controller.handleGatewayEvent({ type: "turn.ended", turn_id: 1, }); await Promise.resolve(); await Promise.resolve(); assert.equal(resumed, false); resolvePlayback(); await Promise.resolve(); await Promise.resolve(); assert.equal(resumed, true); }); test("closing during playback prevents listening resume", async () => { const { controller } = makeController(); let resolvePlayback; controller.player = { finish() {}, reset() {}, stop() {}, waitForCompletion() { return new Promise((resolve) => { resolvePlayback = resolve; }); }, }; controller.turnId = 1; controller.setState("processing"); let resumed = false; controller.startListening = () => { resumed = true; }; controller.handleGatewayEvent({ type: "turn.ended", turn_id: 1, }); await Promise.resolve(); await Promise.resolve(); controller.close(); resolvePlayback(); await Promise.resolve(); await Promise.resolve(); assert.equal(resumed, false); assert.equal(controller.state, "closed"); }); test("binary audio is forwarded to PCMPlayer", async () => { const { controller } = makeController(); const received = []; controller.player = { async enqueue(buffer) { received.push(buffer); }, finish() {}, reset() {}, stop() {}, waitForCompletion: async () => {}, }; controller.turnId = 1; controller.setState("processing"); const pcm = new ArrayBuffer(8); controller.handleAudioChunk(pcm); await controller.playbackPending; assert.deepEqual(received, [pcm]); });