Skip to content

Commit b28276e

Browse files
oscharkoclaude
andauthored
feat(voice): streamed gapless PCM assistant speech via AudioWorklet (#1644)
* feat(voice): streamed gapless PCM assistant speech via AudioWorklet Replace the buffered whole-clip <audio> playout (nothing audible until the entire clip is synthesized + transferred) with a start-on-first-chunk path, keeping the buffered path as a strict fallback so this can never regress. - keiko-playback-worklet.js: a hardened playback AudioWorkletProcessor — a pre-allocated Float32 ring buffer (no per-message spread, no per-quantum allocation), a prime/jitter threshold so the first chunks never underrun, underrun→silence (never a glitch), `null`→instant flush (sub-frame barge-in), and a frames-played position report. - gateway: requestTextToSpeechStream returns the provider audio as a bounded ReadableStream (same auth/egress/error-coding/size cap), not a buffered clip. - BFF: POST /api/voice/speak/stream streams raw PCM (audio/pcm) via the STREAMING sentinel with abort-on-disconnect + write backpressure; the buffered /api/voice/speak route is unchanged. - client: an injectable AudioWorklet PCM sink (AudioContext @ 24kHz). The engine tries it first and falls back to the buffered opus path when WebAudio is unavailable (e.g. under test) or it fails to start. Barge-in flushes the worklet immediately. PCM little-endian decoding carries a sample split across network chunks. Verified: gateway/server/ui suites pass; build:ui ships the worklet to dist/ui/static (served same-origin under script-src 'self', no inline hash); root export-surface contract updated (zero drift); live Azure e2e streams raw 24kHz PCM with ~0.85s time-to-first-audio-byte. Precise interrupt offset and STT verbose_json are documented follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(voice): fall back to buffered playout on any up-front streaming failure Make the streaming sink strictly fallback-safe: an error before audio starts (provider/network error, empty 200) now returns false so useAssistantSpeech runs the buffered path, instead of surfacing a failure. This keeps a turn from being lost when only the buffered route works (e.g. a smoke test that stubs /api/voice/speak but not the stream route) and is the safer production default. Only a mid-stream failure after playback has begun degrades to text. Harden the worklet for short/empty streams: the "end" marker forces any sub-prime remainder to play out and completes once drained (no dependence on having crossed the prime threshold); a no-audio stream is reported as an error by the client rather than hanging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ci): re-trigger checks on streaming-fallback commit The prior push did not trigger a CI run (GitHub synchronize glitch); the parent commit's full suite is green and this only adds an empty commit to re-attach the required checks to HEAD. Squashed away on merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 777483d commit b28276e

14 files changed

Lines changed: 1013 additions & 50 deletions

