diff options
| author | historia <historiavg@proton.me> | 2026-08-25 16:49:36 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-25 16:49:36 -0400 |
| commit | 775b716d86f5e681807698522e56c3d0f861c5bf (patch) | |
| tree | 420b31c7b2f743ee83ad933bbc00073d5f6b9e2a | |
| parent | 867866f131b0b6c76c54272791e7f7dea01db990 (diff) | |
| download | tts-audiobook-generator-775b716d86f5e681807698522e56c3d0f861c5bf.tar.gz | |
feat: combine --speaker and --voice for clarity
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | app/converter/converter.py | 38 | ||||
| -rw-r--r-- | app/converter/tts.py | 265 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 4 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 9 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 16 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 63 | ||||
| -rw-r--r-- | app/ui/hub.py | 15 | ||||
| -rwxr-xr-x | audiobook.py | 44 |
9 files changed, 237 insertions, 220 deletions
@@ -68,8 +68,7 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- | `--model <id>` | `audiocpp`: Choose the model from `server.json` | | `--instructions "..."` | `audiocpp`: voice design or style instruction. Required for voice design models (`vdes`) | | `--option KEY=VALUE` | `audiocpp`: Some models support custom options (e.g. `emotion=netural`) that can be passed with this flag | -| `--voice <name>` | `audiocpp`, `faster`: Server-side voice to request (`audiocpp`: a `voice_preset`/`voice_dir` entry for cloning; with `AUDIOCPP_CLONE_MODEL_ID` set it reroutes to the clone model — typically the Qwen Base model). | -| `--speaker <name>` | `audiocpp`: Built-in Qwen3-TTS CustomVoice speaker (e.g. `Vivian`, `Ryan`, `Uncle_Fu`). Selects speaker mode on the CustomVoice model entry; mutually exclusive with `--voice`. | +| `--voice <name>` | `audiocpp`, `faster`: Voice to request. `audiocpp`: on the Qwen3-TTS CustomVoice entry a built-in speaker (e.g. `Vivian`, `Ryan`, `Uncle_Fu`); on every other family a `voice_preset`/`voice_dir` entry for cloning (with `AUDIOCPP_CLONE_MODEL_ID` set it reroutes to the clone model — typically the Qwen Base model). `faster`: a key in the server's `voices.json`. | | `--clone <path>` | `qwen`: Reference audio (`wav`) for voice cloning. | | `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | | `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. | 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), } diff --git a/audiobook.py b/audiobook.py index b4cd89d..3882e7f 100755 --- a/audiobook.py +++ b/audiobook.py @@ -59,7 +59,6 @@ def convert(backend: str = None, voice: str = None, clone: str = None, model_id: str = None, instructions: str = None, request_options: dict = None, input_dir: Path = None, output_dir: Path = None, api_url: str = None, - speaker: str = None, progress=None, cancel=None, confirm=None, book_files=None, planned=None) -> int: """Run one conversion pass with explicit options (used by the CLI and hub). @@ -102,7 +101,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, book_files, planned = AudiobookConverter.preflight_overwrites( backend=backend, voice=voice, voice_mode=voice_mode, voice_clone_ref_audio=clone, output_format=output_format, - instructions=instructions, speaker=speaker, confirm=confirm, + instructions=instructions, confirm=confirm, ) if not book_files: print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " @@ -121,7 +120,6 @@ def convert(backend: str = None, voice: str = None, clone: str = None, language=language, backend=backend, voice=voice, debug=debug, model_id=model_id, instructions=instructions, request_options=request_options, api_url=api_url, - speaker=speaker, progress=progress, cancel=cancel, ) converter._book_files = book_files @@ -167,7 +165,10 @@ Examples: # No arguments, in a terminal: the full TUI (set up backends, convert). python audiobook.py - # Use the audio.cpp audiocpp_server (speaker mode - Vivian speaker, or a server-side voice) + # Use the audio.cpp audiocpp_server (a built-in speaker, or a server-side voice) + python audiobook.py --backend audiocpp --voice Vivian + + # Use a server-side voice preset (cloning) on the audio.cpp server python audiobook.py --backend audiocpp --voice narrator # Use the audio.cpp audiocpp_server with a voice design model (task 'vdes') @@ -245,25 +246,18 @@ Examples: ) parser.add_argument( "--voice", type=str, default=None, metavar="NAME", - help=("Voice to request from a server-side voice configuration. faster: " - "a key in the server's voices.json ('default' when it was started " - "with --ref-audio). audiocpp: a voice_preset or voice_dir entry " - "(cloning); required for audio.cpp families without built-in " - "speakers (everything except Qwen3-TTS CustomVoice). When " - "AUDIOCPP_CLONE_MODEL_ID names a second server entry (typically " - "the Qwen Base model), --voice reroutes to it. Not used by " - "the qwen backend (use app/converter/config.py SPEAKER or --clone " + help=("Voice to request. faster: a key in the server's voices.json " + "('default' when it was started with --ref-audio). audiocpp: " + "the name of the voice for the selected model entry — for the " + "Qwen3-TTS CustomVoice entry a built-in speaker (e.g. Vivian, " + "Ryan, Uncle Fu), for every other family a voice_preset or " + "voice_dir entry (cloning). When AUDIOCPP_CLONE_MODEL_ID " + "names a second server entry (typically the Qwen Base model), " + "a non-speaker --voice reroutes to it. Not used by the qwen " + "backend (use app/converter/config.py SPEAKER or --clone " "there).") ) parser.add_argument( - "--speaker", type=str, default=None, metavar="NAME", - help=("audiocpp only: a built-in Qwen3-TTS CustomVoice speaker name " - "(e.g. Vivian, Ryan, Uncle Fu). Selects speaker mode on the " - "CustomVoice model entry; mutually exclusive with --voice. " - "Without --voice/--speaker, a CustomVoice entry defaults to " - "the SPEAKER in app/converter/config.py.") - ) - parser.add_argument( "--debug", action="store_true", help=("Troubleshooting mode: dump each chunk's raw audio and the exact text " "sent for it under the debug/ folder (organized per book and chapter), " @@ -338,11 +332,6 @@ Examples: "uses a voice configured on the server (voice_presets or " "voice_dir in its config); select it with --voice (see README)") args.clone = None - if args.voice and args.speaker: - parser.error("--voice and --speaker are mutually exclusive with " - "--backend audiocpp: --voice selects a server-side " - "preset (cloning), --speaker a built-in CustomVoice " - "speaker (see README)") if args.transcription or args.no_transcription: print("[WARNING] --transcription/--no-transcription are ignored with " "--backend audiocpp: the reference transcript is configured on " @@ -373,10 +362,6 @@ Examples: parser.error("--model requires --backend audiocpp; it selects an " "audio.cpp server model entry id") - if args.speaker is not None and args.backend != BACKEND_AUDIOCPP: - parser.error("--speaker requires --backend audiocpp; it selects a " - "built-in Qwen3-TTS CustomVoice speaker") - if args.instructions is not None and args.backend != BACKEND_AUDIOCPP: parser.error("--instructions requires --backend audiocpp; it is " "sent as the audio.cpp request's instructions field") @@ -411,7 +396,6 @@ Examples: request_options=request_options, input_dir=args.input, output_dir=args.output, api_url=api_url, - speaker=args.speaker, )) |
