aboutsummaryrefslogtreecommitdiff
path: root/app/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
committerhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
commita0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (patch)
treed8492bbcbbf6850afc127bae862abe68e1198c0c /app/converter
parent93f106aac2d6411c80a911adac62cd12f80e58be (diff)
downloadtts-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__.py11
-rw-r--r--app/converter/clients/audiocpp.py149
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