Skip to content

Commit eb4bc20

Browse files
neeravmakwanaobviyus
authored andcommitted
OpenAI TTS: use wav for Groq speech
Made-with: Cursor
1 parent 494c25b commit eb4bc20

3 files changed

Lines changed: 134 additions & 5 deletions

File tree

extensions/openai/speech-provider.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { buildOpenAISpeechProvider } from "./speech-provider.js";
33

44
describe("buildOpenAISpeechProvider", () => {
5+
const originalFetch = globalThis.fetch;
6+
7+
afterEach(() => {
8+
globalThis.fetch = originalFetch;
9+
vi.restoreAllMocks();
10+
});
11+
512
it("normalizes provider-owned speech config from raw provider config", () => {
613
const provider = buildOpenAISpeechProvider();
714
const resolved = provider.resolveConfig?.({
@@ -16,6 +23,7 @@ describe("buildOpenAISpeechProvider", () => {
1623
voice: "alloy",
1724
speed: 1.25,
1825
instructions: " Speak warmly ",
26+
responseFormat: " WAV ",
1927
},
2028
},
2129
},
@@ -28,6 +36,7 @@ describe("buildOpenAISpeechProvider", () => {
2836
voice: "alloy",
2937
speed: 1.25,
3038
instructions: "Speak warmly",
39+
responseFormat: "wav",
3140
});
3241
});
3342

@@ -67,4 +76,61 @@ describe("buildOpenAISpeechProvider", () => {
6776
handled: false,
6877
});
6978
});
79+
80+
it("uses wav for Groq-compatible OpenAI TTS endpoints", async () => {
81+
const provider = buildOpenAISpeechProvider();
82+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
83+
expect(init?.body).toBeTruthy();
84+
const body = JSON.parse(String(init?.body)) as { response_format?: string };
85+
expect(body.response_format).toBe("wav");
86+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
87+
});
88+
globalThis.fetch = fetchMock as unknown as typeof fetch;
89+
90+
const result = await provider.synthesize({
91+
text: "hello",
92+
cfg: {} as never,
93+
providerConfig: {
94+
apiKey: "sk-test",
95+
baseUrl: "https://api.groq.com/openai/v1",
96+
model: "canopylabs/orpheus-v1-english",
97+
voice: "daniel",
98+
},
99+
target: "audio-file",
100+
timeoutMs: 1_000,
101+
});
102+
103+
expect(result.outputFormat).toBe("wav");
104+
expect(result.fileExtension).toBe(".wav");
105+
expect(result.voiceCompatible).toBe(false);
106+
});
107+
108+
it("honors explicit responseFormat overrides and clears voice-note compatibility when not opus", async () => {
109+
const provider = buildOpenAISpeechProvider();
110+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
111+
expect(init?.body).toBeTruthy();
112+
const body = JSON.parse(String(init?.body)) as { response_format?: string };
113+
expect(body.response_format).toBe("wav");
114+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
115+
});
116+
globalThis.fetch = fetchMock as unknown as typeof fetch;
117+
118+
const result = await provider.synthesize({
119+
text: "hello",
120+
cfg: {} as never,
121+
providerConfig: {
122+
apiKey: "sk-test",
123+
baseUrl: "https://proxy.example.com/openai/v1",
124+
model: "canopylabs/orpheus-v1-english",
125+
voice: "daniel",
126+
responseFormat: "wav",
127+
},
128+
target: "voice-note",
129+
timeoutMs: 1_000,
130+
});
131+
132+
expect(result.outputFormat).toBe("wav");
133+
expect(result.fileExtension).toBe(".wav");
134+
expect(result.voiceCompatible).toBe(false);
135+
});
70136
});

extensions/openai/speech-provider.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ import {
2121
openaiTTS,
2222
} from "./tts.js";
2323

24+
const OPENAI_SPEECH_RESPONSE_FORMATS = ["mp3", "opus", "wav"] as const;
25+
26+
type OpenAiSpeechResponseFormat = (typeof OPENAI_SPEECH_RESPONSE_FORMATS)[number];
27+
2428
type OpenAITtsProviderConfig = {
2529
apiKey?: string;
2630
baseUrl: string;
2731
model: string;
2832
voice: string;
2933
speed?: number;
3034
instructions?: string;
35+
responseFormat?: OpenAiSpeechResponseFormat;
3136
};
3237

