implementacao de interrupcao em audio streming
This commit is contained in:
204
tests/js/pcm-player.test.mjs
Normal file
204
tests/js/pcm-player.test.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
83
tests/js/vad.test.mjs
Normal file
83
tests/js/vad.test.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { VoiceActivityDetector } from "../../src/voice_assistant/audio_gateway/web/vad.js";
|
||||
|
||||
test("detects speech start after minimum duration", () => {
|
||||
let starts = 0;
|
||||
|
||||
const vad = new VoiceActivityDetector({
|
||||
minSpeechMs: 250,
|
||||
onSpeechStart: () => starts++,
|
||||
});
|
||||
|
||||
vad.start();
|
||||
|
||||
vad.process(0.04, 0);
|
||||
vad.process(0.04, 100);
|
||||
|
||||
assert.equal(starts, 0);
|
||||
|
||||
vad.process(0.04, 260);
|
||||
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("detects speech end after silence", () => {
|
||||
let ends = 0;
|
||||
|
||||
const vad = new VoiceActivityDetector({
|
||||
minSpeechMs: 100,
|
||||
silenceMs: 800,
|
||||
onSpeechEnd: () => ends++,
|
||||
});
|
||||
|
||||
vad.start();
|
||||
|
||||
vad.process(0.04, 0);
|
||||
vad.process(0.04, 120);
|
||||
vad.process(0.04, 300);
|
||||
|
||||
vad.process(0.001, 600);
|
||||
vad.process(0.001, 1000);
|
||||
|
||||
assert.equal(ends, 0);
|
||||
|
||||
vad.process(0.001, 1101);
|
||||
|
||||
assert.equal(ends, 1);
|
||||
});
|
||||
|
||||
test("ignores short noise spikes", () => {
|
||||
let starts = 0;
|
||||
|
||||
const vad = new VoiceActivityDetector({
|
||||
minSpeechMs: 250,
|
||||
onSpeechStart: () => starts++,
|
||||
});
|
||||
|
||||
vad.start();
|
||||
|
||||
vad.process(0.05, 0);
|
||||
vad.process(0.001, 80);
|
||||
vad.process(0.001, 300);
|
||||
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
|
||||
test("stop disables detection", () => {
|
||||
let starts = 0;
|
||||
|
||||
const vad = new VoiceActivityDetector({
|
||||
minSpeechMs: 100,
|
||||
onSpeechStart: () => starts++,
|
||||
});
|
||||
|
||||
vad.start();
|
||||
vad.stop();
|
||||
|
||||
vad.process(0.05, 0);
|
||||
vad.process(0.05, 200);
|
||||
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
339
tests/js/voice-session.test.mjs
Normal file
339
tests/js/voice-session.test.mjs
Normal 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]);
|
||||
});
|
||||
@@ -11,8 +11,10 @@ def test_web_client_page():
|
||||
response = client.get("/ui")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "AgentVoice" in response.text
|
||||
assert "/web/app.js" in response.text
|
||||
assert "Oracle — assistente de voz" in response.text
|
||||
assert 'id="microphone"' in response.text
|
||||
assert 'id="feedback"' in response.text
|
||||
assert "assistantIntegration" in response.text
|
||||
|
||||
|
||||
def test_web_client_javascript():
|
||||
|
||||
Reference in New Issue
Block a user