diff options
| author | historia <historiavg@proton.me> | 2026-09-01 12:12:35 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 12:12:35 -0400 |
| commit | dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (patch) | |
| tree | 0eb951f174d91d6b4c96c9b4ea4e978d1bdfc8cd /app/converter/clients | |
| parent | d15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (diff) | |
| download | tts-audiobook-generator-dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d.tar.gz | |
fix: spec santizer for glm, outetts, miotts, minimax.
Diffstat (limited to 'app/converter/clients')
| -rw-r--r-- | app/converter/clients/__init__.py | 7 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 560 |
2 files changed, 530 insertions, 37 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index d67237d..a107d29 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -51,6 +51,9 @@ from .audiocpp import ( audiocpp_family_voice_policy, audiocpp_request_error, audiocpp_script_input, + allocation_log_note, + build_trimmed_voice_reference, + nvidia_device_memory_report, ) __all__ = [ @@ -84,4 +87,8 @@ __all__ = [ "audiocpp_family_narrates", "audiocpp_family_spec_tasks", "audiocpp_family_voice_policy", "audiocpp_request_error", "audiocpp_script_input", + "allocation_log_note", "build_trimmed_voice_reference", + "nvidia_device_memory_report", "spec_request_option_names", + "AUDIOCPP_VOICE_REQUIRED_FAMILIES", "AUDIOCPP_ALLOCATION_FRAGMENTS", + "AUDIOCPP_REFERENCE_TRIM_SECONDS", ] diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index 224d24a..3637a50 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -1,14 +1,20 @@ """Client for the audio.cpp audiocpp_server (native ggml TTS families).""" +import array +import base64 import json import logging import shutil +import struct +import subprocess +import sys import tempfile import urllib.error import urllib.parse import urllib.request +import wave from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Tuple from .. import config from ..audio import concat_audio_files @@ -113,6 +119,25 @@ AUDIOCPP_CLONE_ONLY_ERRORS = ( "only supports offline voice cloning", # Echo-TTS ) +# Managed-server log where ggml records its allocation failures with the +# exact attempted size and device (see allocation_log_note). +_AUDIOCPP_SERVER_LOG_NAME = "audiocpp" + + +def _ALLOCATION_HINT_TEXT() -> str: + return ( + "The server ran out of device memory while building a compute " + "graph: check what else is using the GPU, and read the server's " + "log (app/logs/audiocpp-server.log), which records the exact " + "allocation size it attempted. Cloning families that encode the " + "whole reference with attention over its length (MOSS-TTS-Local) " + "retry automatically with a shorter reference when the voice's " + "wav is readable locally. For DramaBox, adding \"session_options\": " + "{\"dramabox.mem_saver\": \"true\"} to its server.json model entry " + "trades speed for a much lower memory peak (restart the server " + "after editing).") + + # Deterministic failures whose one-line server message is not actionable # on its own: FRAGMENT -> guidance appended to the "not retryable" error. # Matched like AUDIOCPP_NON_RETRYABLE_ERRORS (case-insensitive, against the @@ -130,8 +155,11 @@ AUDIOCPP_HINTED_ERRORS = ( # accepts, so every request cloning it fails the same way. ("sample capacity exceeded", "The voice's reference audio is longer than this model's encoder " - "accepts: trim the voice's reference wav in the voices folder and " - "re-run Configure Backends → audio.cpp so the server picks it up."), + "accepts: trim the voice's reference wav in the voices folder, or " + "re-run Configure Backends → audio.cpp — the setup writes a larger " + "AudioVAE encoder-sample capacity for VoxCPM entries when a voice " + "wav is longer than the built-in ~15 s ceiling, so no trim is " + "needed (restart the server to pick the new config up)."), # An s2s-only family (e.g. PersonaPlex) hosted for generation: no # hosting of the entry makes it narrate text. ("supports only speech-to-speech", @@ -139,6 +167,37 @@ AUDIOCPP_HINTED_ERRORS = ( "task and cannot generate audiobooks. Consider deleting the model " "from the server configuration (re-run Configure Backends → " "audio.cpp and unselect it)."), + # Graph allocation failures: mostly device memory. The server's log + # carries the exact size the failed allocation attempted. + ("failed to allocate", _ALLOCATION_HINT_TEXT()), + ("allocation failed", _ALLOCATION_HINT_TEXT()), + # A companion package (e.g. MioTTS's MioCodec) is missing where the + # server looks for it. + ("model path does not exist", + "This model needs a companion package that is not installed where " + "the server looks for it (MioTTS loads MioCodec through its " + "miotts.codec_model_path session option). Re-run Configure Backends " + "→ audio.cpp: the setup downloads companion packages alongside the " + "model and writes the needed session options, then restart the " + "server."), + # The package on disk does not match its spec layout (the pre-repair + # GLM-TTS / OuteTTS installs nested their GGUF under a repo + # subdirectory; MiniMax-H3 hosts several GGUFs in one directory). + ("missing model package file", + "The model package on disk does not match its spec layout. Re-run " + "Configure Backends → audio.cpp: the model manager now repairs " + "broken package layouts when downloading, and models installed " + "with a stale layout are re-downloaded."), + ("missing model root", + "The model package on disk does not match its spec layout. Re-run " + "Configure Backends → audio.cpp: the model manager now repairs " + "broken package layouts when downloading, and models installed " + "with a stale layout are re-downloaded."), + ("model directory contains", + "The model directory holds several GGUFs where audio.cpp expects " + "one: hosting the entry from a specific GGUF file (as the current " + "setup does) makes the model loadable. Re-run Configure Backends → " + "audio.cpp to rewrite server.json with the fixed hosting."), ) # Families whose audio.cpp implementation only synthesizes by cloning a @@ -150,12 +209,37 @@ AUDIOCPP_HINTED_ERRORS = ( AUDIOCPP_CLONE_ONLY_FAMILIES = frozenset( {"chatterbox", "confucius4_tts", "echo_tts"}) +# Families whose plain-TTS route still requires a reference voice even +# though their model spec does not declare a "clone" task: Vevo2's +# zero-shot TTS route refuses every request without a timbre reference +# ("requires target_voice or voice speaker audio"), so the voice policy +# treats them like clone-only (the "All" flow then sends the picked +# voice, and a voice-less run is refused with the actionable message +# instead of failing every request server-side). +AUDIOCPP_VOICE_REQUIRED_FAMILIES = frozenset({"vevo2"}) + # 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 +# The allocation-failure fragments that trigger the server-log detail and +# the trimmed-reference retry (see AUDIOCPP_NON_RETRYABLE_ERRORS). +AUDIOCPP_ALLOCATION_FRAGMENTS = ("failed to allocate", "allocation failed") + +# A cloned reference long enough to blow up reference-attention encoders +# (MOSS-TTS-Local's codec encoder: memory grows with the reference's +# square) is retried as this many seconds of the same voice, read from +# the voice's local wav and sent as a base64 voice_ref (bounded by the +# server's 5 MiB inline-reference limit). +AUDIOCPP_REFERENCE_TRIM_SECONDS = 30.0 +_AUDIOCPP_VOICE_REF_MAX_BYTES = 5 * 1024 * 1024 + +# Warn about a nearly-full local GPU before the first request: with +# another process holding the memory, even small graph allocations fail. +_LOW_FREE_DEVICE_MIB = 4096 + # Spec cache (family -> parsed spec dict, or None for unknown). The form # consults the policy and capability tags on every menu render, so each # family's spec is read at most once per process. @@ -204,6 +288,27 @@ def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: return {str(task) for task in spec["tasks"]} +def spec_request_option_names(family: str) -> Set[str]: + """FAMILY's accepted request-option names from its local model spec. + + Used to decide whether an option may be attached to a request (e.g. + the reference_text carried alongside an inline voice_ref): families + whose runtime validates request options against the spec would reject + an unknown key outright. An unknown family (no local spec) yields an + empty set — the caller then omits the option rather than risking a + rejection. + """ + spec = _family_spec(family) + if not spec: + return set() + options = spec.get("options") + request = options.get("request") if isinstance(options, dict) else None + if not isinstance(request, list): + return set() + return {str(item["name"]) for item in request + if isinstance(item, dict) and item.get("name")} + + def audiocpp_entry_supports_design(family: str, task: str, model_id: str) -> bool: """Whether a server model entry can design a voice from a description. @@ -241,12 +346,15 @@ def audiocpp_family_voice_policy(family: str) -> str: 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. + wrongly claim "tts" — always need a reference voice, as do the + AUDIOCPP_VOICE_REQUIRED_FAMILIES whose plain-TTS route demands a + timbre reference despite the spec not declaring a clone task (Vevo2's + zero-shot TTS route). 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: + or family in AUDIOCPP_CLONE_ONLY_FAMILIES \ + or family in AUDIOCPP_VOICE_REQUIRED_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 @@ -324,7 +432,8 @@ def _reference_text_error(voice: Optional[str], server_message: str) -> str: def audiocpp_request_error(status: int, detail: str, - voice: Optional[str] = None) -> Exception: + voice: Optional[str] = None, + log_note: Optional[str] = None) -> Exception: """The exception for a failed audio.cpp speech request. Deterministic request-configuration errors (a fragment in @@ -333,30 +442,206 @@ def audiocpp_request_error(status: int, detail: str, hosting errors (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host hint, hinted errors (AUDIOCPP_HINTED_ERRORS) their per-fragment guidance; everything else returns the plain RuntimeError the retry - loop has always retried. + loop has always retried. LOG_NOTE, when given for an allocation + failure, appends the server log's own record of the failed + allocation (the exact size it attempted, from the managed server's + log file) to the non-retryable message. """ message = _server_error_message(detail) lowered = message.lower() + allocation_failure = any( + fragment in lowered for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS) + error: Exception if _REFERENCE_TEXT_FRAGMENT in lowered: - return NonRetryableTTSError( + error = NonRetryableTTSError( _reference_text_error(voice, message)) - if any(fragment in lowered for fragment in AUDIOCPP_CLONE_ONLY_ERRORS): - return NonRetryableTTSError( + elif any(fragment in lowered for fragment in AUDIOCPP_CLONE_ONLY_ERRORS): + error = 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.") - for fragment, hint in AUDIOCPP_HINTED_ERRORS: - if fragment in lowered: - return NonRetryableTTSError( + else: + hinted = next( + (hint for fragment, hint in AUDIOCPP_HINTED_ERRORS + if fragment in lowered), None) + if hinted is not None: + error = NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " - f"{message}. {hint}") - if any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): - return NonRetryableTTSError( - f"audio.cpp server returned HTTP {status} (not retryable): " - f"{message}") - return RuntimeError(f"audio.cpp server returned HTTP {status}: {detail}") + f"{message}. {hinted}") + elif any(fragment in lowered + for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): + error = NonRetryableTTSError( + f"audio.cpp server returned HTTP {status} (not retryable): " + f"{message}") + else: + error = RuntimeError( + f"audio.cpp server returned HTTP {status}: {detail}") + if allocation_failure and log_note \ + and isinstance(error, NonRetryableTTSError): + error = NonRetryableTTSError(f"{error}{log_note}") + return error + + +def allocation_log_note(message: str) -> str: + """The managed server's own record of a failed allocation, best-effort. + + ggml logs every failed backend-buffer allocation with the exact size + it attempted and the device ("allocating N MiB on device D: cudaMalloc + failed"), while the server's HTTP 500 only carries the model's one-line + message — so the log is where the number lives. Only meaningful for + the locally managed server (app/logs/audiocpp-server.log); remote + servers, or a moved/rotated log, yield "" and the message stays as-is. + """ + lowered = message.lower() + if not any(fragment in lowered + for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS): + return "" + try: + # Imported lazily: backends.audiocpp imports this package, so a + # module-level import would cycle. + from backends import servers as _servers + path = _servers.server_log_path(_AUDIOCPP_SERVER_LOG_NAME) + except Exception: # noqa: BLE001 - diagnostics only, never fatal + return "" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + patterns = ("cudaMalloc failed", "not enough space in the buffer", + "failed to allocate") + matches = [line.strip() for line in text.splitlines() + if any(pattern in line for pattern in patterns)] + if not matches: + return "" + return (f" The server's log ({path}) records the failed allocation as: " + f"{matches[-1]}") + + +def nvidia_device_memory_report() -> Optional[str]: + """Local NVIDIA GPUs' total/free memory as one CSV block, or None. + + Runs ``nvidia-smi --query-gpu=index,memory.total,memory.free`` once; + None when the tool is missing (non-NVIDIA machines), times out, or + reports an error. Used by the client to warn about a nearly-full GPU + before the first request — with another process holding the memory, + even small graph allocations fail. + """ + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=index,memory.total,memory.free", + "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + return result.stdout.strip() + + +def _pcm16_mono_samples(path: Path, max_seconds: float + ) -> Optional[Tuple[int, List[int]]]: + """The first MAX_SECONDS of a wav as (sample rate, mono PCM16 samples). + + Reads with the stdlib wave module (PCM u8/s16/s24/s32), mixes channels + by averaging, and stops at MAX_SECONDS so a long reference costs only + the frames actually sent. Returns None for files the wave module + cannot parse (float-format wavs, non-WAV files) or that carry no + samples — the trimmed-reference retry then does not fire. + """ + max_frames = 0 + if max_seconds > 0: + try: + with wave.open(str(path), "rb") as probe: + rate = probe.getframerate() + max_frames = int(max_seconds * rate) + 1 + except (OSError, EOFError, wave.Error): + return None + try: + with wave.open(str(path), "rb") as handle: + rate = handle.getframerate() + channels = handle.getnchannels() + width = handle.getsampwidth() + frames = handle.getnframes() + raw = handle.readframes(min(frames, max_frames) if max_frames + else frames) + except (OSError, EOFError, wave.Error): + return None + if rate <= 0 or channels <= 0 or width not in (1, 2, 3, 4) or not raw: + return None + frame_bytes = width * channels + frame_count = len(raw) // frame_bytes + if frame_count <= 0: + return None + if width == 2: + values = array.array("h") + values.frombytes(raw[:frame_count * frame_bytes]) + if sys.byteorder == "big": + values.byteswap() + elif width == 1: + # u8 -> s16, scaled to the full 16-bit range. + values = array.array("h", ((byte - 128) << 8 + for byte in raw[:frame_count])) + else: + # s24/s32 -> s16 by dropping the low bits (keeps every value inside + # int16 so the mixdown and the PCM16 container need no clipping). + values = array.array( + "i", (int.from_bytes(raw[i * width:i * width + width], + "little", signed=True) + for i in range(frame_count))) + shift = 8 if width == 3 else 16 + values = array.array("h", (value >> shift for value in values)) + if channels == 1: + return rate, values.tolist() + mixed: List[int] = [] + for index in range(frame_count): + start = index * channels + mixed.append(sum(values[start:start + channels]) // channels) + return rate, mixed + + +def pcm16_wav_bytes(sample_rate: int, samples: List[int]) -> bytes: + """Wrap mono PCM16 samples in a minimal RIFF/WAVE container.""" + data = array.array("h", samples) + if sys.byteorder == "big": + data.byteswap() + payload = data.tobytes() + return (b"RIFF" + + struct.pack("<I", 36 + len(payload)) + + b"WAVEfmt " + struct.pack("<IHHIIHH", 16, 1, 1, + sample_rate, sample_rate * 2, 2, 16) + + b"data" + struct.pack("<I", len(payload)) + + payload) + + +def build_trimmed_voice_reference( + path: Optional[Path], max_seconds: float = AUDIOCPP_REFERENCE_TRIM_SECONDS, + max_bytes: int = _AUDIOCPP_VOICE_REF_MAX_BYTES + ) -> Optional[Tuple[str, float, str]]: + """A base64 voice_ref of the first MAX_SECONDS of a reference wav. + + Returns (base64 wav, seconds used, file name), or None when PATH is + missing or unreadable. The payload keeps the file's native sample rate + and is mixed to mono; it is further capped so the decoded bytes stay + within the server's 5 MiB inline-reference limit (16-bit mono means + ~2.6 MB per 30 s at 44.1 kHz, comfortably inside). + """ + if path is None: + return None + decoded = _pcm16_mono_samples(path, max_seconds) + if decoded is None: + return None + rate, samples = decoded + if not samples: + return None + byte_cap_seconds = max_bytes / (2 * rate) + frames = int(min(max_seconds, byte_cap_seconds) * rate) + samples = samples[:frames] + if not samples: + return None + wav = pcm16_wav_bytes(rate, samples) + return base64.b64encode(wav).decode("ascii"), len(samples) / rate, path.name class AudioCppFamilyProfile: @@ -443,6 +728,12 @@ def audiocpp_entry_voice_capability(family: str, task: str, class AudioCppTTSClient(BaseTTSClient): + # Class-level defaults so a partially-constructed instance behaves like + # a fresh run (tests build clients via __new__; see BaseTTSClient). + _voice_ref_b64: Optional[str] = None + _voice_ref_reference_text: Optional[str] = None + _reference_trim_attempted = False + """Generates audio chunks through an audio.cpp audiocpp_server. Talks to the OpenAI-style HTTP API of audiocpp_server, which hosts TTS @@ -559,6 +850,15 @@ class AudioCppTTSClient(BaseTTSClient): self.design_mode = False self.instruction_voice = False self.plain_mode = False + # Trimmed-reference retry state (see _switch_to_trimmed_reference): + # an inline base64 voice_ref that replaces the voice name after an + # allocation failure, the transcript carried alongside it, and the + # one-attempt guard. The class-level defaults above keep partially + # constructed instances (tests via __new__) behaving like a fresh + # run; the assignments here shadow them for this instance. + self._voice_ref_b64 = None + self._voice_ref_reference_text = None + self._reference_trim_attempted = 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 = "" @@ -653,29 +953,33 @@ class AudioCppTTSClient(BaseTTSClient): f"'{self.family}') serves built-in speakers: pass " "--voice NAME with one of them (e.g. Vivian, Ryan, " "Uncle Fu) to synthesize with it (see README).") + elif audiocpp_family_voice_policy(self.family) \ + == AUDIOCPP_VOICE_REQUIRED: + # Checked before the instruction-voice branch: a family + # whose synthesis needs a reference voice (clone-only, + # Vevo2's zero-shot route) cannot take its voice from an + # instruction, so refuse with the fix instead of failing + # every request server-side. + 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 select the CustomVoice entry for built-in " + "speakers (see README).") 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}") - elif audiocpp_family_voice_policy(self.family) in ( - AUDIOCPP_VOICE_OPTIONAL, AUDIOCPP_VOICE_NONE): + else: # 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 " - 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 " @@ -685,6 +989,7 @@ class AudioCppTTSClient(BaseTTSClient): else self._unload_models_override) if unload: self._unload_server_models() + self._warn_low_device_memory() def _require_synthesis_task(self, models: List[Dict[str, str]]) -> None: """Reject model entries that cannot synthesize narration from text. @@ -927,6 +1232,168 @@ class AudioCppTTSClient(BaseTTSClient): ) # ------------------------------------------------------------------ + # Reference trimming and device diagnostics + # ------------------------------------------------------------------ + + def _warn_low_device_memory(self) -> None: + """Warn once when a local GPU is nearly full before the first request. + + Graph-allocation failures read as "out of memory" even when the + card has gigabytes free for the model itself — what matters is the + free memory at request time, which other processes can hold. A + one-time nvidia-smi query (local hosts only; skipped silently + elsewhere or when the tool is missing) turns that case into an + explicit warning instead of a mysterious 500. + """ + host = (urllib.parse.urlparse(self.api_url).hostname or "").lower() + if host not in ("127.0.0.1", "localhost", "::1"): + return + report = nvidia_device_memory_report() + if not report: + return + for row in report.splitlines(): + parts = [part.strip() for part in row.split(",")] + if len(parts) < 3: + continue + try: + index = int(parts[0]) + total = int(parts[1]) + free = int(parts[2]) + except ValueError: + continue + if free < _LOW_FREE_DEVICE_MIB: + self._report( + f"[WARNING] GPU {index} has {free} MiB free of " + f"{total} MiB — allocation failures under this " + "condition usually mean another process is using " + "the GPU.") + + def _voice_wav_path(self) -> Optional[Path]: + """The selected voice's reference wav on this machine, when readable. + + Resolved from the local checkout's server.json exactly like the + server resolves the request's voice name: the entry's + ``voice_presets[name].voice_ref`` first, then ``voice_dir/<name>.wav``. + Only preset-mode runs with a locally readable file return a path — + remote-only servers (or voice names that only exist server-side) + yield None and the trimmed-reference retry does not fire. + """ + if not self.voice: + return None + name = self.voice + if not name or name in (".", "..") or "/" in name or "\\" in name: + return None + try: + # Imported lazily: backends.audiocpp imports this package, 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 + return None + if checkout is None: + return None + server_json = checkout / "server.json" + try: + data = json.loads(server_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + for entry in data.get("models") or []: + if not isinstance(entry, dict) or entry.get("id") != self.model_id: + continue + presets = entry.get("voice_presets") + if isinstance(presets, dict): + preset = presets.get(name) + if isinstance(preset, dict): + ref = preset.get("voice_ref") + if isinstance(ref, str) and ref: + path = Path(ref) + if not path.is_absolute(): + path = server_json.parent / ref + if path.is_file(): + return path + voice_dir = data.get("voice_dir") + if isinstance(voice_dir, str) and voice_dir: + candidate = Path(voice_dir) / f"{name}.wav" + if candidate.is_file(): + return candidate + return None + + def _voice_transcript(self) -> Optional[str]: + """The voice library transcript for the selected voice, or None. + + Read from the voice directory's prompt_text mapping (the same file + the server consults when it resolves a voice NAME); only carried + alongside an inline voice_ref, where the server's own injection is + bypassed. + """ + if not self.voice: + return None + try: + # Imported lazily: backends.audiocpp imports this package, so a + # module-level import would cycle. + from backends.common import PROMPT_TEXT_FILENAME, read_prompt_text + from backends.audiocpp.build import find_local_checkout + checkout = find_local_checkout() + except Exception: # noqa: BLE001 - best effort + return None + if checkout is None: + return None + try: + data = json.loads((checkout / "server.json") + .read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + voice_dir = data.get("voice_dir") if isinstance(data, dict) else None + if not isinstance(voice_dir, str) or not voice_dir: + return None + try: + return (read_prompt_text(Path(voice_dir) / PROMPT_TEXT_FILENAME) + .get(self.voice) or None) + except OSError: + return None + + def _switch_to_trimmed_reference(self, server_message: str) -> bool: + """Switch the cloning reference to a trimmed local wav, once. + + Some families encode the whole reference with attention over its + length (MOSS-TTS-Local's codec encoder: the required memory grows + with the reference's square), so a long voice reference fails the + graph allocation regardless of how much VRAM the device has. When + the selected voice resolves to a locally readable wav, replace the + voice name with an inline base64 voice_ref cut to + AUDIOCPP_REFERENCE_TRIM_SECONDS and retry the request once; the + trimmed reference then applies to the rest of the run. Only fires + on allocation-failure messages for preset-mode runs; everything + else keeps the original behavior. + """ + lowered = server_message.lower() + if self._reference_trim_attempted \ + or not any(fragment in lowered + for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS) \ + or self.design_mode or self.instruction_voice \ + or self.plain_mode or not self.voice: + return False + self._reference_trim_attempted = True + trimmed = build_trimmed_voice_reference(self._voice_wav_path()) + if trimmed is None: + return False + b64, seconds, source = trimmed + self._voice_ref_b64 = b64 + transcript = self._voice_transcript() + if transcript and "reference_text" in spec_request_option_names( + self.family): + self._voice_ref_reference_text = transcript + self._report( + f"[INFO] {source}'s family encodes the whole reference with " + f"attention over its length; retrying with the first " + f"{seconds:.0f}s of voice '{self.voice}' as the cloning " + "reference (the trimmed reference applies to the rest of this " + "run).") + return True + + # ------------------------------------------------------------------ # HTTP requests # ------------------------------------------------------------------ @@ -946,8 +1413,13 @@ 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; - # plain-TTS runs (no reference voice needed) omit it likewise. - if not self.design_mode and not self.instruction_voice \ + # plain-TTS runs (no reference voice needed) omit it likewise. A + # trimmed-reference retry replaces the voice name with an inline + # base64 voice_ref (see _switch_to_trimmed_reference). + if self._voice_ref_b64 is not None: + payload["voice_ref"] = {"type": "base64", + "data": self._voice_ref_b64} + elif 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: @@ -968,10 +1440,17 @@ class AudioCppTTSClient(BaseTTSClient): # Explicit voice-design or style instruction (required for task # "vdes" entries; a Ctrl/style control on families that read it). payload["instructions"] = self.instructions - if self.request_options: + options = dict(self.request_options) + if self._voice_ref_reference_text \ + and "reference_text" not in options: + # The server only injects the voice library's transcript when it + # resolves the voice NAME; an inline voice_ref bypasses that, so + # carry the transcript explicitly for families that accept it. + options["reference_text"] = self._voice_ref_reference_text + if options: # Generic per-model controls (--option KEY=VALUE): forwarded # verbatim; the model ignores keys it does not know. - payload["options"] = dict(self.request_options) + payload["options"] = options request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST") @@ -985,8 +1464,15 @@ class AudioCppTTSClient(BaseTTSClient): detail = exc.read().decode("utf-8", errors="replace")[:200] except Exception: pass + message = _server_error_message(detail) + if self._switch_to_trimmed_reference(message): + # One retry with the trimmed reference; if that fails too the + # error below carries the server log's allocation detail. + return self._request_wav(text) raise audiocpp_request_error(exc.code, detail, - voice=self.voice) from exc + voice=self.voice, + log_note=allocation_log_note( + message)) 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": |
