diff options
| author | historia <historiavg@proton.me> | 2026-09-01 03:17:01 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 03:17:01 -0400 |
| commit | 058b19e7a65b40b1024a4fdeb2233062ff273cfd (patch) | |
| tree | fe3643872cd6b317a88eec950ae6ecc4d81d843d /app/converter/clients/audiocpp.py | |
| parent | 10e72d4960e865acf5346ab8cf518ed5844fe45c (diff) | |
| download | tts-audiobook-generator-058b19e7a65b40b1024a4fdeb2233062ff273cfd.tar.gz | |
fix: better errors for generate all models
Diffstat (limited to 'app/converter/clients/audiocpp.py')
| -rw-r--r-- | app/converter/clients/audiocpp.py | 159 |
1 files changed, 140 insertions, 19 deletions
diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index df1884d..44a1c3d 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -73,6 +73,32 @@ AUDIOCPP_NON_RETRYABLE_ERRORS = ( "embeds a legacy model spec", # The request named a model the server does not host. "unknown model id", + # The model package on disk is incomplete (a companion file the family + # spec requires — a tokenizer table, a codec — is not where the spec + # looks for it) or ambiguous (several GGUFs, none named as the weights). + # Re-downloading the model package fixes these; retrying cannot. + "missing model package file", + "missing model root", + "model directory contains", + # A companion model directory (e.g. MioTTS's MioCodec) is not installed + # next to the model. + "model path does not exist", + # The hosted session kind cannot synthesize from text at all. + "supports only speech-to-speech", + # The request's voice never resolves to reference audio (families + # without packaged speakers, e.g. Vevo2, need actual audio). + "requires target_voice", + # The prompt is not the script format the family requires (see the + # VibeVoice profile, which formats it client-side). + "has no valid speaker", + # The reference voice's audio is longer than the model's encoder + # capacity (e.g. VoxCPM1/2 AudioVAE): trim the voice's reference wav. + "sample capacity exceeded", + # VRAM/graph allocation failures. In the sequential runs this client + # drives (models unloaded between books) the memory picture does not + # change between attempts, so a failure here repeats identically. + "failed to allocate", + "allocation failed", ) _REFERENCE_TEXT_FRAGMENT = AUDIOCPP_NON_RETRYABLE_ERRORS[0] @@ -87,6 +113,32 @@ AUDIOCPP_CLONE_ONLY_ERRORS = ( "only supports offline voice cloning", # Echo-TTS ) +# Deterministic failures whose one-line server message is not actionable +# on its own: FRAGMENT -> guidance appended to the "not retryable" error. +# Matched like AUDIOCPP_NON_RETRYABLE_ERRORS (case-insensitive, against the +# server's inner error message); the pairs are checked before the generic +# non-retryable branch so the hint replaces the bare message. +AUDIOCPP_HINTED_ERRORS = ( + # Vevo2 (families without packaged speakers): the voice resolved to a + # speaker name without reference audio, so there is nothing to clone. + ("requires target_voice", + "The selected voice resolved to a name without reference audio: " + "point this model entry's voice at actual audio (a voice preset " + "with a reference wav, or the wav in the server's voice directory) " + "and retry."), + # VoxCPM1/2: the reference voice is longer than the AudioVAE encoder + # accepts, so every request cloning it fails the same way. + ("sample capacity exceeded", + "The voice's reference audio is longer than this model's encoder " + "accepts: trim the voice's reference wav in the voices folder and " + "re-run Configure Backends → audio.cpp so the server picks it up."), + # An s2s-only family (e.g. PersonaPlex) hosted for generation: no + # hosting of the entry makes it narrate text. + ("supports only speech-to-speech", + "This model only runs speech-to-speech conversations — it has no " + "text-to-speech task and cannot generate audiobooks."), +) + # 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 @@ -206,6 +258,24 @@ def audiocpp_family_voice_policy(family: str) -> str: return AUDIOCPP_VOICE_OPTIONAL +def audiocpp_family_narrates(family: str) -> Optional[bool]: + """Whether FAMILY can synthesize narration from text at all. + + Resolved from the family spec's task set: narration needs one of the + text-synthesis tasks ("tts" plain, "clone" reference-voice, "vdes" + described-voice). False marks families whose sessions only ever + transform audio (e.g. PersonaPlex, task "s2s" — its entries fail every + request with "supports only speech-to-speech sessions"), which the + Generate form's "All" pick therefore skips. None for families the + local specs do not describe — conservatively treated as capable, so + an unknown family is never silently hidden from the menu. + """ + tasks = audiocpp_family_spec_tasks(family) + if tasks is None: + return None + return bool(tasks & {AUDIOCPP_TASK_TTS, "clone", AUDIOCPP_TASK_VDES}) + + def _server_error_message(detail: str) -> str: """The server's error message from an HTTP error body, else the body. @@ -259,8 +329,9 @@ def audiocpp_request_error(status: int, detail: str, AUDIOCPP_NON_RETRYABLE_ERRORS) become NonRetryableTTSError so the 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. + hint, hinted errors (AUDIOCPP_HINTED_ERRORS) their per-fragment + guidance; everything else returns the plain RuntimeError the retry + loop has always retried. """ message = _server_error_message(detail) lowered = message.lower() @@ -274,6 +345,11 @@ def audiocpp_request_error(status: int, detail: str, "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.") + for fragment, hint in AUDIOCPP_HINTED_ERRORS: + if fragment in lowered: + return NonRetryableTTSError( + f"audio.cpp server returned HTTP {status} (not retryable): " + f"{message}. {hint}") if any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): return NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " @@ -284,23 +360,30 @@ def audiocpp_request_error(status: int, detail: str, class AudioCppFamilyProfile: """Request conventions of one audio.cpp model family. - Language style and whether the family reads a style/instruction prompt; - these are family-level (every entry of a family shares them). Whether a - *specific entry* has built-in speakers is an entry-level concern, decided - by audiocpp_entry_voice_capability, not this profile. + Language style, whether the family reads a style/instruction prompt, + and how the request text is formatted; these are family-level (every + entry of a family shares them). Whether a *specific entry* has + built-in speakers is an entry-level concern, decided by + audiocpp_entry_voice_capability, not this profile. """ def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT, - sends_instructions: bool = False): + sends_instructions: bool = False, + script_prefix: Optional[str] = None): self.language_style = language_style self.sends_instructions = sends_instructions + # SCRIPT_PREFIX, when set, formats every request's text as one + # "<prefix>: text" script line (audiocpp_script_input): the + # family's server implementation parses the prompt as a + # speaker-script and silently drops unprefixed lines (VibeVoice). + self.script_prefix = script_prefix # Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES: # clone-only, no style instructions, and no language field (the model # detects the language itself). Describes higgs_audio_tts, voxcpm2, # fish_audio, dots_tts, dramabox, omnivoice, outetts, glm_tts, miotts, -# moss_tts_*, pocket_tts, vibevoice, ... as well as families added to +# moss_tts_*, pocket_tts, ... as well as families added to # audio.cpp after this table was written. AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile() @@ -316,9 +399,27 @@ AUDIOCPP_FAMILY_PROFILES = { "index_tts2": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), "magpie_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), "supertonic": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + # VibeVoice parses its prompt as a multi-speaker script: every line + # must read "Speaker N: text" and unprefixed lines are dropped, so the + # client flattens each request into one Speaker-1 line (the server + # renormalizes the lowest speaker id to zero — the cloned reference). + "vibevoice": AudioCppFamilyProfile(script_prefix="Speaker 1"), } +def audiocpp_script_input(prefix: str, text: str) -> str: + """TEXT formatted as one "<PREFIX>: text" script line. + + Script-parsed families (VibeVoice) read the prompt line by line and + silently drop every line without a "Speaker N:" prefix, so the request + text — which may contain paragraph breaks — is flattened to a single + line and prefixed. The server renormalizes the lowest speaker id it + finds to zero (the cloned reference voice), so "Speaker 1" is the + right prefix for single-narrator audiobook chunks. + """ + return f"{prefix}: {' '.join(text.split())}" + + def audiocpp_entry_voice_capability(family: str, task: str, model_id: str) -> str: """How a server model entry's voice is supplied — speaker/clone/design. @@ -584,16 +685,30 @@ class AudioCppTTSClient(BaseTTSClient): 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})." - ) + """Reject model entries that cannot synthesize narration from text. + + Two kinds of refusal: an entry hosted with a non-synthesis task + (asr, vc, s2s, ...), and an entry whose *family* has no + text-synthesis task at all in its model spec (e.g. PersonaPlex, + speech-to-speech-only — its sessions reject every request with + "supports only speech-to-speech sessions" regardless of hosting). + """ + 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})." + ) + if audiocpp_family_narrates(self.family) is False: + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' belongs to the " + f"'{self.family}' family, which only transforms audio " + "(speech-to-speech) and cannot synthesize narration from " + "text; pick a TTS model entry with --model (available: " + f"{', '.join(model['id'] for model in models) or 'none'})." + ) def _unload_server_models(self) -> None: """Ask the server to unload every loaded model before generating. @@ -813,9 +928,15 @@ class AudioCppTTSClient(BaseTTSClient): def _request_wav(self, text: str) -> bytes: """POST one sub-chunk and return the raw WAV bytes.""" url = f"{self.api_url}/v1/audio/speech" + input_text = text + if self.profile.script_prefix: + # Script-parsed families (VibeVoice) drop unprefixed lines: + # flatten the sub-chunk into one prefixed script line. + input_text = audiocpp_script_input(self.profile.script_prefix, + text) payload: Dict[str, Any] = { "model": self.model_id, - "input": text, + "input": input_text, } # Design models take no voice field (the voice comes from the # instruction); instruction-voice runs on families without built-in |
