Skip to content

Commit 27b15a1

Browse files
vincentkocsteipete
andauthored
refactor(voice): catalog voice models through providers (#87794)
* refactor(providers): catalog voice models * feat(tts): route speech through voice models * refactor(tts): rename speaker selection fields * refactor(tts): mark default speech models * test(tts): type migrated speaker config assertions * refactor(providers): avoid catalog merge map spread * fix(tts): honor voice model fallbacks * refactor(tts): move speech core into package * chore(tts): register speech core knip workspace * fix(tts): show migrated speaker voice in status * fix(tts): satisfy speech core lint * fix(tts): preserve explicit model aliases * test(tts): narrow provider config assertion * test(doctor): allow slow commitments repair check --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 398b98d commit 27b15a1

96 files changed

Lines changed: 3206 additions & 440 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/knip.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ const config = {
176176
entry: ["src/index.ts!", "src/schema.ts!"],
177177
project: ["src/**/*.ts!"],
178178
},
179+
"packages/speech-core": {
180+
entry: ["api.ts!", "runtime-api.ts!", "speaker.ts!", "voice-models.ts!"],
181+
project: ["**/*.ts!"],
182+
ignoreDependencies: ["openclaw"],
183+
},
179184
"packages/*": {
180185
entry: ["index.js!", "scripts/postinstall.js!"],
181186
project: ["index.js!", "scripts/**/*.js!"],
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
c61b32fda64ee6cd4d4aa5ed6950c4c681a585d49bf5c127b92e562608a0a303 config-baseline.json
2-
ee4c0f0fb15cda02268f2e83d0c5e1c8d0ec0a2c1b2fdb89cdfce308dadb2b8b config-baseline.core.json
3-
ccb0c68e959854b9d54d66b8c78bfba5fe6f8a37e669e2e7e511b02c4c977122 config-baseline.channel.json
1+
feec5d92420f2008f02beed1f09c373a9392bf54fec547087bfa0581d8c5fe85 config-baseline.json
2+
e51fb382c686637ba6c413648fead48950982b72595c9c6aa1a50da109f4024d config-baseline.core.json
3+
6e64ca2148297da39348568c1faf8c6c431efe3c7b53fb29914905f5b88322a4 config-baseline.channel.json
44
1b763a5524aca2d7ecf1eea38f845ad1ffed5c1b37e85e62f6a7902a3ee0f920 config-baseline.plugin.json

docs/channels/discord.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,7 +1217,7 @@ Auto-join example:
12171217
realtime: {
12181218
provider: "openai",
12191219
model: "gpt-realtime-2",
1220-
voice: "cedar",
1220+
speakerVoice: "cedar",
12211221
},
12221222
},
12231223
},
@@ -1227,20 +1227,20 @@ Auto-join example:
12271227

12281228
Notes:
12291229