packages/keiko-model-gateway/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ export {
129129
export {
130130
MAX_SPEECH_AUDIO_BYTES,
131131
requestTextToSpeech,
132+
requestTextToSpeechStream,
132133
type SpeechResponseFormat,
133134
type TextToSpeechErrorKind,
134135
type TextToSpeechOutcome,
135136
type TextToSpeechRequest,
137+
type TextToSpeechStreamOutcome,
138+
type TextToSpeechStreamSuccess,
136139
type TextToSpeechSuccess,
137140
} from "./text-to-speech-adapter.js";
138141

packages/keiko-model-gateway/src/text-to-speech-adapter.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { MAX_SPEECH_AUDIO_BYTES, requestTextToSpeech } from "./text-to-speech-adapter.js";
2+
import {
3+
MAX_SPEECH_AUDIO_BYTES,
4+
requestTextToSpeech,
5+
requestTextToSpeechStream,
6+
} from "./text-to-speech-adapter.js";
37
import { OutboundHttpEgressError } from "./http.js";
48

59
// A recognizable audio byte marker so a test can assert the adapter returns the provider body verbatim
@@ -331,3 +335,67 @@ describe("requestTextToSpeech", () => {
331335
expect(seenBody).not.toContain(SECRET_API_KEY);
332336
});
333337
});
338+
339+
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
340+
const reader = stream.getReader();
341+
const parts: Uint8Array[] = [];
342+
for (;;) {
343+
const { done, value } = await reader.read();
344+
if (done) break;
345+
parts.push(value);
346+
}
347+
const total = parts.reduce((n, p) => n + p.length, 0);
348+
const out = new Uint8Array(total);
349+
let offset = 0;
350+
for (const p of parts) {
351+
out.set(p, offset);
352+
offset += p.length;
353+
}
354+
return out;
355+
}
356+
357+
describe("requestTextToSpeechStream", () => {
358+
it("returns the provider body as a byte stream with the resolved mime type", async () => {
359+
const fetchImpl = mockFetch(() => audioResponse(AUDIO_BYTES, "audio/pcm"));
360+
const outcome = await requestTextToSpeechStream({
361+
endpoint: ENDPOINT,
362+
apiKey: SECRET_API_KEY,
363+
modelId: "keiko-tts",
364+
input: ANSWER,
365+
responseFormat: "pcm",
366+
fetchImpl,
367+
});
368+
expect(outcome.ok).toBe(true);
369+
if (!outcome.ok) return;
370+
expect(outcome.value.mimeType).toBe("audio/pcm");
371+
expect(new TextDecoder().decode(await collect(outcome.value.body))).toBe(AUDIO_MARKER);
372+
});
373+
374+
it("maps a provider error status to a coded kind without streaming a body", async () => {
375+
const fetchImpl = mockFetch(() => new Response("provider error page", { status: 429 }));
376+
const outcome = await requestTextToSpeechStream({
377+
endpoint: ENDPOINT,
378+
apiKey: SECRET_API_KEY,
379+
modelId: "keiko-tts",
380+
input: ANSWER,
381+
fetchImpl,
382+
});
383+
expect(outcome).toEqual({ ok: false, kind: "rate-limited" });
384+
});
385+
386+
it("errors the stream once the audio exceeds the size cap", async () => {
387+
const fetchImpl = mockFetch(() => audioResponse(new Uint8Array(100), "audio/pcm"));
388+
const outcome = await requestTextToSpeechStream({
389+
endpoint: ENDPOINT,
390+
apiKey: SECRET_API_KEY,
391+
modelId: "keiko-tts",
392+
input: ANSWER,
393+
responseFormat: "pcm",
394+
maxAudioBytes: 10,
395+
fetchImpl,
396+
});
397+
expect(outcome.ok).toBe(true);
398+
if (!outcome.ok) return;
399+
await expect(collect(outcome.value.body)).rejects.toThrow();
400+
});
401+
});

packages/keiko-model-gateway/src/text-to-speech-adapter.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,77 @@ export async function requestTextToSpeech(
276276
}
277277
return decodeSuccess(dispatched, built);
278278
}
279+
280+
export interface TextToSpeechStreamSuccess {
281+
// The synthesized audio bytes as they arrive from the provider, capped incrementally so a hostile or
282+
// misconfigured endpoint cannot stream an unbounded body. The BFF pipes these straight to the browser
283+
// (no whole-clip buffering, no base64 envelope) for start-on-first-chunk playback.
284+
readonly body: ReadableStream<Uint8Array>;
285+
readonly mimeType: string;
286+
}
287+
288+
export type TextToSpeechStreamOutcome =
289+
| { readonly ok: true; readonly value: TextToSpeechStreamSuccess }
290+
| { readonly ok: false; readonly kind: TextToSpeechErrorKind };
291+
292+
// Wraps the provider body in a passthrough that aborts once `maxBytes` is exceeded, so the streamed
293+
// response inherits the same size ceiling as the buffered path without ever holding the whole clip.
294+
function boundBodyStream(
295+
source: ReadableStream<Uint8Array>,
296+
maxBytes: number,
297+
): ReadableStream<Uint8Array> {
298+
const reader = source.getReader();
299+
let total = 0;
300+
return new ReadableStream<Uint8Array>({
301+
async pull(controller): Promise<void> {
302+
try {
303+
const { done, value } = await reader.read();
304+
if (done) {
305+
controller.close();
306+
return;
307+
}
308+
total += value.byteLength;
309+
if (total > maxBytes) {
310+
await reader.cancel();
311+
controller.error(new Error("synthesized audio exceeded the size limit"));
312+
return;
313+
}
314+
controller.enqueue(value);
315+
} catch (error) {
316+
controller.error(error);
317+
}
318+
},
319+
cancel(reason): void {
320+
void reader.cancel(reason);
321+
},
322+
});
323+
}
324+
325+
// Streaming variant of requestTextToSpeech: returns the provider audio as a bounded byte stream instead
326+
// of a fully-buffered clip, so the BFF can forward it chunk-by-chunk and the browser can start playback
327+
// on the first chunk. Same provider contract, auth, egress seam, error coding, and size cap; only the
328+
// delivery shape differs. Raw audio is never persisted here.
329+
export async function requestTextToSpeechStream(
330+
request: TextToSpeechRequest,
331+
): Promise<TextToSpeechStreamOutcome> {
332+
const built = buildRequest(request);
333+
const dispatched = await dispatch(built, request.fetchImpl, request.egress);
334+
if (typeof dispatched === "string") {
335+
return { ok: false, kind: dispatched };
336+
}
337+
if (!dispatched.ok) {
338+
const kind = classifyStatus(dispatched.status) ?? "transport";
339+
await discardBody(dispatched);
340+
return { ok: false, kind };
341+
}
342+
if (dispatched.body === null) {
343+
return { ok: false, kind: "empty-audio" };
344+
}
345+
return {
346+
ok: true,
347+
value: {
348+
body: boundBodyStream(dispatched.body, built.maxAudioBytes),
349+
mimeType: resolveMimeType(dispatched, built.responseFormat),
350+
},
351+
};
352+
}

