Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
24 changes: 23 additions & 1 deletion src/tts/engines/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<string, unknown> = {
Expand Down
81 changes: 81 additions & 0 deletions tests/tts/openai.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading