aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 16:49:36 -0400
committerhistoria <historiavg@proton.me>2026-08-25 16:49:36 -0400
commit775b716d86f5e681807698522e56c3d0f861c5bf (patch)
tree420b31c7b2f743ee83ad933bbc00073d5f6b9e2a /app
parent867866f131b0b6c76c54272791e7f7dea01db990 (diff)
downloadtts-audiobook-generator-775b716d86f5e681807698522e56c3d0f861c5bf.tar.gz
feat: combine --speaker and --voice for clarity
Diffstat (limited to 'app')
-rw-r--r--app/converter/converter.py38
-rw-r--r--app/converter/tts.py265
-rw-r--r--app/docs/backend-audiocpp.md4
-rw-r--r--app/tests/test_converter.py9
-rw-r--r--app/tests/test_hub.py16
-rw-r--r--app/tests/test_tts.py63
-rw-r--r--app/ui/hub.py15
7 files changed, 222 insertions, 188 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py
index 6e677c8..80411d3 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -185,7 +185,6 @@ class AudiobookConverter:
instructions: Optional[str] = None,
request_options: Optional[Dict[str, str]] = None,
api_url: Optional[str] = None,
- speaker: Optional[str] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
if speed <= 0:
@@ -206,7 +205,6 @@ class AudiobookConverter:
self.output_format = output_format
self.backend = backend
self.voice = voice
- self.speaker = speaker
self.debug = bool(debug)
# Voice design / style instruction and free-form request options
# (audio.cpp only): forwarded to AudioCppTTSClient, which validates
@@ -219,18 +217,17 @@ class AudiobookConverter:
# configured on the server, so no local reference audio is needed.
self.tts = FasterTTSClient(voice=voice, api_url=api_url)
elif backend == BACKEND_AUDIOCPP:
- # Speaker mode (--speaker or no flag on a CustomVoice entry) uses
- # a built-in speaker name; an explicit --voice selects a
- # server-side preset (cloning). model_id overrides
- # AUDIOCPP_MODEL_ID for multi-model servers; instructions describe
- # or style the voice, request_options pass per-model controls
- # through to the server.
+ # --voice picks the voice: a built-in speaker name on the
+ # CustomVoice entry, or a server-side preset (cloning)
+ # elsewhere. model_id overrides AUDIOCPP_MODEL_ID for
+ # multi-model servers; instructions describe or style the
+ # voice, request_options pass per-model controls through to
+ # the server.
self.tts = AudioCppTTSClient(voice=voice, language=self.language,
model_id=model_id,
instructions=instructions,
request_options=self.request_options,
- api_url=api_url,
- speaker=speaker)
+ api_url=api_url)
else:
self.tts = QwenTTSClient(
voice_mode=voice_mode,
@@ -294,14 +291,13 @@ class AudiobookConverter:
"""Narrator name used in output file names (see compute_narrator_tag)."""
return self.compute_narrator_tag(
self.backend, self.voice, self.voice_mode,
- self.voice_clone_ref_audio, self.instructions, self.speaker)
+ self.voice_clone_ref_audio, self.instructions)
@staticmethod
def compute_narrator_tag(backend: str, voice: Optional[str],
voice_mode: str,
voice_clone_ref_audio: Optional[str],
- instructions: Optional[str] = None,
- speaker: Optional[str] = None) -> str:
+ instructions: Optional[str] = None) -> str:
"""Narrator name used in output file names, without a server connection.
Custom voice mode uses the built-in speaker's display name; voice
@@ -322,8 +318,6 @@ class AudiobookConverter:
elif backend == BACKEND_AUDIOCPP:
if voice:
narrator = voice
- elif speaker:
- narrator = speaker
elif instructions:
# The voice comes from the instruction, not a speaker name.
narrator = "designed"
@@ -677,12 +671,12 @@ class AudiobookConverter:
self._say(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}")
self._say(f"Model id: {self.tts.model_id}")
self._say(f"Model family: {getattr(self.tts, 'family', 'unknown')}")
- if self.voice:
+ if getattr(self.tts, "preset_mode", False):
self._say("Backend: audio.cpp (voice cloning, reference configured on server)")
- self._say(f"Voice: {self.voice}")
- elif self.speaker:
+ self._say(f"Voice: {self.tts.voice}")
+ elif getattr(self.tts, "speaker_mode", False):
self._say("Backend: audio.cpp (custom voice, built-in speaker)")
- self._say(f"Speaker: {self.speaker}")
+ self._say(f"Speaker: {self.tts.voice}")
elif self.instructions:
self._say("Backend: audio.cpp (voice from --instructions description)")
self._say(f"Instruction: {self.instructions}")
@@ -726,7 +720,6 @@ class AudiobookConverter:
voice_clone_ref_audio: Optional[str],
output_format: str,
instructions: Optional[str] = None,
- speaker: Optional[str] = None,
confirm: Optional[Callable[[str, bool], bool]] = None,
) -> Tuple[List[Path], List[Tuple[Path, str]]]:
"""Discover books and ask every overwrite question up front.
@@ -758,8 +751,7 @@ class AudiobookConverter:
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
narrator_tag = AudiobookConverter.compute_narrator_tag(
- backend, voice, voice_mode, voice_clone_ref_audio, instructions,
- speaker)
+ backend, voice, voice_mode, voice_clone_ref_audio, instructions)
for book_file in book_files:
output_name = book_file.stem
if stem_counts[book_file.stem] > 1:
@@ -795,7 +787,7 @@ class AudiobookConverter:
book_files, planned = AudiobookConverter.preflight_overwrites(
self.backend, self.voice, self.voice_mode,
self.voice_clone_ref_audio, self.output_format,
- self.instructions, self.speaker)
+ self.instructions)
if not book_files:
self._say(f"[INFO] No supported files found in {BOOKS_FOLDER}")
diff --git a/app/converter/tts.py b/app/converter/tts.py
index a88697e..7204e0c 100644
--- a/app/converter/tts.py
+++ b/app/converter/tts.py
@@ -260,13 +260,27 @@ def speaker_display_name_for(name: str) -> str:
Accepts either the canonical/config form (e.g. "uncle_fu", "Uncle_Fu")
or the display form ("Uncle Fu"), case-insensitively; unknown names pass
- through unchanged. Used by AudioCppTTSClient to normalize the --speaker /
+ through unchanged. Used by AudioCppTTSClient to normalize the --voice /
Speaker-picker value into what audiocpp_server expects in the request's
voice field.
"""
return SPEAKER_DISPLAY_NAMES.get((name or "").lower(), name)
+def is_builtin_speaker(name: Optional[str]) -> bool:
+ """True when NAME is one of the Qwen3-TTS CustomVoice built-in speakers.
+
+ Matches case-insensitively across the canonical ("Uncle_Fu"), display
+ ("Uncle Fu") and shorthand ("uncle_fu") forms, so the --voice flag and
+ the Convert form's Speaker picker resolve to the same set.
+ """
+ if not name:
+ return False
+ norm = name.lower().replace("_", " ").replace("-", " ")
+ return any(norm == speaker.lower().replace("_", " ")
+ for speaker in QWEN3_TTS_SPEAKERS)
+
+
def speaker_display_name() -> str:
"""Return the display name for the configured custom speaker."""
return speaker_display_name_for(config.SPEAKER)
@@ -870,11 +884,12 @@ class AudioCppTTSClient(_BaseTTSClient):
its voice is supplied; all three are resolved server-side from the
request's "voice"/"instructions" fields:
- - Speaker mode (--speaker NAME, or no flag on a CustomVoice entry):
- Qwen3-TTS CustomVoice only. A built-in speaker name (e.g. "Vivian")
- is passed through, plus the INSTRUCT style prompt. The selected entry
- must be the CustomVoice model (capability == speaker); selecting it
- on a non-speaker entry fails fast with a hint.
+ - Speaker mode (--voice with a built-in speaker name, or no flag on a
+ CustomVoice entry): Qwen3-TTS CustomVoice only. A built-in speaker
+ name (e.g. "Vivian") is passed through, plus the INSTRUCT style
+ prompt. The selected entry must be the CustomVoice model (capability
+ == speaker); a speaker name on a non-speaker entry is treated as a
+ server-side preset instead.
- Preset mode (--voice NAME): a voice configured on the server
(``voice_presets`` or ``voice_dir`` in its config, e.g. a cloning
reference). The name is validated against GET /v1/audio/voices at
@@ -882,9 +897,9 @@ class AudioCppTTSClient(_BaseTTSClient):
plain TTS on a clone-based model instead of failing. When
AUDIOCPP_CLONE_MODEL_ID names a second server entry of the same
family (typically the Qwen Base model), preset requests are routed
- to it. Selecting --voice on a CustomVoice primary with a clone id
- configured is the documented way to switch a speaker setup to
- cloning; without a clone id the voice is validated against the
+ to it. Selecting a non-speaker --voice on a CustomVoice primary with
+ a clone id configured is the documented way to switch a speaker setup
+ to cloning; without a clone id the voice is validated against the
server's voice library.
- Voice design (task "vdes" entries, e.g. Qwen3-TTS VoiceDesign): the
voice is described in natural language through ``instructions``,
@@ -892,11 +907,14 @@ class AudioCppTTSClient(_BaseTTSClient):
A constant per-run seed keeps the designed voice consistent across
chunk boundaries.
- With neither --voice nor --speaker the entry's capability picks the
- mode: design entries require --instructions; speaker entries use the
- built-in CustomVoice speaker in config.SPEAKER; clone entries (the
- Base model, and every other family) fail fast with a hint to pass
- --voice, instead of silently synthesizing with a random default voice.
+ The entry's capability decides how an explicit --voice is read: on a
+ speaker-capable entry a name that matches a built-in speaker selects
+ speaker mode, and every other name is a server-side preset. With no
+ --voice the entry's capability picks the mode: design entries require
+ --instructions; speaker entries use the built-in CustomVoice speaker
+ in config.SPEAKER; clone entries (the Base model, and every other
+ family) fail fast with a hint to pass --voice, instead of silently
+ synthesizing with a random default voice.
``instructions`` also works on non-design entries, where it acts as a
generic style/delivery instruction (voice control): families that read
@@ -918,13 +936,7 @@ class AudioCppTTSClient(_BaseTTSClient):
api_url: Optional[str] = None,
model_id: Optional[str] = None,
instructions: Optional[str] = None,
- request_options: Optional[Dict[str, str]] = None,
- speaker: Optional[str] = None):
- if voice and speaker:
- raise ValueError(
- "--voice and --speaker are mutually exclusive: --voice selects "
- "a server-side preset (cloning), --speaker a built-in "
- "CustomVoice speaker (see README).")
+ request_options: Optional[Dict[str, str]] = None):
self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/")
# Per-run model selection: the --model CLI flag overrides config; an
# empty value is resolved at connect time when the server hosts exactly
@@ -940,14 +952,16 @@ class AudioCppTTSClient(_BaseTTSClient):
# negative "randomize" seed, so a negative value means "send no seed
# at all" (see _request_wav) and the server randomizes.
self._seed = _resolve_request_seed()
- # Voice/picker selection. preset_mode is True iff a server-side
- # preset (--voice) was requested: it gates _select_model's reroute
- # to AUDIOCPP_CLONE_MODEL_ID and the INSTRUCT style-prompt logic.
- # speaker is a built-in CustomVoice speaker name; the entry's
- # capability (resolved in _connect) validates it. The request's
- # "voice" field (self.voice) is filled in _connect per the mode.
- self.preset_mode = bool(voice)
- self.speaker = speaker or None
+ # Voice selection (the --voice name). preset_mode / speaker_mode are
+ # resolved in _connect: a --voice that names a built-in CustomVoice
+ # speaker on a speaker-capable entry selects speaker mode; every
+ # other name (and any name on a clone-capable entry) is a server-side
+ # preset. preset_mode gates _select_model's reroute to
+ # AUDIOCPP_CLONE_MODEL_ID and the INSTRUCT style-prompt logic. The
+ # request's "voice" field (self.voice) is filled in _connect per the
+ # mode.
+ self.preset_mode = False
+ self.speaker_mode = False
self.voice = voice or None
# Style/voice-design instruction sent with every request (the CLI
# --instructions flag overrides AUDIOCPP_INSTRUCTIONS in config.py).
@@ -979,92 +993,103 @@ class AudioCppTTSClient(_BaseTTSClient):
"""Health-check the server and resolve the model, family, task, and voice.
The entry's voice capability (audiocpp_entry_voice_capability, from
- family/task/id) plus the caller's --voice/--speaker/--instructions
- pick the mode: design entries require --instructions and reject
- --voice/--speaker; a --speaker name requires a speaker-capable
- (CustomVoice) entry; a --voice preset validates against the server's
- voice library (and reroutes to AUDIOCPP_CLONE_MODEL_ID when set);
- with neither flag, speaker-capable entries use the built-in
- config.SPEAKER and clone entries fail fast with a hint instead of
- silently synthesizing with a random default voice.
+ family/task/id) plus the caller's --voice/--instructions pick the
+ mode. An explicit --voice on a speaker-capable (CustomVoice) entry
+ that names a built-in speaker selects speaker mode; every other
+ --voice is a server-side preset, validated against the server's
+ voice library (and rerouted to AUDIOCPP_CLONE_MODEL_ID when set).
+ With no --voice, design entries require --instructions, speaker-
+ capable entries use the built-in config.SPEAKER, and clone entries
+ fail fast with a hint instead of silently synthesizing with a
+ random default voice.
"""
self._check_health()
models = self._list_models()
self._auto_pick_model_id(models)
- if self.preset_mode:
- self._select_model(models)
- self._require_model_id(models)
- self._resolve_family(models)
- self._resolve_task(models)
- if self.task not in AUDIOCPP_SYNTHESIS_TASKS:
- available = ", ".join(model["id"] for model in models) or "none"
- raise RuntimeError(
- f"The audio.cpp model '{self.model_id}' has task "
- f"'{self.task}'; audiobook.py can only synthesize with TTS "
- f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). "
- f"Pick a synthesis entry with --model (available: {available})."
- )
- capability = audiocpp_entry_voice_capability(
- self.family, self.task, self.model_id)
- if self.design_mode:
- if self.voice or self.speaker:
- raise RuntimeError(
- f"--voice/--speaker cannot be used with the voice design "
- f"model '{self.model_id}': the voice is described by the "
- "--instructions text instead (see README).")
- if not self.instructions:
- raise RuntimeError(
- f"The audio.cpp model '{self.model_id}' (family "
- f"'{self.family}') is a voice design model: pass a "
- "description of the voice to synthesize with, e.g. "
- '--instructions "A warm adult female narrator with a '
- 'British accent" (see README).')
- print(f"[OK] Connected to audio.cpp server at {self.api_url} "
- f"(model '{self.model_id}', family '{self.family}', "
- "voice design)")
- print(f"[INFO] Designing the voice from: {self.instructions}")
- elif self.speaker is not None:
- if capability != AUDIOCPP_VOICE_SPEAKER:
+ if self.voice is not None:
+ # Explicit --voice: decide between speaker mode and a server-side
+ # preset. A name matching a built-in CustomVoice speaker on a
+ # speaker-capable primary selects speaker mode; every other name
+ # (and any name when the primary entry is absent) is a preset,
+ # validated against the server's voice library and rerouted to
+ # AUDIOCPP_CLONE_MODEL_ID when configured.
+ primary = next((m for m in models if m["id"] == self.model_id),
+ None)
+ if primary is not None:
+ self._resolve_family(models)
+ self._resolve_task(models)
+ capability = audiocpp_entry_voice_capability(
+ self.family, self.task, self.model_id)
+ if capability == AUDIOCPP_VOICE_SPEAKER \
+ and is_builtin_speaker(self.voice):
+ self._require_synthesis_task(models)
+ self.voice = speaker_display_name_for(self.voice)
+ self.speaker_mode = True
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ f"speaker '{self.voice}')")
+ if not self.speaker_mode:
+ # Server-side preset (--voice): validate it and route to
+ # the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set.
+ self.preset_mode = True
+ self._select_model(models)
+ self._require_model_id(models)
+ self._resolve_family(models)
+ self._resolve_task(models)
+ self._require_synthesis_task(models)
+ if self.design_mode:
+ raise RuntimeError(
+ f"--voice cannot be used with the voice design model "
+ f"'{self.model_id}': the voice is described by the "
+ "--instructions text instead (see README).")
+ self._check_voice()
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ f"voice '{self.voice}')")
+ else:
+ # No flag: the entry's capability picks the default mode.
+ self._require_model_id(models)
+ self._resolve_family(models)
+ self._resolve_task(models)
+ self._require_synthesis_task(models)
+ capability = audiocpp_entry_voice_capability(
+ self.family, self.task, self.model_id)
+ if self.design_mode:
+ if not self.instructions:
+ raise RuntimeError(
+ f"The audio.cpp model '{self.model_id}' (family "
+ f"'{self.family}') is a voice design model: pass a "
+ "description of the voice to synthesize with, e.g. "
+ '--instructions "A warm adult female narrator with a '
+ 'British accent" (see README).')
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ "voice design)")
+ print(f"[INFO] Designing the voice from: {self.instructions}")
+ elif capability == AUDIOCPP_VOICE_SPEAKER:
+ # No flag on a CustomVoice entry: the built-in config.SPEAKER.
+ self.voice = speaker_display_name()
+ self.speaker_mode = True
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ f"speaker '{self.voice}')")
+ elif self.instructions:
+ # Families without built-in speakers can still get their voice
+ # from the instruction alone (e.g. OmniVoice voice design).
+ self.instruction_voice = True
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ "instruction voice)")
+ print(f"[INFO] Designing the voice from: {self.instructions}")
+ else:
raise RuntimeError(
f"The audio.cpp model '{self.model_id}' (family "
- f"'{self.family}') has no built-in speakers; --speaker "
- "selects a CustomVoice speaker, so choose the "
- "CustomVoice model entry, or use --voice for a "
- "server-side preset (see README).")
- self.voice = speaker_display_name_for(self.speaker)
- print(f"[OK] Connected to audio.cpp server at {self.api_url} "
- f"(model '{self.model_id}', family '{self.family}', "
- f"speaker '{self.voice}')")
- elif self.voice is not None:
- # Explicit server-side preset (--voice): validate it and route
- # to the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set.
- self._check_voice()
- print(f"[OK] Connected to audio.cpp server at {self.api_url} "
- f"(model '{self.model_id}', family '{self.family}', "
- f"voice '{self.voice}')")
- elif capability == AUDIOCPP_VOICE_SPEAKER:
- # No flag on a CustomVoice entry: the built-in config.SPEAKER.
- self.voice = speaker_display_name()
- print(f"[OK] Connected to audio.cpp server at {self.api_url} "
- f"(model '{self.model_id}', family '{self.family}', "
- f"speaker '{self.voice}')")
- elif self.instructions:
- # Families without built-in speakers can still get their voice
- # from the instruction alone (e.g. OmniVoice voice design).
- self.instruction_voice = True
- print(f"[OK] Connected to audio.cpp server at {self.api_url} "
- f"(model '{self.model_id}', family '{self.family}', "
- "instruction voice)")
- print(f"[INFO] Designing the voice from: {self.instructions}")
- else:
- raise RuntimeError(
- f"The audio.cpp model '{self.model_id}' (family "
- f"'{self.family}') has no built-in speakers, so its voice "
- "must come from the server: rerun with --voice NAME "
- "matching a voice_preset or voice_dir entry in the server "
- "config, or describe a voice with --instructions for "
- "families that support it, or select the CustomVoice entry "
- "for built-in speakers (see README).")
+ f"'{self.family}') has no built-in speakers, so its voice "
+ "must come from the server: rerun with --voice NAME "
+ "matching a voice_preset or voice_dir entry in the server "
+ "config, or describe a voice with --instructions for "
+ "families that support it, or select the CustomVoice entry "
+ "for built-in speakers (see README).")
if self.instructions and not self.design_mode and not self.instruction_voice:
print(f"[INFO] Sending instruction with every request: {self.instructions}")
print("[INFO] Its effect (style, emotion, delivery) depends on the "
@@ -1072,6 +1097,18 @@ class AudioCppTTSClient(_BaseTTSClient):
if config.AUDIOCPP_UNLOAD_MODELS:
self._unload_server_models()
+ def _require_synthesis_task(self, models: List[Dict[str, str]]) -> None:
+ """Reject model entries whose task is not a TTS synthesis task."""
+ if self.task in AUDIOCPP_SYNTHESIS_TASKS:
+ return
+ available = ", ".join(model["id"] for model in models) or "none"
+ raise RuntimeError(
+ f"The audio.cpp model '{self.model_id}' has task "
+ f"'{self.task}'; audiobook.py can only synthesize with TTS "
+ f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). "
+ f"Pick a synthesis entry with --model (available: {available})."
+ )
+
def _unload_server_models(self) -> None:
"""Ask the server to unload every loaded model before generating.
@@ -1181,7 +1218,8 @@ class AudioCppTTSClient(_BaseTTSClient):
Speaker mode needs AUDIOCPP_MODEL_ID (the CustomVoice entry).
Preset mode validates whichever id _select_model resolved, so a
- server hosting only a cloning model works for --voice.
+ server hosting only a cloning model works for --voice. The default
+ error distinguishes the two so the fix is obvious.
"""
model_ids = [model["id"] for model in models]
if self.model_id and self.model_id in model_ids:
@@ -1207,10 +1245,9 @@ class AudioCppTTSClient(_BaseTTSClient):
)
raise RuntimeError(
f"The audio.cpp server at {self.api_url} has no model id "
- f"'{self.model_id}' (configured: {configured}). Speaker mode needs "
- "the Qwen3-TTS CustomVoice model: add a qwen3_tts model entry to "
- "the server config and match AUDIOCPP_MODEL_ID in app/converter/config.py to its "
- "id (or pass --model), or rerun with --voice to use a voice preset "
+ f"'{self.model_id}' (configured: {configured}). Select the "
+ "Qwen3-TTS CustomVoice entry for built-in speakers, or rerun "
+ "with --voice NAME matching a voice_preset or voice_dir entry "
"on any TTS model (see README)."
)
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 3323887..926dc6a 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -81,8 +81,8 @@ In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice
# Higgs Audio (clone-only)
python audiobook.py --backend audiocpp --model Higgs-Audio-v3-TTS-4B-GGUF --voice narrator
-# Qwen3-TTS built-in speaker
-python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF
+# Qwen3-TTS built-in speaker (pick one with --voice, or omit it for config.SPEAKER)
+python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF --voice Vivian
# Qwen3-TTS voice cloning
python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-Base-GGUF --voice narrator
diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py
index 317e772..7aa9c69 100644
--- a/app/tests/test_converter.py
+++ b/app/tests/test_converter.py
@@ -131,7 +131,6 @@ class NarratorTagTests(unittest.TestCase):
converter.voice_clone_ref_audio = ref_audio
converter.backend = tts.BACKEND_QWEN
converter.voice = None
- converter.speaker = None
converter.instructions = instructions
return converter
@@ -160,12 +159,11 @@ class NarratorTagTests(unittest.TestCase):
self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(),
"narrator")
- def _audiocpp_converter(self, voice=None, instructions=None, speaker=None):
+ def _audiocpp_converter(self, voice=None, instructions=None):
converter = self._converter(tts.VOICE_MODE_CUSTOM,
instructions=instructions)
converter.backend = tts.BACKEND_AUDIOCPP
converter.voice = voice
- converter.speaker = speaker
return converter
def test_audiocpp_design_run_uses_designed_tag(self):
@@ -185,11 +183,11 @@ class NarratorTagTests(unittest.TestCase):
def test_audiocpp_explicit_speaker_uses_speaker_tag(self):
# A chosen CustomVoice speaker names the output, not config.SPEAKER.
- converter = self._audiocpp_converter(speaker="Ryan")
+ converter = self._audiocpp_converter(voice="Ryan")
self.assertEqual(converter._narrator_tag(), "Ryan")
def test_audiocpp_explicit_speaker_normalizes_display_name(self):
- converter = self._audiocpp_converter(speaker="Uncle_Fu")
+ converter = self._audiocpp_converter(voice="Uncle_Fu")
self.assertEqual(converter._narrator_tag(), "Uncle_Fu")
def test_preflight_design_run_uses_designed_tag(self):
@@ -558,7 +556,6 @@ class RunOverwritePromptTests(unittest.TestCase):
self.converter.voice_clone_ref_audio = None
self.converter.backend = tts.BACKEND_QWEN
self.converter.voice = None
- self.converter.speaker = None
self.converter.instructions = None
self.converter.speed = 1.0
self.converter.single_file = False
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index c5ca346..965cbaa 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -754,7 +754,8 @@ class ConvertFlowTests(unittest.TestCase):
def test_audiocpp_customvoice_entry_lists_builtin_speakers(self):
# A CustomVoice entry populates the Voice menu with the Qwen3-TTS
- # built-in speakers and maps the pick to --speaker.
+ # built-in speakers and maps the pick to --voice (which the client
+ # resolves as speaker mode).
self._patch_remote(
[{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
"family": "qwen3_tts", "task": "tts"}])
@@ -765,9 +766,9 @@ class ConvertFlowTests(unittest.TestCase):
audiocpp_voice="Ryan", instructions="")
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
- # The speaker is passed as --speaker, not --voice.
- self.assertIsNone(cmd[2]["voice"])
- self.assertEqual(cmd[2]["speaker"], "Ryan")
+ # The picked speaker is passed as --voice; no separate speaker kwarg.
+ self.assertEqual(cmd[2]["voice"], "Ryan")
+ self.assertNotIn("speaker", cmd[2])
fields = self.tui.forms_seen[0][1]
voice_field = self._field("audiocpp_voice")
self.assertEqual(voice_field["choices"](fields),
@@ -792,7 +793,7 @@ class ConvertFlowTests(unittest.TestCase):
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
self.assertEqual(cmd[2]["voice"], "narrator")
- self.assertIsNone(cmd[2]["speaker"])
+ self.assertNotIn("speaker", cmd[2])
self.assertIsNone(cmd[2]["instructions"])
fields = self.tui.forms_seen[0][1]
voice_field = self._field("audiocpp_voice")
@@ -815,7 +816,7 @@ class ConvertFlowTests(unittest.TestCase):
None, [self._remote("audiocpp", "audio.cpp")])
self.assertIsNotNone(cmd)
self.assertIsNone(cmd[2]["voice"])
- self.assertIsNone(cmd[2]["speaker"])
+ self.assertNotIn("speaker", cmd[2])
voice_field = self._field("audiocpp_voice")
# Clone-only: an empty voice is refused (no built-in speaker option).
self.assertIsNotNone(voice_field["validate"](""))
@@ -948,8 +949,7 @@ class ConvertFlowTests(unittest.TestCase):
return_value=root), \
patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
self._answer_form(backend="audiocpp", model_id="qwen",
- audiocpp_voice="(built-in speaker)",
- instructions="")
+ audiocpp_voice="", instructions="")
cmd = self._convert(None, [
self._ready("audiocpp", "audio.cpp"),
self._remote("audiocpp", "audio.cpp")])
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 0e35f78..c17609b 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -512,12 +512,11 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
raise AssertionError(f"unexpected URL: {url}")
return _dispatch
- def _client(self, voice=None, language=None, model_id=None, speaker=None,
- **kwargs):
+ def _client(self, voice=None, language=None, model_id=None, **kwargs):
with patch("converter.tts.urllib.request.urlopen",
side_effect=self._get_responses(**kwargs)):
return AudioCppTTSClient(voice=voice, language=language,
- model_id=model_id, speaker=speaker)
+ model_id=model_id)
def test_unreachable_server_raises_with_readme_pointer(self):
import urllib.error
@@ -549,34 +548,51 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
self.assertEqual(client.language, config.LANGUAGE)
self.assertEqual(client.voice, "Vivian")
self.assertFalse(client.preset_mode)
+ self.assertTrue(client.speaker_mode)
def test_speaker_mode_uses_configured_speaker(self):
with patch.object(config, "SPEAKER", "uncle_fu"):
client = self._client()
self.assertEqual(client.voice, "Uncle Fu")
+ self.assertTrue(client.speaker_mode)
- def test_explicit_speaker_selects_speaker_mode(self):
- # --speaker picks a CustomVoice speaker; the name is normalized to
- # its wire (display) form and no preset validation runs.
- client = self._client(speaker="Uncle_Fu")
+ def test_voice_speaker_name_selects_speaker_mode(self):
+ # --voice naming a built-in CustomVoice speaker selects speaker
+ # mode; the name is normalized to its wire (display) form and no
+ # preset validation runs.
+ client = self._client(voice="Uncle_Fu")
self.assertEqual(client.voice, "Uncle Fu")
self.assertFalse(client.preset_mode)
+ self.assertTrue(client.speaker_mode)
- def test_explicit_speaker_on_clone_entry_raises(self):
- # --speaker is meaningless on a clone-only (Base) entry.
+ def test_voice_speaker_name_on_clone_entry_is_a_preset(self):
+ # --voice on a clone-only (Base) entry is a server-side preset,
+ # not a built-in speaker, so the name is validated against the
+ # server's voice library.
with self.assertRaises(RuntimeError) as ctx:
- self._client(speaker="Ryan", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ self._client(voice="Ryan", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF",
models={"data": [
{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"family": "qwen3_tts"}]})
message = str(ctx.exception)
- self.assertIn("no built-in speakers", message)
+ self.assertIn("'Ryan'", message)
self.assertIn("--voice", message)
- def test_voice_and_speaker_are_mutually_exclusive(self):
- with self.assertRaises(ValueError) as ctx:
- AudioCppTTSClient(voice="narrator", speaker="Ryan")
- self.assertIn("mutually exclusive", str(ctx.exception))
+ def test_voice_speaker_name_does_not_reroute_to_clone_model(self):
+ # A built-in speaker name on a CustomVoice primary selects speaker
+ # mode without the AUDIOCPP_CLONE_MODEL_ID reroute.
+ with patch.object(config, "AUDIOCPP_MODEL_ID",
+ "Qwen3-TTS-CustomVoice"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
+ client = self._client(
+ voice="Ryan",
+ models={"data": [{"id": "Qwen3-TTS-CustomVoice",
+ "family": "qwen3_tts"},
+ {"id": "qwen3-tts-clone",
+ "family": "qwen3_tts"}]})
+ self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice")
+ self.assertTrue(client.speaker_mode)
+ self.assertFalse(client.preset_mode)
def test_no_voice_on_base_entry_raises_instead_of_silent_speaker(self):
# The Base model has no built-in speakers: without --voice the run
@@ -1063,7 +1079,6 @@ class AudioCppTTSClientRequestTests(unittest.TestCase):
client.model_id = config.AUDIOCPP_MODEL_ID
client.preset_mode = preset_mode
client.voice = voice
- client.speaker = None
client.language = language
client._seed = seed
client.family = family
@@ -1518,8 +1533,8 @@ class AudioCppUnloadModelsTests(unittest.TestCase):
client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"]
client.design_mode = False
client.instruction_voice = False
+ client.speaker_mode = False
client.instructions = ""
- client.speaker = None
with patch.object(client, "_check_health"), \
patch.object(client, "_list_models",
return_value=[{"id": client.model_id,
@@ -1548,8 +1563,8 @@ class AudioCppUnloadModelsTests(unittest.TestCase):
client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"]
client.design_mode = False
client.instruction_voice = False
+ client.speaker_mode = False
client.instructions = ""
- client.speaker = None
with patch.object(client, "_check_health"), \
patch.object(client, "_list_models",
return_value=[{"id": client.model_id,
@@ -1591,8 +1606,7 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions=None,
request_options={},
- api_url=None,
- speaker=None)
+ api_url=None)
mock_faster.assert_not_called()
mock_qwen.assert_not_called()
@@ -1604,8 +1618,7 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions=None,
request_options={},
- api_url=None,
- speaker=None)
+ api_url=None)
def test_audiocpp_backend_model_id_is_wired_through(self):
with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
@@ -1615,7 +1628,7 @@ class BackendWiringTests(unittest.TestCase):
mock_audiocpp.assert_called_once_with(
voice="narrator", language=config.LANGUAGE,
model_id="higgs", instructions=None,
- request_options={}, api_url=None, speaker=None)
+ request_options={}, api_url=None)
def test_audiocpp_backend_instructions_and_options_are_wired_through(self):
with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
@@ -1629,7 +1642,7 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions="A warm adult narrator",
request_options={"emotion": "neutral", "speed": "1.1"},
- api_url=None, speaker=None)
+ api_url=None)
def test_qwen_backend_uses_qwen_client(self):
with patch("converter.converter.FasterTTSClient") as mock_faster, \
@@ -1656,7 +1669,7 @@ class BackendWiringTests(unittest.TestCase):
mock_audiocpp.assert_called_once_with(
voice="narrator", language=config.LANGUAGE, model_id=None,
instructions=None, request_options={},
- api_url="http://10.0.0.5:8080", speaker=None)
+ api_url="http://10.0.0.5:8080")
with patch("converter.converter.FasterTTSClient") as mock_faster:
AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
backend=tts.BACKEND_FASTER, voice="narrator",
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 84b4155..f937b84 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -741,7 +741,6 @@ def _preflight(stdscr, cmd: tuple) -> bool:
voice_clone_ref_audio=kwargs.get("clone"),
output_format=kwargs.get("output_format") or config.AUDIO_FORMAT,
instructions=kwargs.get("instructions"),
- speaker=kwargs.get("speaker"),
confirm=confirm)
if not book_files:
tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
@@ -994,19 +993,15 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
capability = audiocpp_entry_voice_capability(
entry.get("family") or "", entry.get("task") or "tts",
entry.get("id") or "")
- picked = result["audiocpp_voice"]
- voice = None
- speaker = None
- if capability == AUDIOCPP_VOICE_SPEAKER:
- speaker = picked or None
- elif capability == AUDIOCPP_VOICE_CLONE:
- voice = picked or None
- # design: neither — the voice comes from --instructions
+ # The picked voice (a built-in speaker name on a CustomVoice entry,
+ # a server-side preset otherwise); the client resolves which it is.
+ voice = result["audiocpp_voice"] or None
+ # design: the voice comes from --instructions
instructions = None
if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER):
instructions = (result["instructions"] or "").strip() or None
kwargs = {
- "model_id": model_id, "voice": voice, "speaker": speaker,
+ "model_id": model_id, "voice": voice,
"instructions": instructions,
**_common_kwargs(result),
}