From acbd9ff2c91182d96c57ffb57bee6e9b3fcbcbd4 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 01:43:41 -0400 Subject: refactor: split tts.py into per-backend packages --- app/converter/clients/audiocpp.py | 716 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 716 insertions(+) create mode 100644 app/converter/clients/audiocpp.py (limited to 'app/converter/clients/audiocpp.py') diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py new file mode 100644 index 0000000..4a161cb --- /dev/null +++ b/app/converter/clients/audiocpp.py @@ -0,0 +1,716 @@ +"""Client for the audio.cpp audiocpp_server (native ggml TTS families).""" + +import json +import logging +import shutil +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .. import config +from ..audio import concat_audio_files +from ..chunking import split_into_chunks +from .base import BaseTTSClient, ConversionCancelled, resolve_request_seed +from .languages import LANGUAGE_ISO_CODES, normalize_language +from .speakers import (is_builtin_speaker, speaker_display_name, + speaker_display_name_for) + +logger = logging.getLogger(__name__) + +# How the "language" request field is expressed by a family. +AUDIOCPP_LANG_DISPLAY = "display" # Qwen display names, e.g. "English" +AUDIOCPP_LANG_ISO = "iso" # ISO 639-1 codes, e.g. "en" +AUDIOCPP_LANG_OMIT = "omit" # no language field; the model detects it + +# The Qwen3-TTS family. Unlike every other family (one model type each), +# qwen3_tts hosts several model *types* under one family id, distinguished +# only by the server entry's id/task: the CustomVoice model (built-in +# speakers, e.g. Vivian/Ryan), the Base model (voice cloning via a +# server-side preset), and the VoiceDesign model (task "vdes"). The +# per-entry voice capability below (audiocpp_entry_voice_capability) +# resolves which is which, driving both the Convert form (which voice +# list to show) and the converter's mode selection. +AUDIOCPP_FAMILY_QWEN3_TTS = "qwen3_tts" + +# Server model entry tasks this client can synthesize audiobooks with, +# taken from GET /v1/models (the "task" field of each entry; a missing task +# is treated as "tts" — a harmless generic default). "vdes" entries are +# voice design models: the voice is described with --instructions instead +# of coming from a speaker or a reference clip. Entries with any other task +# (asr, vc, diar, ...) are rejected at connect time with a hint to pick a +# synthesis entry. +AUDIOCPP_TASK_TTS = "tts" +AUDIOCPP_TASK_VDES = "vdes" +AUDIOCPP_SYNTHESIS_TASKS = (AUDIOCPP_TASK_TTS, "clon", AUDIOCPP_TASK_VDES) + +# The voice capability of a server model entry — how its voice is supplied. +# Resolved per entry from (family, task, id) by +# audiocpp_entry_voice_capability; drives both the Convert form (which +# voice list to show) and the converter (speaker vs preset vs design mode). +# Most families are clone-only; only the Qwen3-TTS CustomVoice entry has +# built-in speakers, and only VoiceDesign entries take a description. +AUDIOCPP_VOICE_SPEAKER = "speaker" # built-in speaker name (Qwen CustomVoice) +AUDIOCPP_VOICE_CLONE = "clone" # server-side preset / voice_dir (Base, others) +AUDIOCPP_VOICE_DESIGN = "design" # voice described by --instructions (vdes) + + +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. + """ + + def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT, + sends_instructions: bool = False): + self.language_style = language_style + self.sends_instructions = sends_instructions + + +# 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 +# audio.cpp after this table was written. +AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile() + +AUDIOCPP_FAMILY_PROFILES = { + AUDIOCPP_FAMILY_QWEN3_TTS: AudioCppFamilyProfile( + language_style=AUDIOCPP_LANG_DISPLAY, + sends_instructions=True, + ), + # Families whose language option takes a code (e.g. "en") instead of + # a Qwen display name; otherwise clone-only like the default profile. + "chatterbox": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + "confucius4_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + "index_tts2": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + "magpie_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + "supertonic": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), +} + + +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. + + Resolved from the entry's family, task and id — the same {id, family, + task} triple GET /v1/models reports, so it works for local server.json + entries and remote live-queried entries alike. Qwen3-TTS is the one + family hosting several model *types* under one family id: the + CustomVoice model (id contains "customvoice") has built-in speakers, the + Base model and any other entry are clone-only, and VoiceDesign entries + (task "vdes") take a description. Every other family is clone-only. + """ + if task == AUDIOCPP_TASK_VDES: + return AUDIOCPP_VOICE_DESIGN + if family == AUDIOCPP_FAMILY_QWEN3_TTS \ + and "customvoice" in (model_id or "").lower(): + return AUDIOCPP_VOICE_SPEAKER + return AUDIOCPP_VOICE_CLONE + + +class AudioCppTTSClient(BaseTTSClient): + """Generates audio chunks through an audio.cpp audiocpp_server. + + Talks to the OpenAI-style HTTP API of audiocpp_server, which hosts TTS + model families through a native ggml runtime (GGUF weights, no Python + serving stack). The server API is family-agnostic; the family and task + of the configured model entry are read from GET /v1/models at startup + and adapt the request payload (language field style, style instructions) + through AUDIOCPP_FAMILY_PROFILES. The entry's voice capability + (audiocpp_entry_voice_capability: speaker / clone / design) decides how + its voice is supplied; all three are resolved server-side from the + request's "voice"/"instructions" fields: + + - Speaker mode (--voice with a built-in speaker name, or no flag on a + CustomVoice entry): Qwen3-TTS CustomVoice only. A built-in speaker + name (e.g. "Vivian") is passed through, plus the INSTRUCT style + prompt. The selected entry must be the CustomVoice model (capability + == speaker); a speaker name on a non-speaker entry is treated as a + server-side preset instead. + - Preset mode (--voice NAME): a voice configured on the server + (``voice_presets`` or ``voice_dir`` in its config, e.g. a cloning + reference). The name is validated against GET /v1/audio/voices at + startup because an unresolvable name would silently fall back to + plain TTS on a clone-based model instead of failing. When + AUDIOCPP_CLONE_MODEL_ID names a second server entry of the same + family (typically the Qwen Base model), preset requests are routed + to it. Selecting a non-speaker --voice on a CustomVoice primary with + a clone id configured is the documented way to switch a speaker setup + to cloning; without a clone id the voice is validated against the + server's voice library. + - Voice design (task "vdes" entries, e.g. Qwen3-TTS VoiceDesign): the + voice is described in natural language through ``instructions``, + which is required and sent with every request (no ``voice`` field). + A constant per-run seed keeps the designed voice consistent across + chunk boundaries. + + 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 + speaker mode, and every other name is a server-side preset. With no + --voice the entry's capability picks the mode: design entries require + --instructions; speaker entries use the built-in CustomVoice speaker + in config.SPEAKER; clone entries (the Base model, and every other + family) fail fast with a hint to pass --voice, instead of silently + synthesizing with a random default voice. + + ``instructions`` also works on non-design entries, where it acts as a + generic style/delivery instruction (voice control): families that read + it (OmniVoice, Qwen3-TTS CustomVoice, ...) shape the voice or delivery + accordingly, and others ignore it. On instruction-conditioned families + without built-in speakers it may replace --voice entirely (the + instruction defines the voice). Extra request options (``--option + KEY=VALUE``, e.g. emotion, voice_id, speed) are forwarded verbatim in + the request's "options" object, which is the server's generic + pass-through for per-model controls. + + Chunking: text is split client-side into sub-requests of at most + config.CHUNK_SIZE words each; each sub-request returns a complete + WAV file and the parts are concatenated with the same lossless path + used for the Qwen client. + """ + + def __init__(self, chunks_dir: Path, + voice: Optional[str] = None, language: Optional[str] = None, + api_url: Optional[str] = None, + model_id: Optional[str] = None, + instructions: Optional[str] = None, + request_options: Optional[Dict[str, str]] = None, + quiet: bool = False): + super().__init__(chunks_dir, quiet=quiet) + self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/") + # Per-run model selection: the --model CLI flag overrides config; an + # empty value is resolved at connect time when the server hosts exactly + # one entry, so multi-model servers don't require editing config.py. + self.model_id = (model_id if model_id is not None + else config.AUDIOCPP_MODEL_ID) or "" + self._model_id_explicit = bool(self.model_id) + # Validate before connecting so bad values fail fast without a server. + self.language = normalize_language( + language if language is not None else config.LANGUAGE) + # One seed value per run, reused for every request (see + # resolve_request_seed). Unlike the Qwen demo, audio.cpp has no + # negative "randomize" seed, so a negative value means "send no seed + # at all" (see _request_wav) and the server randomizes. + self._seed = resolve_request_seed() + # Voice selection (the --voice name). preset_mode / speaker_mode are + # resolved in _connect: a --voice that names a built-in CustomVoice + # speaker on a speaker-capable entry selects speaker mode; every + # other name (and any name on a clone-capable entry) is a server-side + # preset. preset_mode gates _select_model's reroute to + # AUDIOCPP_CLONE_MODEL_ID and the INSTRUCT style-prompt logic. The + # request's "voice" field (self.voice) is filled in _connect per the + # mode. + self.preset_mode = False + self.speaker_mode = False + self.voice = voice or None + # Style/voice-design instruction sent with every request (the CLI + # --instructions flag overrides AUDIOCPP_INSTRUCTIONS in config.py). + # For task "vdes" entries it describes the voice to design; for other + # families it is a generic style instruction when the model reads one. + self.instructions = (instructions if instructions is not None + else config.AUDIOCPP_INSTRUCTIONS or "").strip() + # Free-form per-request options (--option KEY=VALUE) forwarded in the + # request's "options" object; models ignore keys they don't know. + 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, or config.SPEAKER for the default). + self.design_mode = False + self.instruction_voice = 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 = "" + self.task = AUDIOCPP_TASK_TTS + self.profile = AUDIOCPP_DEFAULT_FAMILY_PROFILE + self._connect() + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + def _connected(self, mode: str) -> None: + """Report the resolved connection (MODE: speaker/voice/... label).""" + self._report(f"[OK] Connected to audio.cpp server at {self.api_url} " + f"(model '{self.model_id}', family '{self.family}', " + f"{mode})") + + def _connect(self) -> None: + """Health-check the server and resolve the model, family, task, and voice. + + The entry's voice capability (audiocpp_entry_voice_capability, from + family/task/id) plus the caller's --voice/--instructions pick the + mode. An explicit --voice on a speaker-capable (CustomVoice) entry + that names a built-in speaker selects speaker mode; every other + --voice is a server-side preset, validated against the server's + voice library (and rerouted to AUDIOCPP_CLONE_MODEL_ID when set). + With no --voice, design entries require --instructions, speaker- + capable entries use the built-in config.SPEAKER, and clone entries + fail fast with a hint instead of silently synthesizing with a + random default voice. + """ + self._check_health() + models = self._list_models() + self._auto_pick_model_id(models) + if self.voice is not None: + # Explicit --voice: decide between speaker mode and a server-side + # preset. A name matching a built-in CustomVoice speaker on a + # speaker-capable primary selects speaker mode; every other name + # (and any name when the primary entry is absent) is a preset, + # validated against the server's voice library and rerouted to + # AUDIOCPP_CLONE_MODEL_ID when configured. + primary = next((m for m in models if m["id"] == self.model_id), + None) + if primary is not None: + self._resolve_family(models) + self._resolve_task(models) + capability = audiocpp_entry_voice_capability( + self.family, self.task, self.model_id) + if capability == AUDIOCPP_VOICE_SPEAKER \ + and is_builtin_speaker(self.voice): + self._require_synthesis_task(models) + self.voice = speaker_display_name_for(self.voice) + self.speaker_mode = True + self._connected(f"speaker '{self.voice}'") + if not self.speaker_mode: + # Server-side preset (--voice): validate it and route to + # the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set. + self.preset_mode = True + self._select_model(models) + self._require_model_id(models) + self._resolve_family(models) + self._resolve_task(models) + self._require_synthesis_task(models) + if self.design_mode: + raise RuntimeError( + f"--voice cannot be used with the voice design model " + f"'{self.model_id}': the voice is described by the " + "--instructions text instead (see README).") + self._check_voice() + self._connected(f"voice '{self.voice}'") + else: + # No flag: the entry's capability picks the default mode. + self._require_model_id(models) + self._resolve_family(models) + self._resolve_task(models) + self._require_synthesis_task(models) + capability = audiocpp_entry_voice_capability( + self.family, self.task, self.model_id) + if self.design_mode: + if not self.instructions: + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' (family " + f"'{self.family}') is a voice design model: pass a " + "description of the voice to synthesize with, e.g. " + '--instructions "A warm adult female narrator with a ' + 'British accent" (see README).') + self._connected("voice design") + self._report(f"[INFO] Designing the voice from: {self.instructions}") + elif capability == AUDIOCPP_VOICE_SPEAKER: + # No flag on a CustomVoice entry: the built-in config.SPEAKER. + self.voice = speaker_display_name() + self.speaker_mode = True + self._connected(f"speaker '{self.voice}'") + elif self.instructions: + # Families without built-in speakers can still get their voice + # from the instruction alone (e.g. OmniVoice voice design). + self.instruction_voice = True + self._connected("instruction voice") + self._report(f"[INFO] Designing the voice from: {self.instructions}") + else: + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' (family " + f"'{self.family}') has no built-in speakers, so its voice " + "must come from the server: rerun with --voice NAME " + "matching a voice_preset or voice_dir entry in the server " + "config, or describe a voice with --instructions for " + "families that support it, or select the CustomVoice entry " + "for built-in speakers (see README).") + if self.instructions and not self.design_mode and not self.instruction_voice: + self._report(f"[INFO] Sending instruction with every request: {self.instructions}") + self._report("[INFO] Its effect (style, emotion, delivery) depends on the " + "model family; models without instruction support ignore it.") + if config.AUDIOCPP_UNLOAD_MODELS: + 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})." + ) + + def _unload_server_models(self) -> None: + """Ask the server to unload every loaded model before generating. + + Lazy-loaded entries stay resident until the server exits (unless its + max_loaded_models setting bounds residency), so switching between + configured models across runs can exhaust device memory. Unloading + first frees those leftovers; this run's model reloads transparently + on its first request. Failures only warn: an older server without + the endpoint, or a busy one, must not block a working setup. + Controlled by config.AUDIOCPP_UNLOAD_MODELS (the TUI Settings + "Unload models" option). + """ + request = urllib.request.Request( + f"{self.api_url}/v1/tasks/unload_all_models", data=b"", + method="POST", headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + except Exception as exc: + self._report(f"[WARNING] Could not unload previously loaded models at " + f"{self.api_url}: {exc}") + return + unloaded = [entry for entry in (payload.get("unloaded") or []) + if isinstance(entry, str)] + if unloaded: + self._report(f"[OK] Unloaded {len(unloaded)} model(s) from server memory: " + f"{', '.join(unloaded)}") + else: + logger.debug("No loaded audio.cpp models to unload at %s", self.api_url) + + def _get_json(self, path: str, timeout: int = 10) -> Dict[str, Any]: + """GET a JSON document from the server.""" + url = f"{self.api_url}{path}" + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = exc.read().decode("utf-8", errors="replace")[:200] + except Exception: + pass + raise RuntimeError( + f"audio.cpp server returned HTTP {exc.code} for {path}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"audio.cpp request failed for {path}: {exc.reason}") from exc + + def _check_health(self) -> None: + """Verify the server is reachable and reports healthy.""" + try: + payload = self._get_json("/health") + except Exception as exc: + raise RuntimeError( + f"audio.cpp server not reachable at {self.api_url}: {exc}. " + "Start audiocpp_server first (see the 'audio.cpp backend' " + "section of the README)." + ) from exc + if payload.get("status") != "ok": + raise RuntimeError( + f"The audio.cpp server at {self.api_url} reports status " + f"{payload.get('status')!r} instead of 'ok'") + + def _list_models(self) -> List[Dict[str, str]]: + """Fetch the (id, family, task) triples reported by the server.""" + try: + payload = self._get_json("/v1/models") + except Exception as exc: + raise RuntimeError( + f"The audio.cpp server at {self.api_url} did not answer " + f"/v1/models: {exc}") from exc + entries = payload.get("data") or [] + models: List[Dict[str, str]] = [] + for entry in entries: + if isinstance(entry, dict) and entry.get("id"): + models.append({ + "id": entry["id"], + "family": entry.get("family") or "", + "task": entry.get("task") or "", + }) + return models + + def _auto_pick_model_id(self, models: List[Dict[str, str]]) -> None: + """Resolve an empty model id when the server hosts exactly one entry. + + Multi-model servers generated with several lazily-loaded entries can + be used without editing app/converter/config.py: leave AUDIOCPP_MODEL_ID + (and ``--model``) unset, and the single hosted entry is chosen + automatically. With more than one entry an explicit choice is required + (via ``--model`` or AUDIOCPP_MODEL_ID), since guessing would risk + synthesizing a whole book with the wrong family. + """ + if self.model_id: + return + if len(models) == 1: + self.model_id = models[0]["id"] + logger.info( + "AUDIOCPP_MODEL_ID is unset; using the only server entry '%s'", + self.model_id) + else: + logger.debug( + "AUDIOCPP_MODEL_ID is unset and the server hosts %d entries; " + "an explicit --model or config id is required", + len(models)) + + def _require_model_id(self, models: List[Dict[str, str]]) -> None: + """Verify the model id chosen for this run exists on the server. + + Speaker mode needs AUDIOCPP_MODEL_ID (the CustomVoice entry). + Preset mode validates whichever id _select_model resolved, so a + server hosting only a cloning model works for --voice. The default + error distinguishes the two so the fix is obvious. + """ + model_ids = [model["id"] for model in models] + if self.model_id and self.model_id in model_ids: + return + configured = ", ".join(model_ids) or "none" + if not self.model_id: + raise RuntimeError( + f"The audio.cpp server at {self.api_url} hosts {len(model_ids)} " + f"model entries ({configured}); audiobook.py needs to know which " + "one to use. Pass --model when converting, or set " + "AUDIOCPP_MODEL_ID in app/converter/config.py to one of them " + "(see README)." + ) + if self.preset_mode: + raise RuntimeError( + f"The audio.cpp server at {self.api_url} has no model id " + f"'{self.model_id}' or clone model id " + f"'{config.AUDIOCPP_CLONE_MODEL_ID}' (configured: {configured}). " + "Add a TTS model entry for the family you want to the server " + "config and match AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID " + "in app/converter/config.py to its id, or select it per run with " + "--model (see README)." + ) + raise RuntimeError( + f"The audio.cpp server at {self.api_url} has no model id " + f"'{self.model_id}' (configured: {configured}). Select the " + "Qwen3-TTS CustomVoice entry for built-in speakers, or rerun " + "with --voice NAME matching a voice_preset or voice_dir entry " + "on any TTS model (see README)." + ) + + def _select_model(self, models: List[Dict[str, str]]) -> None: + """Pick the model for preset (cloning) requests. + + Defaults to the primary model id. When AUDIOCPP_CLONE_MODEL_ID is + configured and present on the server, preset requests are routed + to it instead, so one server can host the CustomVoice model for + speaker mode and the Base model for cloning (Qwen3-TTS setups). + A clone id that names a model of a different family is ignored + with a warning, since preset requests must synthesize with the + family the run is configured for. + """ + clone_model_id = config.AUDIOCPP_CLONE_MODEL_ID + if not clone_model_id or clone_model_id == self.model_id: + return + families = {model["id"]: model["family"] for model in models} + if clone_model_id not in families: + # A qwen3_tts primary without its clone entry silently degrades + # (presets are ignored on the CustomVoice model), so that case + # keeps the warning; single-model servers of other families are + # the normal configuration and only get a debug note. + primary_family = families.get(self.model_id) or "" + if primary_family == AUDIOCPP_FAMILY_QWEN3_TTS: + logger.warning( + "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp " + "server; preset requests use '%s' instead", + clone_model_id, self.model_id) + else: + logger.debug( + "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp " + "server; preset requests use '%s' instead", + clone_model_id, self.model_id) + return + primary_family = families.get(self.model_id) + clone_family = families[clone_model_id] + if primary_family and clone_family and primary_family != clone_family: + logger.warning( + "AUDIOCPP_CLONE_MODEL_ID %r hosts family %r, but " + "AUDIOCPP_MODEL_ID %r hosts %r; preset requests stay on " + "'%s'. Point both ids at the same model entry in " + "app/converter/config.py (single-model servers use the same id " + "for both)", + clone_model_id, clone_family, self.model_id, primary_family, + self.model_id) + return + self.model_id = clone_model_id + + def _resolve_family(self, models: List[Dict[str, str]]) -> None: + """Resolve the selected model's family and its request profile. + + The family comes from GET /v1/models; a missing family is an unknown + family that falls through to the generic (clone-only) profile rather + than guessing a specific one — audiocpp_server always reports family + for entries its server.json describes. + """ + entry = next( + (model for model in models if model["id"] == self.model_id), None) + family = (entry["family"] if entry is not None else "") or "" + self.family = family + self.profile = AUDIOCPP_FAMILY_PROFILES.get( + family, AUDIOCPP_DEFAULT_FAMILY_PROFILE) + if not family: + logger.debug("Model '%s' reported no family; using the generic " + "profile", self.model_id) + elif family not in AUDIOCPP_FAMILY_PROFILES: + logger.info( + "audio.cpp family '%s' has no dedicated profile; using the " + "generic profile (voice cloning via --voice, model-detected " + "language)", family) + + def _resolve_task(self, models: List[Dict[str, str]]) -> None: + """Resolve the selected model's task (tts, clon, vdes, ...) and set + design mode for voice design entries. + + The task comes from GET /v1/models and is fixed per server entry by + its server.json config (a VoiceDesign model must be hosted with + "task": "vdes"). Servers that predate the task field hosted plain + TTS models, so a missing task is treated as tts. + """ + entry = next( + (model for model in models if model["id"] == self.model_id), None) + task = (entry["task"] if entry is not None else "") or "" + if not task: + task = AUDIOCPP_TASK_TTS + logger.debug("Model '%s' reported no task; assuming tts", + self.model_id) + self.task = task + self.design_mode = task == AUDIOCPP_TASK_VDES + + def _check_voice(self) -> None: + """Verify the requested voice is available on the server. + + A voice name that matches no server preset or voice-library wav + would be passed through to the model as a cached voice id; on the + Base (cloning) model that is silently ignored and plain TTS audio + comes back, so preset names are validated up front. When the + voices endpoint cannot be queried, validation is skipped with a + warning rather than blocking the run. + """ + query = urllib.parse.urlencode({"model": self.model_id}) + try: + payload = self._get_json(f"/v1/audio/voices?{query}") + except Exception as exc: + logger.warning("Could not list server voices; skipping voice " + "validation: %s", exc) + return + voices = payload.get("voices") or [] + if self.voice not in voices: + available = ", ".join(str(v) for v in voices) or "none" + raise RuntimeError( + f"Voice '{self.voice}' is not available on the audio.cpp server " + f"(available: {available}). Configure it as a voice_preset or " + "voice_dir entry in the server config, or pass a listed name " + "with --voice (see README)." + ) + + # ------------------------------------------------------------------ + # HTTP requests + # ------------------------------------------------------------------ + + 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" + payload: Dict[str, Any] = { + "model": self.model_id, + "input": text, + } + # 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: + payload["voice"] = self.voice + if self.profile.language_style == AUDIOCPP_LANG_DISPLAY: + payload["language"] = self.language + elif self.profile.language_style == AUDIOCPP_LANG_ISO: + iso_code = LANGUAGE_ISO_CODES.get(self.language) + if iso_code: + payload["language"] = iso_code + else: + # "Auto": no code to send, so let the server pick its default. + logger.debug("%s: no language code for %r; omitted from request", + self.family, self.language) + if self._seed >= 0: + # audio.cpp has no negative "randomize" seed; a negative seed + # means "let the server randomize", so the field is omitted. + payload["seed"] = self._seed + if self.instructions: + # Explicit voice-design or style instruction (required for task + # "vdes" entries; a Ctrl/style control on families that read it). + payload["instructions"] = self.instructions + elif not self.preset_mode and config.INSTRUCT \ + and self.profile.sends_instructions: + # Style instruction for the Qwen3-TTS CustomVoice speakers; + # ignored by the Base (cloning) model and other families. + payload["instructions"] = config.INSTRUCT + if self.request_options: + # Generic per-model controls (--option KEY=VALUE): forwarded + # verbatim; the model ignores keys it does not know. + payload["options"] = dict(self.request_options) + request = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST") + timeout = config.API_TIMEOUT + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + wav = response.read() + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = exc.read().decode("utf-8", errors="replace")[:200] + except Exception: + pass + raise RuntimeError(f"audio.cpp server returned HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"audio.cpp request failed: {exc.reason}") from exc + if len(wav) < 12 or wav[:4] != b"RIFF" or wav[8:12] != b"WAVE": + raise RuntimeError("audio.cpp server returned audio that is not a WAV file") + return wav + + # ------------------------------------------------------------------ + # Chunk generation + # ------------------------------------------------------------------ + + def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: + """Generate one audio chunk; returns its path in the chunks folder. + + The text is split into sub-requests of at most ``config.CHUNK_SIZE`` + words each; each sub-request returns a complete WAV file and the + parts are concatenated into one chunk file. + """ + try: + sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE) + if not sub_texts: + raise RuntimeError("No text to synthesize") + + output_path: Optional[Path] = None + with tempfile.TemporaryDirectory(prefix="tts_parts_") as parts_dir, \ + self._chunk_heartbeat(chunk_num): + part_paths = [] + for sub_num, sub_text in enumerate(sub_texts, 1): + wav = self._request_wav(sub_text) + destination = Path(parts_dir) / f"part_{sub_num:02d}.wav" + destination.write_bytes(wav) + part_paths.append(destination) + if len(part_paths) == 1: + output_path = self._chunk_path(chunk_num, ".wav") + shutil.copy2(part_paths[0], output_path) + else: + output_path = self._chunk_path(chunk_num, ".wav") + concat_audio_files(part_paths, output_path) + + logger.debug("Chunk %d generated successfully (%d sub-request(s))", + chunk_num, len(sub_texts)) + return str(output_path) + + except ConversionCancelled: + raise + except Exception as exc: + logger.error("audio.cpp chunk processing failed for chunk %d: %s", + chunk_num, exc) + return None -- cgit v1.2.3