aboutsummaryrefslogtreecommitdiff
path: root/app/backends/audiocpp/catalog.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
committerhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
commitdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (patch)
tree0eb951f174d91d6b4c96c9b4ea4e978d1bdfc8cd /app/backends/audiocpp/catalog.py
parentd15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (diff)
downloadtts-audiobook-generator-dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d.tar.gz
fix: spec santizer for glm, outetts, miotts, minimax.
Diffstat (limited to 'app/backends/audiocpp/catalog.py')
-rw-r--r--app/backends/audiocpp/catalog.py314
1 files changed, 307 insertions, 7 deletions
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
+ ``./<file>``; 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/<target_directory>``).
- 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/<target_directory>``). 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/<target_directory>`` 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/<dir>/<gguf>") 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