|
1 | 1 | import { describe, expect, it } from "vitest"; |
2 | 2 | import { Readable } from "node:stream"; |
3 | 3 | import type { IncomingMessage } from "node:http"; |
4 | | -import { handleVoiceSpeak } from "./voice-handlers.js"; |
| 4 | +import { handleVoiceSpeak, handleVoiceSpeakStream } from "./voice-handlers.js"; |
5 | 5 | import { buildRedactor, createRunRegistry, type UiHandlerDeps } from "./index.js"; |
6 | 6 | import { createInMemoryUiStore } from "./store/index.js"; |
7 | | -import type { RouteContext } from "./routes.js"; |
| 7 | +import { STREAMING, type RouteContext, type RouteResult } from "./routes.js"; |
8 | 8 | import type { |
9 | 9 | GatewayConfig, |
10 | 10 | TextToSpeechOutcome, |
11 | 11 | TextToSpeechRequest, |
| 12 | + TextToSpeechStreamOutcome, |
12 | 13 | } from "@oscharko-dev/keiko-model-gateway"; |
13 | 14 |
|
14 | 15 | const PROVIDER_SECRET = "voice-tts-secret-token-1234567890"; |
@@ -360,3 +361,117 @@ describe("POST /api/voice/speak — provider failure mapping (AC4)", () => { |
360 | 361 | expect(serialized).not.toContain(PROVIDER_BASE_URL); |
361 | 362 | }); |
362 | 363 | }); |
| 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