Mark server-live sidecar passes as live mode and cover the stream heartbeat with a test

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 21:37:01 +01:00
co-authored by Claude Opus 5.5
parent dc64bb8198
commit afce794cbf
5 changed files with 41 additions and 8 deletions
+2
View File
@@ -60,6 +60,8 @@ export function createLiveWebSocket(deps: LiveWebSocketDeps) {
audioPath,
language: ws.data.language,
task: ws.data.task,
// Short passes: sequential decode, no batching/VAD (D-WS5a).
mode: "live",
},
(segment) => {
text.push(segment.text);
+4 -1
View File
@@ -316,6 +316,8 @@ export interface TranscribeDeps {
maxUploadBytes?: number;
/** Directory for uploaded-PCM temp files. Default: the OS temp dir. */
tempDir?: string;
/** Blank-line heartbeat period on the ndjson stream (D-021). Default 5 s. */
heartbeatIntervalMs?: number;
}
function json(body: unknown, status: number): Response {
@@ -409,6 +411,7 @@ async function writeBodyToFile(
export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandlers {
const maxUploadBytes = deps.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
const tempDir = deps.tempDir ?? tmpdir();
const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
return {
async status(request) {
@@ -460,7 +463,7 @@ export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandle
} catch {
clearInterval(heartbeat);
}
}, HEARTBEAT_INTERVAL_MS);
}, heartbeatIntervalMs);
const chunks: TranscriptChunk[] = [];
try {
await deps.sidecar.transcribe(
+4 -3
View File
@@ -5,7 +5,7 @@ Used by tests/transcribe.test.ts to exercise the real Bun-side supervisor
requested model name:
"ok" two segments (the second reports the audio byte count,
language and task for pass-through assertions), then done
language, task and mode for pass-through assertions), then done
"boom" an error line
"die" exit(1) mid-request (crash simulation)
"wait-for-cancel" one segment, then block until the next stdin line
@@ -53,6 +53,7 @@ for line in sys.stdin:
emit({"type": "segment", "id": request_id, "start": 0.0, "end": 1.5,
"text": " Hello"})
emit({"type": "segment", "id": request_id, "start": 1.5, "end": 3.0,
"text": " world. [model=%s bytes=%d lang=%s task=%s]"
% (model, size, message.get("language"), message.get("task"))})
"text": " world. [model=%s bytes=%d lang=%s task=%s mode=%s]"
% (model, size, message.get("language"), message.get("task"),
message.get("mode"))})
emit({"type": "done", "id": request_id, "cancelled": False})
+3 -2
View File
@@ -111,7 +111,7 @@ function sessionData(overrides: Partial<LiveSessionData> = {}): LiveSessionData
}
describe("createLiveWebSocket", () => {
test("a committed segment writes the exact PCM bytes and passes model/language/task through", async () => {
test("a committed segment writes the exact PCM bytes and passes model/language/task/mode through", async () => {
const tempDir = await makeTempDir();
const sidecar = fakeSidecar();
const handler = createLiveWebSocket({ sidecar });
@@ -123,7 +123,8 @@ describe("createLiveWebSocket", () => {
await waitFor(() => sidecar.requests.length > 0);
expect(sidecar.requests[0]).toEqual(
expect.objectContaining({ model: "base", language: "da", task: "translate" }),
// Live passes are short: sequential decoding, no batching/VAD (D-WS5a).
expect.objectContaining({ model: "base", language: "da", task: "translate", mode: "live" }),
);
// The bytes written to the temp file are exactly the committed segment's
// samples — not, e.g., a whole shared buffer (see the byteOffset/byteLength fix).
+28 -2
View File
@@ -52,6 +52,8 @@ function fakeSidecar(
segments?: SidecarSegment[];
error?: string;
waitForAbort?: boolean;
/** Quiet time before the first segment (model load, VAD, first batch). */
delayMs?: number;
} = {},
): FakeSidecar {
const segments = behavior.segments ?? [
@@ -72,6 +74,7 @@ function fakeSidecar(
new Uint8Array(await Bun.file(request.audioPath).arrayBuffer()),
);
if (behavior.error) throw new Error(behavior.error);
if (behavior.delayMs) await Bun.sleep(behavior.delayMs);
onSegment(segments[0]!);
if (behavior.waitForAbort) {
await new Promise<void>((resolve) => {
@@ -323,6 +326,25 @@ describe("POST /api/transcribe", () => {
expect(await readdir(tempDir)).toEqual([]);
});
test("heartbeat blank lines keep a quiet stream alive before the first segment (D-021)", async () => {
// The batched pipeline (D-WS5a) runs VAD over the whole file and decodes a
// full batch before its first segment, so the quiet stretch is longer.
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar({ delayMs: 200 }),
getSessionUser: sessionFromCookie,
tempDir: await makeTempDir(),
heartbeatIntervalMs: 20,
});
const response = await handlers.post(
postRequest("?model=onnx-community/whisper-base", pcmBody([0.1]), { cookie: "sid=ok" }),
);
const text = await response.text();
const beforeFirstSegment = text.slice(0, text.indexOf("{"));
expect(beforeFirstSegment.length).toBeGreaterThanOrEqual(2);
expect(beforeFirstSegment.trim()).toBe("");
expect(text).toContain('"type":"result"');
});
test("sidecar errors travel in-stream after the 200 header", async () => {
const tempDir = await makeTempDir();
const handlers = createTranscribeHandlers({
@@ -385,8 +407,12 @@ describe("createSidecar (fake python sidecar)", () => {
expect(outcome).toEqual({ cancelled: false });
expect(segments).toHaveLength(2);
expect(segments[0]).toEqual({ start: 0, end: 1.5, text: " Hello" });
// The fixture echoes what it received (audio bytes, language, task).
expect(segments[1]!.text).toContain("model=ok bytes=12 lang=da task=translate");
// The fixture echoes what it received (audio bytes, language, task, mode).
expect(segments[1]!.text).toContain("model=ok bytes=12 lang=da task=translate mode=file");
const live: SidecarSegment[] = [];
await sidecar.transcribe({ model: "ok", audioPath, mode: "live" }, (s) => live.push(s));
expect(live[1]!.text).toContain("mode=live");
});
test("sidecar 'error' lines reject that request only", async () => {