Skip to content

Commit 361737d

Browse files
committed
fix(tts): honor telephony voice overrides
1 parent a224810 commit 361737d

11 files changed

Lines changed: 133 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818
- Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output.
1919
- Agents/commands: add `/steer <message>` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934)
2020
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
21+
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
2122
- Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions.
2223
- Doctor/config: `doctor --fix` now commits safe legacy migrations even when unrelated validation issues (e.g. a missing plugin) prevent full validation from passing, so `agents.defaults.llm` and other known-legacy keys are always cleaned up by `doctor --fix` regardless of other config problems. Fixes #76798. (#76800) Thanks @hclsys.
2324
- Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi.

extensions/azure-speech/speech-provider.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,42 @@ describe("buildAzureSpeechProvider", () => {
176176
});
177177
});
178178

179+
it("honors voice and language overrides for telephony output", async () => {
180+
const provider = buildAzureSpeechProvider();
181+
const result = await provider.synthesizeTelephony?.({
182+
text: "hello",
183+
cfg: {} as never,
184+
providerConfig: {
185+
apiKey: "key",
186+
region: "eastus",
187+
voice: "en-US-JennyNeural",
188+
lang: "en-US",
189+
},
190+
providerOverrides: {
191+
voice: "en-US-AriaNeural",
192+
lang: "es-US",
193+
},
194+
timeoutMs: 30_000,
195+
});
196+
197+
expect(azureSpeechTTSMock).toHaveBeenCalledWith({
198+
text: "hello",
199+
apiKey: "key",
200+
baseUrl: "https://eastus.tts.speech.microsoft.com",
201+
endpoint: undefined,
202+
region: "eastus",
203+
voice: "en-US-AriaNeural",
204+
lang: "es-US",
205+
outputFormat: "raw-8khz-8bit-mono-mulaw",
206+
timeoutMs: 30_000,
207+
});
208+
expect(result).toEqual({
209+
audioBuffer: Buffer.from("audio-bytes"),
210+
outputFormat: "raw-8khz-8bit-mono-mulaw",
211+
sampleRate: 8_000,
212+
});
213+
});
214+
179215
it("lists voices through config or explicit request auth", async () => {
180216
const provider = buildAzureSpeechProvider();
181217
const voices = await provider.listVoices?.({

extensions/azure-speech/speech-provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin {
279279
},
280280
synthesizeTelephony: async (req) => {
281281
const config = readAzureSpeechProviderConfig(req.providerConfig);
282+
const overrides = readAzureSpeechOverrides(req.providerOverrides);
282283
const apiKey = resolveApiKey(config);
283284
if (!apiKey) {
284285
throw new Error("Azure Speech API key missing");
@@ -290,8 +291,8 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin {
290291
baseUrl: config.baseUrl,
291292
endpoint: config.endpoint,
292293
region: config.region,
293-
voice: config.voice,
294-
lang: config.lang,
294+
voice: overrides.voice ?? config.voice,
295+
lang: overrides.lang ?? config.lang,
295296
outputFormat: DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT,
296297
timeoutMs: resolveTimeoutMs(config, req.timeoutMs),
297298
});

extensions/google/speech-provider.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,11 +397,44 @@ describe("Google speech provider", () => {
397397
cfg: {},
398398
providerConfig: {
399399
apiKey: "google-test-key",
400+
model: "google/gemini-3.1-flash-tts",
400401
voice: "Kore",
402+
audioProfile: "Speak calmly.",
403+
speakerName: "Default speaker",
404+
},
405+
providerOverrides: {
406+
model: "google/gemini-3.1-pro-tts",
407+
voiceName: "Puck",
408+
audioProfile: "Speak brightly.",
409+
speakerName: "Override speaker",
401410
},
402411
timeoutMs: 5_000,
403412
});
404413

414+
expect(postJsonRequestMock).toHaveBeenCalledWith(
415+
expect.objectContaining({
416+
url: "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-tts:generateContent",
417+
body: expect.objectContaining({
418+
contents: [
419+
{
420+
role: "user",
421+
parts: [
422+
{ text: "Speak brightly.\n\nSpeaker name: Override speaker\n\nPhone call audio." },
423+
],
424+
},
425+
],
426+
generationConfig: expect.objectContaining({
427+
speechConfig: {
428+
voiceConfig: {
429+
prebuiltVoiceConfig: {
430+
voiceName: "Puck",
431+
},
432+
},
433+
},
434+
}),
435+
}),
436+
}),
437+
);
405438
expect(result).toEqual({
406439
audioBuffer: pcm,
407440
outputFormat: "pcm",

extensions/google/speech-provider.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,7 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
640640
},
641641
synthesizeTelephony: async (req) => {
642642
const config = readGoogleTtsProviderConfig(req.providerConfig);
643+
const overrides = readGoogleTtsOverrides(req.providerOverrides);
643644
const apiKey = resolveGoogleTtsApiKey({
644645
cfg: req.cfg,
645646
providerConfig: req.providerConfig,
@@ -654,10 +655,10 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
654655
request: sanitizeConfiguredModelProviderRequest(
655656
req.cfg?.models?.providers?.google?.request,
656657
),
657-
model: config.model,
658-
voiceName: config.voiceName,
659-
audioProfile: config.audioProfile,
660-
speakerName: config.speakerName,
658+
model: normalizeGoogleTtsModel(overrides.model ?? config.model),
659+
voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName),
660+
audioProfile: overrides.audioProfile ?? config.audioProfile,
661+
speakerName: overrides.speakerName ?? config.speakerName,
661662
timeoutMs: req.timeoutMs,
662663
});
663664
return {

extensions/gradium/speech-provider.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,16 @@ describe("gradium speech provider", () => {
9898
const result = await provider.synthesizeTelephony!({
9999
text: "Telephony test",
100100
cfg: {} as never,
101-
providerConfig: { apiKey: "gsk_test123" },
101+
providerConfig: { apiKey: "gsk_test123", voiceId: "default-voice" },
102+
providerOverrides: { voiceId: "override-voice" },
102103
timeoutMs: 30_000,
103104
});
104105

105106
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
106-
expect(JSON.parse(init.body as string).output_format).toBe("ulaw_8000");
107+
expect(JSON.parse(init.body as string)).toMatchObject({
108+
voice_id: "override-voice",
109+
output_format: "ulaw_8000",
110+
});
107111
expect(result.outputFormat).toBe("ulaw_8000");
108112
expect(result.sampleRate).toBe(8_000);
109113
expect(result.audioBuffer).toEqual(audioData);

extensions/gradium/speech-provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
9696
},
9797
synthesizeTelephony: async (req) => {
9898
const config = readGradiumProviderConfig(req.providerConfig);
99+
const overrides = req.providerOverrides ?? {};
99100
const apiKey = config.apiKey || process.env.GRADIUM_API_KEY;
100101
if (!apiKey) {
101102
throw new Error("Gradium API key missing");
@@ -106,7 +107,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
106107
text: req.text,
107108
apiKey,
108109
baseUrl: config.baseUrl,
109-
voiceId: config.voiceId,
110+
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
110111
outputFormat,
111112
timeoutMs: req.timeoutMs,
112113
});

extensions/inworld/speech-provider.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,18 +190,19 @@ describe("buildInworldSpeechProvider", () => {
190190
text: "Hello",
191191
cfg: {} as never,
192192
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
193+
providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 },
193194
timeoutMs: 30_000,
194195
});
195196

196197
expect(inworldTTSMock).toHaveBeenCalledWith({
197198
text: "Hello",
198199
apiKey: "key",
199200
baseUrl: "https://api.inworld.ai",
200-
voiceId: "Sarah",
201-
modelId: "inworld-tts-1.5-max",
201+
voiceId: "Ashley",
202+
modelId: "inworld-tts-1.5-mini",
202203
audioEncoding: "PCM",
203204
sampleRateHertz: 22_050,
204-
temperature: undefined,
205+
temperature: 0.6,
205206
timeoutMs: 30_000,
206207
});
207208
expect(result).toEqual({

extensions/inworld/speech-provider.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export function buildInworldSpeechProvider(): SpeechProviderPlugin {
197197
},
198198
synthesizeTelephony: async (req) => {
199199
const config = readInworldProviderConfig(req.providerConfig);
200+
const overrides = readInworldOverrides(req.providerOverrides);
200201
const apiKey = config.apiKey || process.env.INWORLD_API_KEY;
201202
if (!apiKey) {
202203
throw new Error("Inworld API key missing");
@@ -207,11 +208,11 @@ export function buildInworldSpeechProvider(): SpeechProviderPlugin {
207208
text: req.text,
208209
apiKey,
209210
baseUrl: config.baseUrl,
210-
voiceId: config.voiceId,
211-
modelId: config.modelId,
211+
voiceId: overrides.voiceId ?? config.voiceId,
212+
modelId: overrides.modelId ?? config.modelId,
212213
audioEncoding: "PCM",
213214
sampleRateHertz: sampleRate,
214-
temperature: config.temperature,
215+
temperature: overrides.temperature ?? config.temperature,
215216
timeoutMs: req.timeoutMs,
216217
});
217218

extensions/xai/speech-provider.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,39 @@ describe("xai speech provider", () => {
6868
}),
6969
);
7070
});
71+
72+
it("honors voice, language, and speed overrides for telephony output", async () => {
73+
const provider = buildXaiSpeechProvider();
74+
const result = await provider.synthesizeTelephony?.({
75+
text: "hello",
76+
cfg: {},
77+
providerConfig: {
78+
apiKey: "xai-key",
79+
baseUrl: "https://api.x.ai/v1",
80+
voiceId: "eve",
81+
language: "en",
82+
speed: 1,
83+
},
84+
providerOverrides: {
85+
voice: "aura",
86+
language: "es",
87+
speed: 1.2,
88+
},
89+
timeoutMs: 5_000,
90+
});
91+
92+
expect(result).toEqual({
93+
audioBuffer: Buffer.from("audio-bytes"),
94+
outputFormat: "pcm",
95+
sampleRate: 24_000,
96+
});
97+
expect(xaiTTSMock).toHaveBeenLastCalledWith(
98+
expect.objectContaining({
99+
voiceId: "aura",
100+
language: "es",
101+
speed: 1.2,
102+
responseFormat: "pcm",
103+
}),
104+
);
105+
});
71106
});

0 commit comments

Comments
 (0)