1230-
- `voice.tts` overrides `messages.tts` for `stt-tts` voice playback only. Realtime modes use `voice.realtime.voice`.
1230+
- `voice.tts` overrides `messages.tts` for `stt-tts` voice playback only. Realtime modes use `voice.realtime.speakerVoice`.
12311231
- `voice.mode` controls the conversation path. The default is `agent-proxy`: a realtime voice front end handles turn timing, interruption, and playback, delegates substantive work to the routed OpenClaw agent through `openclaw_agent_consult`, and treats the result like a typed Discord prompt from that speaker. `stt-tts` keeps the older batch STT plus TTS flow. `bidi` lets the realtime model converse directly while exposing `openclaw_agent_consult` for the OpenClaw brain.
12321232
- `voice.agentSession` controls which OpenClaw conversation receives voice turns. Leave it unset for the voice channel's own session, or set `{ mode: "target", target: "channel:<text-channel-id>" }` to make the voice channel act as the microphone/speaker extension of an existing Discord text channel session such as `#maintainers`.
12331233
- `voice.model` overrides the OpenClaw agent brain for Discord voice responses and realtime consults. Leave it unset to inherit the routed agent model. It is separate from `voice.realtime.model`.
12341234
- `voice.followUsers` lets the bot join, move, and leave Discord voice with selected users. See [Follow users in voice](#follow-users-in-voice) for behavior rules and examples.
12351235
- `agent-proxy` routes speech through `discord-voice`, which preserves normal owner/tool authorization for the speaker and target session but hides the agent `tts` tool because Discord voice owns playback. By default, `agent-proxy` gives the consult full owner-equivalent tool access for owner speakers (`voice.realtime.toolPolicy: "owner"`) and strongly prefers consulting the OpenClaw agent before substantive answers (`voice.realtime.consultPolicy: "always"`). In that default `always` mode, the realtime layer does not auto-speak filler before the consult answer; it captures and transcribes speech, then speaks the routed OpenClaw answer. If multiple forced consult answers finish while Discord is still playing the first answer, later exact-speech answers are queued until playback idles instead of replacing speech mid-sentence.
12361236
- In `stt-tts` mode, STT uses `tools.media.audio`; `voice.model` does not affect transcription.
1237-
- In realtime modes, `voice.realtime.provider`, `voice.realtime.model`, and `voice.realtime.voice` configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use `voice.realtime.model: "gpt-realtime-2"` and `voice.model: "openai-codex/gpt-5.5"`.
1237+
- In realtime modes, `voice.realtime.provider`, `voice.realtime.model`, and `voice.realtime.speakerVoice` configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use `voice.realtime.model: "gpt-realtime-2"` and `voice.model: "openai-codex/gpt-5.5"`.
12381238
- Realtime voice modes include small `IDENTITY.md`, `USER.md`, and `SOUL.md` profile files in the realtime provider instructions by default so fast direct turns keep the same identity, user grounding, and persona as the routed OpenClaw agent. Set `voice.realtime.bootstrapContextFiles` to a subset to customize this, or `[]` to disable it. The supported realtime bootstrap files are limited to those profile files; `AGENTS.md` stays in the normal agent context. The injected profile context does not replace `openclaw_agent_consult` for workspace work, current facts, memory lookup, or tool-backed actions.
12391239
- In OpenAI `agent-proxy` realtime mode, set `voice.realtime.requireWakeName: true` to keep Discord realtime voice silent until a transcript starts or ends with a wake name. Configured wake names must be one or two words. If `voice.realtime.wakeNames` is unset, OpenClaw uses the routed agent `name` plus `OpenClaw`, falling back to the agent id plus `OpenClaw`. Wake-name gating disables realtime provider auto-response, routes accepted turns through the OpenClaw agent consult path, and gives a short spoken acknowledgement when a leading wake name is recognized from partial transcription before the final transcript arrives.
12401240
- The OpenAI realtime provider accepts current Realtime 2 event names and legacy Codex-compatible aliases for output audio and transcript events, so compatible provider snapshots can drift without dropping assistant audio.
12411241
- `voice.realtime.bargeIn` controls whether Discord speaker-start events interrupt active realtime playback. If unset, it follows the realtime provider's input-audio interruption setting.
12421242
- `voice.realtime.minBargeInAudioEndMs` controls the minimum assistant playback duration before an OpenAI realtime barge-in truncates audio. Default: `250`. Set `0` for immediate interruption in low-echo rooms, or raise it for echo-heavy speaker setups.
1243-
- For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.openai.voice` or `voice.tts.providers.openai.voice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model.
1243+
- For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.providers.openai.speakerVoice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model.
12441244
- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel.
12451245
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`) for owner-gated commands and channel actions. Agent tool visibility follows the configured tool policy for the routed session.
12461246
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent.
@@ -1331,7 +1331,7 @@ Default agent-proxy voice-channel session example:
13311331
realtime: {
13321332
provider: "openai",
13331333
model: "gpt-realtime-2",
1334-
voice: "cedar",
1334+
speakerVoice: "cedar",
13351335
},
13361336
},
13371337
},
@@ -1353,9 +1353,11 @@ Legacy STT plus TTS example:
13531353
model: "openai/gpt-5.4-mini",
13541354
tts: {
13551355
provider: "openai",
1356-
openai: {
1357-
model: "gpt-4o-mini-tts",
1358-
voice: "cedar",
1356+
providers: {
1357+
openai: {
1358+
model: "gpt-4o-mini-tts",
1359+
speakerVoice: "cedar",
1360+
},
13591361
},
13601362
},
13611363
},
@@ -1377,7 +1379,7 @@ Realtime bidi example:
13771379
realtime: {
13781380
provider: "openai",
13791381
model: "gpt-realtime-2",
1380-
voice: "cedar",
1382+
speakerVoice: "cedar",
13811383
toolPolicy: "safe-read-only",
13821384
consultPolicy: "always",
13831385
},
@@ -1404,7 +1406,7 @@ Voice as an extension of an existing Discord channel session:
14041406
realtime: {
14051407
provider: "openai",
14061408
model: "gpt-realtime-2",
1407-
voice: "cedar",
1409+
speakerVoice: "cedar",
14081410
},
14091411
},
14101412
},
@@ -1435,7 +1437,7 @@ Echo-heavy OpenAI Realtime example:
14351437
realtime: {
14361438
provider: "openai",
14371439
model: "gpt-realtime-2",
1438-
voice: "cedar",
1440+
speakerVoice: "cedar",
14391441
bargeIn: true,
14401442
minBargeInAudioEndMs: 500,
14411443
consultPolicy: "always",

docs/gateway/config-agents.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,7 +1056,7 @@ for provider examples and precedence.
10561056
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
10571057
tts: {
10581058
providers: {
1059-
elevenlabs: { voiceId: "EXAVITQu4vr4xnSDxMaL" },
1059+
elevenlabs: { speakerVoiceId: "EXAVITQu4vr4xnSDxMaL" },
10601060
},
10611061
},
10621062
skills: ["docs-search"], // replaces agents.defaults.skills when set
@@ -1415,7 +1415,7 @@ Batches rapid text-only messages from the same sender into a single agent turn.
14151415
elevenlabs: {
14161416
apiKey: "elevenlabs_api_key",
14171417
baseUrl: "https://api.elevenlabs.io",
1418-
voiceId: "voice_id",
1418+
speakerVoiceId: "voice_id",
14191419
modelId: "eleven_multilingual_v2",
14201420
seed: 42,
14211421
applyTextNormalization: "auto",
@@ -1429,15 +1429,15 @@ Batches rapid text-only messages from the same sender into a single agent turn.
14291429
},
14301430
},
14311431
microsoft: {
1432-
voice: "en-US-AvaMultilingualNeural",
1432+
speakerVoice: "en-US-AvaMultilingualNeural",
14331433
lang: "en-US",
14341434
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
14351435
},
14361436
openai: {
14371437
apiKey: "openai_api_key",
14381438
baseUrl: "https://api.openai.com/v1",
14391439
model: "gpt-4o-mini-tts",
1440-
voice: "alloy",
1440+
speakerVoice: "alloy",
14411441
},
14421442
},
14431443
},
@@ -1465,7 +1465,7 @@ Defaults for Talk mode (macOS/iOS/Android).
14651465
provider: "elevenlabs",
14661466
providers: {
14671467
elevenlabs: {
1468-
voiceId: "elevenlabs_voice_id",
1468+
speakerVoiceId: "elevenlabs_voice_id",
14691469
voiceAliases: {
14701470
Clawd: "EXAVITQu4vr4xnSDxMaL",
14711471
Roger: "CwhRBWXzGAHq8TQ4Fs17",
@@ -1489,7 +1489,7 @@ Defaults for Talk mode (macOS/iOS/Android).
14891489
providers: {
14901490
openai: {
14911491
model: "gpt-realtime-2",
1492-
voice: "cedar",
1492+
speakerVoice: "cedar",
14931493
},
14941494
},
14951495
instructions: "Speak warmly and keep answers brief.",

docs/gateway/doctor.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ That stages grounded durable candidates into the short-term dreaming store while
264264
- `routing.transcribeAudio` → `tools.media.audio.models`
265265
- `messages.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) → `messages.tts.providers.<provider>`
266266
- `messages.tts.provider: "edge"` and `messages.tts.providers.edge` → `messages.tts.provider: "microsoft"` and `messages.tts.providers.microsoft`
267+
- TTS speaker selection fields (`voice`/`voiceName`/`voiceId`) → `speakerVoice`/`speakerVoiceId`
267268
- `channels.discord.voice.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) → `channels.discord.voice.tts.providers.<provider>`
268269
- `channels.discord.accounts.<id>.voice.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) → `channels.discord.accounts.<id>.voice.tts.providers.<provider>`
269270
- `plugins.entries.voice-call.config.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) → `plugins.entries.voice-call.config.tts.providers.<provider>`

docs/plugins/google-meet.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ Optional overrides:
11241124
introMessage: "Say exactly: I'm here.",
11251125
providers: {
11261126
google: {
1127-
voice: "Kore",
1127+
speakerVoice: "Kore",
11281128
},
11291129
},
11301130
},
@@ -1141,7 +1141,7 @@ ElevenLabs for both agent-mode listening and speaking:
11411141
providers: {
11421142
elevenlabs: {
11431143
modelId: "eleven_v3",
1144-
voiceId: "pMsXgVXv3BLzUgSXRplE",
1144+
speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
11451145
},
11461146
},
11471147
},
@@ -1169,11 +1169,11 @@ ElevenLabs for both agent-mode listening and speaking:
11691169
```
11701170

