Skip to content

Commit 9304855

Browse files
committed
Switch speak/dub default voices to the requested language's native voice
Each streaming-TTS voice speaks exactly one language, but `assembly speak` and `assembly dub` always defaulted to the English voices (jane / the English rotation) regardless of the requested language — a German dub came out in jane's voice. A new aai_cli/tts/voices.py maps every voice to its language (giovanni=it, lola=es, juergen=de, rafael=pt, estelle=fr, the rest en). With no explicit --voice, both commands now rotate through the requested language's native voices — most languages ship exactly one, so the language alone selects the voice. English keeps the curated multi-speaker rotation, a language without a catalog voice falls back to it, and an explicit --voice (bare or SPEAKER=VOICE) still always wins. https://claude.ai/code/session_01PPkdXahnabDwMBwCWcSEGA
1 parent dcb96e2 commit 9304855

11 files changed

Lines changed: 282 additions & 34 deletions

File tree

aai_cli/commands/dub.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def dub(
6060
[],
6161
"--voice",
6262
help="Voice id for every speaker (e.g. jane, michael, paul), or SPEAKER=VOICE "
63-
"to pin a diarized speaker (repeatable, e.g. --voice A=jane).",
63+
"to pin a diarized speaker (repeatable, e.g. --voice A=jane). Default: the "
64+
"target language's native voice(s).",
6465
),
6566
model: str = typer.Option(
6667
llm.DEFAULT_MODEL,

aai_cli/commands/speak.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
[
2020
("Speak text aloud (sandbox only)", 'assembly --sandbox speak "Hello there, friend."'),
2121
(
22-
"Pick a voice and language",
23-
'assembly --sandbox speak "Bonjour" --voice jane --language French',
22+
"Pick a language (its native voice is selected automatically)",
23+
'assembly --sandbox speak "Bonjour" --language French',
2424
),
2525
(
2626
"Speak a diarized transcript, one voice per speaker",
@@ -46,7 +46,12 @@ def speak(
4646
help="Voice id (e.g. jane, michael, mary, paul, eve, george), or SPEAKER=VOICE "
4747
"for diarized input (repeatable, e.g. --voice A=jane).",
4848
),
49-
language: str = typer.Option(DEFAULT_LANGUAGE, "--language", help="Language of the text."),
49+
language: str = typer.Option(
50+
DEFAULT_LANGUAGE,
51+
"--language",
52+
help="Language of the text. The default voice follows it "
53+
"(e.g. --language Italian speaks with giovanni).",
54+
),
5055
sample_rate: int | None = typer.Option(
5156
None,
5257
"--sample-rate",

aai_cli/dub_exec.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from aai_cli import llm as gateway
3232
from aai_cli.context import AppState
3333
from aai_cli.errors import APIError, CLIError, UsageError
34-
from aai_cli.tts import audio, dialogue, session
34+
from aai_cli.tts import audio, dialogue, session, voices
3535
from aai_cli.tts.session import SpeakConfig
3636

3737
# ISO-639-1 codes accepted by --lang, mapped to the language *name* both the
@@ -349,15 +349,18 @@ def _assign_voices(
349349
utterances: list[_Utterance],
350350
translations: list[str],
351351
voice_values: list[str],
352+
language: str,
352353
) -> tuple[list[tuple[str, str]], dict[str, str]]:
353354
"""Resolve each translated utterance to ``(voice, text)`` plus the speaker→voice map.
354355
355356
A bare ``--voice`` dubs every speaker with that one voice; ``SPEAKER=VOICE``
356-
mappings pin individual speakers; everyone else takes the rotation in
357-
first-appearance order (the same rules as `assembly speak`).
357+
mappings pin individual speakers; everyone else takes the target language's
358+
rotation in first-appearance order (the same rules as `assembly speak`) —
359+
each voice speaks one language, so a non-English dub switches to that
360+
language's native voice(s).
358361
"""
359362
bare_voice, overrides = dialogue.parse_voice_overrides(voice_values)
360-
rotation = (bare_voice,) if bare_voice is not None else dialogue.DEFAULT_VOICE_ROTATION
363+
rotation = (bare_voice,) if bare_voice is not None else voices.rotation_for(language)
361364
segments = [
362365
dialogue.Segment(utterance.speaker, translated)
363366
# strict=True is an invariant guard only: _translate returns exactly one
@@ -384,7 +387,7 @@ def run_dub(opts: DubOptions, state: AppState, *, json_mode: bool) -> None:
384387
translations = _translate(
385388
api_key, utterances, language, opts, json_mode=json_mode, quiet=state.quiet
386389
)
387-
resolved, speakers = _assign_voices(utterances, translations, opts.voice)
390+
resolved, speakers = _assign_voices(utterances, translations, opts.voice, language)
388391
pcm_segments, sample_rate = _synthesize(
389392
api_key, resolved, language, json_mode=json_mode, quiet=state.quiet
390393
)

aai_cli/speak_exec.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
from aai_cli import environments, output, stdio
1515
from aai_cli.context import AppState
1616
from aai_cli.errors import CLIError, UsageError
17-
from aai_cli.tts import audio, dialogue, session
17+
from aai_cli.tts import audio, dialogue, session, voices
1818

19-
# The streaming-TTS reference client defaults to the PocketTTS "jane" voice and
20-
# English, so the CLI sends the same and a bare `assembly speak` works out of the box.
21-
# Override either with --voice/--language.
22-
DEFAULT_VOICE = "jane"
19+
# The streaming-TTS reference client defaults to English, so the CLI does the
20+
# same. The default voice follows the language (voices.default_voice): each
21+
# voice speaks one language, so e.g. --language Italian switches to giovanni
22+
# unless --voice overrides it.
2323
DEFAULT_LANGUAGE = "English"
2424

2525

@@ -161,7 +161,7 @@ def _speak_dialogue(
161161
json_mode=json_mode,
162162
)
163163
resolved, speakers = dialogue.assign_voices(
164-
segments, dialogue.DEFAULT_VOICE_ROTATION, overrides
164+
segments, voices.rotation_for(opts.language), overrides
165165
)
166166
with output.status("Synthesizing speech…", json_mode=json_mode, quiet=quiet):
167167
result = session.synthesize_dialogue(
@@ -209,7 +209,7 @@ def run_speak(opts: SpeakOptions, state: AppState, *, json_mode: bool) -> None:
209209
_speak_single(
210210
api_key,
211211
spoken,
212-
bare_voice or DEFAULT_VOICE,
212+
bare_voice or voices.default_voice(opts.language),
213213
opts,
214214
json_mode=json_mode,
215215
quiet=state.quiet,

aai_cli/tts/dialogue.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,6 @@ def flush() -> None:
6565
return [turn for turn in merged if turn.text]
6666

6767

68-
DEFAULT_VOICE_ROTATION = ("jane", "michael", "mary", "paul", "eve", "george")
69-
70-
7168
def parse_voice_overrides(values: list[str]) -> tuple[str | None, dict[str, str]]:
7269
"""Split repeatable ``--voice`` values into ``(bare_voice, {speaker_id: voice})``.
7370

aai_cli/tts/voices.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""The streaming-TTS voice catalog: every voice speaks exactly one language.
2+
3+
When no ``--voice`` is chosen, `assembly speak` and `assembly dub` pick the
4+
voice from the requested language: a non-English language switches to that
5+
language's native voice(s) — most ship exactly one, so the language alone
6+
selects the voice — while English keeps the curated multi-speaker rotation.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
# Voice id -> ISO 639-1 code of the (single) language the voice speaks.
12+
VOICE_LANGUAGES: dict[str, str] = {
13+
"alba": "en",
14+
"anna": "en",
15+
"azelma": "en",
16+
"bill_boerst": "en",
17+
"caro_davy": "en",
18+
"charles": "en",
19+
"cosette": "en",
20+
"eponine": "en",
21+
"estelle": "fr",
22+
"eve": "en",
23+
"fantine": "en",
24+
"george": "en",
25+
"giovanni": "it",
26+
"jane": "en",
27+
"javert": "en",
28+
"jean": "en",
29+
"juergen": "de",
30+
"lola": "es",
31+
"marius": "en",
32+
"mary": "en",
33+
"michael": "en",
34+
"paul": "en",
35+
"peter_yearsley": "en",
36+
"rafael": "pt",
37+
"stuart_bell": "en",
38+
"vera": "en",
39+
}
40+
41+
# The language names the TTS `language` param uses, keyed by ISO code. Only
42+
# languages with at least one catalog voice belong here (rotation_for relies
43+
# on that invariant to never resolve to an empty rotation). Deliberately
44+
# narrower than dub_exec.LANGUAGE_NAMES, which also lists voiceless languages
45+
# the translator supports.
46+
_LANGUAGE_NAMES: dict[str, str] = {
47+
"de": "German",
48+
"en": "English",
49+
"es": "Spanish",
50+
"fr": "French",
51+
"it": "Italian",
52+
"pt": "Portuguese",
53+
}
54+
55+
_NAME_TO_CODE = {name.casefold(): code for code, name in _LANGUAGE_NAMES.items()}
56+
57+
# English has many voices; this curated subset keeps multi-speaker output
58+
# varied with the confirmed-working voices. Non-English languages rotate
59+
# through their own (usually single) native voices instead.
60+
ENGLISH_ROTATION = ("jane", "michael", "mary", "paul", "eve", "george")
61+
62+
63+
def language_code(language: str | None) -> str | None:
64+
"""Normalize a language value (ISO code or name, any case) to its code,
65+
or None when it has no catalog voices — the TTS service and the dub
66+
translator accept more languages than the catalog covers."""
67+
if language is None:
68+
return None
69+
cleaned = language.strip().casefold()
70+
if cleaned in _LANGUAGE_NAMES:
71+
return cleaned
72+
return _NAME_TO_CODE.get(cleaned)
73+
74+
75+
def rotation_for(language: str | None) -> tuple[str, ...]:
76+
"""The default voice rotation for a language.
77+
78+
English — and any language without catalog voices — keeps the curated
79+
English rotation; a language with native voices rotates through those, so
80+
a single-voice language always switches to its one voice.
81+
"""
82+
code = language_code(language)
83+
if code is None or code == "en":
84+
return ENGLISH_ROTATION
85+
return tuple(voice for voice, spoken in VOICE_LANGUAGES.items() if spoken == code)
86+
87+
88+
def default_voice(language: str | None) -> str:
89+
"""The voice used when none is chosen: the language's first rotation voice."""
90+
return rotation_for(language)[0]

tests/__snapshots__/test_snapshots_help_run.ambr

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@
244244
│ --voice TEXT Voice id for every speaker (e.g. jane, │
245245
│ michael, paul), or SPEAKER=VOICE to pin a │
246246
│ diarized speaker (repeatable, e.g. --voice │
247-
│ A=jane). │
247+
│ A=jane). Default: the target language's │
248+
│ native voice(s). │
248249
│ --out PATH Output file (default: │
249250
│ <name>.dub.<lang><ext> next to the input). │
250251
│ --json -j Emit JSON describing the dubbed file. │
@@ -426,7 +427,10 @@
426427
│ SPEAKER=VOICE for diarized │
427428
│ input (repeatable, e.g. --voice │
428429
│ A=jane). │
429-
│ --language TEXT Language of the text. │
430+
│ --language TEXT Language of the text. The │
431+
│ default voice follows it (e.g. │
432+
│ --language Italian speaks with │
433+
│ giovanni). │
430434
│ [default: English] │
431435
│ --sample-rate INTEGER RANGE [x>=1] Output sample rate in Hz │
432436
│ (positive). Server default if │
@@ -441,8 +445,8 @@
441445
Examples
442446
Speak text aloud (sandbox only)
443447
$ assembly --sandbox speak "Hello there, friend."
444-
Pick a voice and language
445-
$ assembly --sandbox speak "Bonjour" --voice jane --language French
448+
Pick a language (its native voice is selected automatically)
449+
$ assembly --sandbox speak "Bonjour" --language French
446450
Speak a diarized transcript, one voice per speaker
447451
$ assembly transcribe meeting.mp3 --speaker-labels | assembly --sandbox speak
448452
Override a speaker's voice

tests/test_dub_pipeline.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ def test_run_dub_pipeline_end_to_end(
9393
assert "dubbing" in system["content"]
9494
assert "German" in system["content"]
9595

96-
# Synthesis: the translated text, rotation voices in speaker order, target language.
96+
# Synthesis: the translated text in the target language, every speaker on
97+
# German's one native voice (the language selects the voice).
9798
assert [(cfg.voice, cfg.text) for cfg in fake_synthesize] == [
98-
("jane", "DE:Hello."),
99-
("michael", "DE:World."),
99+
("juergen", "DE:Hello."),
100+
("juergen", "DE:World."),
100101
]
101102
assert all(cfg.language == "German" for cfg in fake_synthesize)
102103

@@ -137,7 +138,7 @@ def test_run_dub_pipeline_end_to_end(
137138
"language": "German",
138139
"transcript_id": "tr_dub",
139140
"utterances": 2,
140-
"speakers": {"A": "jane", "B": "michael"},
141+
"speakers": {"A": "juergen", "B": "juergen"},
141142
"sample_rate": SAMPLE_RATE,
142143
"audio_duration_seconds": 5.0,
143144
}
@@ -157,7 +158,7 @@ def test_run_dub_human_summary(
157158
assert "dub.de.mp4" in out
158159
assert "dubbed to German" in out
159160
assert "2 utterances" in out
160-
assert "A=jane, B=michael" in out
161+
assert "A=juergen, B=juergen" in out
161162

162163

163164
def test_bare_voice_dubs_every_speaker(
@@ -173,8 +174,29 @@ def test_voice_overrides_pin_speakers_without_consuming_rotation(
173174
):
174175
opts = dataclasses.replace(DEFAULTS, media=str(media), voice=["A=mary"])
175176
_run(opts, json_mode=True)
176-
# A is pinned; B still takes the first rotation voice (overrides don't consume slots).
177-
assert [cfg.voice for cfg in fake_synthesize] == ["mary", "jane"]
177+
# A is pinned; B still takes German's native voice from the rotation.
178+
assert [cfg.voice for cfg in fake_synthesize] == ["mary", "juergen"]
179+
180+
181+
def test_english_dub_keeps_the_multi_voice_rotation(
182+
media, fake_transcribe, fake_translate, fake_synthesize, fake_ffmpeg
183+
):
184+
# English has many voices, so speakers still rotate through the curated set
185+
# instead of collapsing onto one voice.
186+
opts = dataclasses.replace(DEFAULTS, media=str(media), language="en")
187+
_run(opts, json_mode=True)
188+
assert [cfg.voice for cfg in fake_synthesize] == ["jane", "michael"]
189+
190+
191+
def test_language_without_a_native_voice_falls_back_to_english_rotation(
192+
media, fake_transcribe, fake_translate, fake_synthesize, fake_ffmpeg
193+
):
194+
# Japanese is translatable but has no catalog voice: the dub still runs,
195+
# on the English rotation.
196+
opts = dataclasses.replace(DEFAULTS, media=str(media), language="ja")
197+
_run(opts, json_mode=True)
198+
assert [cfg.voice for cfg in fake_synthesize] == ["jane", "michael"]
199+
assert all(cfg.language == "Japanese" for cfg in fake_synthesize)
178200

179201

180202
def test_transcript_id_reuses_existing_transcript(

tests/test_speak.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,27 @@ def test_voice_and_language_flow_into_config(monkeypatch, fake_synthesize):
115115
assert cfg.query_params() == {"voice": "jane", "language": "English"}
116116

117117

118+
def test_default_voice_follows_the_language(monkeypatch, fake_synthesize):
119+
# Each voice speaks one language: with no --voice, a non-English --language
120+
# switches to that language's native voice instead of English "jane".
121+
monkeypatch.setattr("aai_cli.speak_exec.audio.play_pcm", lambda *a, **k: None)
122+
result = runner.invoke(app, ["--sandbox", "speak", "Ciao", "--language", "Italian"])
123+
assert result.exit_code == 0
124+
cfg = fake_synthesize["cfg"]
125+
assert cfg.voice == "giovanni"
126+
assert cfg.language == "Italian"
127+
128+
129+
def test_explicit_voice_beats_the_language_default(monkeypatch, fake_synthesize):
130+
monkeypatch.setattr("aai_cli.speak_exec.audio.play_pcm", lambda *a, **k: None)
131+
result = runner.invoke(
132+
app, ["--sandbox", "speak", "Bonjour", "--voice", "jane", "--language", "French"]
133+
)
134+
assert result.exit_code == 0
135+
# A chosen voice always wins; the language only drives the default.
136+
assert fake_synthesize["cfg"].voice == "jane"
137+
138+
118139
def test_json_mode_emits_metadata_object_on_stdout(monkeypatch, fake_synthesize):
119140
monkeypatch.setattr("aai_cli.speak_exec.audio.play_pcm", lambda *a, **k: None)
120141
result = runner.invoke(app, ["--sandbox", "speak", "Hi", "--voice", "jane", "--json"])
@@ -166,6 +187,16 @@ def test_labeled_stdin_uses_dialogue_path_with_default_rotation(fake_dialogue):
166187
]
167188

168189

190+
def test_dialogue_rotation_follows_the_language(fake_dialogue):
191+
# French has exactly one native voice, so every speaker switches to it —
192+
# the language selects the voice in dialogue mode too.
193+
text = "Speaker A: Bonjour.\nSpeaker B: Salut."
194+
result = runner.invoke(app, ["--sandbox", "speak", "--language", "French"], input=text)
195+
assert result.exit_code == 0
196+
assert fake_dialogue["segments"] == [("estelle", "Bonjour."), ("estelle", "Salut.")]
197+
assert fake_dialogue["language"] == "French"
198+
199+
169200
def test_speaker_voice_override_is_applied(fake_dialogue):
170201
text = "Speaker A: One.\nSpeaker B: Two."
171202
result = runner.invoke(

tests/test_tts_dialogue.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,3 @@ def test_assign_voices_override_beats_rotation_without_consuming_a_slot():
101101
resolved, mapping = dialogue.assign_voices(segs, ["jane", "michael"], {"a": "vera"})
102102
assert [v for v, _ in resolved] == ["vera", "jane"]
103103
assert mapping == {"A": "vera", "B": "jane"}
104-
105-
106-
def test_default_rotation_is_the_confirmed_working_voices():
107-
assert dialogue.DEFAULT_VOICE_ROTATION == ("jane", "michael", "mary", "paul", "eve", "george")

0 commit comments

Comments
 (0)