diff --git a/src/server/live.ts b/src/server/live.ts index 25bf36f..61bfe5b 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -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); diff --git a/src/server/transcribe.ts b/src/server/transcribe.ts index aacf696..fba9192 100644 --- a/src/server/transcribe.ts +++ b/src/server/transcribe.ts @@ -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( diff --git a/tests/fixtures/fake_sidecar.py b/tests/fixtures/fake_sidecar.py index ac4f3e6..b69b8e2 100644 --- a/tests/fixtures/fake_sidecar.py +++ b/tests/fixtures/fake_sidecar.py @@ -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}) diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index 3c41acc..da024e7 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -111,7 +111,7 @@ function sessionData(overrides: Partial = {}): 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). diff --git a/tests/transcribe.test.ts b/tests/transcribe.test.ts index 25e92e1..eb7cdfb 100644 --- a/tests/transcribe.test.ts +++ b/tests/transcribe.test.ts @@ -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((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 () => {