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 /app/converter | |
| parent | 867866f131b0b6c76c54272791e7f7dea01db990 (diff) | |
| download | tts-audiobook-generator-775b716d86f5e681807698522e56c3d0f861c5bf.tar.gz | |
feat: combine --speaker and --voice for clarity
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/converter.py | 38 | ||||
| -rw-r--r-- | app/converter/tts.py | 265 |
2 files changed, 166 insertions, 137 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)." ) |