11711171
The persistent Meet voice comes from
1172-
`messages.tts.providers.elevenlabs.voiceId`. Agent replies can also use
1173-
per-reply `[[tts:voiceId=... model=eleven_v3]]` directives when TTS model
1172+
`messages.tts.providers.elevenlabs.speakerVoiceId`. Agent replies can also use
1173+
per-reply `[[tts:speakerVoiceId=... model=eleven_v3]]` directives when TTS model
11741174
overrides are enabled, but config is the deterministic default for meetings.
11751175
On join, the logs should show `transcriptionProvider=elevenlabs` and each
1176-
spoken reply should log `provider=elevenlabs model=eleven_v3 voice=<voiceId>`.
1176+
spoken reply should log `provider=elevenlabs model=eleven_v3 speakerVoiceId=<voiceId>`.
11771177

11781178
Twilio-only config:
11791179

docs/plugins/voice-call.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Voice-call credentials accept SecretRefs. `plugins.entries.voice-call.config.twi
115115
responseSystemPrompt: "You are a concise baseball card specialist.",
116116
tts: {
117117
providers: {
118-
openai: { voice: "alloy" },
118+
openai: { speakerVoice: "alloy" },
119119
},
120120
},
121121
},
@@ -322,7 +322,7 @@ for tool work, current information, memory lookups, or workspace state.
322322
google: {
323323
apiKey: "${GEMINI_API_KEY}",
324324
model: "gemini-2.5-flash-native-audio-preview-12-2025",
325-
voice: "Kore",
325+
speakerVoice: "Kore",
326326
silenceDurationMs: 500,
327327
startSensitivity: "high",
328328
},
@@ -455,7 +455,7 @@ speech on calls. You can override it under the plugin config with the
455455
provider: "elevenlabs",
456456
providers: {
457457
elevenlabs: {
458-
voiceId: "pMsXgVXv3BLzUgSXRplE",
458+
speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
459459
modelId: "eleven_multilingual_v2",
460460
},
461461
},
@@ -486,7 +486,7 @@ Behavior notes:
486486
tts: {
487487
provider: "openai",
488488
providers: {
489-
openai: { voice: "alloy" },
489+
openai: { speakerVoice: "alloy" },
490490
},
491491
},
492492
},
@@ -505,7 +505,7 @@ Behavior notes:
505505
providers: {
506506
elevenlabs: {
507507
apiKey: "elevenlabs_key",
508-
voiceId: "pMsXgVXv3BLzUgSXRplE",
508+
speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
509509
modelId: "eleven_multilingual_v2",
510510
},
511511
},
@@ -528,7 +528,7 @@ Behavior notes:
528528
providers: {
529529
openai: {
530530
model: "gpt-4o-mini-tts",
531-
voice: "marin",
531+
speakerVoice: "marin",
532532
},
533533
},
534534
},
@@ -599,7 +599,7 @@ you can usually override only the provider voice:
599599
tts: {
600600
provider: "openai",
601601
providers: {
602-
openai: { voice: "coral" },
602+
openai: { speakerVoice: "coral" },
603603
},
604604
},
605605
numbers: {
@@ -608,7 +608,7 @@ you can usually override only the provider voice:
608608
responseSystemPrompt: "You are a concise baseball card specialist.",
609609
tts: {
610610
providers: {
611-
openai: { voice: "alloy" },
611+
openai: { speakerVoice: "alloy" },
612612
},
613613
},
614614
},

docs/providers/azure-speech.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ provider-owned output format through `X-Microsoft-OutputFormat`.
4545
provider: "azure-speech",
4646
providers: {
4747
"azure-speech": {
48-
voice: "en-US-JennyNeural",
48+
speakerVoice: "en-US-JennyNeural",
4949
lang: "en-US",
5050
},
5151
},
@@ -69,7 +69,7 @@ provider-owned output format through `X-Microsoft-OutputFormat`.
6969
| `region` | `messages.tts.providers.azure-speech.region` | Azure Speech resource region. Falls back to `AZURE_SPEECH_REGION` or `SPEECH_REGION`. |
7070
| `endpoint` | `messages.tts.providers.azure-speech.endpoint` | Optional Azure Speech endpoint/base URL override. |
7171
| `baseUrl` | `messages.tts.providers.azure-speech.baseUrl` | Optional Azure Speech base URL override. |
72-
| `voice` | `messages.tts.providers.azure-speech.voice` | Azure voice ShortName (default `en-US-JennyNeural`). |
72+
| `speakerVoice` | `messages.tts.providers.azure-speech.speakerVoice` | Azure voice ShortName (default `en-US-JennyNeural`). Legacy alias: `voice`. |
7373
| `lang` | `messages.tts.providers.azure-speech.lang` | SSML language code (default `en-US`). |
7474
| `outputFormat` | `messages.tts.providers.azure-speech.outputFormat` | Audio-file output format (default `audio-24khz-48kbitrate-mono-mp3`). |
7575
| `voiceNoteOutputFormat` | `messages.tts.providers.azure-speech.voiceNoteOutputFormat` | Voice-note output format (default `ogg-24khz-16bit-mono-opus`). |

docs/providers/elevenlabs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export ELEVENLABS_API_KEY="..."
3434
providers: {
3535
elevenlabs: {
3636
apiKey: "${ELEVENLABS_API_KEY}",
37-
voiceId: "pMsXgVXv3BLzUgSXRplE",
37+
speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
3838
modelId: "eleven_multilingual_v2",
3939
},
4040
},

docs/providers/google.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ To use Google as the default TTS provider:
303303
providers: {
304304
google: {
305305
model: "gemini-3.1-flash-tts-preview",
306-
voiceName: "Kore",
306+
speakerVoice: "Kore",
307307
audioProfile: "Speak professionally with a calm tone.",
308308
},
309309
},
@@ -367,7 +367,7 @@ Example Voice Call realtime config:
367367
providers: {
368368
google: {
369369
model: "gemini-2.5-flash-native-audio-preview-12-2025",
370-
voice: "Kore",
370+
speakerVoice: "Kore",
371371
activityHandling: "start-of-activity-interrupts",
372372
turnCoverage: "only-activity",
373373
},

0 commit comments

Comments
 (0)