aboutsummaryrefslogtreecommitdiff
path: root/app/converter/clients/audiocpp.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 03:17:01 -0400
committerhistoria <historiavg@proton.me>2026-09-01 03:17:01 -0400
commit058b19e7a65b40b1024a4fdeb2233062ff273cfd (patch)
treefe3643872cd6b317a88eec950ae6ecc4d81d843d /app/converter/clients/audiocpp.py
parent10e72d4960e865acf5346ab8cf518ed5844fe45c (diff)
downloadtts-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.py159
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