fix(frontend): track playback completion and meaningful inactivity
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
let processor: AudioWorkletProcessor;
|
||||
const port = { onmessage: undefined as ((event: MessageEvent) => void) | undefined, postMessage: vi.fn() };
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
port.postMessage.mockClear();
|
||||
vi.stubGlobal("sampleRate", 48000);
|
||||
vi.stubGlobal(
|
||||
"AudioWorkletProcessor",
|
||||
class {
|
||||
port = port;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("registerProcessor", (_name: string, Processor: new () => AudioWorkletProcessor) => {
|
||||
processor = new Processor();
|
||||
});
|
||||
await import("./audio-playback.worklet");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function send(data: unknown) {
|
||||
port.onmessage?.(new MessageEvent("message", { data }));
|
||||
}
|
||||
function render(blocks: number) {
|
||||
let audible = false;
|
||||
for (let index = 0; index < blocks; index++) {
|
||||
const output = new Float32Array(128);
|
||||
processor.process([], [[output]]);
|
||||
audible ||= output.some((value) => value !== 0);
|
||||
}
|
||||
return audible;
|
||||
}
|
||||
it("flushes a final chunk shorter than the prebuffer and signals after playback finishes", () => {
|
||||
send({ kind: "audio", samples: new Float32Array(160).fill(0.5), sequence: 1 });
|
||||
expect(render(1)).toBe(false);
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
send({ kind: "finish", sequence: 1 });
|
||||
expect(render(1)).toBe(true);
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
render(12);
|
||||
expect(port.postMessage).toHaveBeenCalledExactlyOnceWith({ kind: "drained", sequence: 1 });
|
||||
});
|
||||
it("discards cleared samples and identifies the latest playback generation", () => {
|
||||
send({ kind: "audio", samples: new Float32Array(16000).fill(0.5), sequence: 1 });
|
||||
expect(render(1)).toBe(true);
|
||||
send({ kind: "clear", sequence: 2 });
|
||||
render(2);
|
||||
expect(port.postMessage).toHaveBeenLastCalledWith({ kind: "drained", sequence: 2 });
|
||||
expect(render(10)).toBe(false);
|
||||
send({ kind: "audio", samples: new Float32Array(160).fill(0.25), sequence: 3 });
|
||||
send({ kind: "finish", sequence: 3 });
|
||||
expect(render(12)).toBe(true);
|
||||
expect(port.postMessage).toHaveBeenLastCalledWith({ kind: "drained", sequence: 3 });
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
type PlaybackMessage =
|
||||
{ kind: "config"; inputRate: number } | { kind: "audio"; samples: Float32Array } | { kind: "clear" };
|
||||
| { kind: "config"; inputRate: number }
|
||||
| { kind: "audio"; samples: Float32Array; sequence: number }
|
||||
| { kind: "clear" | "finish"; sequence: number };
|
||||
|
||||
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
private rate = 16000;
|
||||
@@ -10,6 +12,8 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
private fadeIn = 0;
|
||||
private fadeOut = 0;
|
||||
private lastSample = 0;
|
||||
private sequence = 0;
|
||||
private finished = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -22,16 +26,24 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
this.sequence = data.sequence;
|
||||
this.finished = false;
|
||||
if (data.samples.length) {
|
||||
this.queue.push(data.samples);
|
||||
}
|
||||
break;
|
||||
case "clear":
|
||||
this.sequence = data.sequence;
|
||||
this.finished = true;
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.fraction = 0;
|
||||
this.fadeOut = 32;
|
||||
break;
|
||||
case "finish":
|
||||
this.sequence = data.sequence;
|
||||
this.finished = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -66,7 +78,7 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
for (let index = 0; index < output.length; index++) {
|
||||
let value = 0;
|
||||
if (!this.playing && this.queue.length && this.bufferedMs() >= 90) {
|
||||
if (!this.playing && this.queue.length && (this.finished || this.bufferedMs() >= 90)) {
|
||||
this.playing = true;
|
||||
this.fadeIn = 32;
|
||||
this.fadeOut = 0;
|
||||
@@ -102,6 +114,10 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
stereo[index] = value;
|
||||
}
|
||||
}
|
||||
if (this.finished && !this.playing && !this.queue.length) {
|
||||
this.finished = false;
|
||||
this.port.postMessage({ kind: "drained", sequence: this.sequence });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,19 @@ export class AudioEngine {
|
||||
private closed = false;
|
||||
private gate: NoiseGate;
|
||||
private muted = false;
|
||||
private playbackSequence = 0;
|
||||
|
||||
constructor(context: AudioContext, gate: NoiseGate) {
|
||||
this.context = context;
|
||||
this.gate = gate;
|
||||
}
|
||||
|
||||
async start(onChunk: (chunk: ArrayBuffer) => void, onLevel: (rms: number) => void, speaking: () => boolean) {
|
||||
async start(
|
||||
onChunk: (chunk: ArrayBuffer) => void,
|
||||
onLevel: (rms: number) => void,
|
||||
speaking: () => boolean,
|
||||
onDrained: () => void,
|
||||
) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
|
||||
if (this.closed) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
@@ -58,6 +64,11 @@ export class AudioEngine {
|
||||
playback.port.postMessage({ kind: "config", inputRate: 16000 });
|
||||
this.capture = capture;
|
||||
this.playback = playback;
|
||||
playback.port.onmessage = (event: MessageEvent<{ kind: string; sequence: number }>) => {
|
||||
if (!this.closed && event.data.kind === "drained" && event.data.sequence === this.playbackSequence) {
|
||||
onDrained();
|
||||
}
|
||||
};
|
||||
this.setGate(this.gate);
|
||||
this.setMuted(this.muted);
|
||||
capture.port.onmessage = (event: MessageEvent<ArrayBuffer | { kind: "level"; rms: number }>) => {
|
||||
@@ -110,10 +121,13 @@ export class AudioEngine {
|
||||
return;
|
||||
}
|
||||
const samples = decodePcm(base64);
|
||||
this.playback?.port.postMessage({ kind: "audio", samples }, [samples.buffer]);
|
||||
this.playback?.port.postMessage({ kind: "audio", samples, sequence: ++this.playbackSequence }, [samples.buffer]);
|
||||
}
|
||||
clear() {
|
||||
this.playback?.port.postMessage({ kind: "clear" });
|
||||
this.playback?.port.postMessage({ kind: "clear", sequence: ++this.playbackSequence });
|
||||
}
|
||||
finishPlayback() {
|
||||
this.playback?.port.postMessage({ kind: "finish", sequence: this.playbackSequence });
|
||||
}
|
||||
setGate(gate: NoiseGate) {
|
||||
this.gate = gate;
|
||||
|
||||
Reference in New Issue
Block a user