747 lines
15 KiB
JavaScript
747 lines
15 KiB
JavaScript
|
|
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 = () => {};
|
|
globalThis.requestAnimationFrame = () => 1;
|
|
|
|
|
|
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.handleFlushed();
|
|
|
|
const messages = controller.ws.sent
|
|
.filter((message) => typeof message === "string")
|
|
.map((message) => JSON.parse(message));
|
|
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "audio.stop" &&
|
|
message.turn_id === 1
|
|
)
|
|
);
|
|
|
|
assert.equal(controller.state, "processing");
|
|
});
|
|
|
|
|
|
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]);
|
|
});
|
|
|
|
|
|
|
|
test("barge-in stops playback and starts a new turn", () => {
|
|
const { controller } = makeController();
|
|
|
|
let playbackStopped = false;
|
|
|
|
controller.player = {
|
|
stop() {
|
|
playbackStopped = true;
|
|
},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
// Simulate user speech detected during TTS.
|
|
controller.requestBargeIn();
|
|
|
|
assert.equal(playbackStopped, true);
|
|
assert.equal(controller.state, "interrupting");
|
|
assert.equal(controller.bargeInPending, true);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(controller.ws.sent[0]),
|
|
{
|
|
type: "interrupt",
|
|
turn_id: 1,
|
|
}
|
|
);
|
|
|
|
// Gateway confirms the previous turn was cancelled.
|
|
controller.handleGatewayEvent({
|
|
type: "interrupted",
|
|
turn_id: 1,
|
|
});
|
|
|
|
assert.equal(controller.state, "starting");
|
|
assert.equal(controller.ws.closed, false);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(controller.ws.sent[1]),
|
|
{
|
|
type: "audio.start",
|
|
}
|
|
);
|
|
|
|
// Gateway accepts the new turn.
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
assert.equal(controller.turnId, 2);
|
|
assert.equal(controller.state, "recording");
|
|
assert.equal(controller.bargeInPending, false);
|
|
});
|
|
|
|
|
|
|
|
test("barge-in preserves PCM until new turn starts", () => {
|
|
const { controller } = makeController();
|
|
|
|
controller.player = {
|
|
stop() {},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
const pcm1 = new ArrayBuffer(4);
|
|
const pcm2 = new ArrayBuffer(6);
|
|
const pcm3 = new ArrayBuffer(8);
|
|
|
|
// Audio captured before speech confirmation.
|
|
controller.handlePCM(pcm1);
|
|
|
|
controller.requestBargeIn();
|
|
|
|
// Audio captured while interruption is pending.
|
|
controller.handlePCM(pcm2);
|
|
|
|
assert.equal(controller.ws.sent.length, 1);
|
|
assert.equal(controller.bargeInBuffer.length, 2);
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "interrupted",
|
|
turn_id: 1,
|
|
});
|
|
|
|
// Still no PCM sent to the old turn.
|
|
assert.equal(controller.state, "starting");
|
|
assert.equal(controller.ws.sent.length, 2);
|
|
|
|
// Additional speech arrives before turn.started.
|
|
controller.handlePCM(pcm3);
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
const sent = controller.ws.sent;
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(sent[0]),
|
|
{ type: "interrupt", turn_id: 1 }
|
|
);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(sent[1]),
|
|
{ type: "audio.start" }
|
|
);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(sent[2]),
|
|
{
|
|
type: "audio.chunk",
|
|
turn_id: 2,
|
|
sequence_number: 0,
|
|
}
|
|
);
|
|
|
|
assert.equal(sent[3], pcm1);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(sent[4]),
|
|
{
|
|
type: "audio.chunk",
|
|
turn_id: 2,
|
|
sequence_number: 1,
|
|
}
|
|
);
|
|
|
|
assert.equal(sent[5], pcm2);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(sent[6]),
|
|
{
|
|
type: "audio.chunk",
|
|
turn_id: 2,
|
|
sequence_number: 2,
|
|
}
|
|
);
|
|
|
|
assert.equal(sent[7], pcm3);
|
|
|
|
assert.equal(controller.sequence, 3);
|
|
assert.equal(controller.state, "recording");
|
|
});
|
|
|
|
|
|
test("barge-in finishes speech before new turn starts", () => {
|
|
const { controller, workletMessages } = makeController();
|
|
|
|
controller.player = {
|
|
stop() {},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
const pcm = new ArrayBuffer(8);
|
|
|
|
controller.handlePCM(pcm);
|
|
controller.requestBargeIn();
|
|
|
|
// User finishes speaking before the Gateway responds.
|
|
controller.finishBargeInSpeech();
|
|
|
|
assert.equal(controller.bargeInSpeechEnded, true);
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "interrupted",
|
|
turn_id: 1,
|
|
});
|
|
|
|
assert.equal(controller.state, "starting");
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
// Buffered audio must be transmitted before flushing.
|
|
assert.equal(controller.sequence, 1);
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(controller.ws.sent[2]),
|
|
{
|
|
type: "audio.chunk",
|
|
turn_id: 2,
|
|
sequence_number: 0,
|
|
}
|
|
);
|
|
|
|
assert.equal(controller.ws.sent[3], pcm);
|
|
assert.equal(controller.state, "flushing");
|
|
|
|
assert.deepEqual(
|
|
workletMessages.at(-1),
|
|
{ type: "flush" }
|
|
);
|
|
});
|
|
|
|
|
|
|
|
|
|
test("barge-in continues recording until speech ends", () => {
|
|
const { controller, workletMessages } = makeController();
|
|
|
|
controller.player = {
|
|
stop() {},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
controller.bargeInVad.start();
|
|
controller.requestBargeIn();
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "interrupted",
|
|
turn_id: 1,
|
|
});
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
assert.equal(controller.state, "recording");
|
|
assert.equal(controller.bargeInRecording, true);
|
|
assert.equal(controller.bargeInVad.active, true);
|
|
|
|
// Speech continues after the new turn starts.
|
|
const pcm = new ArrayBuffer(8);
|
|
controller.handlePCM(pcm);
|
|
|
|
assert.equal(controller.sequence, 1);
|
|
|
|
// Barge-in VAD detects the end of speech.
|
|
controller.finishBargeInSpeech();
|
|
|
|
assert.equal(controller.bargeInRecording, false);
|
|
assert.equal(controller.state, "flushing");
|
|
|
|
assert.deepEqual(
|
|
workletMessages.at(-1),
|
|
{ type: "flush" }
|
|
);
|
|
|
|
// Worklet confirms that remaining PCM was emitted.
|
|
controller.handleFlushed();
|
|
|
|
assert.equal(controller.state, "processing");
|
|
|
|
const messages = controller.ws.sent
|
|
.filter((message) => typeof message === "string")
|
|
.map((message) => JSON.parse(message));
|
|
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "audio.stop" &&
|
|
message.turn_id === 2
|
|
)
|
|
);
|
|
});
|
|
|
|
|
|
test("late INVALID_TURN recovers barge-in session", () => {
|
|
const { controller } = makeController();
|
|
|
|
controller.player = {
|
|
stop() {},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
controller.requestBargeIn();
|
|
|
|
assert.equal(controller.state, "interrupting");
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "error",
|
|
code: "INVALID_TURN",
|
|
message: "Turn 1 is no longer active.",
|
|
turn_id: 1,
|
|
});
|
|
|
|
assert.equal(controller.state, "starting");
|
|
assert.equal(controller.ws.closed, false);
|
|
|
|
const messages = controller.ws.sent
|
|
.filter((message) => typeof message === "string")
|
|
.map((message) => JSON.parse(message));
|
|
|
|
assert.deepEqual(
|
|
messages.map((message) => message.type),
|
|
["interrupt", "audio.start"]
|
|
);
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
assert.equal(controller.state, "recording");
|
|
assert.equal(controller.turnId, 2);
|
|
});
|
|
|
|
|
|
test("late TTS chunk during barge-in does not close session", () => {
|
|
const { controller } = makeController();
|
|
|
|
let stopped = false;
|
|
|
|
controller.player = {
|
|
stop() {
|
|
stopped = true;
|
|
},
|
|
reset() {},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
controller.requestBargeIn();
|
|
|
|
assert.equal(stopped, true);
|
|
assert.equal(controller.state, "interrupting");
|
|
|
|
// A late PCM chunk from the interrupted TTS.
|
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
|
|
|
// The WebSocket session must remain alive.
|
|
assert.equal(controller.ws.closed, false);
|
|
assert.equal(controller.state, "interrupting");
|
|
assert.equal(controller.bargeInPending, true);
|
|
});
|
|
|
|
|
|
test("barge-in resets player before new TTS response", () => {
|
|
const { controller } = makeController();
|
|
|
|
let finished = false;
|
|
let resetCount = 0;
|
|
|
|
controller.player = {
|
|
stop() {
|
|
finished = true;
|
|
},
|
|
|
|
reset() {
|
|
finished = false;
|
|
resetCount++;
|
|
},
|
|
};
|
|
|
|
controller.turnId = 1;
|
|
controller.setState("speaking");
|
|
|
|
controller.requestBargeIn();
|
|
|
|
assert.equal(finished, true);
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "interrupted",
|
|
turn_id: 1,
|
|
});
|
|
|
|
assert.equal(controller.state, "starting");
|
|
|
|
controller.handleGatewayEvent({
|
|
type: "turn.started",
|
|
turn_id: 2,
|
|
});
|
|
|
|
assert.equal(controller.state, "recording");
|
|
assert.equal(finished, false);
|
|
assert.equal(resetCount, 1);
|
|
});
|
|
|
|
|
|
test("session.ready configures output before starting microphone capture", () => {
|
|
const { controller } = makeController();
|
|
const operations = [];
|
|
const format = { encoding: "pcm_s16le", sample_rate: 24000, channels: 1 };
|
|
controller.player = { configure: (received) => operations.push(received) };
|
|
controller.startListening = () => operations.push("listen");
|
|
controller.handleGatewayEvent({ type: "session.ready", output_audio: format });
|
|
assert.deepEqual(operations, [format, "listen"]);
|
|
});
|