fix(tts): honor telephony voice overrides · openclaw/openclaw@361737d
vincentkoc
·
2026-05-04
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
18 | 18 | - 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. |
19 | 19 | - 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) |
20 | 20 | - 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. |
21 | 22 | - Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions. |
22 | 23 | - 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. |
23 | 24 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -176,6 +176,42 @@ describe("buildAzureSpeechProvider", () => {
|
176 | 176 | }); |
177 | 177 | }); |
178 | 178 | |
| 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 | + |
179 | 215 | it("lists voices through config or explicit request auth", async () => { |
180 | 216 | const provider = buildAzureSpeechProvider(); |
181 | 217 | const voices = await provider.listVoices?.({ |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -279,6 +279,7 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin {
|
279 | 279 | }, |
280 | 280 | synthesizeTelephony: async (req) => { |
281 | 281 | const config = readAzureSpeechProviderConfig(req.providerConfig); |
| 282 | +const overrides = readAzureSpeechOverrides(req.providerOverrides); |
282 | 283 | const apiKey = resolveApiKey(config); |
283 | 284 | if (!apiKey) { |
284 | 285 | throw new Error("Azure Speech API key missing"); |
@@ -290,8 +291,8 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin {
|
290 | 291 | baseUrl: config.baseUrl, |
291 | 292 | endpoint: config.endpoint, |
292 | 293 | region: config.region, |
293 | | -voice: config.voice, |
294 | | -lang: config.lang, |
| 294 | +voice: overrides.voice ?? config.voice, |
| 295 | +lang: overrides.lang ?? config.lang, |
295 | 296 | outputFormat: DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT, |
296 | 297 | timeoutMs: resolveTimeoutMs(config, req.timeoutMs), |
297 | 298 | }); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -397,11 +397,44 @@ describe("Google speech provider", () => {
|
397 | 397 | cfg: {}, |
398 | 398 | providerConfig: { |
399 | 399 | apiKey: "google-test-key", |
| 400 | +model: "google/gemini-3.1-flash-tts", |
400 | 401 | 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", |
401 | 410 | }, |
402 | 411 | timeoutMs: 5_000, |
403 | 412 | }); |
404 | 413 | |
| 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 | +); |
405 | 438 | expect(result).toEqual({ |
406 | 439 | audioBuffer: pcm, |
407 | 440 | outputFormat: "pcm", |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -640,6 +640,7 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
|
640 | 640 | }, |
641 | 641 | synthesizeTelephony: async (req) => { |
642 | 642 | const config = readGoogleTtsProviderConfig(req.providerConfig); |
| 643 | +const overrides = readGoogleTtsOverrides(req.providerOverrides); |
643 | 644 | const apiKey = resolveGoogleTtsApiKey({ |
644 | 645 | cfg: req.cfg, |
645 | 646 | providerConfig: req.providerConfig, |
@@ -654,10 +655,10 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
|
654 | 655 | request: sanitizeConfiguredModelProviderRequest( |
655 | 656 | req.cfg?.models?.providers?.google?.request, |
656 | 657 | ), |
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, |
661 | 662 | timeoutMs: req.timeoutMs, |
662 | 663 | }); |
663 | 664 | return { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -98,12 +98,16 @@ describe("gradium speech provider", () => {
|
98 | 98 | const result = await provider.synthesizeTelephony!({ |
99 | 99 | text: "Telephony test", |
100 | 100 | cfg: {} as never, |
101 | | -providerConfig: { apiKey: "gsk_test123" }, |
| 101 | +providerConfig: { apiKey: "gsk_test123", voiceId: "default-voice" }, |
| 102 | +providerOverrides: { voiceId: "override-voice" }, |
102 | 103 | timeoutMs: 30_000, |
103 | 104 | }); |
104 | 105 | |
105 | 106 | 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 | +}); |
107 | 111 | expect(result.outputFormat).toBe("ulaw_8000"); |
108 | 112 | expect(result.sampleRate).toBe(8_000); |
109 | 113 | expect(result.audioBuffer).toEqual(audioData); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -96,6 +96,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
|
96 | 96 | }, |
97 | 97 | synthesizeTelephony: async (req) => { |
98 | 98 | const config = readGradiumProviderConfig(req.providerConfig); |
| 99 | +const overrides = req.providerOverrides ?? {}; |
99 | 100 | const apiKey = config.apiKey || process.env.GRADIUM_API_KEY; |
100 | 101 | if (!apiKey) { |
101 | 102 | throw new Error("Gradium API key missing"); |
@@ -106,7 +107,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
|
106 | 107 | text: req.text, |
107 | 108 | apiKey, |
108 | 109 | baseUrl: config.baseUrl, |
109 | | -voiceId: config.voiceId, |
| 110 | +voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId, |
110 | 111 | outputFormat, |
111 | 112 | timeoutMs: req.timeoutMs, |
112 | 113 | }); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -190,18 +190,19 @@ describe("buildInworldSpeechProvider", () => {
|
190 | 190 | text: "Hello", |
191 | 191 | cfg: {} as never, |
192 | 192 | providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" }, |
| 193 | +providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 }, |
193 | 194 | timeoutMs: 30_000, |
194 | 195 | }); |
195 | 196 | |
196 | 197 | expect(inworldTTSMock).toHaveBeenCalledWith({ |
197 | 198 | text: "Hello", |
198 | 199 | apiKey: "key", |
199 | 200 | 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", |
202 | 203 | audioEncoding: "PCM", |
203 | 204 | sampleRateHertz: 22_050, |
204 | | -temperature: undefined, |
| 205 | +temperature: 0.6, |
205 | 206 | timeoutMs: 30_000, |
206 | 207 | }); |
207 | 208 | expect(result).toEqual({ |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -197,6 +197,7 @@ export function buildInworldSpeechProvider(): SpeechProviderPlugin {
|
197 | 197 | }, |
198 | 198 | synthesizeTelephony: async (req) => { |
199 | 199 | const config = readInworldProviderConfig(req.providerConfig); |
| 200 | +const overrides = readInworldOverrides(req.providerOverrides); |
200 | 201 | const apiKey = config.apiKey || process.env.INWORLD_API_KEY; |
201 | 202 | if (!apiKey) { |
202 | 203 | throw new Error("Inworld API key missing"); |
@@ -207,11 +208,11 @@ export function buildInworldSpeechProvider(): SpeechProviderPlugin {
|
207 | 208 | text: req.text, |
208 | 209 | apiKey, |
209 | 210 | baseUrl: config.baseUrl, |
210 | | -voiceId: config.voiceId, |
211 | | -modelId: config.modelId, |
| 211 | +voiceId: overrides.voiceId ?? config.voiceId, |
| 212 | +modelId: overrides.modelId ?? config.modelId, |
212 | 213 | audioEncoding: "PCM", |
213 | 214 | sampleRateHertz: sampleRate, |
214 | | -temperature: config.temperature, |
| 215 | +temperature: overrides.temperature ?? config.temperature, |
215 | 216 | timeoutMs: req.timeoutMs, |
216 | 217 | }); |
217 | 218 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -68,4 +68,39 @@ describe("xai speech provider", () => {
|
68 | 68 | }), |
69 | 69 | ); |
70 | 70 | }); |
| 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 | +}); |
71 | 106 | }); |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。