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/__init__.py | 4 + app/backends/audiocpp/catalog.py | 314 +++++++++++++++++++++++++++++++++++++- app/backends/audiocpp/models.py | 297 ++++++++++++++++++++++++++--------- app/backends/audiocpp/wizard.py | 62 +++++--- 4 files changed, 585 insertions(+), 92 deletions(-) (limited to 'app/backends/audiocpp') diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index 64122c9..3344eb5 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -30,11 +30,13 @@ from .constants import ( ) from .catalog import ( detect_backend, + entry_model_path, is_clone_only_family, is_design_package, hosting_task, load_model_catalog, package_dir_options, + sanitize_model_spec, build_model_entry, build_server_config, load_server_config, @@ -42,6 +44,7 @@ from .catalog import ( rehost_clone_only_entries, request_options_families, supports_request_options, + apply_entry_session_options, ) from .models import ( delete_model_files, @@ -100,6 +103,7 @@ __all__ = [ "build_model_entry", "build_server_config", "load_server_config", "server_config_selections", "rehost_clone_only_entries", "request_options_families", "supports_request_options", + "entry_model_path", "sanitize_model_spec", "apply_entry_session_options", # models "missing_model_entries", "installed_model_entries", "unused_installed_entries", "delete_model_files", "install_models", 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 diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py index d81dcfc..16c61e9 100644 --- a/app/backends/audiocpp/models.py +++ b/app/backends/audiocpp/models.py @@ -16,6 +16,11 @@ from . import catalog as _catalog # runner kills it and reports exit 124 (see run_console_subprocess). DOWNLOAD_STALL_TIMEOUT = 300 +# Spec sanitizing lives with the catalog (the wizard's catalog view applies +# the same in-memory repair; the download path materializes it through its +# sanitized specs copy). The alias keeps this module's historical name. +_sanitize_model_spec = _catalog.sanitize_model_spec + def _installed_display_names(audiocpp_dir: Path, model_entries: Optional[List[dict]], install_guidance: List[Tuple[str, str]] @@ -25,19 +30,19 @@ def _installed_display_names(audiocpp_dir: Path, MODEL_ENTRIES and INSTALL_GUIDANCE are built in lockstep by ``_build_entries`` (one guidance pair per entry), so the pairs resolve positionally: each entry's ``path`` is checked against the checkout - exactly like ``_all_models_present`` resolves it. Returns an empty set + file-precisely (see ``_entry_present``). Returns an empty set when ENTRIES is None or does not line up with the guidance (no filtering — every model counts as not installed). """ if model_entries is None or len(model_entries) != len(install_guidance): return set() + packages_by_dir = _catalog_packages_by_dir(audiocpp_dir) installed: Set[str] = set() for entry, (name, _install_id) in zip(model_entries, install_guidance): rel = entry.get("path") if not isinstance(rel, str) or not rel: continue - path = Path(rel) if Path(rel).is_absolute() else audiocpp_dir / rel - if _model_path_present(path): + if _entry_present(audiocpp_dir, rel, packages_by_dir): installed.add(name) return installed @@ -67,10 +72,40 @@ def _split_pending_and_installed( return pending, noted +def _merge_companions(audiocpp_dir: Path, + pending: List[Tuple[str, str]], + companions: Optional[List[Tuple[str, str]]] + ) -> List[Tuple[str, str]]: + """Fold COMPANIONS into PENDING, skipping ones already on disk. + + Each companion is a (display name, install id) pair for a package a + hosted model requires but the TTS catalog never offers (MioCodec for + MioTTS). Companions already pending (same install id) or already + installed (their package's files present, checked file-precisely) are + dropped; the installed ones are reported so the user knows the + requirement is satisfied. Companion ids with no matching spec package + are kept (the install command will report what is wrong). + """ + merged = list(pending) + seen = {install_id for _name, install_id in pending} + for name, install_id in companions or []: + if install_id in seen: + continue + package = _companion_package(audiocpp_dir, install_id) + if package is not None \ + and _package_files_present(audiocpp_dir, package): + print(f"[OK] {name} is already installed.") + continue + seen.add(install_id) + merged.append((name, install_id)) + return merged + + def _install_models(audiocpp_dir: Path, install_guidance: List[Tuple[str, str]], download: bool, emit=None, cancel=None, - model_entries: Optional[List[dict]] = None) -> int: + model_entries: Optional[List[dict]] = None, + companions: Optional[List[Tuple[str, str]]] = None) -> int: """Report and optionally run the model install commands. When MODEL_ENTRIES (built in lockstep with INSTALL_GUIDANCE by @@ -78,11 +113,16 @@ def _install_models(audiocpp_dir: Path, installed and never re-downloaded or printed as commands; when every selected model is present nothing runs at all. The remaining models get one ``python install `` command each (de-duped by - install id). When DOWNLOAD is True each command is run in the audio.cpp - checkout via ``subprocess`` so the models are downloaded automatically; - a failing install is reported as a warning and does not abort the - remaining downloads. When DOWNLOAD is False (or the model manager is - missing) the commands are only printed after a note that setup downloads + install id). COMPANIONS carries (display name, install id) pairs for + packages a hosted model needs but the TTS catalog does not offer + (MioCodec for MioTTS): they join the pending list, de-duped against + it and skipped when their package's files are already on disk — so a + configure run that only adds the companion still downloads it. When + DOWNLOAD is True each command is run in the audio.cpp checkout via + ``subprocess`` so the models are downloaded automatically; a failing + install is reported as a warning and does not abort the remaining + downloads. When DOWNLOAD is False (or the model manager is missing) + the commands are only printed after a note that setup downloads them automatically — copy-pasteable for a manual install. With EMIT given (the in-TUI task view) each download streams its output @@ -91,9 +131,10 @@ def _install_models(audiocpp_dir: Path, progress bar and cancel gracefully. CANCEL aborts a running download. When the checkout's model specs carry a broken ``strip_prefix`` (a dot - prefix over repo-root files — the manager rejects every file in such a - package) and the manager supports ``--specs-dir``, the installs run - against a sanitized temporary copy of the specs (see + prefix over repo-root files, or a single-GGUF package nested under a + repository directory with no prefix — the manager installs files the + server cannot load) and the manager supports ``--specs-dir``, the + installs run against a sanitized temporary copy of the specs (see ``_prepare_specs_dir``); the checkout itself is left untouched. Specs that cannot be repaired confidently are left as-is: those installs fail, are reported as warnings, and the remaining downloads continue. @@ -101,13 +142,14 @@ def _install_models(audiocpp_dir: Path, Returns 0 when every command succeeded (or nothing needed running), 130 when cancelled, 1 when any download failed. """ - if not install_guidance: + if not install_guidance and not companions: return 0 manager = audiocpp_dir / "tools" / "model_manager_v2.py" installed_names = _installed_display_names( audiocpp_dir, model_entries, install_guidance) pending, installed_noted = _split_pending_and_installed( install_guidance, installed_names) + pending = _merge_companions(audiocpp_dir, pending, companions) supports_progress = emit is not None and _manager_supports_progress(manager) @@ -295,18 +337,29 @@ def _prepare_specs_dir(audiocpp_dir: Path) -> Optional[Path]: return staging -def download_applicable(audiocpp_dir: Path, model_entries: List[dict]) -> bool: +def download_applicable(audiocpp_dir: Path, model_entries: List[dict], + companions: Optional[List[Tuple[str, str]]] = None + ) -> bool: """True when the wizard's "download models automatically?" row applies. The audio.cpp model manager must be present (otherwise the install commands can only be printed), and at least one selected model must be - missing from disk (see ``_all_models_present``), so an already-configured - checkout is not asked to re-download models it already has. + missing from disk (see ``_all_models_present``), or one COMPANION + package a hosted model needs (MioCodec for MioTTS) must be — so an + already-configured checkout is not asked to re-download models it + already has. """ manager = audiocpp_dir / "tools" / "model_manager_v2.py" if not manager.is_file(): return False - return not _all_models_present(audiocpp_dir, model_entries) + if not _all_models_present(audiocpp_dir, model_entries): + return True + for _name, install_id in companions or []: + package = _companion_package(audiocpp_dir, install_id) + if package is None \ + or not _package_files_present(audiocpp_dir, package): + return True + return False def _build_tree_families(catalog: List[dict]) -> List[dict]: @@ -357,33 +410,146 @@ def _model_path_present(path: Path) -> bool: return False +def _package_files_present(audiocpp_dir: Path, package: dict) -> bool: + """True when PACKAGE's files are on disk at their strip_prefix-stripped paths. + + Mirrors model_manager_v2.py's own ``package_is_installed`` check, which + is what decides a re-install. Used instead of the plain "directory is + non-empty" check so a stale install from a broken spec layout (e.g. the + GLM/OuteTTS GGUFs nested under ``Text to audio (TTS)/`` before the spec + sanitizer existed) counts as missing and gets re-downloaded correctly. + """ + files = package.get("files") or [] + if not files: + return False + prefix = str(package.get("strip_prefix") or "").rstrip("/") + base = audiocpp_dir / "models" / str(package.get("target_directory") or "") + for item in files: + if not isinstance(item, str): + return False + local = item + if prefix: + if not local.startswith(prefix + "/"): + return False + local = local[len(prefix) + 1:] + try: + if not (base / local).is_file(): + return False + except OSError: + return False + return True + + +def _entry_directory_key(rel: str) -> str: + """The ```` a server.json model path belongs to. + + ``models/`` entries map to ````; entries hosted from a file + inside a package directory (``models//``, the multi-GGUF + hosting convention) map to ```` as well, so old directory-style + and new file-style entries of the same package compare equal. + """ + stripped = rel[len("models/"):] if rel.startswith("models/") else rel + return stripped.split("/", 1)[0] + + +def _catalog_packages_by_dir(audiocpp_dir: Path) -> Dict[str, List[dict]]: + """Map each catalog package target directory to its packages. + + The catalog's packages carry the sanitized strip prefixes (see + ``load_model_catalog``), so presence checks see the same layout the + model manager will install. Families whose specs cannot be read yield + no entries and callers fall back to the plain path check. + """ + try: + entries = _catalog.load_model_catalog(audiocpp_dir) + except (NotADirectoryError, OSError): + return {} + by_dir: Dict[str, List[dict]] = {} + for entry in entries: + for package in entry.get("packages") or []: + if not isinstance(package, dict): + continue + directory = str(package.get("target_directory") or entry["family"]) + by_dir.setdefault(directory, []).append(package) + return by_dir + + +def _companion_package(audiocpp_dir: Path, install_id: str) -> Optional[dict]: + """The spec package with INSTALL_ID across every model spec. + + Companion packages live in specs the TTS catalog filters out + (miocodec is an audio_tools family with no text-synthesis task), so + the lookup scans all model_specs/*.json with the sanitizer applied, + matching the sanitized layout the download will use. + """ + try: + spec_paths = sorted((audiocpp_dir / "model_specs").glob("*.json")) + except OSError: + return None + for spec_path in spec_paths: + try: + spec = json.loads(spec_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(spec, dict): + continue + _catalog.sanitize_model_spec(spec) + for package in spec.get("packages") or []: + if isinstance(package, dict) \ + and str(package.get("id") or "") == install_id: + return package + return None + + +def _entry_present(audiocpp_dir: Path, rel: str, + packages_by_dir: Dict[str, List[dict]]) -> bool: + """Whether the model entry path REL holds its package's files. + + When REL's directory matches a catalog package, the check is + file-precise (every package file at its stripped path). Otherwise the + plain path check applies: the entry may host a package the local + specs do not describe (an older checkout, a custom path), and "the + configured path exists" is then the best available signal. + """ + directory = _entry_directory_key(rel) + candidates = packages_by_dir.get(directory) + if candidates is not None: + return any(_package_files_present(audiocpp_dir, package) + for package in candidates) + path = Path(rel) if Path(rel).is_absolute() else audiocpp_dir / rel + return _model_path_present(path) + + def _all_models_present(audiocpp_dir: Path, model_entries: List[dict]) -> bool: - """True when every selected model entry's path already holds files on disk. + """True when every selected model entry's files are on disk. - Paths resolve against the checkout (where model_manager_v2.py installs - them), honoring absolute paths. Used by the wizard to skip the - "Automatically download the selected models" prompt when nothing is - actually missing. An empty selection is treated as not-present. + Presence is file-precise against the catalog packages (see + ``_entry_present``): a directory that merely exists — e.g. holding a + stale install from a since-repaired spec layout — counts as missing so + the next download replaces it with the correct layout. An empty + selection is treated as not-present. """ if not model_entries: return False + packages_by_dir = _catalog_packages_by_dir(audiocpp_dir) for entry in model_entries: rel = entry.get("path") if not isinstance(rel, str) or not rel: return False - path = Path(rel) if Path(rel).is_absolute() else audiocpp_dir / rel - if not _model_path_present(path): + if not _entry_present(audiocpp_dir, rel, packages_by_dir): return False return True -def missing_model_entries(server_json: Path) -> List[dict]: - """Return the server.json model entries whose files are not on disk. +def _server_entries_by_presence(server_json: Path, present: bool) -> List[dict]: + """The server.json model entries whose on-disk presence matches PRESENT. - Paths resolve exactly like audiocpp_server resolves them (relative paths - against the server.json's directory). Each returned entry carries the - entry ``id`` and ``rel`` (the configured path string); used by ``detect`` - to warn that a conversion would fail until the models are installed. + Presence is file-precise against the catalog packages when the + server.json's directory hosts the model_specs (the usual checkout + layout); otherwise the plain path check applies. Each returned entry + carries the entry ``id`` and ``rel`` (the configured path string), + resolved exactly like the server resolves them (relative against the + server.json's directory; absolute paths honored). """ try: data = json.loads(server_json.read_text(encoding="utf-8")) @@ -392,18 +558,37 @@ def missing_model_entries(server_json: Path) -> List[dict]: if not isinstance(data, dict): return [] base = server_json.parent - missing: List[dict] = [] + packages_by_dir = _catalog_packages_by_dir(base) + matching: List[dict] = [] for entry in data.get("models") or []: if not isinstance(entry, dict): continue rel = entry.get("path") if not isinstance(rel, str) or not rel: continue - path = Path(rel) if Path(rel).is_absolute() else base / rel - if _model_path_present(path): + if _entry_present(base, rel, packages_by_dir) != present: continue - missing.append({"id": str(entry.get("id") or rel), "rel": rel}) - return missing + matching.append({"id": str(entry.get("id") or rel), "rel": rel}) + return matching + + +def missing_model_entries(server_json: Path) -> List[dict]: + """Return the server.json model entries whose files are not on disk. + + Used by ``detect`` to warn that a conversion would fail until the + models are installed (see ``_server_entries_by_presence``). + """ + return _server_entries_by_presence(server_json, present=False) + + +def installed_model_entries(server_json: Path) -> List[dict]: + """Return the server.json model entries whose files ARE on disk. + + The complement of ``missing_model_entries`` (see + ``_server_entries_by_presence``). Used by the wizard's "Delete unused + models?" step to find already-downloaded models that were unselected. + """ + return _server_entries_by_presence(server_json, present=True) def _install_id_by_path(audiocpp_dir: Path) -> Dict[str, str]: @@ -422,35 +607,6 @@ def _install_id_by_path(audiocpp_dir: Path) -> Dict[str, str]: return by_path -def installed_model_entries(server_json: Path) -> List[dict]: - """Return the server.json model entries whose files ARE on disk. - - The complement of ``missing_model_entries``: each returned entry carries - the entry ``id`` and ``rel`` (the configured path string), resolved - exactly like ``missing_model_entries`` (relative against the server.json's - directory). Used by the wizard's "Delete unused models?" step to find - already-downloaded models that were unselected. - """ - try: - data = json.loads(server_json.read_text(encoding="utf-8")) - except (OSError, ValueError): - return [] - if not isinstance(data, dict): - return [] - base = server_json.parent - installed: List[dict] = [] - for entry in data.get("models") or []: - if not isinstance(entry, dict): - continue - rel = entry.get("path") - if not isinstance(rel, str) or not rel: - continue - path = Path(rel) if Path(rel).is_absolute() else base / rel - if _model_path_present(path): - installed.append({"id": str(entry.get("id") or rel), "rel": rel}) - return installed - - def missing_model_install_guidance(audiocpp_dir: Path, missing: List[dict]) -> List[Tuple[str, str]]: """Map MISSING model entries to (display name, install id) pairs. @@ -530,15 +686,20 @@ def hand_install_guidance(audiocpp_dir: Path, def unused_installed_entries(server_json: Path, new_paths: Set[str]) -> List[dict]: - """Return installed server.json entries whose path is not in NEW_PATHS. + """Return installed server.json entries whose package is not in NEW_PATHS. The already-downloaded models (see ``installed_model_entries``) that the new selection does not host any more — the candidates for the wizard's - "Delete unused models?" prompt. Entries whose files are not on disk are - never listed (there is nothing to delete). + "Delete unused models?" prompt. Entries are compared by their package + directory key (``_entry_directory_key``), so an entry re-hosted from + ``models/`` to ``models//`` (the multi-GGUF convention) + is not offered for deletion when the new config still hosts that + directory. Entries whose files are not on disk are never listed (there + is nothing to delete). """ + new_keys = {_entry_directory_key(path) for path in new_paths} return [entry for entry in installed_model_entries(server_json) - if entry["rel"] not in new_paths] + if _entry_directory_key(entry["rel"]) not in new_keys] def delete_model_files(server_json: Path, entries: List[dict]) -> int: diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index 97f50a5..a1a2a6a 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -26,10 +26,12 @@ from . import configsync as _configsync from . import models as _models from . import prebuilt as _prebuilt from . import voices as _voices -from .catalog import (_backend_options, build_model_entry, - build_server_config, detect_backend, hosting_task, +from .catalog import (_backend_options, build_model_entry, build_server_config, + detect_backend, entry_model_path, hosting_task, load_model_catalog, load_server_config, - package_dir_options, server_config_selections) + MIOTTS_CODEC_DISPLAY_NAME, MIOTTS_CODEC_INSTALL_ID, + package_dir_options, server_config_selections, + apply_entry_session_options) from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL, BACKENDS, DEFAULT_HOST, TASK_TTS, TASK_VDES) @@ -80,7 +82,7 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], task_picker: Callable[[str], str], known_tasks: Optional[Dict[Tuple[str, str], str]] = None ) -> Tuple[List[dict], List[str], List[Tuple[str, str]], - List[str], bool]: + List[Tuple[str, str]], List[str], bool]: """Build server.json model entries from the selected families/packages. TASK_PICKER is called for each design package to choose vdes/tts. @@ -89,12 +91,18 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], was hosted instead of re-asking. Each entry's server id is its package ``target_directory`` (flattened to a token), so packages from the same family never collide; an id that does collide (across families) is - auto-suffixed without prompting. Returns (model_entries, entry_ids, - install_guidance, design_entry_ids, include_clone). + auto-suffixed without prompting. Entry paths come from the catalog + (``entry_model_path``): normally ``models/``, or the + package's first GGUF file when the package ships several GGUFs into one + directory (audio.cpp refuses multi-GGUF directories). COMPANION_GUIDANCE + carries the companion packages hosted models require but the TTS catalog + never offers (MioCodec for MioTTS). Returns (model_entries, entry_ids, + install_guidance, companion_guidance, design_entry_ids, include_clone). """ model_entries: List[dict] = [] entry_ids: List[str] = [] install_guidance: List[Tuple[str, str]] = [] + companion_guidance: List[Tuple[str, str]] = [] design_entry_ids: List[str] = [] include_clone = False for family in family_keys: @@ -120,12 +128,16 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], model_id = f"{base_id}-{n}" entry_ids.append(model_id) model_entries.append(build_model_entry( - family, model_id, f"models/{opt['target_directory']}", + family, model_id, + entry_model_path(entry, opt["target_directory"]), task=task)) install_guidance.append((entry["display_name"], opt["install_id"])) if task == TASK_VDES: design_entry_ids.append(model_id) - return (model_entries, entry_ids, install_guidance, + if any(str(e.get("family")) == "miotts" for e in model_entries): + companion_guidance.append( + (MIOTTS_CODEC_DISPLAY_NAME, MIOTTS_CODEC_INSTALL_ID)) + return (model_entries, entry_ids, install_guidance, companion_guidance, design_entry_ids, include_clone) @@ -139,7 +151,11 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], After a successful run the console output is the path of the written server.json. The model install commands (and optional automatic download) are handled separately by _install_models, called by both - UI modes once the user has decided whether to download. + UI modes once the user has decided whether to download. Before the + document is written, apply_entry_session_options bakes in the + per-entry session options heavy families need (MioTTS's codec path; + a VoxCPM AudioVAE encoder capacity sized to the voice directory's + longest reference), reported as one summary line. """ voice_dir: Optional[str] = None if transcripts: @@ -149,6 +165,11 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], print(f"[OK] Wrote {prompt_path}") voice_dir = str(wav_dir.resolve()) + configured = apply_entry_session_options(model_entries, wav_dir, + audiocpp_dir) + if configured: + print(f"[OK] Added family session options to: {', '.join(configured)}") + server_config = build_server_config( host=host, port=port, backend=backend, lazy_load=lazy_load, model_entries=model_entries, voice_dir=voice_dir) @@ -269,7 +290,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser raise _GoBack() return result - model_entries, entry_ids, install_guidance, \ + model_entries, entry_ids, install_guidance, companion_guidance, \ design_entry_ids, include_clone = _build_entries( s["family_keys"], s["chosen"], s["catalog_by_family"], task_picker, known_tasks=s["existing_tasks"]) @@ -277,6 +298,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "model_entries": model_entries, "entry_ids": entry_ids, "install_guidance": install_guidance, + "companion_guidance": companion_guidance, "design_entry_ids": design_entry_ids, "include_clone": include_clone, }) @@ -299,6 +321,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "model_entries": s["model_entries"], "entry_ids": s["entry_ids"], "install_guidance": s["install_guidance"], + "companion_guidance": s["companion_guidance"], "design_entry_ids": s["design_entry_ids"], "include_clone": s["include_clone"], "host": host, @@ -419,8 +442,9 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser s["plan"] = _plan_from_mode(mode, wav_files, existing) s["download"] = bool(result.get("download")) and ( - _models.download_applicable(s["audiocpp_dir"], - s["model_entries"])) + _models.download_applicable( + s["audiocpp_dir"], s["model_entries"], + companions=s.get("companion_guidance"))) s["delete_unused"] = bool(result.get("delete_unused")) \ and bool(s["unused_entries"]) return _finalize() @@ -540,7 +564,9 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "visible": lambda fs: bool(s["include_clone"]), }) - if _models.download_applicable(s["audiocpp_dir"], s["model_entries"]): + if _models.download_applicable( + s["audiocpp_dir"], s["model_entries"], + companions=s.get("companion_guidance")): fields.append({ "key": "download", "label": "Download the selected models automatically?", @@ -718,7 +744,8 @@ def _execute_lanes(settings: dict, def install(emit, cancel): _models._install_models(audiocpp_dir, settings["install_guidance"], settings["download"], emit=emit, cancel=cancel, - model_entries=settings["model_entries"]) + model_entries=settings["model_entries"], + companions=settings.get("companion_guidance")) _build._print_launch_hint(audiocpp_dir, settings["output_path"]) return 0 # Everything already on disk: the install step just reports it, so the @@ -992,9 +1019,9 @@ def _collect_from_flags(args: argparse.Namespace, def task_picker(install_id: str) -> str: return TASK_VDES - model_entries, entry_ids, install_guidance, design_entry_ids, include_clone = \ - _build_entries(family_keys, chosen, catalog_by_family, - task_picker) + model_entries, entry_ids, install_guidance, companion_guidance, \ + design_entry_ids, include_clone = _build_entries( + family_keys, chosen, catalog_by_family, task_picker) # Server settings. Host is always 127.0.0.1 and the port comes from # AUDIOCPP_API_URL in app/converter/config.py (the Settings screen) — @@ -1056,6 +1083,7 @@ def _collect_from_flags(args: argparse.Namespace, "model_entries": model_entries, "entry_ids": entry_ids, "install_guidance": install_guidance, + "companion_guidance": companion_guidance, "design_entry_ids": design_entry_ids, "include_clone": include_clone, "host": host, -- cgit v1.2.3