3338
type OpenAITtsProviderOverrides = {
@@ -36,6 +41,57 @@ type OpenAITtsProviderOverrides = {
3641
speed?: number;
3742
};
3843

44+
function normalizeOpenAISpeechResponseFormat(
45+
value: unknown,
46+
): OpenAiSpeechResponseFormat | undefined {
47+
const next = trimToUndefined(typeof value === "string" ? value : undefined)?.toLowerCase();
48+
if (!next) {
49+
return undefined;
50+
}
51+
if (
52+
OPENAI_SPEECH_RESPONSE_FORMATS.includes(next as (typeof OPENAI_SPEECH_RESPONSE_FORMATS)[number])
53+
) {
54+
return next as OpenAiSpeechResponseFormat;
55+
}
56+
throw new Error(`Invalid OpenAI speech responseFormat: ${next}`);
57+
}
58+
59+
function isGroqSpeechBaseUrl(baseUrl: string): boolean {
60+
try {
61+
const hostname = new URL(baseUrl).hostname.toLowerCase();
62+
return hostname === "groq.com" || hostname.endsWith(".groq.com");
63+
} catch {
64+
return false;
65+
}
66+
}
67+
68+
function resolveSpeechResponseFormat(
69+
baseUrl: string,
70+
target: "audio-file" | "voice-note",
71+
configuredFormat?: OpenAiSpeechResponseFormat,
72+
): OpenAiSpeechResponseFormat {
73+
if (configuredFormat) {
74+
return configuredFormat;
75+
}
76+
if (isGroqSpeechBaseUrl(baseUrl)) {
77+
return "wav";
78+
}
79+
return target === "voice-note" ? "opus" : "mp3";
80+
}
81+
82+
function responseFormatToFileExtension(
83+
format: OpenAiSpeechResponseFormat,
84+
): ".mp3" | ".opus" | ".wav" {
85+
switch (format) {
86+
case "opus":
87+
return ".opus";
88+
case "wav":
89+
return ".wav";
90+
default:
91+
return ".mp3";
92+
}
93+
}
94+
3995
function normalizeOpenAIProviderConfig(
4096
rawConfig: Record<string, unknown>,
4197
): OpenAITtsProviderConfig {
@@ -54,6 +110,7 @@ function normalizeOpenAIProviderConfig(
54110
voice: trimToUndefined(raw?.voice) ?? "coral",
55111
speed: asFiniteNumber(raw?.speed),
56112
instructions: trimToUndefined(raw?.instructions),
113+
responseFormat: normalizeOpenAISpeechResponseFormat(raw?.responseFormat),
57114
};
58115
}
59116

@@ -66,6 +123,8 @@ function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProvid
66123
voice: trimToUndefined(config.voice) ?? normalized.voice,
67124
speed: asFiniteNumber(config.speed) ?? normalized.speed,
68125
instructions: trimToUndefined(config.instructions) ?? normalized.instructions,
126+
responseFormat:
127+
normalizeOpenAISpeechResponseFormat(config.responseFormat) ?? normalized.responseFormat,
69128
};
70129
}
71130

@@ -171,7 +230,11 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
171230
if (!apiKey) {
172231
throw new Error("OpenAI API key missing");
173232
}
174-
const responseFormat = req.target === "voice-note" ? "opus" : "mp3";
233+
const responseFormat = resolveSpeechResponseFormat(
234+
config.baseUrl,
235+
req.target,
236+
config.responseFormat,
237+
);
175238
const audioBuffer = await openaiTTS({
176239
text: req.text,
177240
apiKey,
@@ -186,8 +249,8 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
186249
return {
187250
audioBuffer,
188251
outputFormat: responseFormat,
189-
fileExtension: responseFormat === "opus" ? ".opus" : ".mp3",
190-
voiceCompatible: req.target === "voice-note",
252+
fileExtension: responseFormatToFileExtension(responseFormat),
253+
voiceCompatible: req.target === "voice-note" && responseFormat === "opus",
191254
};
192255
},
193256
synthesizeTelephony: async (req) => {

extensions/openai/tts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function openaiTTS(params: {
112112
voice: string;
113113
speed?: number;
114114
instructions?: string;
115-
responseFormat: "mp3" | "opus" | "pcm";
115+
responseFormat: "mp3" | "opus" | "pcm" | "wav";
116116
timeoutMs: number;
117117
}): Promise<Buffer> {
118118
const { text, apiKey, baseUrl, model, voice, speed, instructions, responseFormat, timeoutMs } =

0 commit comments

Comments
 (0)