diff options
| author | historia <historiavg@proton.me> | 2026-08-30 20:42:02 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-30 20:42:02 -0400 |
| commit | a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (patch) | |
| tree | d8492bbcbbf6850afc127bae862abe68e1198c0c /app/converter | |
| parent | 93f106aac2d6411c80a911adac62cd12f80e58be (diff) | |
| download | tts-audiobook-generator-a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4.tar.gz | |
fix: non-clone models correctly supported in tui, restart server when needed
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/clients/__init__.py | 11 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 149 |
2 files changed, 150 insertions, 10 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index 16fc99c..fd3df64 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -26,6 +26,7 @@ from .transcribe import (transcribe_reference_audio, from .qwen import CUSTOM_VOICE_MODEL_ID, MODEL_SIZE, QwenTTSClient from .faster import SAMPLE_RATE, FasterTTSClient from .audiocpp import ( + AUDIOCPP_CLONE_ONLY_FAMILIES, AUDIOCPP_DEFAULT_FAMILY_PROFILE, AUDIOCPP_FAMILY_PROFILES, AUDIOCPP_FAMILY_QWEN3_TTS, @@ -37,10 +38,16 @@ from .audiocpp import ( AUDIOCPP_TASK_VDES, AUDIOCPP_VOICE_CLONE, AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_NONE, + AUDIOCPP_VOICE_OPTIONAL, + AUDIOCPP_VOICE_REQUIRED, AUDIOCPP_VOICE_SPEAKER, AudioCppFamilyProfile, AudioCppTTSClient, audiocpp_entry_voice_capability, + audiocpp_family_spec_tasks, + audiocpp_family_voice_policy, + audiocpp_request_error, ) __all__ = [ @@ -66,6 +73,10 @@ __all__ = [ "AUDIOCPP_FAMILY_QWEN3_TTS", "AUDIOCPP_TASK_TTS", "AUDIOCPP_TASK_VDES", "AUDIOCPP_SYNTHESIS_TASKS", "AUDIOCPP_VOICE_SPEAKER", "AUDIOCPP_VOICE_CLONE", "AUDIOCPP_VOICE_DESIGN", + "AUDIOCPP_CLONE_ONLY_FAMILIES", "AUDIOCPP_VOICE_REQUIRED", + "AUDIOCPP_VOICE_OPTIONAL", "AUDIOCPP_VOICE_NONE", "AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE", "AUDIOCPP_FAMILY_PROFILES", "audiocpp_entry_voice_capability", + "audiocpp_family_spec_tasks", "audiocpp_family_voice_policy", + "audiocpp_request_error", ] diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index da8d364..c441047 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -8,7 +8,7 @@ import urllib.error import urllib.parse import urllib.request from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from .. import config from ..audio import concat_audio_files @@ -76,6 +76,94 @@ AUDIOCPP_NON_RETRYABLE_ERRORS = ( ) _REFERENCE_TEXT_FRAGMENT = AUDIOCPP_NON_RETRYABLE_ERRORS[0] +# HTTP error body fragments identifying a model family whose server +# implementation rejects the hosting task of its entry (e.g. Chatterbox +# hosted with task "tts"): the session is created per server.json task, +# so every request fails identically until the entry is re-hosted with +# task "clon" and the server restarted. +AUDIOCPP_CLONE_ONLY_ERRORS = ( + "supports voicecloning and voiceconversion", # Chatterbox + "supports the voicecloning task", # Confucius4-TTS + "only supports offline voice cloning", # Echo-TTS +) + +# Families whose audio.cpp implementation only synthesizes by cloning a +# reference voice: their session rejects plain TTS regardless of how the +# entry is hosted. chatterbox's own model spec wrongly lists "tts" among +# its tasks (the binary throws "Chatterbox supports VoiceCloning and +# VoiceConversion"), so the set is explicit knowledge here rather than +# something read from the specs. +AUDIOCPP_CLONE_ONLY_FAMILIES = frozenset( + {"chatterbox", "confucius4_tts", "echo_tts"}) + +# How a family's voice is supplied — resolved per family from the local +# audio.cpp checkout's model_specs (see audiocpp_family_voice_policy): +AUDIOCPP_VOICE_REQUIRED = "required" # clone-only: a reference voice is mandatory +AUDIOCPP_VOICE_OPTIONAL = "optional" # tts + clone: blank voice means plain TTS +AUDIOCPP_VOICE_NONE = "none" # pure TTS: no cloning, no voice at all + +# Spec-task cache for audiocpp_family_voice_policy (family -> tasks or +# None for unknown). The form consults the policy on every menu render, +# so each family's spec is read at most once per process. +_FAMILY_SPEC_TASKS: Dict[str, Optional[Set[str]]] = {} + + +def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: + """FAMILY's task set from the local audio.cpp checkout's model_specs. + + Reads ``<checkout>/model_specs/<family>.json`` (the checkout the setup + wizard manages, which also ships the specs for remote servers) and + returns its "tasks" list as a set, or None when the checkout is + missing, the family is not described, or the spec is unparsable. + Results are cached per process. + """ + if family in _FAMILY_SPEC_TASKS: + return _FAMILY_SPEC_TASKS[family] + tasks: Optional[Set[str]] = None + try: + # Imported lazily: backends.audiocpp imports this package (its + # voices module), so a module-level import would cycle. + from backends.audiocpp.build import find_local_checkout + checkout = find_local_checkout() + except Exception: # noqa: BLE001 - best effort: no specs, no policy + checkout = None + if checkout is not None: + try: + spec = json.loads((checkout / "model_specs" / f"{family}.json") + .read_text(encoding="utf-8")) + except (OSError, ValueError): + spec = None + if isinstance(spec, dict) and isinstance(spec.get("tasks"), list): + tasks = {str(task) for task in spec["tasks"]} + _FAMILY_SPEC_TASKS[family] = tasks + return tasks + + +def audiocpp_family_voice_policy(family: str) -> str: + """How a family's voice is supplied — required, optional, or none. + + Pure-TTS families (spec tasks without "clone") synthesize with no + voice at all; mixed families (tts + clone) may run without one (plain + TTS) or clone a reference; clone-only families — the explicit + AUDIOCPP_CLONE_ONLY_FAMILIES set, which also repairs specs that + wrongly claim "tts" — always need a reference voice. Unknown families + (no local specs) keep the conservative clone-only default the client + has always applied. + """ + if family == AUDIOCPP_FAMILY_QWEN3_TTS \ + or family in AUDIOCPP_CLONE_ONLY_FAMILIES: + # Qwen3-TTS is entry-typed (speaker/clone/design capability per + # model id), so the family policy stays out of its way. + return AUDIOCPP_VOICE_REQUIRED + tasks = audiocpp_family_spec_tasks(family) + if not tasks: + return AUDIOCPP_VOICE_REQUIRED + if "clone" not in tasks: + return AUDIOCPP_VOICE_NONE + if "tts" not in tasks: + return AUDIOCPP_VOICE_REQUIRED + return AUDIOCPP_VOICE_OPTIONAL + def _server_error_message(detail: str) -> str: """The server's error message from an HTTP error body, else the body. @@ -128,14 +216,23 @@ def audiocpp_request_error(status: int, detail: str, Deterministic request-configuration errors (a fragment in AUDIOCPP_NON_RETRYABLE_ERRORS) become NonRetryableTTSError so the - chunk retry loop skips attempts that cannot succeed; everything else - returns the plain RuntimeError the retry loop has always retried. + chunk retry loop skips attempts that cannot succeed; clone-only + hosting errors (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host + hint; everything else returns the plain RuntimeError the retry loop + has always retried. """ message = _server_error_message(detail) lowered = message.lower() if _REFERENCE_TEXT_FRAGMENT in lowered: return NonRetryableTTSError( _reference_text_error(voice, message)) + if any(fragment in lowered for fragment in AUDIOCPP_CLONE_ONLY_ERRORS): + return NonRetryableTTSError( + f"audio.cpp server returned HTTP {status} (not retryable): " + f"{message}. This model family only synthesizes by cloning a " + "reference voice, so its server entry must be hosted with task " + '"clon" — re-run Configure Backends → audio.cpp (or edit ' + "server.json) and restart the server.") if any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): return NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " @@ -231,6 +328,10 @@ class AudioCppTTSClient(BaseTTSClient): which is required and sent with every request (no ``voice`` field). A constant per-run seed keeps the designed voice consistent across chunk boundaries. + - Plain TTS (families whose spec has no "clone" task, and mixed + tts+clone families used without a voice): no reference voice is + needed, so no ``voice`` field is sent. Clone-only families (e.g. + Chatterbox) always require ``--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 @@ -300,10 +401,13 @@ class AudioCppTTSClient(BaseTTSClient): self.request_options: Dict[str, str] = dict(request_options or {}) # Set during _connect: design_mode for "vdes" entries, instruction_voice # when a family without built-in speakers gets its voice from the - # instruction alone (no voice field). self.voice is also finalized - # there (the speaker/preset name). + # instruction alone (no voice field), and plain_mode for plain-TTS + # runs on families that synthesize without a reference voice (also + # no voice field). self.voice is also finalized there (the + # speaker/preset name). self.design_mode = False self.instruction_voice = False + self.plain_mode = False # Family and task of the selected model entry and the family's request # profile; all are resolved from GET /v1/models during _connect. self.family = "" @@ -330,9 +434,11 @@ class AudioCppTTSClient(BaseTTSClient): that names a built-in speaker selects speaker mode; every other --voice is a server-side preset, validated against the server's voice library. Without a --voice, design entries require - --instructions and every other capability requires --voice — the - run fails fast with a hint instead of silently synthesizing with a - random default voice. + --instructions, families that synthesize without a reference voice + (pure-TTS, or mixed tts+clone used plainly) run in plain mode, and + every other capability requires --voice — the run fails fast with + a hint instead of silently synthesizing with a random default + voice. """ self._check_health() models = self._list_models() @@ -402,6 +508,14 @@ class AudioCppTTSClient(BaseTTSClient): self.instruction_voice = True self._connected("instruction voice") self._report(f"[INFO] Designing the voice from: {self.instructions}") + elif audiocpp_family_voice_policy(self.family) in ( + AUDIOCPP_VOICE_OPTIONAL, AUDIOCPP_VOICE_NONE): + # The family synthesizes without a reference voice — a + # pure-TTS family (spec tasks without "clone") or a mixed + # tts+clone family used without one. Plain TTS: no voice + # field is sent at all. + self.plain_mode = True + self._connected("plain TTS") else: raise RuntimeError( f"The audio.cpp model '{self.model_id}' (family " @@ -599,6 +713,19 @@ class AudioCppTTSClient(BaseTTSClient): self.model_id) self.task = task self.design_mode = task == AUDIOCPP_TASK_VDES + if self.family in AUDIOCPP_CLONE_ONLY_FAMILIES \ + and self.task == AUDIOCPP_TASK_TTS: + # The session is created from the entry's hosting task, so a + # clone-only family hosted with "tts" fails every request at + # session-creation time — before any synthesis. Refuse here + # with the fix instead of letting the server 500 each chunk. + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' (family " + f"'{self.family}') only synthesizes by cloning a reference " + "voice, but its server entry is hosted with task 'tts', " + "which the model rejects on every request. Re-run " + "Configure Backends → audio.cpp to re-host it with task " + '"clon", then restart the server.') def _check_voice(self) -> None: """Verify the requested voice is available on the server. @@ -640,8 +767,10 @@ class AudioCppTTSClient(BaseTTSClient): } # Design models take no voice field (the voice comes from the # instruction); instruction-voice runs on families without built-in - # speakers omit it too, since no speaker or preset was requested. - if not self.design_mode and not self.instruction_voice: + # speakers omit it too, since no speaker or preset was requested; + # plain-TTS runs (no reference voice needed) omit it likewise. + if not self.design_mode and not self.instruction_voice \ + and not self.plain_mode: payload["voice"] = self.voice if self.profile.language_style == AUDIOCPP_LANG_DISPLAY: payload["language"] = self.language |
