From a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 Mon Sep 17 00:00:00 2001 From: historia Date: Sun, 30 Aug 2026 20:42:02 -0400 Subject: fix: non-clone models correctly supported in tui, restart server when needed --- README.md | 2 +- app/backends/audiocpp/__init__.py | 11 +- app/backends/audiocpp/catalog.py | 75 +++++++++++++- app/backends/audiocpp/constants.py | 3 + app/backends/audiocpp/models.py | 12 ++- app/backends/audiocpp/wizard.py | 7 +- app/converter/clients/__init__.py | 11 ++ app/converter/clients/audiocpp.py | 149 +++++++++++++++++++++++++-- app/docs/backend-audiocpp.md | 19 +++- app/tests/test_backends_audiocpp.py | 124 ++++++++++++++++++++++ app/tests/test_hub.py | 200 ++++++++++++++++++++++++++++++++---- app/tests/test_tts.py | 180 +++++++++++++++++++++++++++++++- app/ui/hub.py | 115 ++++++++++++++++++--- 13 files changed, 848 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index dd01079..2c0f185 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ python audiobook.py --input-file the_odyssey.epub --output-file the_odyssey.mp3 | ---------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--backend {audiocpp,qwen,faster}` | `audiocpp` | TTS server to use. Required: there is no default backend. | | `--format {mp3,m4b,ogg,flac}` | `m4b` | Output format (default: the `AUDIO_FORMAT` setting in `app/converter/config.py`, `m4b`). | -| `--voice ` | `Vivian` | Voice to request. For `faster` this is the key in `voices.json` (required). For the Qwen CustomVoice model this is the speaker (Ryan, Vivian, etc.). | +| `--voice ` | `Vivian` | Voice to request. For `faster` this is the key in `voices.json` (required). For the Qwen CustomVoice model this is the speaker (Ryan, Vivian, etc.). For audio.cpp it is a server-side clone voice: required for clone-only families (e.g. Chatterbox), optional on mixed families and unused by pure-TTS models (e.g. Supertonic). | | `--input ` | `./input` | Directory containing the books to convert (default: the `INPUT_DIR` setting in `app/converter/config.py`, `./input`; relative paths resolve against the project root). | | `--output ` | `./output` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). | | `--input-file ` | `books/dune.epub` | Convert one specific book (`.txt`/`.pdf`/`.epub`) | diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index d65c36b..64122c9 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -24,18 +24,22 @@ from .constants import ( DEFAULT_HOST, FALLBACK_PORT, PATCH_DIR, + TASK_CLON, TASK_TTS, TASK_VDES, ) from .catalog import ( detect_backend, + is_clone_only_family, is_design_package, + hosting_task, load_model_catalog, package_dir_options, build_model_entry, build_server_config, load_server_config, server_config_selections, + rehost_clone_only_entries, request_options_families, supports_request_options, ) @@ -89,11 +93,12 @@ from .status import detect __all__ = [ # constants "AUDIOCPP_DIR_NAME", "AUDIOCPP_GIT_URL", "BACKENDS", "DEFAULT_HOST", - "FALLBACK_PORT", "PATCH_DIR", "TASK_TTS", "TASK_VDES", + "FALLBACK_PORT", "PATCH_DIR", "TASK_TTS", "TASK_CLON", "TASK_VDES", # catalog "detect_backend", "load_model_catalog", "is_design_package", - "package_dir_options", "build_model_entry", "build_server_config", - "load_server_config", "server_config_selections", + "is_clone_only_family", "hosting_task", "package_dir_options", + "build_model_entry", "build_server_config", + "load_server_config", "server_config_selections", "rehost_clone_only_entries", "request_options_families", "supports_request_options", # models "missing_model_entries", "installed_model_entries", diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py index 65b525b..78908a3 100644 --- a/app/backends/audiocpp/catalog.py +++ b/app/backends/audiocpp/catalog.py @@ -6,7 +6,9 @@ import sys from pathlib import Path from typing import Dict, List, Optional, Set, Tuple -from .constants import TASK_TTS +from converter.clients import AUDIOCPP_CLONE_ONLY_FAMILIES, audiocpp_family_spec_tasks + +from .constants import TASK_CLON, TASK_TTS DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE) @@ -220,6 +222,77 @@ def is_design_package(package: dict) -> bool: return bool(DESIGN_PACKAGE_RE.search(text)) +def is_clone_only_family(family: str, tasks: Optional[Set[str]] = None + ) -> bool: + """True when FAMILY's audio.cpp implementation rejects plain TTS. + + Such families can only synthesize by cloning a reference voice, so + their server entries must be hosted with task "clon" — hosting them + with "tts" fails every speech request at session-creation time. + Families are classified from the explicit known-clone-only set + (AUDIOCPP_CLONE_ONLY_FAMILIES, which also covers specs that wrongly + claim "tts" — Chatterbox) or from a spec task list that names only + "clone" (TASKS, when the caller has it; without one the specs are + read best-effort). + """ + if family in AUDIOCPP_CLONE_ONLY_FAMILIES: + return True + if tasks is None: + tasks = audiocpp_family_spec_tasks(family) + return bool(tasks) and set(tasks) == {"clone"} + + +def hosting_task(entry: dict) -> str: + """The server.json task a family's non-design packages are hosted with. + + Clone-only families (see ``is_clone_only_family``) get "clon" so their + cloning sessions can be created at all; every other family keeps + "tts", which serves plain TTS and — where the family supports it — + cloning through the request's voice field alike. + """ + if is_clone_only_family(str(entry.get("family") or ""), + tasks=set(entry.get("tasks") or []) or None): + return TASK_CLON + return TASK_TTS + + +def rehost_clone_only_entries(server_json: Path, data: dict) -> List[str]: + """Re-host "tts"-tasked clone-only entries in DATA as "clon", in place. + + Server.json files written before clone-only hosting existed carry + task "tts" for families whose audio.cpp implementation rejects plain + TTS sessions (e.g. Chatterbox), so every speech request fails with + HTTP 500. Each such entry's task is rewritten to "clon"; when anything + changed, the document is written back to SERVER_JSON (same layout the + wizard writes). Returns the repaired entries' ids, in order — empty + when nothing needed changing (or the document is unusable). + """ + models = data.get("models") + if not isinstance(models, list): + return [] + repaired: List[str] = [] + for entry in models: + if not isinstance(entry, dict): + continue + if str(entry.get("task") or "") != TASK_TTS: + continue + family = str(entry.get("family") or "") + if not family or not is_clone_only_family(family): + continue + entry["task"] = TASK_CLON + repaired.append(str(entry.get("id") or family)) + if repaired: + try: + with server_json.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + handle.write("\n") + except OSError: + # The in-memory document is fixed either way; a failed write + # only means the fix does not survive the process. + pass + return repaired + + def package_dir_options(entry: dict) -> List[dict]: """Return one option per distinct target_directory of a family's packages. diff --git a/app/backends/audiocpp/constants.py b/app/backends/audiocpp/constants.py index 957590b..017dd60 100644 --- a/app/backends/audiocpp/constants.py +++ b/app/backends/audiocpp/constants.py @@ -14,6 +14,9 @@ BACKENDS = ("cuda", "vulkan", "hip", "cpu") TASK_TTS = "tts" +TASK_CLON = "clon" + + TASK_VDES = "vdes" diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py index f2c4e5e..d81dcfc 100644 --- a/app/backends/audiocpp/models.py +++ b/app/backends/audiocpp/models.py @@ -313,10 +313,16 @@ def _build_tree_families(catalog: List[dict]) -> List[dict]: """Shape the catalog into the checkbox_tree widget's family list.""" families: List[dict] = [] for entry in catalog: - capabilities = ["tts"] - if "clone" in entry["tasks"]: + tasks = set(entry["tasks"]) + # Clone-only families (e.g. Chatterbox) cannot synthesize without + # a reference voice, so they do not advertise plain "tts". + clone_only = _catalog.is_clone_only_family(entry["family"], tasks) + capabilities = [] + if not clone_only: + capabilities.append("tts") + if "clone" in tasks or clone_only: capabilities.append("cloning") - if "design" in entry["tasks"]: + if "design" in tasks: capabilities.append("design") name = entry["display_name"] options = [] diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index f35aac1..97f50a5 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -27,7 +27,7 @@ from . import models as _models from . import prebuilt as _prebuilt from . import voices as _voices from .catalog import (_backend_options, build_model_entry, - build_server_config, detect_backend, + build_server_config, detect_backend, hosting_task, load_model_catalog, load_server_config, package_dir_options, server_config_selections) from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL, BACKENDS, @@ -107,7 +107,10 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], if task is None: task = task_picker(opt["install_id"]) else: - task = TASK_TTS + # Clone-only families (Chatterbox, Confucius4-TTS, + # Echo-TTS) reject plain-TTS sessions, so they are hosted + # with task "clon"; everything else keeps "tts". + task = hosting_task(entry) base_id = opt["target_directory"].replace("/", "-") model_id = base_id if model_id in entry_ids: 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 ``/model_specs/.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 diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 32ce39b..aab98b0 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -87,11 +87,20 @@ Create a `server.json` config file. One server can host multiple models and mult "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF", "task": "tts", "mode": "offline" + }, + { + "id": "Chatterbox-GGUF", + "family": "chatterbox", + "path": "models/Chatterbox-GGUF", + "task": "clon", + "mode": "offline" } ] } ``` +The `task` field decides which session type the server creates for the entry, and model families are picky about it: clone-only families — **Chatterbox**, **Confucius4-TTS** and **Echo-TTS** — reject plain-TTS sessions outright (every speech request fails with an HTTP 500 like `Chatterbox supports VoiceCloning and VoiceConversion`), so they must be hosted with `"task": "clon"`. Voice-design packages are hosted with `"task": "vdes"`; every other family keeps `"task": "tts"`, which serves plain TTS and (where the family supports it) voice cloning through the request's `voice` field alike. The setup wizard writes the right task automatically, and opening **Generate Audiobooks** re-hosts stale `tts`-tasked clone-only entries in an existing `server.json` (restarting the managed server to load the fix). + ### Run audio.cpp and the audiobook script Run the server with this config file. The `audiocpp_server` path will be slightly different depending on your platform and build options: @@ -103,9 +112,15 @@ Run the server with this config file. The `audiocpp_server` path will be slightl In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice` from server.json: ```bash -# Higgs Audio (clone-only) +# Higgs Audio voice cloning (the family also does plain TTS: omit --voice) python audiobook.py --backend audiocpp --model Higgs-Audio-v3-TTS-4B-GGUF --voice narrator +# Chatterbox (clone-only: a reference voice is required) +python audiobook.py --backend audiocpp --model Chatterbox-GGUF --voice narrator + +# Supertonic (pure TTS: no voice needed, none can be cloned) +python audiobook.py --backend audiocpp --model Supertonic-GGUF + # Qwen3-TTS built-in speaker (pick one with --voice) python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF --voice Vivian @@ -144,7 +159,7 @@ If accurate transcripts are not available, cloning without one is possible per run with `--option x_vector_only_mode=true` (speaker-embedding-only cloning — no transcript needed, noticeably lower speaker similarity). -In the hub's **Generate Audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only. +In the hub's **Generate Audiobooks** form the Model picker shows each entry's voice capability: `speaker` (built-in Qwen3-TTS speakers), `tts` (pure-TTS families that need no voice at all), `tts/clone` (mixed families that work either way), `clone` (clone-only families) or `design`. The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** on clone-capable entries (listing the server's preset/voice_dir entries) — it is hidden entirely on pure-TTS families, and on mixed families it leads with a blank **(built-in)** pick that means plain TTS without a reference voice (the default). Clone-only families keep the voice required. Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only. The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate Audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config). diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 08de777..8b1698b 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -637,6 +637,130 @@ class BuildServerConfigTests(unittest.TestCase): self.assertEqual(entry["mode"], "offline") +class CloneOnlyHostingTests(unittest.TestCase): + """Clone-only family classification and server.json hosting tasks.""" + + def test_clone_only_set_members(self): + for family in ("chatterbox", "confucius4_tts", "echo_tts"): + self.assertTrue(make_server.is_clone_only_family(family)) + + def test_clone_only_from_spec_tasks(self): + self.assertTrue(make_server.is_clone_only_family( + "future_tts", tasks={"clone"})) + + def test_mixed_and_pure_families_are_not_clone_only(self): + self.assertFalse(make_server.is_clone_only_family( + "higgs_audio_tts", tasks={"tts", "clone"})) + self.assertFalse(make_server.is_clone_only_family( + "supertonic", tasks={"tts"})) + + def test_unknown_family_without_tasks_is_not_clone_only(self): + # No spec, no explicit knowledge: keep the generic (tts) hosting. + self.assertFalse(make_server.is_clone_only_family("brand_new")) + + def test_hosting_task_clone_only_family(self): + self.assertEqual(make_server.hosting_task( + {"family": "chatterbox", "tasks": ["tts", "clone", "vc"]}), + "clon") + + def test_hosting_task_regular_family(self): + self.assertEqual(make_server.hosting_task( + {"family": "f5_tts", "tasks": ["tts", "clone"]}), "tts") + + +class BuildEntriesHostingTests(unittest.TestCase): + """_build_entries hosts clone-only families with task "clon".""" + + @staticmethod + def _catalog_entry(family, tasks): + return {"family": family, "display_name": family, + "description": "", "languages": ["en"], "tasks": tasks, + "clone_capable": "clone" in tasks, "packages": [], + "install_id": f"{family}_q8_0", + "default_path": f"models/{family}-GGUF"} + + @staticmethod + def _option(directory): + return {"target_directory": directory, "install_id": "pkg", + "design": False, "recommended": True} + + def _entries(self, catalog_entry): + entries, _, _, _, _ = make_server.wizard._build_entries( + [catalog_entry["family"]], + {catalog_entry["family"]: [self._option(catalog_entry["family"])]}, + {catalog_entry["family"]: catalog_entry}, + lambda install_id: "tts") + return entries + + def test_chatterbox_is_hosted_with_clon(self): + entry = self._entries(self._catalog_entry( + "chatterbox", ["tts", "clone", "vc"]))[0] + self.assertEqual(entry["task"], "clon") + self.assertEqual(entry["family"], "chatterbox") + + def test_clone_only_spec_family_is_hosted_with_clon(self): + entry = self._entries(self._catalog_entry( + "confucius4_tts", ["clone"]))[0] + self.assertEqual(entry["task"], "clon") + + def test_mixed_family_is_hosted_with_tts(self): + entry = self._entries(self._catalog_entry( + "f5_tts", ["tts", "clone"]))[0] + self.assertEqual(entry["task"], "tts") + + +class RehostCloneOnlyEntriesTests(unittest.TestCase): + """server.json repair: clone-only entries re-hosted from "tts".""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.server_json = Path(self._td.name) / "server.json" + + def tearDown(self): + self._td.cleanup() + + def _data(self, *models): + return {"host": "127.0.0.1", "port": 8080, "backend": "cuda", + "lazy_load": False, "models": list(models)} + + def _read(self): + return json.loads(self.server_json.read_text(encoding="utf-8")) + + def test_chatterbox_tts_entry_is_rehosted_and_persisted(self): + data = self._data({"id": "Chatterbox-GGUF", "family": "chatterbox", + "path": "models/Chatterbox-GGUF", "task": "tts", + "mode": "offline"}) + repaired = make_server.rehost_clone_only_entries(self.server_json, + data) + self.assertEqual(repaired, ["Chatterbox-GGUF"]) + self.assertEqual(data["models"][0]["task"], "clon") + # The fix is written back so the server picks it up on restart. + self.assertEqual(self._read()["models"][0]["task"], "clon") + + def test_non_clone_only_entries_are_untouched(self): + data = self._data({"id": "q", "family": "qwen3_tts", + "path": "models/Q", "task": "tts", + "mode": "offline"}) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, data), []) + self.assertEqual(data["models"][0]["task"], "tts") + self.assertFalse(self.server_json.exists()) + + def test_vdes_and_clon_tasks_are_left_alone(self): + data = self._data({"id": "c", "family": "chatterbox", + "path": "m", "task": "clon", "mode": "offline"}, + {"id": "d", "family": "qwen3_tts", + "path": "m2", "task": "vdes", "mode": "offline"}) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, data), []) + + def test_unusable_document_is_ignored(self): + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, {"models": "nope"}), []) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, {}), []) + + class InstallModelsTests(unittest.TestCase): """Printing or auto-running the model install commands.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index de7e4ea..6ce1943 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -13,6 +13,7 @@ from pathlib import Path from unittest.mock import patch from backends import BackendInfo, BackendStatus, ServerSpec +from converter.clients import audiocpp as audiocpp_client from tests.test_tui import FakeCurses, FakeScreen from ui import hub, tui @@ -821,6 +822,18 @@ class ConvertFlowTests(unittest.TestCase): patcher = patch.object(hub.tui, name, getattr(self.tui, name)) patcher.start() self.addCleanup(patcher.stop) + # Family voice policies are resolved from the local audio.cpp + # checkout's model_specs, which a fresh clone does not have (the + # checkout is downloaded by setup): seed the client's spec cache + # with the classifications these tests rely on, so they stay + # hermetic. Unknown families keep the clone-only default. + spec_cache = audiocpp_client._FAMILY_SPEC_TASKS + spec_cache.clear() + spec_cache.update({ + "higgs_audio_tts": {"tts", "clone"}, + "supertonic": {"tts"}, + }) + self.addCleanup(spec_cache.clear) # Keys shared by every backend entry; a "-remote" backend's other # option keys are namespaced under "." in the form dict @@ -952,9 +965,10 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id); ids are - # padded so the type column lines up across entries. + # padded so the type column lines up across entries. A mixed + # tts+clone family reads as "(tts/clone)". self.assertEqual(self._field("model_id")["choices"], - [("higgs (clone)", "higgs")]) + [("higgs (tts/clone)", "higgs")]) def test_model_menu_lines_the_type_column_up(self): # Ids are padded to the widest id: every (type) starts on the same @@ -972,7 +986,7 @@ class ConvertFlowTests(unittest.TestCase): # "a-much-longer-model-id" is 22 columns wide; both types open at # column 24 ("(" right after the two-space gutter). self.assertEqual(choices[0], - ("short".ljust(22) + " (clone)", "short")) + ("short".ljust(22) + " (tts/clone)", "short")) self.assertEqual(choices[1], ("a-much-longer-model-id (clone)", "a-much-longer-model-id")) @@ -1032,11 +1046,11 @@ class ConvertFlowTests(unittest.TestCase): self.assertTrue(instr["visible"](fields)) def test_audiocpp_model_switch_keeps_the_picked_voice(self): - # Switching models whose voice list is unchanged (two clone + # Switching models whose voice list is unchanged (two clone-only # entries sharing one server's voices) keeps the picked voice # instead of snapping back to the list's first entry. self._patch_remote( - [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "alpha", "family": "chatterbox", "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}], voices=["narrator", "second"]) self._answer_form(backend="audiocpp-remote", model_id="alpha", @@ -1090,8 +1104,8 @@ class ConvertFlowTests(unittest.TestCase): # that model's first voice (and re-points again on the way back). models = patch.object( hub.audiocpp_backend, "fetch_server_models", - lambda url: [{"id": "alpha", "family": "higgs_audio_tts", - "task": "tts"}, + lambda url: [{"id": "alpha", "family": "chatterbox", + "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}]) voices = patch.object( @@ -1117,7 +1131,7 @@ class ConvertFlowTests(unittest.TestCase): # never survives a move to a built-in-speaker entry (and vice # versa), and a design entry clears the voice again. self._patch_remote( - [{"id": "clone", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "clone", "family": "chatterbox", "task": "clon"}, {"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}, {"id": "design", "family": "qwen3_tts", "task": "vdes"}], @@ -1237,17 +1251,27 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): - # A non-qwen3_tts family needs a --voice; a blank value refuses. + # A clone-only family (Chatterbox) needs a --voice; a blank value + # refuses. A mixed tts+clone family (higgs_audio_tts) accepts the + # blank pick — it means plain TTS without a reference. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}, + {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="narrator", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field["value"] = "narrator" + model_field["value"] = "higgs" + model_field["on_change"](fields) + # Mixed family: the blank (built-in) pick is valid. + self.assertIsNone(voice_field["validate"]("")) def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self): # On a CustomVoice entry the Voice field is labelled "Built-in @@ -1281,13 +1305,13 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(label(fields), "Voice to clone") def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self): - # An Instructions text substitutes for the voice: blank Voice passes - # validation when instructions are present (instruction-voice mode), - # and is still refused without one. + # An Instructions text substitutes for the voice on clone-only + # families: blank Voice passes validation when instructions are + # present, and is still refused without one. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) @@ -1300,12 +1324,12 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(voice["validate"]("")) def test_audiocpp_no_voices_with_instructions_still_converts(self): - # A clone-capable entry whose server lists no voices is refused by + # A clone-only entry whose server lists no voices is refused by # default — but an instruction provides the voice instead. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1319,6 +1343,47 @@ class ConvertFlowTests(unittest.TestCase): instr["value"] = "designed narrator" self.assertIsNone(voice["validate"]("")) + def test_audiocpp_pure_tts_entry_hides_the_voice_menu(self): + # Pure-TTS families (spec tasks without "clone") synthesize with + # no voice at all: the Voice menu is hidden entirely, the model + # menu reads "(tts)", and Generate! sends no voice. + self._patch_remote( + [{"id": "supertonic", "family": "supertonic", "task": "tts"}]) + self._answer_form(backend="audiocpp-remote", model_id="supertonic", + audiocpp_voice=None, instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertFalse(voice_field["visible"](fields)) + self.assertEqual(self._field("model_id")["choices"], + [("supertonic (tts)", "supertonic")]) + + def test_audiocpp_mixed_family_offers_a_built_in_blank_pick(self): + # Mixed tts+clone families lead the Voice menu with a blank + # "(built-in)" pick meaning plain TTS (no reference voice), and + # the blank pick is the default. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertTrue(voice_field["visible"](fields)) + self.assertEqual(voice_field["choices"](fields), + [("", "(built-in)"), ("narrator", "narrator")]) + # A kept clone pick survives a mixed-family switch; blank is valid. + voice_field["value"] = "narrator" + self.assertIsNone(voice_field["validate"]("narrator")) + self.assertIsNone(voice_field["validate"]("")) + def test_audiocpp_request_options_map_to_kwargs(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], @@ -1478,9 +1543,9 @@ class ConvertFlowTests(unittest.TestCase): # No voices listed for a required-voice model: the form still opens # with an empty Voice field (Generate-time validation reports it). self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1520,6 +1585,34 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") + def test_audiocpp_local_rehosts_clone_only_entries(self): + # server.json written before clone-only hosting existed carries + # task "tts" for Chatterbox: opening the form re-hosts it with + # task "clon" on disk and flags the run so the autostart plan + # restarts the managed server with the corrected config. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + server_json = root / "server.json" + server_json.write_text(json.dumps({ + "models": [{"id": "Chatterbox-GGUF", + "family": "chatterbox", "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, + "find_local_checkout", return_value=root): + self._answer_form(backend="audiocpp", + model_id="Chatterbox-GGUF", + audiocpp_voice="Narrator", + instructions="") + cmd = self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertTrue(cmd[2]["audiocpp_rehost"]) + # The repair was persisted: the entry is hosted with "clon". + data = json.loads(server_json.read_text(encoding="utf-8")) + self.assertEqual(data["models"][0]["task"], "clon") + def test_managed_and_remote_both_offered(self): # A ready managed audio.cpp (server.json) AND a running remote # audio.cpp: both entries appear. The managed entry reads server.json @@ -2036,6 +2129,34 @@ class PrepareRunConfigTests(unittest.TestCase): self.assertNotIn("restart_server", kwargs) self.assertEqual(cfg.server_url, spec.url) + def test_rehost_flag_is_popped_and_reported_in_the_notice(self): + # The convert form's config repair travels as "audiocpp_rehost": + # popped from the converter kwargs and surfaced as the run notice. + spec = self._spec("audiocpp", "http://127.0.0.1:8080") + status = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, servers=[spec]) + kwargs = {"restart_server": "audiocpp", "audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch("backends.common.server_running", + return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertTrue(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertIn("restarted", cfg.notice) + + def test_rehost_notice_without_restart_when_server_was_down(self): + # The autostart path boots the fixed server.json anyway, so the + # notice only reports the re-hosting. + kwargs = {"audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[]): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertFalse(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertNotIn("restarted", cfg.notice) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): # The run-view toggle is not a converter kwarg: it moves onto the # config (and defaults to off when the form did not send it). @@ -2282,8 +2403,45 @@ class AddAutostartTests(unittest.TestCase): # external to this tool, so there is nothing to start/stop here. cmd = ("convert", "audiocpp", {"api_url": "http://10.0.0.5:8080"}) hub._add_autostart(cmd, []) + + def _audiocpp_status(self): + spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["x"]) + return BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, running=True, + servers=[spec]) + + def test_rehosted_config_restarts_the_managed_audiocpp_server(self): + # The convert form re-hosted clone-only families with task "clon" + # in server.json: the running managed server still hosts the stale + # tasks, so it is stopped and rebooted before converting. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["restart_server"], "audiocpp") self.assertNotIn("autostart", cmd[2]) + def test_rehosted_config_with_foreign_server_refuses_the_run(self): + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False): + message = hub._add_autostart(cmd, [self._audiocpp_status()]) + self.assertIsNotNone(message) + self.assertIn("stop it first", message) + self.assertNotIn("restart_server", cmd[2]) + + def test_rehosted_config_autostarts_when_server_is_down(self): + # Server not running: the plain autostart path boots it with the + # corrected server.json — no restart needed. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=False): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["autostart"], "audiocpp") + self.assertNotIn("restart_server", cmd[2]) + class SettingsTests(unittest.TestCase): """Settings menu: field collection, validation, config.py writing.""" diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 3538d8b..9067443 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -21,6 +21,9 @@ from converter.clients import ( AUDIOCPP_TASK_VDES, AUDIOCPP_VOICE_CLONE, AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_NONE, + AUDIOCPP_VOICE_OPTIONAL, + AUDIOCPP_VOICE_REQUIRED, AUDIOCPP_VOICE_SPEAKER, BACKEND_AUDIOCPP, BACKEND_FASTER, @@ -38,10 +41,13 @@ from converter.clients import ( FasterTTSClient, QwenTTSClient, audiocpp_entry_voice_capability, + audiocpp_family_voice_policy, + audiocpp_request_error, normalize_language, transcribe_reference_audio_detailed, whisper_backend_problem, ) +from converter.clients import audiocpp as audiocpp_client from converter.clients.base import NonRetryableTTSError from converter.converter import AudiobookConverter @@ -1028,13 +1034,16 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) def test_speaker_mode_rejected_for_clone_only_family(self): + # An unknown family (no spec, no built-in speakers) keeps the + # conservative clone-only default: without --voice the run fails + # fast instead of guessing. client = None try: client = self._client(voice=None, models={"data": [ - {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "some_new_family"}]}) except RuntimeError as exc: message = str(exc) - self.assertIn("voxcpm2", message) + self.assertIn("some_new_family", message) self.assertIn("--voice", message) self.assertIn("no built-in speakers", message) self.assertIsNone(client) @@ -1116,10 +1125,174 @@ class AudiocppEntryVoiceCapabilityTests(unittest.TestCase): # The "customvoice" substring only marks a speaker for the qwen3_tts # family; another family with a lookalike id stays clone-only. self.assertEqual(self._cap("future_tts", "tts", - "Qwen3-TTS-CustomVoice"), + "Qwen3-TTS-CustomVoice"), AUDIOCPP_VOICE_CLONE) +class AudioCppFamilyVoicePolicyTests(unittest.TestCase): + """The per-family voice policy resolver (required/optional/none).""" + + def setUp(self): + # Seed the spec cache instead of reading the (gitignored, setup- + # downloaded) checkout's model_specs, so the tests are hermetic. + cache = audiocpp_client._FAMILY_SPEC_TASKS + cache.clear() + cache.update({ + "higgs_audio_tts": {"tts", "clone"}, + "supertonic": {"tts"}, + "confucius4_tts": {"clone"}, + }) + self.addCleanup(cache.clear) + + def test_pure_tts_family_needs_no_voice(self): + self.assertEqual(audiocpp_family_voice_policy("supertonic"), + AUDIOCPP_VOICE_NONE) + + def test_mixed_family_has_an_optional_voice(self): + self.assertEqual(audiocpp_family_voice_policy("higgs_audio_tts"), + AUDIOCPP_VOICE_OPTIONAL) + + def test_clone_only_spec_is_required(self): + self.assertEqual(audiocpp_family_voice_policy("confucius4_tts"), + AUDIOCPP_VOICE_REQUIRED) + + def test_chatterbox_is_required_despite_its_spec(self): + # The chatterbox spec wrongly lists "tts": the explicit + # clone-only set wins so the binary's rejection is mirrored. + self.assertEqual(audiocpp_family_voice_policy("chatterbox"), + AUDIOCPP_VOICE_REQUIRED) + + def test_qwen3_tts_is_entry_typed_and_stays_required(self): + # Qwen3-TTS is decided per entry (speaker/clone/design), so the + # family policy never loosens its voice requirement. + self.assertEqual(audiocpp_family_voice_policy("qwen3_tts"), + AUDIOCPP_VOICE_REQUIRED) + + def test_unknown_family_keeps_the_conservative_default(self): + self.assertEqual(audiocpp_family_voice_policy("brand_new_family"), + AUDIOCPP_VOICE_REQUIRED) + + +class AudioCppPlainTtsModeTests(unittest.TestCase): + """Plain-TTS runs: families that synthesize without a reference voice.""" + + @staticmethod + def _json_response(payload): + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = json.dumps(payload).encode("utf-8") + return response + + # Minimal WAV: _request_wav only validates the RIFF/WAVE header. + _WAV = b"RIFF\x04\x00\x00\x00WAVE" + + def _client(self, family, task="tts", voice=None, captured=None): + def _dispatch(request, **_kwargs): + url = request if isinstance(request, str) else request.full_url + if url.endswith("/health"): + return self._json_response({"status": "ok"}) + if url.endswith("/v1/models"): + return self._json_response( + {"data": [{"id": "model", "family": family, + "task": task}]}) + if "/v1/audio/voices" in url: + return self._json_response({"voices": ["narrator"]}) + if url.endswith("/v1/audio/speech"): + if captured is not None: + captured.append(json.loads(request.data.decode("utf-8"))) + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = self._WAV + return response + if url.endswith("/unload_all_models"): + return self._json_response({"unloaded": []}) + raise AssertionError(f"unexpected URL: {url}") + + # The patch stays up for the whole test so _request_wav calls land + # on the dispatch too (capturing the speech payload). + patcher = patch("converter.clients.faster.urllib.request.urlopen", + side_effect=_dispatch) + patcher.start() + self.addCleanup(patcher.stop) + return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, + model_id="model") + + def test_pure_tts_family_connects_in_plain_mode(self): + client = self._client("supertonic") + self.assertTrue(client.plain_mode) + self.assertFalse(client.preset_mode) + self.assertFalse(client.design_mode) + + def test_mixed_family_without_voice_runs_plain(self): + client = self._client("higgs_audio_tts") + self.assertTrue(client.plain_mode) + + def test_plain_mode_omits_the_voice_field(self): + captured = [] + client = self._client("supertonic", captured=captured) + client._request_wav("Hello world.") + self.assertNotIn("voice", captured[0]) + self.assertEqual(captured[0]["input"], "Hello world.") + + def test_clone_only_family_without_voice_still_raises(self): + # Unknown families keep the conservative clone-only default. + with self.assertRaises(RuntimeError) as ctx: + self._client("some_new_family") + self.assertIn("--voice", str(ctx.exception)) + + def test_clone_only_family_hosted_as_tts_fails_fast(self): + # A Chatterbox entry hosted with task "tts" fails every request at + # session-creation time: refuse at connect with the re-host hint + # instead of 500ing each chunk. + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox") + message = str(ctx.exception) + self.assertIn("chatterbox", message) + self.assertIn('"clon"', message) + self.assertIn("Configure Backends", message) + + def test_clone_only_family_hosted_as_tts_fails_fast_with_voice(self): + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox", task="tts", voice="narrator") + self.assertIn('"clon"', str(ctx.exception)) + + def test_clone_only_family_hosted_as_clon_needs_a_voice(self): + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox", task="clon") + message = str(ctx.exception) + self.assertIn("--voice", message) + self.assertIn("voice_preset", message) + + +class AudioCppCloneOnlyErrorTests(unittest.TestCase): + """The non-retryable classification of clone-only hosting 500s.""" + + def _error(self, message): + return audiocpp_request_error( + 500, json.dumps({"error": {"message": message}})) + + def test_chatterbox_hosting_error_is_not_retryable(self): + exc = self._error( + "Chatterbox supports VoiceCloning and VoiceConversion") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("VoiceCloning and VoiceConversion", str(exc)) + self.assertIn('"clon"', str(exc)) + + def test_confucius_hosting_error_is_not_retryable(self): + exc = self._error("Confucius4-TTS supports the VoiceCloning task") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_echo_hosting_error_is_not_retryable(self): + exc = self._error("Echo-TTS only supports offline voice cloning") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_unrelated_error_stays_retryable(self): + exc = self._error("model busy") + self.assertNotIsInstance(exc, NonRetryableTTSError) + self.assertIn("model busy", str(exc)) + + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -1150,6 +1323,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.instructions = instructions or "" client.request_options = dict(request_options or {}) client.design_mode = task == AUDIOCPP_TASK_VDES + client.plain_mode = False # Mirrors the connect-time rule: an instruction-defined voice on a # clone-capable entry with no --voice (design mode takes precedence). capability = audiocpp_entry_voice_capability( diff --git a/app/ui/hub.py b/app/ui/hub.py index fa05685..29d2229 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -56,6 +56,9 @@ from converter.converter import ( from converter.clients import ( AUDIOCPP_VOICE_CLONE, AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_NONE, + AUDIOCPP_VOICE_OPTIONAL, + AUDIOCPP_VOICE_REQUIRED, AUDIOCPP_VOICE_SPEAKER, BACKEND_AUDIOCPP, BACKEND_FASTER, @@ -63,6 +66,7 @@ from converter.clients import ( LANGUAGE_CHOICES, QWEN3_TTS_SPEAKERS, audiocpp_entry_voice_capability, + audiocpp_family_voice_policy, normalize_language, ) from ui import runview, taskview, tui @@ -1075,6 +1079,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, except (OSError, ValueError): tui.flash(stdscr, f"Could not read {server_json}.") return None + # Re-host clone-only families still carried with task "tts" + # (written before the hosting rule existed): their sessions fail + # on every request until the entry is hosted with "clon". The + # repair is saved to server.json; a running managed server is + # restarted by the autostart plan (see _add_autostart). + rehosted = audiocpp_backend.rehost_clone_only_entries(server_json, + data) models = data.get("models") or [] if not models: tui.flash(stdscr, "No model entries in server.json. Reconfigure " @@ -1089,6 +1100,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, url = api_url local = False data = {} + rehosted: list = [] models = audiocpp_backend.fetch_server_models(url) if models is None: tui.flash(stdscr, f"Could not list models from the audio.cpp " @@ -1161,22 +1173,39 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, entry.get("family") or "", entry.get("task") or "tts", entry.get("id") or "") + def model_voice_policy(fields) -> str: + """The selected entry's family voice policy (required/optional/none).""" + return audiocpp_family_voice_policy( + model_entry(fields).get("family") or "") + def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice. A model switch that keeps the same voice list (two clone entries sharing one server's voices) keeps the current pick: only a value - the new list cannot offer is re-pointed at its default. + the new list cannot offer is re-pointed at its default. Families + that synthesize without a voice (design, pure TTS, mixed used + plainly) default to the blank pick. """ voice_field = next(f for f in fields if f.get("key") == prefix + "audiocpp_voice") capability = model_capability(fields) - if capability == AUDIOCPP_VOICE_DESIGN: + if capability == AUDIOCPP_VOICE_DESIGN \ + or model_voice_policy(fields) == AUDIOCPP_VOICE_NONE: voice_field["value"] = None return if capability == AUDIOCPP_VOICE_SPEAKER: voices = QWEN3_TTS_SPEAKERS else: # clone + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL: + # Blank is a valid pick (plain TTS): keep the current pick + # when the list still offers it, else fall back to blank. + voices = voices_for(_field_value(fields, prefix + "model_id")) + if not voice_field.get("value") \ + or voice_field["value"] in voices: + return + voice_field["value"] = "" + return voices = voices_for(_field_value(fields, prefix + "model_id")) if voice_field.get("value") in voices: return # the new list still offers the pick: keep it @@ -1188,9 +1217,14 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # Built-in Qwen3-TTS CustomVoice speakers; no server query needed. return [(s, s) for s in QWEN3_TTS_SPEAKERS] if capability == AUDIOCPP_VOICE_CLONE: - return [(v, v) for v in voices_for(_field_value( + voices = [(v, v) for v in voices_for(_field_value( fields, prefix + "model_id"))] - return [] # design: the field is hidden + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL: + # Mixed tts+clone family: the blank pick means plain TTS + # (no reference voice), so it always leads the menu. + return [("", "(built-in)")] + voices + return voices + return [] # design or pure TTS: the field is hidden def no_voices_hint(_fs=None) -> str: """Why a clone-capable entry has no selectable voices. @@ -1209,15 +1243,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def voice_validate(value): """Refuse Generate! when this entry's clone voice is unavailable. - An Instructions text substitutes for the voice: on families that - condition synthesis on instructions alone the client designs the - voice from it (instruction-voice mode), so an empty Voice is - accepted when an instruction is present. + A blank Voice is valid on mixed tts+clone families (plain TTS — + the model's own default voice) and, on any clone-capable entry, + when an Instructions text substitutes for the voice: on families + that condition synthesis on instructions alone the client designs + the voice from it (instruction-voice mode). """ if model_capability(fields) != AUDIOCPP_VOICE_CLONE: return None has_instruction = bool(str(_field_value( fields, prefix + "instructions") or "").strip()) + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL \ + and not (value or "").strip(): + # Mixed family, blank pick: plain TTS without a reference. + return None if not voices_for(_field_value(fields, prefix + "model_id")): return None if has_instruction else no_voices_hint() return None if (value or has_instruction) \ @@ -1230,21 +1269,37 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, default_capability = audiocpp_entry_voice_capability( default_entry.get("family") or "", default_entry.get("task") or "tts", default_entry.get("id") or "") + default_policy = audiocpp_family_voice_policy( + default_entry.get("family") or "") initial_voice = None if default_capability == AUDIOCPP_VOICE_SPEAKER: initial_voice = QWEN3_TTS_SPEAKERS[0] elif default_capability == AUDIOCPP_VOICE_CLONE: - initial = voices_for(default_model) - initial_voice = initial[0] if initial else "" + if default_policy == AUDIOCPP_VOICE_OPTIONAL: + # Mixed family: the blank pick (plain TTS) is the default. + initial_voice = "" + else: + initial = voices_for(default_model) + initial_voice = initial[0] if initial else "" # The Model picker reads as a two-column table: pad every id to the # widest one so the (type) column starts on the same position. id_width = max(len(entry.get("id") or "") for entry in models) def _label(entry: dict) -> str: + family = entry.get("family") or "" capability = audiocpp_entry_voice_capability( - entry.get("family") or "", entry.get("task") or "tts", - entry.get("id") or "") + family, entry.get("task") or "tts", entry.get("id") or "") + if capability == AUDIOCPP_VOICE_CLONE: + # The generic clone capability is refined by the family's + # voice policy: pure-TTS families need no voice at all, mixed + # families may run with or without one, clone-only families + # (and unknown families) always clone a reference. + capability = { + AUDIOCPP_VOICE_NONE: "tts", + AUDIOCPP_VOICE_OPTIONAL: "tts/clone", + AUDIOCPP_VOICE_REQUIRED: "clone", + }[audiocpp_family_voice_policy(family)] return f"{entry.get('id') or '':<{id_width}} ({capability})" def entry_supports_options(fs) -> bool: @@ -1279,6 +1334,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "on_change": reset_voice}, # The label tracks the entry's capability: a built-in speaker on # CustomVoice, otherwise the name of a server-side voice to clone. + # Hidden on design entries (the voice is described) and on + # pure-TTS families (no cloning, no voice at all). {"key": prefix + "audiocpp_voice", "label": lambda fs: ("Built-in voice" if model_capability(fs) == AUDIOCPP_VOICE_SPEAKER @@ -1286,7 +1343,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), - "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, + "visible": lambda fs: not ( + model_capability(fs) == AUDIOCPP_VOICE_DESIGN + or (model_capability(fs) == AUDIOCPP_VOICE_CLONE + and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE)), "on_empty_choices": no_voices_hint, "validate": voice_validate}, # Style/voice-design instruction. Required for design entries (the @@ -1330,7 +1390,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "request_options": request_options, **_common_kwargs(result), } - if api_url is not None: + if api_url is None: + # The managed entry: server.json was repaired on disk when it + # hosted clone-only families with task "tts" — the autostart + # plan restarts the running server to load the fix. + if rehosted: + kwargs["audiocpp_rehost"] = True + else: kwargs["api_url"] = api_url return ("convert", BACKEND_AUDIOCPP, kwargs) @@ -1812,6 +1878,9 @@ def _prepare_run_config(backend: str, kwargs: dict # A running managed qwen server hosting another model than the run's # selection: stop it and boot the new model before converting. restart_name = kwargs.pop("restart_server", None) + # The convert form re-hosted clone-only audio.cpp models with task + # "clon" in server.json (a config repair; the restart above loads it). + rehosted = bool(kwargs.pop("audiocpp_rehost", None)) # The run-view behavior toggle (not a converter kwarg): stop the server # and quit the TUI once the generation ends. stop_and_exit = bool(kwargs.pop("stop_and_exit", False)) @@ -1833,6 +1902,11 @@ def _prepare_run_config(backend: str, kwargs: dict status = next((s for s in detect_all(refresh=True) if s.key == backend), None) notice = "" + if rehosted: + notice = ('re-hosted clone-only audio.cpp model(s) with task ' + '"clon" in server.json' + + ("; the managed server is restarted to load it" + if restart_name else "")) spec: Optional[ServerSpec] = None if autostart: spec = _find_spec(autostart) @@ -1922,6 +1996,19 @@ def _add_autostart(cmd: tuple, statuses) -> Optional[str]: if not common.server_running(spec.url): kwargs["autostart"] = spec.name return None + if status.key == BACKEND_AUDIOCPP: + # The convert form repaired server.json on disk (clone-only + # families re-hosted with task "clon"): a running managed server + # still hosts the stale tasks, so stop and boot it before + # converting. A foreign server cannot be restarted here. + if kwargs.get("audiocpp_rehost"): + if servers.alive(spec.name): + kwargs["restart_server"] = spec.name + else: + return (f"a server this tool did not start is running at " + f"{spec.url} — stop it first so the corrected " + "audio.cpp configuration is loaded") + return None if status.key != BACKEND_QWEN or len(status.servers) != 1: return None wanted = _qwen_wanted_model(kwargs) -- cgit v1.2.3