implementacao de interrupcao em audio streming

This commit is contained in:
2026-10-16 01:15:34 +00:00
parent 19a415daae
commit 2449b34137
12 changed files with 2119 additions and 85 deletions

View File

@@ -0,0 +1,339 @@
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]);
});