diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index 4ef6fcfb..88ba926e 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -16,6 +16,7 @@ import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; import { toMerged } from "es-toolkit/object"; import { ModelPartial } from "../types"; +import { t2aDefaultSampleRate } from '../../utils/audio-formats'; import { sttFormFields, submitSttForm, @@ -263,7 +264,7 @@ export class SpeechSDK extends Client { }, audio_setting: { format: "mp3", - sample_rate: 32000, + sample_rate: t2aDefaultSampleRate(params.audio_setting?.format ?? 'mp3', 32000), bitrate: 128000, channel: 1, }, diff --git a/src/utils/audio-formats.ts b/src/utils/audio-formats.ts index ba11c8a2..cfba263e 100644 --- a/src/utils/audio-formats.ts +++ b/src/utils/audio-formats.ts @@ -24,7 +24,9 @@ const T2A_SAMPLE_RATE: Partial> = { }; export function t2aDefaultSampleRate(format: string, fallback: number): number { - return T2A_SAMPLE_RATE[format as T2AFormat] ?? fallback; + return Object.hasOwn(T2A_SAMPLE_RATE, format) + ? T2A_SAMPLE_RATE[format as T2AFormat] ?? fallback + : fallback; } export function validateT2AStreaming(format: string, stream: boolean): void { diff --git a/test/sdk/speech.test.ts b/test/sdk/speech.test.ts index 0f1120b4..de9c857c 100644 --- a/test/sdk/speech.test.ts +++ b/test/sdk/speech.test.ts @@ -113,6 +113,76 @@ describe('SpeechSDK.save', () => { describe('SpeechSDK.validateParams', () => { const sdk = new SpeechSDK({ apiKey: 'sk-test', region: 'global' }); + it.each([ + ['mp3', 32000], + ['pcm', 32000], + ['flac', 32000], + ['wav', 32000], + ['opus', 24000], + ['pcmu_raw', 8000], + ['pcmu_wav', 8000], + ])('defaults the sample rate for %s to %i Hz', async (format, expected) => { + await withStubbedFetch( + () => jsonResponse(makeSpeechResponse()), + async (sent) => { + await sdk.synthesize({ text: 'hello', audio_setting: { format } }); + expect(JSON.parse(sent.init!.body as string).audio_setting.sample_rate).toBe(expected); + }, + ); + }); + + it('preserves an explicitly provided sample rate', async () => { + await withStubbedFetch( + () => jsonResponse(makeSpeechResponse()), + async (sent) => { + const audioSetting = { format: 'opus', sample_rate: 16000, bitrate: 64000, channel: 2 }; + await sdk.synthesize({ text: 'hello', audio_setting: audioSetting }); + expect(JSON.parse(sent.init!.body as string).audio_setting).toEqual(audioSetting); + expect(audioSetting).toEqual({ format: 'opus', sample_rate: 16000, bitrate: 64000, channel: 2 }); + }, + ); + }); + + it('keeps the mp3 defaults when audio settings are omitted', async () => { + await withStubbedFetch( + () => jsonResponse(makeSpeechResponse()), + async (sent) => { + await sdk.synthesize({ text: 'hello' }); + const settings = JSON.parse(sent.init!.body as string).audio_setting; + expect(settings.format).toBe('mp3'); + expect(settings.sample_rate).toBe(32000); + expect(settings.bitrate).toBe(128000); + expect(settings.channel).toBe(1); + }, + ); + }); + + it.each(['unknown', 'constructor', 'toString', '__proto__'])( + 'preserves unknown format %s and falls back to a numeric sample rate', + async (format) => { + await withStubbedFetch( + () => jsonResponse(makeSpeechResponse()), + async (sent) => { + await sdk.synthesize({ text: 'hello', audio_setting: { format } }); + const settings = JSON.parse(sent.init!.body as string).audio_setting; + expect(settings.format).toBe(format); + expect(settings.sample_rate).toBe(32000); + }, + ); + }, + ); + + it('applies the format default to streaming requests too', async () => { + await withStubbedFetch( + () => sseResponse([{ data: JSON.stringify(makeSpeechResponse()) }]), + async (sent) => { + const stream = await sdk.synthesize({ text: 'hello', audio_setting: { format: 'pcmu_raw' }, stream: true }); + for await (const chunk of stream) { void chunk; } + expect(JSON.parse(sent.init!.body as string).audio_setting.sample_rate).toBe(8000); + }, + ); + }); + it('throws when text is missing', async () => { await expect(sdk.synthesize({} as any)).rejects.toThrow('text is required'); });