diff --git a/docs-site/docs/configure/tts-provider-guides/speech-sdk.md b/docs-site/docs/configure/tts-provider-guides/speech-sdk.md index 11ceb2d..3ebfe8b 100644 --- a/docs-site/docs/configure/tts-provider-guides/speech-sdk.md +++ b/docs-site/docs/configure/tts-provider-guides/speech-sdk.md @@ -24,7 +24,7 @@ See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior. ## Built-in models -- `openai/gpt-4o-mini-tts` +- `openai/gpt-4o-mini-tts` (works with your existing OpenAI API key) - `elevenlabs/eleven_multilingual_v2` - `cartesia/sonic-3.5` - `deepgram/aura-2` diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index 7c98c6d..ab02839 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -696,10 +696,15 @@ const SPEECH_SDK_PROVIDER_FACTORIES: Record = { async function runSpeechSdkRequest( request: ResolvedServerTTSRequest, signal: AbortSignal, - maxRetries: number, + upstreamSettings: ResolvedTtsUpstreamRuntimeSettings, ): Promise { const model = request.model as string; const prefix = speechSdkProviderPrefix(model); + const modelId = model.slice(prefix.length + 1); + if (!prefix || !modelId) { + throw new Error(`Invalid Speech SDK model "${model}". Expected "provider/model".`); + } + const factory = SPEECH_SDK_PROVIDER_FACTORIES[prefix]; if (!factory) { throw new Error( @@ -707,19 +712,30 @@ async function runSpeechSdkRequest( ); } - const modelId = model.slice(prefix.length + 1); // 'default' is the placeholder voice for providers without a static voice // list; omit it so the provider's own default applies. const voice = request.voice === 'default' ? undefined : request.voice; - const result = await generateSpeech({ - model: factory({ apiKey: request.apiKey || undefined })(modelId || undefined), - text: request.text, - voice: voice as string, - output: { format: 'mp3' }, - maxRetries, - abortSignal: signal, - }); - return Buffer.from(result.audio.uint8Array); + + // Overall budget across the SDK's internal retries, mirroring the timeout + // the OpenAI-compatible path configures on its client. + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), upstreamSettings.ttsUpstreamTimeoutMs); + const onAbort = () => timeoutController.abort(); + signal.addEventListener('abort', onAbort, { once: true }); + try { + const result = await generateSpeech({ + model: factory({ apiKey: request.apiKey || undefined })(modelId), + text: request.text, + voice: voice as string, + output: { format: 'mp3' }, + maxRetries: upstreamSettings.ttsUpstreamMaxRetries, + abortSignal: timeoutController.signal, + }); + return Buffer.from(result.audio.uint8Array); + } finally { + clearTimeout(timeoutId); + signal.removeEventListener('abort', onAbort); + } } async function runProviderRequest( @@ -733,7 +749,7 @@ async function runProviderRequest( const raw = request.provider === 'replicate' ? await runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries) : request.provider === 'speech-sdk' - ? await runSpeechSdkRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries) + ? await runSpeechSdkRequest(request, signal, upstreamSettings) : await runOpenAiCompatibleRequest(request, signal, upstreamSettings); // OpenAI-compatible servers (and some Replicate models) may emit wav/ogg/etc.; diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index ef40d55..3fc3b04 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -149,4 +149,17 @@ describe('speech-sdk request resolution', () => { apiKey: 'unused', })).rejects.toThrow('Unknown Speech SDK provider prefix'); }); + + test('rejects models missing the provider/model form', async () => { + for (const model of ['openai', 'openai/', '/gpt-4o-mini-tts']) { + await expect(generateTTSBuffer({ + text: 'hello', + voice: 'alloy', + speed: 1, + model, + provider: 'speech-sdk', + apiKey: 'unused', + })).rejects.toThrow('Expected "provider/model"'); + } + }); });