From dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 1 Sep 2026 12:12:35 -0400 Subject: fix: spec santizer for glm, outetts, miotts, minimax. --- app/backends/audiocpp/catalog.py | 314 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 307 insertions(+), 7 deletions(-) (limited to 'app/backends/audiocpp/catalog.py') diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py index 1743d15..1048185 100644 --- a/app/backends/audiocpp/catalog.py +++ b/app/backends/audiocpp/catalog.py @@ -3,9 +3,11 @@ import json import re import sys +import wave from pathlib import Path from typing import Dict, List, Optional, Set, Tuple +from backends.common import find_wav_files from converter.clients import AUDIOCPP_CLONE_ONLY_FAMILIES, audiocpp_family_spec_tasks from .constants import TASK_CLON, TASK_TTS, TASK_VDES @@ -167,14 +169,117 @@ def _default_package(packages: List[dict]) -> Optional[dict]: return packages[0] +def spec_gguf_rooted(spec: dict) -> bool: + """True when SPEC's gguf source resolves its weights from ``$gguf``. + + The ``$gguf`` root is audio.cpp's single-GGUF convention: the model + directory must hold exactly one top-level GGUF (or ``model.gguf``), + which then carries the weights. Packages that feed such a source must + therefore install their GGUF at the top of the target directory, not + nested under a repository subdirectory. + """ + for source in spec.get("sources") or []: + if not isinstance(source, dict) or source.get("format") != "gguf": + continue + roots = source.get("roots") + if isinstance(roots, dict) and any( + value == "$gguf" for value in roots.values() + if isinstance(value, str)): + return True + return False + + +def package_common_prefix(files: list) -> Optional[str]: + """The one directory prefix every file path in FILES shares, or None. + + Only exact single-component matches count: every file must contain a + ``/`` and start with the same first component. A flat package (no ``/`` + at all) or a mixed one (some files under ``config/``, some at the root, + like minimax_music3's intentional multi-file layout) yields None. + """ + prefixes: List[str] = [] + for item in files: + if not isinstance(item, str) or "/" not in item: + return None + prefix = item.split("/", 1)[0] + if not prefix: + return None + prefixes.append(prefix) + if not prefixes: + return None + first = prefixes[0] + if first in (".", ".."): + return None + return first if all(prefix == first for prefix in prefixes) else None + + +def sanitize_model_spec(spec: dict) -> bool: + """Repair broken package ``strip_prefix`` entries in SPEC, in place. + + Two upstream spec bug classes have shipped in the audio.cpp checkout, + and both make the model manager install files where the server cannot + find them: + + - A dot ``strip_prefix`` ("." or "./") is meant for files written + ``./``; when the package instead lists repo-root files bare + (``model.gguf``), the manager rejects the whole package ("file path + does not start with strip_prefix '.': ...") and nothing can be + downloaded. Root-level files need no prefix at all (upstream specs + like minimax_music3.json store ""), so dropping the dot prefix is + the safe repair. Prefixes naming a real directory are left alone — + the correct remote paths cannot be guessed. + + - A package with NO strip_prefix whose files all nest under one + repository directory (``Text to audio (TTS)/GLM-TTS_Q8.gguf`` — + glm_tts and outetts shipped like this) installs the GGUF under that + subdirectory. The server then finds no top-level GGUF and falls back + to the safetensors source, failing on a companion file the GGUF + package never ships ("missing model package file 'tokenizer_merges'"). + When the spec's gguf source resolves weights from ``$gguf`` (the + single-GGUF convention) and every file shares one directory prefix, + that prefix is what the strip_prefix should have been, so set it. + Packages whose gguf source names tensors by explicit file paths + (minimax_music3's nested multi-GGUF layout) are left untouched. + + Returns True when SPEC changed. + """ + changed = False + for package in spec.get("packages") or []: + if not isinstance(package, dict): + continue + prefix = str(package.get("strip_prefix") or "").rstrip("/") + if prefix in (".", ".."): + files = package.get("files") + if isinstance(files, list) and files \ + and all(isinstance(item, str) + and item.startswith(prefix + "/") + for item in files): + continue + package["strip_prefix"] = "" + changed = True + continue + if prefix or package.get("format") != "gguf" \ + or not spec_gguf_rooted(spec): + continue + common = package_common_prefix(package.get("files") or []) + if common is None: + continue + package["strip_prefix"] = common + changed = True + return changed + + def load_model_catalog(audiocpp_dir: Path) -> List[dict]: """Read model_specs/*.json and return the TTS-capable families. Each returned entry has: family, display_name, description, languages, - clone_capable, packages (the full list from the spec), install_id - (recommended package id), and default_path (``models/``). - All families are treated equally and listed in alphabetical order by - display name. + clone_capable, packages (the full list from the spec, with broken + ``strip_prefix`` entries repaired in memory via sanitize_model_spec — + the checkout's files are never written back; the download path + materializes the same repair through its sanitized specs copy), + install_id (recommended package id), and default_path + (``models/``). All families are treated equally and + listed in alphabetical order by display name. """ specs_dir = audiocpp_dir / "model_specs" if not specs_dir.is_dir(): @@ -187,6 +292,7 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]: spec = json.loads(spec_path.read_text(encoding="utf-8")) except (OSError, ValueError): continue + sanitize_model_spec(spec) tasks = spec.get("tasks") or [] if tasks: # A task list that names no text-synthesis capability means the @@ -342,21 +448,72 @@ def package_dir_options(entry: dict) -> List[dict]: return options +def entry_model_path(entry: dict, target_directory: Optional[str] = None) -> str: + """The server.json model path that hosts ENTRY's package for TARGET_DIRECTORY. + + ``models/`` normally (the recommended package's + directory when TARGET_DIRECTORY is None). audio.cpp only loads a model + directory that holds exactly one top-level GGUF, so a package that + ships several GGUFs into one directory (MiniMax-H3's text-encoder / + DiT / audio-VAE / video-VAE bundle) is hosted from its first GGUF + file instead: the explicit file path makes the server pick the gguf + source, and the directory stays the model root that the spec's named + tensors resolve from. + """ + directory = target_directory + if directory is None: + default_path = str(entry.get("default_path") or "") + directory = default_path[len("models/"):] \ + if default_path.startswith("models/") else default_path + if not directory: + directory = str(entry.get("family") or "") + package = next( + (item for item in entry.get("packages") or [] + if isinstance(item, dict) + and str(item.get("target_directory") or entry.get("family")) == directory + and item.get("format") == "gguf"), + None) + if package is None: + return f"models/{directory}" + prefix = str(package.get("strip_prefix") or "").rstrip("/") + ggufs: List[str] = [] + for item in package.get("files") or []: + if not isinstance(item, str) or not item.lower().endswith(".gguf"): + continue + local = item + if prefix and local.startswith(prefix + "/"): + local = local[len(prefix) + 1:] + elif prefix: + continue + ggufs.append(local) + if len(ggufs) > 1: + return f"models/{directory}/{ggufs[0]}" + return f"models/{directory}" + + def build_model_entry(family: str, model_id: str, model_path: str, - task: str = TASK_TTS) -> dict: + task: str = TASK_TTS, + session_options: Optional[Dict[str, str]] = None) -> dict: """Assemble one server.json model entry. ``task`` defaults to "tts"; voice design packages are hosted with "vdes" so the server runs its design session for speech requests (audiobook.py then requires --instructions with that entry). + SESSION_OPTIONS carries per-entry session-level options the server + applies at session creation (e.g. MioTTS's codec model path, or a + VoxCPM AudioVAE encoder capacity sized for long voice references); + an empty mapping is omitted so the entry keeps its minimal shape. """ - return { + entry = { "id": model_id, "family": family, "path": model_path, "task": task, "mode": "offline", } + if session_options: + entry["session_options"] = dict(session_options) + return entry def build_server_config(host: str, port: int, backend: str, lazy_load: bool, @@ -423,7 +580,14 @@ def server_config_selections(server_config: dict, path = entry.get("path") if not isinstance(path, str): continue - target = path[len("models/"):] if path.startswith("models/") else path + rel = path[len("models/"):] if path.startswith("models/") else path + # Entries hosted from a specific model file (e.g. MiniMax-H3's + # multi-GGUF directory, "models//") belong to the + # directory's catalog option, not to a made-up nested one. Unprefixed + # and absolute paths keep their raw target as before (the wizard's + # valid-directory check filters those it cannot offer again). + target = rel.split("/", 1)[0] \ + if path.startswith("models/") and "/" in rel else rel if family not in selected_dirs: selected_dirs[family] = [] if target not in selected_dirs[family]: @@ -432,3 +596,139 @@ def server_config_selections(server_config: dict, return selected_dirs, tasks + + +# --------------------------------------------------------------------------- +# Session options the setup bakes into server.json entries +# --------------------------------------------------------------------------- + +# MioTTS loads its MioCodec companion through the ``miotts.codec_model_path`` +# session option: the server's built-in default looks for the codec as a +# sibling of the GGUF's materialized sidecar root (/tmp/audiocpp-gguf/...), +# which is never where the model manager installs it. Pointing the option at +# the installed codec package directory is the supported path, so the wizard +# writes it (and downloads the codec alongside the model — it is an +# audio_tools family, not a TTS one, so the model catalog never offers it). +MIOTTS_CODEC_INSTALL_ID = "miocodec_q8_0" +MIOTTS_CODEC_DIRECTORY = "MioCodec-25Hz-44.1kHz-v2-GGUF" +MIOTTS_CODEC_MODEL_PATH = f"models/{MIOTTS_CODEC_DIRECTORY}" +MIOTTS_CODEC_DISPLAY_NAME = "MioCodec 25Hz 44.1kHz v2 (required by MioTTS)" + +# VoxCPM-style AudioVAE encoders cap the reference audio they encode at a +# fixed sample count (240000 samples ≈ 15 s at the VAE's 16 kHz rate, per +# the audio.cpp runtime). A voice reference longer than that fails every +# cloning request ("sample capacity exceeded"). The session option below +# raises the cap; the wizard sizes it to the longest wav in the voice +# directory so the configured voices all work without hand-trimming. +VOXCPM_ENCODER_CAPACITY_DEFAULT_SAMPLES = 240_000 +VOXCPM_ENCODER_CAPACITY_SAMPLE_RATE = 16_000 +VOXCPM_ENCODER_CAPACITY_MAX_SAMPLES = 240_000 * 20 +# Fallback key when a family's spec does not name the option (older +# checkouts may lack voxcpm1.json; the spec is preferred when present). +VOXCPM_ENCODER_CAPACITY_FALLBACK_KEYS = { + "voxcpm2": "voxcpm2.audiovae_encoder_sample_capacity", + "voxcpm1": "voxcpm1.audiovae_encoder_sample_capacity", +} + + +def wav_seconds(path: Path) -> Optional[float]: + """A PCM WAV file's duration in seconds, or None when unreadable. + + The stdlib wave module handles the PCM variants voice references use; + float-format or non-WAV files (mp3 renamed, exotic headers) yield None + so callers skip them instead of guessing a duration. + """ + try: + with wave.open(str(path), "rb") as handle: + frames = handle.getnframes() + rate = handle.getframerate() + except (OSError, EOFError, wave.Error): + return None + if rate <= 0 or frames <= 0: + return None + return frames / rate + + +def voxcpm_encoder_capacity_option(family: str, audiocpp_dir: Path) -> Optional[str]: + """FAMILY's AudioVAE encoder-capacity session option key, or None. + + Discovered from the family's model spec (the option whose name carries + ``encoder_sample_capacity``), falling back to the known VoxCPM keys so + a spec-less family still gets the right option name rather than a + broken server.json entry. + """ + try: + spec = json.loads( + (audiocpp_dir / "model_specs" / f"{family}.json") + .read_text(encoding="utf-8")) + except (OSError, ValueError): + spec = None + if isinstance(spec, dict): + options = spec.get("options") + session = options.get("session") if isinstance(options, dict) else None + if isinstance(session, list): + for item in session: + name = item.get("name") if isinstance(item, dict) else None + if isinstance(name, str) and "encoder_sample_capacity" in name: + return name + return VOXCPM_ENCODER_CAPACITY_FALLBACK_KEYS.get(family) + + +def voxcpm_encoder_capacity_samples(max_seconds: Optional[float]) -> Optional[int]: + """The encoder-sample capacity that fits MAX_SECONDS, or None. + + Rounds up to a multiple of the model's default 240000-sample capacity + and clamps to a generous ceiling (8 minutes) so an absurdly long + reference cannot push VRAM through the roof; anything at or below the + default needs no override at all. + """ + if max_seconds is None or max_seconds <= 0: + return None + default = VOXCPM_ENCODER_CAPACITY_DEFAULT_SAMPLES + needed = max_seconds * VOXCPM_ENCODER_CAPACITY_SAMPLE_RATE + samples = ((int(needed) + default - 1) // default) * default + if samples <= default: + return None + return min(samples, VOXCPM_ENCODER_CAPACITY_MAX_SAMPLES) + + +def apply_entry_session_options(model_entries: List[dict], + wav_dir: Optional[Path], + audiocpp_dir: Path) -> List[str]: + """Add the per-entry session options heavy families need, in place. + + MioTTS entries get ``miotts.codec_model_path`` (see MIOTTS_CODEC_*: + the server's default codec path cannot be satisfied by a normal + install). VoxCPM-family entries get an AudioVAE encoder-sample + capacity sized to the longest wav in WAV_DIR, so voice references + longer than the model's built-in 15 s ceiling still clone (returned + as None when the voice directory is unknown, unreadable, or only + holds short wavs — the default then applies as before). Existing + session options are preserved; a hand-set codec path is never + overridden. Returns the entry ids that gained options (for the + wizard's summary line), in order. + """ + max_seconds: Optional[float] = None + if wav_dir is not None: + for path in find_wav_files(Path(wav_dir)): + seconds = wav_seconds(path) + if seconds is not None and (max_seconds is None + or seconds > max_seconds): + max_seconds = seconds + applied: List[str] = [] + for entry in model_entries: + family = str(entry.get("family") or "") + current = dict(entry.get("session_options") or {}) + options = dict(current) + if family == "miotts" \ + and "miotts.codec_model_path" not in options: + options["miotts.codec_model_path"] = MIOTTS_CODEC_MODEL_PATH + if family.startswith("voxcpm") and max_seconds is not None: + key = voxcpm_encoder_capacity_option(family, audiocpp_dir) + samples = voxcpm_encoder_capacity_samples(max_seconds) + if key and samples: + options[key] = str(samples) + if options != current: + entry["session_options"] = options + applied.append(str(entry.get("id") or family)) + return applied -- cgit v1.2.3