packages/keiko-server/src/deps.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import type {
6363
SpeechToTextRequest,
6464
TextToSpeechOutcome,
6565
TextToSpeechRequest,
66+
TextToSpeechStreamOutcome,
6667
} from "@oscharko-dev/keiko-model-gateway";
6768
import {
6869
createRelationshipStorePort,
@@ -285,6 +286,12 @@ export interface UiHandlerDeps {
285286
readonly voiceSpeechRequest?:
286287
| ((request: TextToSpeechRequest) => Promise<TextToSpeechOutcome>)
287288
| undefined;
289+
// Streaming counterpart of voiceSpeechRequest (Issue #1556). Lets the /api/voice/speak/stream route
290+
// forward provider PCM chunk-by-chunk in tests without touching global fetch. Production leaves it
291+
// undefined and uses requestTextToSpeechStream; raw audio is streamed through, never persisted.
292+
readonly voiceSpeechStreamRequest?:
293+
| ((request: TextToSpeechRequest) => Promise<TextToSpeechStreamOutcome>)
294+
| undefined;
288295
// Issue #497 (Epic #491) — realtime voice proxied-SDP negotiation seam (ADR-0058 D3/D6). Lets the
289296
// WebSocket control plane perform the browser↔provider SDP exchange through the provider-neutral
290297
// realtime adapter without touching global fetch in tests. Production leaves this undefined and

packages/keiko-server/src/routes.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
handleEvidenceDetail,
1919
} from "./read-handlers.js";
2020
import { handleGetWorkspaceState, handlePutWorkspaceState } from "./workspace-state-handlers.js";
21-
import { handleVoiceSpeak, handleVoiceTranscribe } from "./voice-handlers.js";
21+
import {
22+
handleVoiceSpeak,
23+
handleVoiceSpeakStream,
24+
handleVoiceTranscribe,
25+
} from "./voice-handlers.js";
2226
import { handleVoiceRecapBuild } from "./voice-recap.js";
2327
import {
2428
handleCreateRun,
@@ -279,6 +283,7 @@ export const API_ROUTES: readonly RouteDefinition[] = [
279283
// the visible assistant answer text (inside the JSON + CSRF envelope) and receive synthesized audio
280284
// as base64; answers VOICE_UNAVAILABLE when no speech-output capability is configured/enabled.
281285
{ method: "POST", pattern: "/api/voice/speak", handler: handleVoiceSpeak },
286+
{ method: "POST", pattern: "/api/voice/speak/stream", handler: handleVoiceSpeakStream },
282287
// Issue #504 (Epic #491, ADR-0067) — optional, capability-gated, user-triggered voice session recap.
283288
// POST the committed transcript text (content-free counts alongside) and derive memory candidates via
284289
// the EXISTING governed capture path; candidates surface in the existing review queue as "proposed".

packages/keiko-server/src/voice-handlers.speak.test.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { describe, expect, it } from "vitest";
22
import { Readable } from "node:stream";
33
import type { IncomingMessage } from "node:http";
4-
import { handleVoiceSpeak } from "./voice-handlers.js";
4+
import { handleVoiceSpeak, handleVoiceSpeakStream } from "./voice-handlers.js";
55
import { buildRedactor, createRunRegistry, type UiHandlerDeps } from "./index.js";
66
import { createInMemoryUiStore } from "./store/index.js";
7-
import type { RouteContext } from "./routes.js";
7+
import { STREAMING, type RouteContext, type RouteResult } from "./routes.js";
88
import type {
99
GatewayConfig,
1010
TextToSpeechOutcome,
1111
TextToSpeechRequest,
12+
TextToSpeechStreamOutcome,
1213
} from "@oscharko-dev/keiko-model-gateway";
1314

1415
const PROVIDER_SECRET = "voice-tts-secret-token-1234567890";
@@ -360,3 +361,117 @@ describe("POST /api/voice/speak — provider failure mapping (AC4)", () => {
360361
expect(serialized).not.toContain(PROVIDER_BASE_URL);
361362
});
362363
});
364+
365+
// A minimal ServerResponse fake capturing the streaming write path.
366+
class FakeRes {
367+
statusCode: number | undefined;
368+
headers: Record<string, string> | undefined;
369+
readonly chunks: Uint8Array[] = [];
370+
ended = false;
371+
destroyed = false;
372+
writeHead(status: number, headers?: Record<string, string>): this {
373+
this.statusCode = status;
374+
this.headers = headers;
375+
return this;
376+
}
377+
write(chunk: Uint8Array): boolean {
378+
this.chunks.push(chunk);
379+
return true;
380+
}
381+
end(): void {
382+
this.ended = true;
383+
}
384+
on(): this {
385+
return this;
386+
}
387+
destroy(): void {
388+
this.destroyed = true;
389+
}
390+
}
391+
392+
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
393+
let i = 0;
394+
return new ReadableStream<Uint8Array>({
395+
pull(controller): void {
396+
const chunk = chunks[i];
397+
if (chunk !== undefined) {
398+
i += 1;
399+
controller.enqueue(chunk);
400+
} else {
401+
controller.close();
402+
}
403+
},
404+
});
405+
}
406+
407+
function streamCtx(body: unknown, res: FakeRes): RouteContext {
408+
return {
409+
req: Readable.from([Buffer.from(JSON.stringify(body), "utf8")]) as IncomingMessage,
410+
res: res as unknown as RouteContext["res"],
411+
params: {},
412+
url: new URL("http://127.0.0.1/api/voice/speak/stream"),
413+
};
414+
}
415+
416+
function streamOk(
417+
body: ReadableStream<Uint8Array>,
418+
mimeType = "audio/pcm",
419+
): TextToSpeechStreamOutcome {
420+
return { ok: true, value: { body, mimeType } };
421+
}
422+
423+
describe("POST /api/voice/speak/stream", () => {
424+
it("requests pcm, streams the provider bytes with audio/pcm, and returns STREAMING", async () => {
425+
const seen: TextToSpeechRequest[] = [];
426+
const res = new FakeRes();
427+
const deps = depsWith({
428+
config: SPEECH_OUTPUT_CONFIG,
429+
configPresent: true,
430+
voiceSpeechStreamRequest: (
431+
request: TextToSpeechRequest,
432+
): Promise<TextToSpeechStreamOutcome> => {
433+
seen.push(request);
434+
return Promise.resolve(
435+
streamOk(streamOf([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])])),
436+
);
437+
},
438+
});
439+
440+
const outcome = await handleVoiceSpeakStream(streamCtx({ text: "spoken answer" }, res), deps);
441+
expect(outcome).toBe(STREAMING);
442+
expect(res.statusCode).toBe(200);
443+
expect(res.headers?.["Content-Type"]).toBe("audio/pcm");
444+
expect(Buffer.concat(res.chunks.map((c) => Buffer.from(c)))).toEqual(
445+
Buffer.from([1, 2, 3, 4, 5]),
446+
);
447+
expect(res.ended).toBe(true);
448+
// The streaming path requests raw pcm (fastest to first audio).
449+
expect(seen[0]?.responseFormat).toBe("pcm");
450+
expect(seen[0]?.signal).toBeDefined();
451+
});
452+
453+
it("returns a coded error RouteResult BEFORE any headers when synthesis fails", async () => {
454+
const res = new FakeRes();
455+
const deps = depsWith({
456+
config: SPEECH_OUTPUT_CONFIG,
457+
configPresent: true,
458+
voiceSpeechStreamRequest: (): Promise<TextToSpeechStreamOutcome> =>
459+
Promise.resolve({ ok: false, kind: "rate-limited" }),
460+
});
461+
const outcome = await handleVoiceSpeakStream(streamCtx({ text: "spoken answer" }, res), deps);
462+
expect(outcome).not.toBe(STREAMING);
463+
expect((outcome as RouteResult).status).toBe(429);
464+
expect(res.statusCode).toBeUndefined(); // never committed a 200 + audio headers
465+
expect(res.ended).toBe(false);
466+
});
467+
468+
it("returns 503 VOICE_UNAVAILABLE for an STT-only deployment (no streaming)", async () => {
469+
const res = new FakeRes();
470+
const outcome = await handleVoiceSpeakStream(
471+
streamCtx({ text: "x" }, res),
472+
depsWith({ config: STT_ONLY_CONFIG, configPresent: true }),
473+
);
474+
expect((outcome as RouteResult).status).toBe(503);
475+
expect(res.statusCode).toBeUndefined();
476+
});
477+
});

0 commit comments

Comments
 (0)