From e3bbbae3bd9660bac9f0eba2fc8af6eb85fffd58 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 8 Jun 2026 14:29:52 +0700 Subject: [PATCH] Clean up OpenAI TTS test seam --- .../speech/providers/openai/tts.test.ts | 67 ++++++++++--------- .../src/server/speech/providers/openai/tts.ts | 49 +++++++++----- 2 files changed, 69 insertions(+), 47 deletions(-) diff --git a/packages/server/src/server/speech/providers/openai/tts.test.ts b/packages/server/src/server/speech/providers/openai/tts.test.ts index 7f9a335b0a..0c607bd7cb 100644 --- a/packages/server/src/server/speech/providers/openai/tts.test.ts +++ b/packages/server/src/server/speech/providers/openai/tts.test.ts @@ -1,45 +1,40 @@ import type pino from "pino"; import { ReadableStream } from "node:stream/web"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import pinoLogger from "pino"; +import { describe, expect, test } from "vitest"; -const openAiMocks = vi.hoisted(() => ({ - createSpeech: vi.fn(), -})); +import { + OpenAITTS, + type OpenAITTSAudioClient, + type OpenAITTSSpeechRequest, + type OpenAITTSSpeechResponse, +} from "./tts.js"; -vi.mock("openai", () => ({ - OpenAI: vi.fn().mockImplementation( - class { - audio = { - speech: { - create: openAiMocks.createSpeech, +class FakeOpenAITTSClient implements OpenAITTSAudioClient { + public readonly speechRequests: OpenAITTSSpeechRequest[] = []; + public readonly audio: OpenAITTSAudioClient["audio"]; + private readonly speechResponse: OpenAITTSSpeechResponse; + + constructor(speechResponse: OpenAITTSSpeechResponse) { + this.speechResponse = speechResponse; + this.audio = { + speech: { + create: async (request: OpenAITTSSpeechRequest) => { + this.speechRequests.push(request); + return this.speechResponse; }, - }; - }, - ), -})); - -import { OpenAITTS } from "./tts.js"; + }, + }; + } +} function createLogger(): pino.Logger { - const logger = { - child: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - info: vi.fn(), - }; - - logger.child.mockReturnValue(logger); - - return logger as unknown as pino.Logger; + return pinoLogger({ enabled: false }); } describe("OpenAITTS", () => { - beforeEach(() => { - openAiMocks.createSpeech.mockReset(); - }); - test("returns a Node stream from the OpenAI web response body", async () => { - openAiMocks.createSpeech.mockResolvedValue({ + const openaiClient = new FakeOpenAITTSClient({ body: new ReadableStream({ start(controller) { controller.enqueue(Buffer.from("audio")); @@ -48,7 +43,7 @@ describe("OpenAITTS", () => { }), }); - const tts = new OpenAITTS({ apiKey: "sk-test" }, createLogger()); + const tts = new OpenAITTS({ apiKey: "sk-test" }, createLogger(), { openaiClient }); const result = await tts.synthesizeSpeech("hello"); const chunks: Buffer[] = []; @@ -58,5 +53,13 @@ describe("OpenAITTS", () => { expect(Buffer.concat(chunks).toString()).toBe("audio"); expect(result.format).toBe("pcm"); + expect(openaiClient.speechRequests).toEqual([ + { + model: "tts-1", + voice: "alloy", + input: "hello", + response_format: "pcm", + }, + ]); }); }); diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 9813b4760a..e71129fe34 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -13,12 +13,35 @@ export interface TTSConfig { responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; } +export interface OpenAITTSSpeechRequest { + model: NonNullable; + voice: NonNullable; + input: string; + response_format: NonNullable; +} + +export interface OpenAITTSSpeechResponse { + body: unknown; +} + +export interface OpenAITTSAudioClient { + audio: { + speech: { + create(request: OpenAITTSSpeechRequest): Promise; + }; + }; +} + +export interface OpenAITTSOptions { + openaiClient?: OpenAITTSAudioClient; +} + export class OpenAITTS implements TextToSpeechProvider { - private readonly openaiClient: OpenAI; - private readonly config: TTSConfig; + private readonly openaiClient: OpenAITTSAudioClient; + private readonly config: Required; private readonly logger: pino.Logger; - constructor(ttsConfig: TTSConfig, parentLogger: pino.Logger) { + constructor(ttsConfig: TTSConfig, parentLogger: pino.Logger, options: OpenAITTSOptions = {}) { this.config = { model: "tts-1", voice: "alloy", @@ -26,9 +49,11 @@ export class OpenAITTS implements TextToSpeechProvider { ...ttsConfig, }; this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "tts" }); - this.openaiClient = new OpenAI({ - apiKey: ttsConfig.apiKey, - }); + this.openaiClient = + options.openaiClient ?? + new OpenAI({ + apiKey: ttsConfig.apiKey, + }); this.logger.info( { voice: this.config.voice, model: this.config.model, format: this.config.responseFormat }, @@ -54,16 +79,10 @@ export class OpenAITTS implements TextToSpeechProvider { ); const response = await this.openaiClient.audio.speech.create({ - model: this.config.model!, - voice: this.config.voice!, + model: this.config.model, + voice: this.config.voice, input: text, - response_format: this.config.responseFormat as - | "mp3" - | "opus" - | "aac" - | "flac" - | "wav" - | "pcm", + response_format: this.config.responseFormat, }); if (!response.body) {