From a9e61ffc116c7483b13959c4372defc1152d2d1b Mon Sep 17 00:00:00 2001 From: "Ricardo M." Date: Sun, 25 Oct 2026 17:06:14 +0100 Subject: [PATCH] feat(tts): OpenAI engine talks to any OpenAI-compatible server engines.openai() gains `baseUrl` and `timeoutMs`, so self-hosted TTS servers that implement /v1/audio/speech work without a custom engine. With `baseUrl` set, OPENAI_API_KEY is never read, so an OpenAI key is not sent to another server; an explicit `apiKey` is passed through, otherwise a placeholder, since the SDK refuses an empty key. `baseUrl` is recorded in the engine metadata. With openai >= 5, Node's built-in fetch still ends a request after ~300 s without response headers, whatever `timeoutMs` says; documented, not worked around. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UV59h7xwn1rScigEo4KA88 --- README.md | 18 +++++++++ src/tts/engines/openai.ts | 24 +++++++++++- tests/tts/openai.test.ts | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/tts/openai.test.ts diff --git a/README.md b/README.md index a35e70a..0f7ea4f 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,24 @@ choco install ffmpeg # Windows } ``` + **OpenAI-compatible servers** — `engines.openai()` also talks to any server that implements OpenAI's + `/v1/audio/speech`, such as a self-hosted TTS server. With `baseUrl` set, `OPENAI_API_KEY` is never read, so your + OpenAI key is not sent to that server; pass `apiKey` if the server needs one: + + ```js + tts: { + engine: engines.openai({ + baseUrl: 'http://localhost:8881/v1', + model: 'my-model', + timeoutMs: 600_000, // slow local servers + }), + } + ``` + + `timeoutMs` raises the SDK's timeout, but with `openai` 5 or newer a request still fails after about 5 minutes + without response headers: those versions use Node's built-in `fetch`, whose own 300-second limit applies first. + `openai` 4 honours `timeoutMs` beyond that. + 2. **Record** — Playwright runs the demo script in a real browser. The `narration` fixture records timestamps for each `mark()` call. Video is captured at native resolution. 3. **Align** — Each TTS clip is placed at its scene's recorded timestamp. Overlapping clips are pushed forward with a 100ms gap. All clips are mixed into a single `narration-aligned.wav`. diff --git a/src/tts/engines/openai.ts b/src/tts/engines/openai.ts index 2b5d70f..3ad641f 100644 --- a/src/tts/engines/openai.ts +++ b/src/tts/engines/openai.ts @@ -7,26 +7,44 @@ export interface OpenAIEngineOptions { model?: string; /** System instructions for the model (supported by gpt-4o-mini-tts and newer). */ instructions?: string; + /** + * Base URL of any OpenAI-compatible server, including the `/v1` segment (e.g. `http://localhost:8881/v1`). + * Default: OpenAI's own API. When set, `OPENAI_API_KEY` is never read, so an OpenAI key is not sent to + * another server; pass `apiKey` if that server needs one. + */ + baseUrl?: string; + /** + * Request timeout in milliseconds, for slow local servers. Default: the OpenAI SDK's (10 minutes). + * With `openai` >= 5, Node's built-in `fetch` still gives up after ~300 s without response headers. + */ + timeoutMs?: number; } export class OpenAIEngine implements TTSEngine { private apiKey: string; private model: string; private instructions?: string; + private baseUrl?: string; + private timeoutMs?: number; constructor(options?: OpenAIEngineOptions) { this.apiKey = options?.apiKey ?? ''; this.model = options?.model ?? 'tts-1'; this.instructions = options?.instructions; + this.baseUrl = options?.baseUrl; + this.timeoutMs = options?.timeoutMs; } describe(): TTSEngineMetadata { const meta: TTSEngineMetadata = { engine: 'openai', model: this.model }; if (this.instructions) meta.instructions = this.instructions; + if (this.baseUrl) meta.baseUrl = this.baseUrl; return meta; } private resolveApiKey(): string { + // Many local servers need no key, but the SDK refuses an empty one. + if (this.baseUrl) return this.apiKey || 'unused'; const key = this.apiKey || process.env.OPENAI_API_KEY || ''; if (!key) { throw new Error( @@ -45,7 +63,11 @@ export class OpenAIEngine implements TTSEngine { OPENAI_DEP, ); - const client = new OpenAI({ apiKey: this.resolveApiKey() }); + const client = new OpenAI({ + apiKey: this.resolveApiKey(), + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + ...(this.timeoutMs != null ? { timeout: this.timeoutMs } : {}), + }); // Request raw PCM so we can build an exact WAV without ffmpeg pipe artifacts const params: Record = { diff --git a/tests/tts/openai.test.ts b/tests/tts/openai.test.ts new file mode 100644 index 0000000..5e4c484 --- /dev/null +++ b/tests/tts/openai.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { OpenAIEngine } from '../../src/tts/engines/openai.js'; + +/** + * The SDK is mocked at its constructor, so these assert what the engine hands the client (where it connects, + * with which key and timeout) and the request it sends; the PCM-to-WAV path is covered by its own tests. + */ +const clientOptions = vi.fn(); +const create = vi.fn(); + +vi.mock('openai', () => ({ + OpenAI: class { + audio = { speech: { create } }; + constructor(options: unknown) { + clientOptions(options); + } + }, +})); + +describe('OpenAIEngine', () => { + const savedKey = process.env.OPENAI_API_KEY; + + beforeEach(() => { + clientOptions.mockReset(); + create.mockReset().mockResolvedValue(new Response(Buffer.alloc(480))); + delete process.env.OPENAI_API_KEY; + }); + + afterEach(() => { + if (savedKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = savedKey; + }); + + it('talks to OpenAI with OPENAI_API_KEY by default', async () => { + process.env.OPENAI_API_KEY = 'sk-env'; + await new OpenAIEngine().generate('Hello', {}); + expect(clientOptions).toHaveBeenCalledWith({ apiKey: 'sk-env' }); + }); + + it('still requires a key for OpenAI itself', async () => { + await expect(new OpenAIEngine().generate('Hello', {})).rejects.toThrow('requires an API key'); + }); + + it('connects to baseUrl with the given timeout', async () => { + await new OpenAIEngine({ baseUrl: 'http://localhost:8881/v1', timeoutMs: 1_800_000 }).generate('Hello', {}); + expect(clientOptions).toHaveBeenCalledWith({ + apiKey: 'unused', + baseURL: 'http://localhost:8881/v1', + timeout: 1_800_000, + }); + }); + + it('never sends OPENAI_API_KEY to another server', async () => { + process.env.OPENAI_API_KEY = 'sk-env'; + await new OpenAIEngine({ baseUrl: 'http://localhost:8881/v1' }).generate('Hello', {}); + expect(clientOptions.mock.calls[0][0].apiKey).toBe('unused'); + }); + + it('sends an explicit apiKey to the custom server', async () => { + await new OpenAIEngine({ baseUrl: 'https://tts.example/v1', apiKey: 'tok' }).generate('Hello', {}); + expect(clientOptions.mock.calls[0][0].apiKey).toBe('tok'); + }); + + it('passes the voice and asks for PCM', async () => { + await new OpenAIEngine({ baseUrl: 'http://localhost:8881/v1', model: 'cosyvoice3' }).generate('Hola', { + voice: 'narrator/es', + }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ model: 'cosyvoice3', voice: 'narrator/es', input: 'Hola', response_format: 'pcm' }), + ); + }); + + it("names a custom server in the video's metadata", () => { + expect(new OpenAIEngine({ baseUrl: 'http://localhost:8881/v1' }).describe()).toEqual({ + engine: 'openai', + model: 'tts-1', + baseUrl: 'http://localhost:8881/v1', + }); + expect(new OpenAIEngine().describe()).toEqual({ engine: 'openai', model: 'tts-1' }); + }); +});