From 4bac3a5be1bdda4a99693d06c45f071ca0c47c21 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:06:20 -0700 Subject: [PATCH 1/7] feat: add portable Stable Audio TFLite adapter --- .github/workflows/ci.yml | 2 + backend/lsdj/controller.py | 57 +- backend/lsdj/runtime_paths.py | 6 + backend/lsdj/sa3.py | 840 +++++++++++++++++++++------ backend/lsdj/sa3_audio.py | 176 ++++++ backend/lsdj/sa3_contract.py | 143 +++++ backend/tests/test_controller.py | 51 +- backend/tests/test_runtime_paths.py | 21 +- backend/tests/test_sa3.py | 594 ++++++++++++------- backend/tests/test_sa3_audio.py | 119 ++++ backend/tests/test_sa3_manifest.py | 91 +++ docs/stable-audio-backends.md | 95 +++ sa3-tflite-pin.json | 86 +++ scripts/sa3-tflite-requirements.in | 8 + scripts/sa3-tflite-requirements.lock | 505 ++++++++++++++++ 15 files changed, 2382 insertions(+), 412 deletions(-) create mode 100644 backend/lsdj/sa3_audio.py create mode 100644 backend/lsdj/sa3_contract.py create mode 100644 backend/tests/test_sa3_audio.py create mode 100644 backend/tests/test_sa3_manifest.py create mode 100644 docs/stable-audio-backends.md create mode 100644 sa3-tflite-pin.json create mode 100644 scripts/sa3-tflite-requirements.in create mode 100644 scripts/sa3-tflite-requirements.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892bd6a..9bf1456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,8 @@ jobs: run: >- uv run --frozen --only-group ci python -m pytest tests/test_sa3.py + tests/test_sa3_audio.py + tests/test_sa3_manifest.py tests/test_models.py::test_readiness_classifies_a_checkout tests/test_models.py::test_readiness_missing_when_no_checkout diff --git a/backend/lsdj/controller.py b/backend/lsdj/controller.py index 2e3893a..7e8f7d8 100644 --- a/backend/lsdj/controller.py +++ b/backend/lsdj/controller.py @@ -10,7 +10,6 @@ import argparse import asyncio import contextlib -import io import json import logging import math @@ -18,7 +17,6 @@ import os import queue import time -import wave import uvicorn from fastapi import FastAPI, HTTPException, Request @@ -234,34 +232,11 @@ def _generation_number( return float(value) -def _validate_init_wav(data: bytes) -> None: +def _normalize_init_wav(data: bytes) -> bytes: try: - with wave.open(io.BytesIO(data), "rb") as source: - channels = source.getnchannels() - sample_width = source.getsampwidth() - sample_rate = source.getframerate() - frames = source.getnframes() - compression = source.getcomptype() - pcm_bytes = source.readframes(frames) - except (EOFError, wave.Error): - raise HTTPException( - status_code=422, detail="'init_audio' must be a valid WAV file" - ) from None - if ( - compression != "NONE" - or channels not in (1, 2) - or sample_width != 2 - or sample_rate != 44_100 - or frames == 0 - or len(pcm_bytes) != frames * channels * sample_width - ): - raise HTTPException( - status_code=422, - detail=( - "'init_audio' must be non-empty 44.1 kHz 16-bit PCM WAV " - "with one or two channels" - ), - ) + return sa3.normalize_wav(data).wav + except sa3.AudioFormatError as error: + raise HTTPException(status_code=422, detail=f"'init_audio' {error}") from None async def _read_init_audio(upload: UploadFile) -> bytes: @@ -278,8 +253,7 @@ async def _read_init_audio(upload: UploadFile) -> bytes: ) chunks.append(chunk) data = b"".join(chunks) - _validate_init_wav(data) - return data + return _normalize_init_wav(data) async def _read_capped_body(request: Request, limit: int, detail: str) -> bytes: @@ -446,6 +420,19 @@ def _validate_generate_request( ) options["apg"] = apg + if "steps" in parsed: + steps = parsed["steps"] + if ( + isinstance(steps, bool) + or not isinstance(steps, int) + or not sa3.MIN_STEPS <= steps <= sa3.MAX_STEPS + ): + raise HTTPException( + status_code=422, + detail=f"'steps' must be an integer from {sa3.MIN_STEPS}-{sa3.MAX_STEPS}", + ) + options["steps"] = steps + if "negative_prompt" in parsed: negative_prompt = parsed["negative_prompt"] if not isinstance(negative_prompt, str): @@ -675,9 +662,17 @@ async def generate_audio(request: Request) -> Response: except sa3.GenerationFailed as error: logger.warning("generation failed: %s", error) raise HTTPException(status_code=502, detail=str(error)) from None + except sa3.GenerationCancelled as error: + raise HTTPException(status_code=499, detail=str(error)) from None return Response(content=wav, media_type="audio/wav") +@app.get("/api/sa3/status") +def stable_audio_status() -> dict: + """Selected runtime, feature matrix, limitations, and active generation.""" + return sa3.status() + + @app.get("/api/models") def list_models() -> dict: """The downloaded models + RAM info for the deck UI's model picker and the diff --git a/backend/lsdj/runtime_paths.py b/backend/lsdj/runtime_paths.py index 0648c45..db0f01b 100644 --- a/backend/lsdj/runtime_paths.py +++ b/backend/lsdj/runtime_paths.py @@ -39,6 +39,12 @@ def staging_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None: def sa3_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None: env = os.environ if env is None else env + neutral_override = _path(env, "SA3_HOME") + if neutral_override is not None: + return neutral_override + tflite_override = _path(env, "SA3_TFLITE_HOME") + if tflite_override is not None: + return tflite_override override = _path(env, "SA3_MLX_HOME") if override is not None: return override diff --git a/backend/lsdj/sa3.py b/backend/lsdj/sa3.py index c2d2e30..829a45a 100644 --- a/backend/lsdj/sa3.py +++ b/backend/lsdj/sa3.py @@ -1,44 +1,50 @@ -"""Stable Audio 3 generation via a spawned sa3_mlx subprocess (ADR-0012). - -Nothing here imports sa3_mlx code: the checkout's own venv python runs its -CLI once per generation and the WAV comes back as bytes. The interpreter is -invoked directly — `uv run` would resolve the checkout's repo-root torch -project (measured), and the `./sa3` wrapper exists for humans and may -prompt. Generations are serialised so the transient ~1.5 GB peak never -stacks next to the two deck workers. +"""Runtime-neutral Stable Audio 3 service. + +LSDJ spawns an official, pinned upstream CLI for each generation. Apple +Silicon uses MLX; Linux and Windows use LiteRT/TFLite. Both adapters receive +the same request object, execute strictly offline against app-installed assets, +and return a validated canonical WAV. """ +from __future__ import annotations + import asyncio +import contextlib +import json import os import pathlib +import platform as host_platform +import re +import signal +import subprocess +import sys import tempfile -from collections.abc import Sequence +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass from . import runtime_paths +from .sa3_audio import AudioFormatError, inspect_canonical_wav, normalize_wav +from .sa3_audio import validate_output_wav as _validate_output_wav +from .sa3_contract import ( + BackendName, + GenerationRequest, + ProgressEvent, + capabilities_for, +) -# CLI vocabulary of scripts/sa3_mlx.py at the pinned commit (sa3-pin.json). -# Pads use the small DiTs with the SAME-S decoder; tracks (M19, ADR-0013) -# the medium DiT, which pairs with SAME-L. +# Both pinned official CLIs deliberately share these names. KINDS = {"sfx": "sm-sfx", "music": "sm-music", "track": "medium"} DECODERS = {"sfx": "same-s", "music": "same-s", "track": "same-l"} SAMPLER_STEPS = 8 +MIN_STEPS = 1 +MAX_STEPS = 100 MIN_SECONDS = 0.5 MAX_SECONDS = 32.0 -# Stability's published ceiling for the medium DiT (6:20). TRACK_MAX_SECONDS = 380.0 MAX_SECONDS_FOR = {"sfx": MAX_SECONDS, "music": MAX_SECONDS, "track": TRACK_MAX_SECONDS} -# A safety ceiling, not a UX limit: the prompt is passed to the sa3_mlx CLI as a -# single argv (see `generate`), so an unbounded prompt would blow the OS arg-length -# limit, and it guards the loopback endpoint against a pathological body. Set generous -# enough to hold a large structured/JSON prompt (a pasted song spec runs ~8 KB) with -# headroom, while staying far below the OS argv limit. The model's text encoder -# truncates beyond its own window anyway. -MAX_PROMPT_LENGTH = 32000 - -# Issue #54 generation controls. These are trust-boundary limits, mirrored by -# `controller.generate_audio`; the CLI itself is wider in places, but unbounded -# loopback input must not become unbounded argv, model guidance, or memory use. +MAX_PROMPT_LENGTH = 32_000 + MIN_INIT_NOISE_LEVEL = 0.01 MAX_INIT_NOISE_LEVEL = 5.0 MIN_CFG = -20.0 @@ -49,116 +55,596 @@ MAX_INIT_AUDIO_BYTES = 16 * 1024 * 1024 MAX_GENERATE_METADATA_BYTES = 64 * 1024 -# Measured small-DiT generation is ~1.5 s; the margin covers a cold -# filesystem cache and slower machines, not a first-ever weight download -# (see SETUP_HINT). TIMEOUT_SECONDS = 120 +TFLITE_THREADS_DEFAULT = 4 +TFLITE_THREADS_MAX = 8 + +STATE_MISSING = "missing" +STATE_VENV_MISSING = "venv_missing" +STATE_NOT_WARMED = "not_warmed" +STATE_READY = "ready" +STATE_UNSUPPORTED = "unsupported" +STATE_FAILED = "failed" -SETUP_HINT = ( - "sa3_mlx checkout not found - install Stable Audio 3 from the app's settings " - "drawer (the model manager), or point SA3_MLX_HOME at an existing checkout" +WARMED_STAMP = ".lsdj-warmed" +TFLITE_PROVENANCE_STAMP = ".lsdj-provenance.json" +TFLITE_RUNTIME_REPO = "https://github.com/Stability-AI/stable-audio-3" +TFLITE_RUNTIME_REVISION = "a0b57f5483c4588f827f3552b7d5c6ca2a9687be" +TFLITE_MODELS_REPO = "stabilityai/stable-audio-3-optimized" +TFLITE_MODELS_REVISION = "6736003cb57d06b7b1fdc36fad31b2a3709e4774" + +_MLX_SUBDIR = pathlib.Path("optimized/mlx") +_TFLITE_SUBDIR = pathlib.Path("optimized/tflite") +_MLX_SCRIPT = pathlib.Path("scripts/sa3_mlx.py") +_TFLITE_SCRIPT = pathlib.Path("scripts/sa3_tflite.py") + +# fp32 is the official TFLite default and the only precision for which upstream +# supports LoRA. The secure installer consumes sa3-tflite-pin.json and places +# these files before the worker is ever started. +_TFLITE_SHARED_ASSETS = ( + pathlib.Path("models/tokenizer.model"), + pathlib.Path("models/tflite/t5gemma/encoder_fp16.tflite"), ) +_TFLITE_MODEL_ASSETS = { + "sfx": ( + pathlib.Path("models/tflite/sa3-sm-sfx/dit_fp32.tflite"), + pathlib.Path("models/tflite/same-s/dec_fp32.tflite"), + ), + "music": ( + pathlib.Path("models/tflite/sa3-sm-music/dit_fp32.tflite"), + pathlib.Path("models/tflite/same-s/dec_fp32.tflite"), + ), + "track": ( + pathlib.Path("models/tflite/sa3-m/dit_fp32.tflite"), + pathlib.Path("models/tflite/same-l/dec_fp32.tflite"), + ), +} +_TFLITE_ENCODER_ASSET = { + "sfx": pathlib.Path("models/tflite/same-s/enc_fp32.tflite"), + "music": pathlib.Path("models/tflite/same-s/enc_fp32.tflite"), + "track": pathlib.Path("models/tflite/same-l/enc_fp32.tflite"), +} -def timeout_for(seconds: float) -> float: - """Deadline for one generation, scaled to the requested length. +class GenerationUnavailable(Exception): + """No supported, ready Stable Audio runtime exists on this machine.""" - The published medium benchmark is ~15 s wall for a 2-minute track on - M4-Pro-class hardware, so a second of deadline per second of audio is - ~8x slack on top of the flat base — a wedge kill-switch, not a UX - promise (ADR-0013).""" - return TIMEOUT_SECONDS + seconds +class GenerationFailed(Exception): + """The selected runtime failed or produced invalid audio.""" -_generation_lock = asyncio.Semaphore(1) +class GenerationCancelled(Exception): + """The caller cancelled a generation and the worker was stopped.""" -class GenerationUnavailable(Exception): - """No usable sa3_mlx checkout on this machine.""" +class UnsupportedCapability(GenerationUnavailable): + """A request names a capability the selected backend cannot honour.""" -class GenerationFailed(Exception): - """The CLI ran and did not produce a WAV.""" +@dataclass(frozen=True) +class RuntimeSelection: + backend: BackendName + checkout: pathlib.Path + runtime_dir: pathlib.Path + executable: pathlib.Path + script: pathlib.Path -# Canonical SA3 install states, shared verbatim with the Rust `model_status` -# and the model-manager UI (issue #43): the readiness contract is one of these. -STATE_MISSING = "missing" -STATE_VENV_MISSING = "venv_missing" -STATE_NOT_WARMED = "not_warmed" -STATE_READY = "ready" -WARMED_STAMP = ".lsdj-warmed" +def timeout_for(seconds: float) -> float: + """Wedge deadline, not a performance promise.""" + return TIMEOUT_SECONDS + seconds -def _checkout_candidates(env: dict) -> list[pathlib.Path]: - """The checkout root explicitly supplied by the Rust host. +def _normalise_arch(machine: str) -> str: + value = machine.strip().lower() + return "arm64" if value in {"arm64", "aarch64"} else value - No platform fallback lives here: independently rebuilding a macOS/XDG/ - Windows location is precisely how the two sides drifted before issue #107. - """ + +def select_backend( + env: Mapping[str, str] | None = None, + *, + platform_name: str | None = None, + machine: str | None = None, +) -> BackendName: + """Select the backend deterministically; never fall back silently.""" + env = os.environ if env is None else env + platform_name = sys.platform if platform_name is None else platform_name + machine = host_platform.machine() if machine is None else machine + arch = _normalise_arch(machine) + override = env.get("LSDJ_SA3_BACKEND", "").strip().lower() + if override: + try: + chosen = BackendName(override) + except ValueError: + raise GenerationUnavailable( + "LSDJ_SA3_BACKEND must be 'mlx' or 'tflite'" + ) from None + if chosen is BackendName.MLX and not ( + platform_name == "darwin" and arch == "arm64" + ): + raise GenerationUnavailable( + "the MLX Stable Audio backend requires Apple Silicon macOS" + ) + if chosen is BackendName.TFLITE and platform_name not in { + "darwin", + "linux", + "win32", + }: + raise GenerationUnavailable( + f"the TFLite Stable Audio backend does not support {platform_name}/{arch}" + ) + return chosen + if platform_name == "darwin" and arch == "arm64": + return BackendName.MLX + if platform_name in {"linux", "win32"}: + return BackendName.TFLITE + raise GenerationUnavailable( + f"no Stable Audio backend supports {platform_name}/{arch}" + ) + + +def _checkout_candidates(env: Mapping[str, str]) -> list[pathlib.Path]: checkout = runtime_paths.sa3_home(env) return [] if checkout is None else [checkout] +def _layout(backend: BackendName) -> tuple[pathlib.Path, pathlib.Path]: + if backend is BackendName.MLX: + return _MLX_SUBDIR, _MLX_SCRIPT + return _TFLITE_SUBDIR, _TFLITE_SCRIPT + + +def _tflite_provenance_error(runtime_dir: pathlib.Path) -> str | None: + stamp = runtime_dir / TFLITE_PROVENANCE_STAMP + try: + parsed = json.loads(stamp.read_text()) + except (OSError, json.JSONDecodeError): + return "the verified TFLite provenance stamp is missing or unreadable" + expected = { + "runtime": { + "repo": TFLITE_RUNTIME_REPO, + "revision": TFLITE_RUNTIME_REVISION, + }, + "models": { + "repo": TFLITE_MODELS_REPO, + "revision": TFLITE_MODELS_REVISION, + }, + } + if parsed != expected: + return "the installed TFLite runtime/model revisions do not match LSDJ's pin" + return None + + +def resolve_runtime( + env: Mapping[str, str] | None = None, + *, + platform_name: str | None = None, + machine: str | None = None, +) -> RuntimeSelection | None: + env = os.environ if env is None else env + backend = select_backend(env, platform_name=platform_name, machine=machine) + subdir, script_rel = _layout(backend) + for checkout in _checkout_candidates(env): + runtime_dir = checkout / subdir + executable = runtime_paths.venv_python( + runtime_dir / ".venv", platform=platform_name + ) + script = runtime_dir / script_rel + if executable.is_file() and script.is_file(): + return RuntimeSelection( + backend=backend, + checkout=checkout, + runtime_dir=runtime_dir, + executable=executable, + script=script, + ) + return None + + def resolve_mlx_dir( - env: dict | None = None, home: pathlib.Path | None = None + env: Mapping[str, str] | None = None, home: pathlib.Path | None = None ) -> pathlib.Path | None: - """First checkout whose optimized/mlx has a venv and the CLI script.""" + """Compatibility probe used by existing model-manager tests.""" + del home env = os.environ if env is None else env - del home # retained for API compatibility; platform paths come from Rust. for checkout in _checkout_candidates(env): - mlx_dir = checkout / "optimized" / "mlx" - python = runtime_paths.venv_python(mlx_dir / ".venv") - script = mlx_dir / "scripts" / "sa3_mlx.py" - if python.is_file() and script.is_file(): - return mlx_dir + runtime_dir = checkout / _MLX_SUBDIR + executable = runtime_paths.venv_python(runtime_dir / ".venv", platform="darwin") + if executable.is_file() and (runtime_dir / _MLX_SCRIPT).is_file(): + return runtime_dir return None -def readiness(env: dict | None = None, home: pathlib.Path | None = None) -> dict: - """The SA3 install state for the model manager (issue #43). - - Walks the same candidates as `resolve_mlx_dir` and classifies the first - checkout that has an `optimized/mlx` dir: +def resolve_tflite_dir( + env: Mapping[str, str] | None = None, + *, + platform_name: str | None = None, +) -> pathlib.Path | None: + env = os.environ if env is None else env + for checkout in _checkout_candidates(env): + runtime_dir = checkout / _TFLITE_SUBDIR + executable = runtime_paths.venv_python( + runtime_dir / ".venv", platform=platform_name + ) + if executable.is_file() and (runtime_dir / _TFLITE_SCRIPT).is_file(): + return runtime_dir + return None - - ``missing`` no checkout with an ``optimized/mlx`` dir - - ``venv_missing`` checkout present, but no ``.venv``/CLI script - - ``not_warmed`` venv present, but the ``.lsdj-warmed`` stamp is absent - - ``ready`` venv present and warmed - Returns ``{"state", "checkout", "mlx_dir"}`` (paths are str or None). The - Rust `model_status` mirrors this exact logic and these exact identifiers. - """ +def readiness( + env: Mapping[str, str] | None = None, + home: pathlib.Path | None = None, + *, + platform_name: str | None = None, + machine: str | None = None, +) -> dict: + del home env = os.environ if env is None else env - del home # retained for API compatibility; platform paths come from Rust. - - first_with_mlx: tuple[pathlib.Path, pathlib.Path] | None = None + try: + backend = select_backend(env, platform_name=platform_name, machine=machine) + except GenerationUnavailable as error: + return { + "state": STATE_UNSUPPORTED, + "backend": None, + "checkout": None, + "runtime_dir": None, + "mlx_dir": None, + "detail": str(error), + } + subdir, script_rel = _layout(backend) + first_runtime: tuple[pathlib.Path, pathlib.Path] | None = None for checkout in _checkout_candidates(env): - mlx_dir = checkout / "optimized" / "mlx" - if not mlx_dir.is_dir(): + runtime_dir = checkout / subdir + if not runtime_dir.is_dir(): continue - if first_with_mlx is None: - first_with_mlx = (checkout, mlx_dir) - python = runtime_paths.venv_python(mlx_dir / ".venv") - script = mlx_dir / "scripts" / "sa3_mlx.py" - if not (python.is_file() and script.is_file()): + if first_runtime is None: + first_runtime = (checkout, runtime_dir) + executable = runtime_paths.venv_python( + runtime_dir / ".venv", platform=platform_name + ) + if not (executable.is_file() and (runtime_dir / script_rel).is_file()): continue - warmed = (mlx_dir / WARMED_STAMP).is_file() + warmed = (runtime_dir / WARMED_STAMP).is_file() + provenance_error = ( + _tflite_provenance_error(runtime_dir) + if backend is BackendName.TFLITE + else None + ) + state = ( + STATE_FAILED + if warmed and provenance_error is not None + else STATE_READY + if warmed + else STATE_NOT_WARMED + ) return { - "state": STATE_READY if warmed else STATE_NOT_WARMED, + "state": state, + "backend": backend.value, "checkout": str(checkout), - "mlx_dir": str(mlx_dir), + "runtime_dir": str(runtime_dir), + "mlx_dir": str(runtime_dir) if backend is BackendName.MLX else None, + "detail": provenance_error if state == STATE_FAILED else None, } - - if first_with_mlx is not None: - checkout, mlx_dir = first_with_mlx + if first_runtime is not None: + checkout, runtime_dir = first_runtime return { "state": STATE_VENV_MISSING, + "backend": backend.value, "checkout": str(checkout), - "mlx_dir": str(mlx_dir), + "runtime_dir": str(runtime_dir), + "mlx_dir": str(runtime_dir) if backend is BackendName.MLX else None, + "detail": None, } - return {"state": STATE_MISSING, "checkout": None, "mlx_dir": None} + return { + "state": STATE_MISSING, + "backend": backend.value, + "checkout": None, + "runtime_dir": None, + "mlx_dir": None, + "detail": None, + } + + +_generation_state: dict = { + "state": "idle", + "backend": None, + "mode": None, + "progress": None, +} + + +def status( + env: Mapping[str, str] | None = None, + *, + platform_name: str | None = None, + machine: str | None = None, +) -> dict: + ready = readiness(env, platform_name=platform_name, machine=machine) + backend_value = ready["backend"] + capabilities = ( + None + if backend_value is None + else capabilities_for(BackendName(backend_value)).as_dict() + ) + return { + **ready, + "capabilities": capabilities, + "generation": dict(_generation_state), + "maxSeconds": dict(MAX_SECONDS_FOR), + } + + +def _tflite_threads(env: Mapping[str, str]) -> int: + raw = env.get("LSDJ_SA3_TFLITE_THREADS", str(TFLITE_THREADS_DEFAULT)) + try: + threads = int(raw) + except ValueError: + raise GenerationUnavailable( + "LSDJ_SA3_TFLITE_THREADS must be an integer" + ) from None + if not 1 <= threads <= TFLITE_THREADS_MAX: + raise GenerationUnavailable( + f"LSDJ_SA3_TFLITE_THREADS must be 1-{TFLITE_THREADS_MAX}" + ) + return threads + + +def _required_tflite_assets(request: GenerationRequest) -> tuple[pathlib.Path, ...]: + paths = [*_TFLITE_SHARED_ASSETS, *_TFLITE_MODEL_ASSETS[request.kind]] + if request.init_audio is not None: + paths.append(_TFLITE_ENCODER_ASSET[request.kind]) + return tuple(paths) + + +def _preflight(selection: RuntimeSelection, request: GenerationRequest) -> None: + if request.inpaint_range is not None and request.init_audio is None: + raise UnsupportedCapability("inpainting requires init audio") + if request.negative_prompt is not None and ( + request.cfg is None or request.cfg == 1 + ): + raise UnsupportedCapability("negative prompt requires CFG other than 1") + if request.apg is not None and (request.cfg is None or request.cfg == 1): + raise UnsupportedCapability("APG requires CFG other than 1") + if request.lora_strengths is not None and len(request.lora_strengths) != len( + request.lora_dirs or () + ): + raise UnsupportedCapability("every LoRA must have exactly one aligned strength") + if not MIN_STEPS <= request.steps <= MAX_STEPS: + raise UnsupportedCapability(f"steps must be {MIN_STEPS}-{MAX_STEPS}") + if selection.backend is not BackendName.TFLITE: + return + if not (selection.runtime_dir / WARMED_STAMP).is_file(): + raise GenerationUnavailable( + "the TFLite runtime has not completed its verified warm-up" + ) + if provenance_error := _tflite_provenance_error(selection.runtime_dir): + raise GenerationUnavailable(provenance_error) + missing = [ + str(path) + for path in _required_tflite_assets(request) + if not (selection.runtime_dir / path).is_file() + ] + if missing: + names = ", ".join(missing) + raise GenerationUnavailable( + "the pinned TFLite model bundle is incomplete; install it from the " + f"model manager before generating (missing: {names})" + ) + + +def build_argv( + selection: RuntimeSelection, + request: GenerationRequest, + *, + out_path: pathlib.Path, + init_path: pathlib.Path | None, + env: Mapping[str, str] | None = None, +) -> list[str]: + """Translate the neutral request to an official CLI argument vector.""" + env = os.environ if env is None else env + _preflight(selection, request) + argv = [ + str(selection.executable), + str(selection.script), + "--prompt", + request.prompt, + "--dit", + KINDS[request.kind], + "--decoder", + DECODERS[request.kind], + "--seconds", + f"{request.seconds:g}", + "--steps", + str(request.steps), + "--out", + str(out_path), + ] + if selection.backend is BackendName.TFLITE: + argv.extend(("--precision", "fp32", "--threads", str(_tflite_threads(env)))) + if request.init_audio is not None: + if init_path is None: + raise UnsupportedCapability("init audio requires a normalized input path") + argv.extend(("--init-audio", str(init_path))) + if request.init_noise_level is not None: + argv.extend(("--init-noise-level", f"{request.init_noise_level:g}")) + if request.inpaint_range is not None: + start, end = request.inpaint_range + argv.extend(("--inpaint-range", f"{start:g},{end:g}")) + if request.negative_prompt is not None: + argv.extend(("--negative-prompt", request.negative_prompt)) + if request.cfg is not None: + argv.extend(("--cfg", f"{request.cfg:g}")) + if request.apg is not None: + argv.extend(("--apg", f"{request.apg:g}")) + if request.seed is not None: + argv.extend(("--seed", str(request.seed))) + for index, lora_dir in enumerate(request.lora_dirs or ()): + argv.extend(("--lora", lora_dir)) + if request.lora_strengths is not None: + argv.append(f"strength={request.lora_strengths[index]:g}") + return argv + + +_PROGRESS_PATTERNS = ( + ("sampling", re.compile(r"sampling step (\d+)/(\d+)")), + ("decode", re.compile(r"decode chunk (\d+)/(\d+)")), +) +_SENSITIVE_OUTPUT = re.compile( + r"(?i)(prompt|init audio|--lora|hf[_-]?token|hugging_face_hub_token|authorization)" +) + + +def _progress_from_line(line: str) -> ProgressEvent | None: + for stage, pattern in _PROGRESS_PATTERNS: + if match := pattern.search(line): + return ProgressEvent( + stage=stage, + current=int(match.group(1)), + total=int(match.group(2)), + message=f"{stage} {match.group(1)}/{match.group(2)}", + ) + return None + + +async def _drain_output( + stream: asyncio.StreamReader, + on_progress: Callable[[ProgressEvent], None] | None, +) -> str: + tail = bytearray() + pending = bytearray() + while chunk := await stream.read(4096): + tail.extend(chunk) + if len(tail) > 8192: + del tail[:-8192] + pending.extend(chunk) + while True: + newline = pending.find(b"\n") + if newline < 0: + break + line = bytes(pending[:newline]).decode(errors="replace") + del pending[: newline + 1] + if len(pending) > 8192: + del pending[:-8192] + event = _progress_from_line(line) + if event is not None and on_progress is not None: + on_progress(event) + if len(pending) > 8192: + del pending[:-8192] + return tail.decode(errors="replace") + + +def _safe_failure_tail(output: str, backend: BackendName) -> str: + lines = [ + line.strip() + for line in output.splitlines() + if line.strip() and not _SENSITIVE_OUTPUT.search(line) + ] + tail = "\n".join(lines[-8:])[-1000:] + return tail or f"the {backend.value} Stable Audio process failed" + + +async def _stop_process(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + if os.name == "posix": + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGTERM) + else: + with contextlib.suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=1.0) + return + except TimeoutError: + pass + if os.name == "posix": + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + else: + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.wait() + + +def _child_environment(selection: RuntimeSelection) -> dict[str, str]: + env = dict(os.environ) + # Models are installed and verified by the app. Missing files must fail + # rather than trigger upstream's mutable first-run downloader. + env["HF_HUB_OFFLINE"] = "1" + env["HF_HUB_DISABLE_TELEMETRY"] = "1" + env["DO_NOT_TRACK"] = "1" + env.pop("HF_TOKEN", None) + env.pop("HUGGING_FACE_HUB_TOKEN", None) + if selection.backend is BackendName.TFLITE: + threads = str(_tflite_threads(os.environ)) + env["OMP_NUM_THREADS"] = threads + env["OPENBLAS_NUM_THREADS"] = threads + env["TF_NUM_INTRAOP_THREADS"] = threads + return env + + +async def _run_cli( + selection: RuntimeSelection, + argv: list[str], + *, + seconds: float, + cancel_event: asyncio.Event | None, + on_progress: Callable[[ProgressEvent], None] | None, +) -> tuple[int, str]: + spawn_options: dict = { + "cwd": selection.runtime_dir, + "env": _child_environment(selection), + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.STDOUT, + } + if os.name == "posix": + spawn_options["start_new_session"] = True + elif os.name == "nt": + flags = subprocess.CREATE_NEW_PROCESS_GROUP + if selection.backend is BackendName.TFLITE: + flags |= subprocess.BELOW_NORMAL_PRIORITY_CLASS + spawn_options["creationflags"] = flags + process = await asyncio.create_subprocess_exec(*argv, **spawn_options) + if selection.backend is BackendName.TFLITE and hasattr(os, "setpriority"): + with contextlib.suppress(OSError): + os.setpriority(os.PRIO_PROCESS, process.pid, 10) + assert process.stdout is not None + drain = asyncio.create_task(_drain_output(process.stdout, on_progress)) + wait = asyncio.create_task(process.wait()) + cancel = ( + asyncio.create_task(cancel_event.wait()) if cancel_event is not None else None + ) + watched = {wait} + if cancel is not None: + watched.add(cancel) + try: + done, _ = await asyncio.wait( + watched, timeout=timeout_for(seconds), return_when=asyncio.FIRST_COMPLETED + ) + if not done: + await _stop_process(process) + raise GenerationFailed( + f"generation timed out after {timeout_for(seconds):g}s" + ) + if cancel is not None and cancel in done and cancel.result(): + await _stop_process(process) + raise GenerationCancelled("generation cancelled") + return_code = await wait + return return_code, await drain + except asyncio.CancelledError: + await _stop_process(process) + raise + finally: + if cancel is not None: + cancel.cancel() + if not drain.done(): + drain.cancel() + with contextlib.suppress(asyncio.CancelledError): + await drain + + +_generation_lock = asyncio.Semaphore(1) async def generate( @@ -173,82 +659,104 @@ async def generate( cfg: float | None = None, apg: float | None = None, seed: int | None = None, + steps: int = SAMPLER_STEPS, lora_dirs: Sequence[str] | None = None, lora_strengths: Sequence[float] | None = None, + cancel_event: asyncio.Event | None = None, + on_progress: Callable[[ProgressEvent], None] | None = None, ) -> bytes: - """Run one generation and return the WAV bytes. - - Raises GenerationUnavailable when no checkout resolves and - GenerationFailed when the CLI errors, times out, or writes nothing. - Inputs are assumed validated at the trust boundary (controller). - """ - mlx_dir = resolve_mlx_dir() - if mlx_dir is None: - raise GenerationUnavailable(SETUP_HINT) - async with _generation_lock: - with tempfile.TemporaryDirectory(prefix="sa3-") as tmp: - out_path = pathlib.Path(tmp) / "out.wav" - argv = [ - str(runtime_paths.venv_python(mlx_dir / ".venv")), - str(mlx_dir / "scripts" / "sa3_mlx.py"), - "--prompt", - prompt, - "--dit", - KINDS[kind], - "--decoder", - DECODERS[kind], - "--seconds", - f"{seconds:g}", - "--steps", - str(SAMPLER_STEPS), - "--out", - str(out_path), - ] - if init_audio is not None: - init_path = pathlib.Path(tmp) / "init.wav" - init_path.write_bytes(init_audio) - argv.extend(("--init-audio", str(init_path))) - if init_noise_level is not None: - argv.extend(("--init-noise-level", f"{init_noise_level:g}")) - if inpaint_range is not None: - start, end = inpaint_range - argv.extend(("--inpaint-range", f"{start:g},{end:g}")) - if negative_prompt is not None: - argv.extend(("--negative-prompt", negative_prompt)) - if cfg is not None: - argv.extend(("--cfg", f"{cfg:g}")) - if apg is not None: - argv.extend(("--apg", f"{apg:g}")) - if seed is not None: - argv.extend(("--seed", str(seed))) - if lora_dirs: - # One --lora group per adapter (upstream's PR #57/#65 syntax): - # the directory plus its strength=S option. The CLI resolves - # the .safetensors inside and merges all deltas at DiT load. - for index, lora_dir in enumerate(lora_dirs): - argv.extend(("--lora", lora_dir)) - if lora_strengths is not None: - argv.append(f"strength={lora_strengths[index]:g}") - process = await asyncio.create_subprocess_exec( - *argv, - cwd=mlx_dir, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - timeout = timeout_for(seconds) + """Generate one validated WAV through the platform-selected backend.""" + request = GenerationRequest( + prompt=prompt, + seconds=seconds, + kind=kind, + init_audio=init_audio, + init_noise_level=init_noise_level, + inpaint_range=inpaint_range, + negative_prompt=negative_prompt, + cfg=cfg, + apg=apg, + seed=seed, + steps=steps, + lora_dirs=lora_dirs, + lora_strengths=lora_strengths, + ) + try: + selection = resolve_runtime() + except GenerationUnavailable: + raise + if selection is None: + backend = select_backend() + raise GenerationUnavailable( + f"the {backend.value} Stable Audio runtime is unavailable; " + "install the pinned runtime and model bundle from the model manager" + ) + + normalized = None + if init_audio is not None: + try: + normalized = inspect_canonical_wav(init_audio) + except AudioFormatError: try: - output, _ = await asyncio.wait_for( - process.communicate(), timeout=timeout + normalized = normalize_wav(init_audio) + except AudioFormatError as error: + raise GenerationFailed(str(error)) from None + + mode = request.mode( + input_seconds=None if normalized is None else normalized.seconds + ) + _generation_state.update( + state="queued", + backend=selection.backend.value, + mode=mode.value, + progress=None, + ) + + def report(event: ProgressEvent) -> None: + _generation_state["progress"] = event.as_dict() + if on_progress is not None: + on_progress(event) + + async with _generation_lock: + _generation_state["state"] = "running" + staging = runtime_paths.staging_home() + if staging is not None: + staging.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="sa3-", dir=staging) as tmp: + tmp_path = pathlib.Path(tmp) + out_path = tmp_path / "out.wav" + init_path = None + if normalized is not None: + init_path = tmp_path / "init.wav" + init_path.write_bytes(normalized.wav) + argv = build_argv( + selection, + request, + out_path=out_path, + init_path=init_path, ) - except TimeoutError: - process.kill() - await process.wait() - raise GenerationFailed( - f"generation timed out after {timeout:g}s" - ) from None - if process.returncode != 0 or not out_path.is_file(): - # The CLI's last lines name the problem; progress bars and - # ANSI noise live further up. - tail = output.decode(errors="replace").strip()[-500:] - raise GenerationFailed(tail or "sa3_mlx produced no output") - return out_path.read_bytes() + return_code, output = await _run_cli( + selection, + argv, + seconds=seconds, + cancel_event=cancel_event, + on_progress=report, + ) + if return_code != 0 or not out_path.is_file(): + raise GenerationFailed( + _safe_failure_tail(output, selection.backend) + ) + max_output_bytes = round(seconds * 44_100) * 4 + 1024 * 1024 + if out_path.stat().st_size > max_output_bytes: + raise GenerationFailed( + "backend WAV is larger than the requested duration" + ) + try: + return _validate_output_wav(out_path.read_bytes(), seconds) + except AudioFormatError as error: + raise GenerationFailed(str(error)) from None + finally: + _generation_state.update( + state="idle", backend=None, mode=None, progress=None + ) diff --git a/backend/lsdj/sa3_audio.py b/backend/lsdj/sa3_audio.py new file mode 100644 index 0000000..9f93288 --- /dev/null +++ b/backend/lsdj/sa3_audio.py @@ -0,0 +1,176 @@ +"""Bounded WAV normalization and validation for Stable Audio 3. + +Only uncompressed integer PCM WAV is accepted at the HTTP boundary. LSDJ +converts sample width, channel layout, and sample rate itself, so neither SA3 +backend can fall through to its optional system-``ffmpeg`` path. +""" + +from __future__ import annotations + +import io +import math +import struct +import wave +from dataclasses import dataclass + +SAMPLE_RATE = 44_100 +CHANNELS = 2 +SAMPLE_WIDTH = 2 +MAX_INPUT_SECONDS = 380.0 + + +class AudioFormatError(ValueError): + """The WAV is corrupt or uses an encoding LSDJ does not accept.""" + + +@dataclass(frozen=True) +class NormalizedAudio: + wav: bytes + frames: int + seconds: float + + +def _decode_sample(raw: bytes, width: int) -> float: + if width == 1: + return (raw[0] - 128) / 128.0 + if width == 2: + return int.from_bytes(raw, "little", signed=True) / 32768.0 + if width == 3: + value = int.from_bytes(raw, "little", signed=False) + if value & 0x800000: + value -= 1 << 24 + return value / 8388608.0 + if width == 4: + return int.from_bytes(raw, "little", signed=True) / 2147483648.0 + raise AudioFormatError("PCM sample width must be 8, 16, 24, or 32 bits") + + +def _stereo_frame( + raw: bytes, index: int, channels: int, width: int +) -> tuple[float, float]: + start = index * channels * width + left = _decode_sample(raw[start : start + width], width) + if channels == 1: + return left, left + right_start = start + width + return left, _decode_sample(raw[right_start : right_start + width], width) + + +def _pcm16(value: float) -> int: + value = min(1.0, max(-1.0, value)) + if value <= -1.0: + return -32768 + return min(32767, max(-32768, round(value * 32767.0))) + + +def normalize_wav(data: bytes) -> NormalizedAudio: + """Return canonical 44.1 kHz stereo PCM16 WAV bytes. + + The conversion is deterministic and bounded by ``MAX_INPUT_SECONDS``. + Multichannel input follows the official TFLite runtime's semantics and + retains the first two channels; mono is duplicated. + """ + try: + with wave.open(io.BytesIO(data), "rb") as source: + channels = source.getnchannels() + width = source.getsampwidth() + rate = source.getframerate() + frames = source.getnframes() + compression = source.getcomptype() + if compression != "NONE": + raise AudioFormatError("WAV must use uncompressed integer PCM") + if channels < 1 or channels > 32: + raise AudioFormatError("WAV must have between 1 and 32 channels") + if width not in (1, 2, 3, 4): + raise AudioFormatError("PCM sample width must be 8, 16, 24, or 32 bits") + if rate < 8_000 or rate > 384_000: + raise AudioFormatError( + "WAV sample rate must be between 8 kHz and 384 kHz" + ) + if frames < 1: + raise AudioFormatError("WAV must contain audio frames") + seconds = frames / rate + if not math.isfinite(seconds) or seconds > MAX_INPUT_SECONDS: + raise AudioFormatError( + f"WAV must be at most {MAX_INPUT_SECONDS:g} seconds" + ) + raw = source.readframes(frames) + except AudioFormatError: + raise + except (EOFError, wave.Error, OverflowError, struct.error): + raise AudioFormatError("init audio must be a valid PCM WAV file") from None + + expected_bytes = frames * channels * width + if len(raw) != expected_bytes: + raise AudioFormatError("WAV sample data is truncated") + if channels == CHANNELS and width == SAMPLE_WIDTH and rate == SAMPLE_RATE: + return NormalizedAudio(wav=data, frames=frames, seconds=seconds) + + target_frames = max(1, round(frames * SAMPLE_RATE / rate)) + pcm = bytearray(target_frames * CHANNELS * SAMPLE_WIDTH) + source_per_target = rate / SAMPLE_RATE + last_source_frame = frames - 1 + for index in range(target_frames): + position = index * source_per_target + lower = min(int(position), last_source_frame) + upper = min(lower + 1, last_source_frame) + fraction = position - lower + left_lower, right_lower = _stereo_frame(raw, lower, channels, width) + left_upper, right_upper = _stereo_frame(raw, upper, channels, width) + left_sample = left_lower + (left_upper - left_lower) * fraction + right_sample = right_lower + (right_upper - right_lower) * fraction + offset = index * 4 + struct.pack_into(" NormalizedAudio: + """Validate a canonical backend WAV without copying its entire payload.""" + try: + with wave.open(io.BytesIO(data), "rb") as source: + channels = source.getnchannels() + width = source.getsampwidth() + rate = source.getframerate() + frames = source.getnframes() + compression = source.getcomptype() + if ( + compression != "NONE" + or channels != CHANNELS + or width != SAMPLE_WIDTH + or rate != SAMPLE_RATE + or frames < 1 + ): + raise AudioFormatError( + "backend output must be non-empty 44.1 kHz stereo PCM16 WAV" + ) + read_frames = 0 + while read_frames < frames: + chunk_frames = min(65_536, frames - read_frames) + chunk = source.readframes(chunk_frames) + if len(chunk) != chunk_frames * CHANNELS * SAMPLE_WIDTH: + raise AudioFormatError("backend WAV payload is truncated") + read_frames += chunk_frames + except AudioFormatError: + raise + except (EOFError, wave.Error, OverflowError): + raise AudioFormatError("backend produced a corrupt WAV") from None + return NormalizedAudio(wav=data, frames=frames, seconds=frames / SAMPLE_RATE) + + +def validate_output_wav(data: bytes, seconds: float) -> bytes: + output = inspect_canonical_wav(data) + expected_frames = round(seconds * SAMPLE_RATE) + if output.frames != expected_frames: + raise AudioFormatError( + f"backend produced {output.frames} frames; expected {expected_frames}" + ) + return data diff --git a/backend/lsdj/sa3_contract.py b/backend/lsdj/sa3_contract.py new file mode 100644 index 0000000..1e1c773 --- /dev/null +++ b/backend/lsdj/sa3_contract.py @@ -0,0 +1,143 @@ +"""Runtime-neutral Stable Audio 3 service contract. + +The desktop app owns this contract. MLX and TFLite are implementation details: +they receive the same validated request and must either honour every populated +control or reject it explicitly. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum + + +class BackendName(StrEnum): + MLX = "mlx" + TFLITE = "tflite" + + +class GenerationMode(StrEnum): + TEXT_TO_AUDIO = "text_to_audio" + AUDIO_TO_AUDIO = "audio_to_audio" + INPAINT = "inpaint" + CONTINUATION = "continuation" + + +@dataclass(frozen=True) +class GenerationRequest: + prompt: str + seconds: float + kind: str + init_audio: bytes | None = None + init_noise_level: float | None = None + inpaint_range: tuple[float, float] | None = None + negative_prompt: str | None = None + cfg: float | None = None + apg: float | None = None + seed: int | None = None + steps: int = 8 + lora_dirs: Sequence[str] | None = None + lora_strengths: Sequence[float] | None = None + + def mode(self, *, input_seconds: float | None = None) -> GenerationMode: + if self.init_audio is None: + return GenerationMode.TEXT_TO_AUDIO + if self.inpaint_range is None: + return GenerationMode.AUDIO_TO_AUDIO + start, end = self.inpaint_range + one_sample = 1 / 44_100 + if ( + input_seconds is not None + and self.seconds > input_seconds + and abs(start - input_seconds) <= one_sample + and abs(end - self.seconds) <= one_sample + ): + return GenerationMode.CONTINUATION + return GenerationMode.INPAINT + + +@dataclass(frozen=True) +class BackendCapabilities: + backend: BackendName + modes: tuple[GenerationMode, ...] + controls: tuple[str, ...] + models: tuple[str, ...] + progress: bool + cancellation: bool + preview: bool + limitations: tuple[str, ...] + + def as_dict(self) -> dict: + return { + "backend": self.backend.value, + "modes": [mode.value for mode in self.modes], + "controls": list(self.controls), + "models": list(self.models), + "progress": self.progress, + "cancellation": self.cancellation, + "preview": self.preview, + "limitations": list(self.limitations), + } + + +@dataclass(frozen=True) +class ProgressEvent: + stage: str + current: int | None + total: int | None + message: str + + def as_dict(self) -> dict: + return { + "stage": self.stage, + "current": self.current, + "total": self.total, + "message": self.message, + } + + +COMMON_MODES = tuple(GenerationMode) +COMMON_CONTROLS = ( + "positive_prompt", + "negative_prompt", + "duration", + "steps", + "seed", + "init_noise_level", + "cfg", + "apg", + "inpaint_range", + "lora", +) +COMMON_MODELS = ("small_music", "small_sfx", "medium") + +MLX_CAPABILITIES = BackendCapabilities( + backend=BackendName.MLX, + modes=COMMON_MODES, + controls=COMMON_CONTROLS, + models=COMMON_MODELS, + progress=True, + cancellation=True, + preview=False, + limitations=("The pinned MLX CLI does not expose partial audio previews.",), +) + +TFLITE_CAPABILITIES = BackendCapabilities( + backend=BackendName.TFLITE, + modes=COMMON_MODES, + controls=COMMON_CONTROLS, + models=COMMON_MODELS, + progress=True, + cancellation=True, + preview=False, + limitations=( + "The official TFLite CLI does not expose partial audio previews.", + "Per-step LoRA gating is MLX-only; LSDJ supports TFLite LoRA strength but not step ranges.", + "The portable backend is CPU-only and does not use an NVIDIA GPU.", + ), +) + + +def capabilities_for(backend: BackendName) -> BackendCapabilities: + return MLX_CAPABILITIES if backend is BackendName.MLX else TFLITE_CAPABILITIES diff --git a/backend/tests/test_controller.py b/backend/tests/test_controller.py index 1720317..81d05b9 100644 --- a/backend/tests/test_controller.py +++ b/backend/tests/test_controller.py @@ -123,6 +123,7 @@ async def fake_generate(prompt, seconds, kind, **options): cfg=4.5, apg=0.75, seed=12345, + steps=12, ), ) assert response.status_code == 200 @@ -137,6 +138,7 @@ async def fake_generate(prompt, seconds, kind, **options): "apg": 0.75, "negative_prompt": "vocals", "seed": 12345, + "steps": 12, }, ) ] @@ -292,6 +294,7 @@ async def fake_generate(prompt, seconds, kind, **options): monkeypatch.setattr(controller.sa3, "generate", fake_generate) response = client.post("/api/generate", files=generate_multipart(metadata, source)) assert response.status_code == 200 + normalized = sa3.normalize_wav(source).wav assert calls == [ ( "vinyl spinback", @@ -301,7 +304,7 @@ async def fake_generate(prompt, seconds, kind, **options): "init_noise_level": 0.55, "seed": 7, "inpaint_range": (0.0, 3.0), - "init_audio": source, + "init_audio": normalized, }, ) ] @@ -323,6 +326,7 @@ async def fake_generate(prompt, seconds, kind, **options): cfg=sa3.MIN_CFG, apg=sa3.MIN_APG, seed=sa3.MAX_SEED, + steps=sa3.MIN_STEPS, ), ) assert response.status_code == 200 @@ -333,6 +337,7 @@ async def fake_generate(prompt, seconds, kind, **options): "apg": sa3.MIN_APG, "negative_prompt": "kick", "seed": sa3.MAX_SEED, + "steps": sa3.MIN_STEPS, } ] @@ -353,6 +358,7 @@ async def fake_generate(prompt, seconds, kind, **options): cfg=sa3.MAX_CFG, apg=sa3.MAX_APG, seed=0, + steps=sa3.MAX_STEPS, ), ) assert response.status_code == 200 @@ -363,6 +369,7 @@ async def fake_generate(prompt, seconds, kind, **options): "apg": sa3.MAX_APG, "negative_prompt": "kick", "seed": 0, + "steps": sa3.MAX_STEPS, } ] @@ -455,6 +462,11 @@ async def fake_generate(prompt, seconds, kind): # pragma: no cover {"seed": 1.5}, {"seed": -1}, {"seed": sa3.MAX_SEED + 1}, + {"steps": None}, + {"steps": True}, + {"steps": 1.5}, + {"steps": sa3.MIN_STEPS - 1}, + {"steps": sa3.MAX_STEPS + 1}, {"inpaint_range": None}, {"inpaint_range": []}, {"inpaint_range": [0]}, @@ -500,9 +512,6 @@ async def fake_generate(prompt, seconds, kind, **options): [ b"", b"not a wave", - pcm16_wav(sample_rate=48_000), - pcm16_wav(channels=3), - pcm16_wav(sample_width=1), pcm16_wav(frames=0), pcm16_wav()[:-4], ], @@ -518,6 +527,24 @@ async def fake_generate(prompt, seconds, kind, **options): # pragma: no cover assert response.status_code == 422 +def test_generate_normalizes_sample_rate_width_and_channel_layout(client, monkeypatch): + calls = [] + source = pcm16_wav(sample_rate=48_000, channels=3, sample_width=1, frames=48) + + async def fake_generate(prompt, seconds, kind, **options): + calls.append(options["init_audio"]) + return b"RIFFwav" + + monkeypatch.setattr(controller.sa3, "generate", fake_generate) + response = client.post( + "/api/generate", files=generate_multipart(generate_request(), source) + ) + assert response.status_code == 200 + normalized = sa3.inspect_canonical_wav(calls[0]) + assert normalized.frames == 44 + assert normalized.seconds == pytest.approx(44 / 44_100) + + def test_generate_rejects_an_oversized_init_file(client, monkeypatch): monkeypatch.setattr(sa3, "MAX_INIT_AUDIO_BYTES", 48) response = client.post( @@ -634,6 +661,22 @@ async def fake_generate(prompt, seconds, kind): assert "no DiT weights" in response.json()["detail"] +def test_generate_maps_cancellation_to_499(client, monkeypatch): + async def fake_generate(prompt, seconds, kind): + raise controller.sa3.GenerationCancelled("generation cancelled") + + monkeypatch.setattr(controller.sa3, "generate", fake_generate) + response = client.post("/api/generate", json=generate_request()) + assert response.status_code == 499 + + +def test_sa3_status_exposes_the_runtime_contract(client, monkeypatch): + monkeypatch.setattr(controller.sa3, "status", lambda: {"backend": "tflite"}) + response = client.get("/api/sa3/status") + assert response.status_code == 200 + assert response.json() == {"backend": "tflite"} + + # --- /api/render (M18, the third Magenta engine) -------------------------- diff --git a/backend/tests/test_runtime_paths.py b/backend/tests/test_runtime_paths.py index bff70f8..1563be8 100644 --- a/backend/tests/test_runtime_paths.py +++ b/backend/tests/test_runtime_paths.py @@ -28,17 +28,32 @@ def test_all_roots_preserve_spaces_and_non_ascii(): ) -def test_compatibility_overrides_win_without_home_guessing(): +def test_backend_neutral_override_wins_without_home_guessing(): env = { "LSDJ_ASSETS_HOME": "/host/assets", - "SA3_MLX_HOME": "/custom/SA 3", + "SA3_HOME": "/custom/portable SA 3", + "SA3_TFLITE_HOME": "/custom/TFLite SA 3", + "SA3_MLX_HOME": "/custom/MLX SA 3", + } + assert runtime_paths.sa3_home(env) == pathlib.Path("/custom/portable SA 3") + + +def test_backend_specific_compatibility_overrides_win_without_home_guessing(): + env = { + "LSDJ_ASSETS_HOME": "/host/assets", + "SA3_TFLITE_HOME": "/custom/TFLite SA 3", + "SA3_MLX_HOME": "/custom/MLX SA 3", "SA3_LORAS_HOME": "/custom/适配器", } - assert runtime_paths.sa3_home(env) == pathlib.Path("/custom/SA 3") + assert runtime_paths.sa3_home(env) == pathlib.Path("/custom/TFLite SA 3") assert runtime_paths.loras_home(env) == pathlib.Path("/custom/适配器") assert runtime_paths.sa3_home({}) is None assert runtime_paths.loras_home({}) is None + assert runtime_paths.sa3_home({"SA3_MLX_HOME": "/custom/MLX SA 3"}) == ( + pathlib.Path("/custom/MLX SA 3") + ) + def test_venv_interpreter_layout_is_platform_specific_and_structured(): venv = pathlib.Path("/profiles/DJ Name/模型/.venv") diff --git a/backend/tests/test_sa3.py b/backend/tests/test_sa3.py index 5fb5505..dde30d4 100644 --- a/backend/tests/test_sa3.py +++ b/backend/tests/test_sa3.py @@ -1,249 +1,427 @@ -"""sa3 generation tests: checkout resolution and the subprocess contract. - -A copied or linked Python interpreter runs a fake sa3_mlx CLI so the real spawn -path — argument passing, --out handling, failure, and timeout mapping — is -exercised without MLX or weights. -""" +"""Model-free contract tests for the MLX and TFLite Stable Audio adapters.""" import asyncio +import io +import json import os import pathlib import shutil import sys +import wave import pytest -from lsdj import sa3 +from lsdj import runtime_paths, sa3 +from lsdj.sa3_contract import BackendName, GenerationMode, GenerationRequest -FAKE_WAV = b"RIFFfakewavdata" -# Writes the fake WAV to whatever follows --out and records one argv element per -# line beside the copied venv interpreter so tests can assert the exact CLI -# contract. This is Python rather than a shell stub so the subprocess contract -# runs unchanged on macOS, Linux, and Windows without a model runtime. -SUCCESS_STUB = """import pathlib +SUCCESS_STUB = r"""import os +import pathlib import shutil import sys - -args = [sys.argv[0], *sys.argv[1:]] -runtime_dir = pathlib.Path(sys.executable).parent -(runtime_dir / "argv.txt").write_text("\\n".join(args) + "\\n") -out = pathlib.Path(sys.argv[sys.argv.index("--out") + 1]) +import wave + +runtime_dir = pathlib.Path(__file__).resolve().parent.parent +(runtime_dir / "argv.txt").write_text("\n".join(sys.argv) + "\n") +(runtime_dir / "env.txt").write_text( + f"offline={os.environ.get('HF_HUB_OFFLINE')}\n" + f"token={os.environ.get('HF_TOKEN')}\n" + f"threads={os.environ.get('OMP_NUM_THREADS')}\n" +) if "--init-audio" in sys.argv: - init_audio = pathlib.Path(sys.argv[sys.argv.index("--init-audio") + 1]) - shutil.copyfile(init_audio, runtime_dir / "init.wav") -out.write_bytes(b"RIFFfakewavdata") + source = pathlib.Path(sys.argv[sys.argv.index("--init-audio") + 1]) + shutil.copyfile(source, runtime_dir / "init.wav") +seconds = float(sys.argv[sys.argv.index("--seconds") + 1]) +frames = round(seconds * 44100) +out = pathlib.Path(sys.argv[sys.argv.index("--out") + 1]) +with wave.open(str(out), "wb") as target: + target.setnchannels(2) + target.setsampwidth(2) + target.setframerate(44100) + target.writeframes(b"\0" * frames * 4) """ +PROGRESS_STUB = SUCCESS_STUB.replace( + "seconds = float", 'print("sampling step 1/2", flush=True)\nseconds = float' +).replace("frames = round", 'print("sampling step 2/2", flush=True)\nframes = round') + FAILURE_STUB = """import sys +print("prompt super secret user prompt") print("error: no DiT weights found") sys.exit(3) """ - -# Exits cleanly without writing the WAV. -SILENT_STUB = """pass +SILENT_STUB = "pass\n" +CORRUPT_STUB = """import pathlib, sys +pathlib.Path(sys.argv[sys.argv.index("--out") + 1]).write_bytes(b"RIFFbad") """ - -TIMEOUT_STUB = """import time -time.sleep(30) +WRONG_DURATION_STUB = """import pathlib, sys, wave +out = pathlib.Path(sys.argv[sys.argv.index("--out") + 1]) +with wave.open(str(out), "wb") as target: + target.setnchannels(2); target.setsampwidth(2); target.setframerate(44100) + target.writeframes(b"\\0" * round(0.25 * 44100) * 4) """ +TIMEOUT_STUB = "import time\ntime.sleep(30)\n" -def make_checkout(root: pathlib.Path, stub_body: str) -> pathlib.Path: - """Lay out /optimized/mlx with a portable fake CLI runtime.""" - mlx_dir = root / "optimized" / "mlx" - (mlx_dir / ".venv" / "bin").mkdir(parents=True) - (mlx_dir / "scripts").mkdir() - (mlx_dir / "scripts" / "sa3_mlx.py").write_text(stub_body) - (mlx_dir / ".venv" / "pyvenv.cfg").write_text( - f"home = {sys.base_prefix}\n" - "include-system-site-packages = false\n" - f"version = {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n" +def pcm16_wav(seconds: float = 0.25) -> bytes: + output = io.BytesIO() + with wave.open(output, "wb") as target: + target.setnchannels(2) + target.setsampwidth(2) + target.setframerate(44_100) + target.writeframes(b"\0" * round(seconds * 44_100) * 4) + return output.getvalue() + + +def _install_interpreter(runtime_dir: pathlib.Path, platform_name: str) -> pathlib.Path: + executable = runtime_paths.venv_python( + runtime_dir / ".venv", platform=platform_name ) - python = mlx_dir / ".venv" / "bin" / "python" - if os.name == "nt": - # Creating symlinks normally requires elevated Windows privileges. - # Keep the extensionless contract probe and the executable name that - # CreateProcess appends when an argv program has no extension. - shutil.copyfile(sys.executable, python) - shutil.copyfile(sys.executable, python.with_suffix(".exe")) + executable.parent.mkdir(parents=True) + if platform_name == sys.platform: + if os.name == "nt": + shutil.copyfile(sys.executable, executable) + else: + executable.symlink_to(sys.executable) else: - # Preserve relocatable interpreter/library relationships on Unix. - python.symlink_to(sys.executable) - return mlx_dir + executable.write_bytes(b"fake") + return executable + + +def make_runtime( + root: pathlib.Path, + backend: BackendName, + stub: str = SUCCESS_STUB, + *, + platform_name: str | None = None, + assets: bool = True, +) -> sa3.RuntimeSelection: + platform_name = sys.platform if platform_name is None else platform_name + subdir = "mlx" if backend is BackendName.MLX else "tflite" + script_name = "sa3_mlx.py" if backend is BackendName.MLX else "sa3_tflite.py" + runtime_dir = root / "optimized" / subdir + script = runtime_dir / "scripts" / script_name + script.parent.mkdir(parents=True) + script.write_text(stub) + executable = _install_interpreter(runtime_dir, platform_name) + (runtime_dir / sa3.WARMED_STAMP).write_text("ready\n") + if backend is BackendName.TFLITE: + (runtime_dir / sa3.TFLITE_PROVENANCE_STAMP).write_text( + json.dumps( + { + "runtime": { + "repo": sa3.TFLITE_RUNTIME_REPO, + "revision": sa3.TFLITE_RUNTIME_REVISION, + }, + "models": { + "repo": sa3.TFLITE_MODELS_REPO, + "revision": sa3.TFLITE_MODELS_REVISION, + }, + } + ) + ) + if backend is BackendName.TFLITE and assets: + request = GenerationRequest("probe", 0.5, "sfx", init_audio=pcm16_wav()) + for relative in sa3._required_tflite_assets(request): + path = runtime_dir / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"fixture") + for kind in ("music", "track"): + request = GenerationRequest("probe", 0.5, kind, init_audio=pcm16_wav()) + for relative in sa3._required_tflite_assets(request): + path = runtime_dir / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"fixture") + return sa3.RuntimeSelection(backend, root, runtime_dir, executable, script) -class TestResolveMlxDir: - def test_env_override_wins(self, tmp_path): - mlx_dir = make_checkout(tmp_path / "elsewhere", SUCCESS_STUB) - resolved = sa3.resolve_mlx_dir( - env={"SA3_MLX_HOME": str(tmp_path / "elsewhere")}, home=tmp_path / "home" +@pytest.fixture +def tflite_runtime(tmp_path, monkeypatch): + def install(stub=SUCCESS_STUB, *, assets=True): + selection = make_runtime( + tmp_path / "Stable Audio 模型", BackendName.TFLITE, stub, assets=assets ) - assert resolved == mlx_dir + monkeypatch.setenv("SA3_HOME", str(selection.checkout)) + monkeypatch.setenv("LSDJ_SA3_BACKEND", "tflite") + return selection - def test_resolves_the_host_supplied_assets_home(self, tmp_path): - assets = tmp_path / "DJ Name" / "模型 assets" - mlx_dir = make_checkout(assets / "stable-audio-3", SUCCESS_STUB) - assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) == mlx_dir + return install - def test_checkout_without_venv_is_skipped(self, tmp_path): - assets = tmp_path / "assets" - checkout = assets / "stable-audio-3" - (checkout / "optimized" / "mlx" / "scripts").mkdir(parents=True) - (checkout / "optimized" / "mlx" / "scripts" / "sa3_mlx.py").write_text("#") - assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) is None - def test_nothing_resolves_to_none(self, tmp_path): - assert sa3.resolve_mlx_dir(env={}) is None +@pytest.mark.parametrize( + ("platform_name", "machine", "expected"), + [ + ("darwin", "arm64", BackendName.MLX), + ("darwin", "aarch64", BackendName.MLX), + ("linux", "x86_64", BackendName.TFLITE), + ("linux", "aarch64", BackendName.TFLITE), + ("win32", "AMD64", BackendName.TFLITE), + ], +) +def test_backend_selection_is_explicit(platform_name, machine, expected): + assert ( + sa3.select_backend({}, platform_name=platform_name, machine=machine) is expected + ) -@pytest.fixture -def checkout(tmp_path, monkeypatch): - """Install a stub checkout, point SA3_MLX_HOME at it, return mlx dir.""" +def test_backend_override_is_validated(): + assert ( + sa3.select_backend( + {"LSDJ_SA3_BACKEND": "tflite"}, + platform_name="darwin", + machine="x86_64", + ) + is BackendName.TFLITE + ) + with pytest.raises(sa3.GenerationUnavailable, match="requires Apple Silicon"): + sa3.select_backend( + {"LSDJ_SA3_BACKEND": "mlx"}, + platform_name="win32", + machine="AMD64", + ) + with pytest.raises(sa3.GenerationUnavailable, match="must be"): + sa3.select_backend( + {"LSDJ_SA3_BACKEND": "cuda"}, + platform_name="linux", + machine="x86_64", + ) - def install(stub_body): - mlx_dir = make_checkout(tmp_path / "sa3", stub_body) - monkeypatch.setenv("SA3_MLX_HOME", str(tmp_path / "sa3")) - return mlx_dir - return install +def test_unsupported_platform_fails_instead_of_guessing(): + with pytest.raises(sa3.GenerationUnavailable, match="no Stable Audio backend"): + sa3.select_backend({}, platform_name="freebsd", machine="x86_64") -class TestGenerate: - def test_returns_wav_bytes(self, checkout): - checkout(SUCCESS_STUB) - wav = asyncio.run(sa3.generate("vinyl spinback", 3.0, "sfx")) - assert wav == FAKE_WAV - - def test_default_cli_argv_is_unchanged(self, checkout): - mlx_dir = checkout(SUCCESS_STUB) - asyncio.run(sa3.generate("deep house loop", 7.74, "music")) - argv = (mlx_dir / ".venv" / "bin" / "argv.txt").read_text().splitlines() - assert argv[:-1] == [ - str(mlx_dir / "scripts" / "sa3_mlx.py"), - "--prompt", - "deep house loop", - "--dit", - "sm-music", - "--decoder", - "same-s", - "--seconds", - "7.74", - "--steps", - "8", - "--out", - ] - assert pathlib.Path(argv[-1]).name == "out.wav" - - def test_passes_the_full_generation_surface_and_init_bytes(self, checkout): - mlx_dir = checkout(SUCCESS_STUB) - init_audio = b"RIFFsource-WAVE" - asyncio.run( - sa3.generate( - "warm dub loop", - 8.0, - "music", - init_audio=init_audio, - init_noise_level=0.6, - inpaint_range=(1.25, 2.5), - negative_prompt="vocals", - cfg=4.5, - apg=0.75, - seed=12345, - ) +def test_runtime_resolution_uses_windows_venv_layout(tmp_path): + selection = make_runtime( + tmp_path / "Audio Runtime", + BackendName.TFLITE, + platform_name="win32", + ) + resolved = sa3.resolve_runtime( + {"SA3_HOME": str(selection.checkout)}, + platform_name="win32", + machine="AMD64", + ) + assert resolved is not None + assert resolved.executable.name == "python.exe" + assert resolved.executable.parent.name == "Scripts" + + +def test_status_exposes_backend_capabilities_and_real_limitations(tmp_path): + selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) + result = sa3.status( + {"SA3_HOME": str(selection.checkout)}, + platform_name="linux", + machine="x86_64", + ) + assert result["state"] == sa3.STATE_READY + assert result["backend"] == "tflite" + assert result["capabilities"]["preview"] is False + assert result["capabilities"]["cancellation"] is True + assert any( + "Per-step LoRA" in item for item in result["capabilities"]["limitations"] + ) + + +def test_status_fails_closed_for_unverified_tflite_provenance(tmp_path): + selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) + (selection.runtime_dir / sa3.TFLITE_PROVENANCE_STAMP).write_text("{}") + result = sa3.status( + {"SA3_HOME": str(selection.checkout)}, + platform_name="linux", + machine="x86_64", + ) + assert result["state"] == sa3.STATE_FAILED + assert "do not match" in result["detail"] + + +def test_generation_modes_include_continuation(): + request = GenerationRequest( + "continue", + 2.0, + "music", + init_audio=b"wav", + inpaint_range=(1.0, 2.0), + ) + assert request.mode(input_seconds=1.0) is GenerationMode.CONTINUATION + assert request.mode(input_seconds=0.5) is GenerationMode.INPAINT + + +def _option(argv: list[str], name: str) -> str: + return argv[argv.index(name) + 1] + + +def test_mlx_and_tflite_translate_the_same_service_controls(tmp_path): + mlx = make_runtime(tmp_path / "mlx-root", BackendName.MLX) + tflite = make_runtime(tmp_path / "tflite-root", BackendName.TFLITE) + request = GenerationRequest( + "warm dub loop", + 0.5, + "music", + init_audio=pcm16_wav(), + init_noise_level=0.6, + inpaint_range=(0.1, 0.4), + negative_prompt="vocals", + cfg=4.5, + apg=0.75, + seed=12345, + steps=12, + lora_dirs=["/adapters/one", "/adapters/two"], + lora_strengths=[0.75, 1.5], + ) + commands = [ + sa3.build_argv( + selection, + request, + out_path=tmp_path / f"{selection.backend}.wav", + init_path=tmp_path / "init.wav", + env={}, ) - argv = (mlx_dir / ".venv" / "bin" / "argv.txt").read_text().splitlines() - init_index = argv.index("--init-audio") - assert pathlib.Path(argv[init_index + 1]).name == "init.wav" - assert argv[init_index + 2 :] == [ - "--init-noise-level", - "0.6", - "--inpaint-range", - "1.25,2.5", - "--negative-prompt", - "vocals", - "--cfg", - "4.5", - "--apg", - "0.75", - "--seed", - "12345", - ] - assert (mlx_dir / ".venv" / "bin" / "init.wav").read_bytes() == init_audio - - def test_passes_one_lora_group_per_adapter_with_its_strength(self, checkout): - # Issue #66 (ADR-0028): each adapter rides the argv as its own - # --lora group — the directory plus a strength=S option (the - # upstream PR #57/#65 CLI syntax). - mlx_dir = checkout(SUCCESS_STUB) - asyncio.run( - sa3.generate( - "maqam phrasing", - 120.0, - "track", - lora_dirs=["/adapters/medium/maqam", "/adapters/medium/breaks"], - lora_strengths=[0.75, 1.5], - ) + for selection in (mlx, tflite) + ] + for flag in ( + "--prompt", + "--dit", + "--decoder", + "--seconds", + "--steps", + "--init-audio", + "--init-noise-level", + "--inpaint-range", + "--negative-prompt", + "--cfg", + "--apg", + "--seed", + ): + assert _option(commands[0], flag) == _option(commands[1], flag) + assert commands[1][commands[1].index("--precision") + 1] == "fp32" + assert commands[1][commands[1].index("--threads") + 1] == "4" + first_lora = commands[1].index("--lora") + assert commands[1][first_lora : first_lora + 6] == [ + "--lora", + "/adapters/one", + "strength=0.75", + "--lora", + "/adapters/two", + "strength=1.5", + ] + + +def test_long_medium_request_maps_to_the_official_model_without_allocating_audio( + tmp_path, +): + selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) + request = GenerationRequest("long-form dub", 380.0, "track", steps=8) + argv = sa3.build_argv( + selection, + request, + out_path=tmp_path / "out.wav", + init_path=None, + env={}, + ) + assert _option(argv, "--dit") == "medium" + assert _option(argv, "--decoder") == "same-l" + assert _option(argv, "--seconds") == "380" + assert sa3.timeout_for(380.0) == sa3.TIMEOUT_SECONDS + 380.0 + + +def test_generate_returns_a_validated_wav_and_runs_offline(tflite_runtime): + selection = tflite_runtime() + wav = asyncio.run(sa3.generate("vinyl spinback", 0.5, "sfx", seed=7)) + assert sa3.inspect_canonical_wav(wav).frames == 22_050 + env = (selection.runtime_dir / "env.txt").read_text() + assert "offline=1" in env + assert "token=None" in env + assert "threads=4" in env + + +def test_generate_passes_full_control_surface_and_normalized_input(tflite_runtime): + selection = tflite_runtime() + source = pcm16_wav() + asyncio.run( + sa3.generate( + "warm dub loop", + 0.5, + "music", + init_audio=source, + init_noise_level=0.6, + inpaint_range=(0.1, 0.4), + negative_prompt="vocals", + cfg=4.5, + apg=0.75, + seed=12345, + steps=12, ) - argv = (mlx_dir / ".venv" / "bin" / "argv.txt").read_text().splitlines() - first = argv.index("--lora") - assert argv[first : first + 6] == [ - "--lora", - "/adapters/medium/maqam", - "strength=0.75", - "--lora", - "/adapters/medium/breaks", - "strength=1.5", - ] - - def test_lora_without_strengths_omits_the_option(self, checkout): - # No strengths → bare --lora groups; the CLI's default (1.0) applies. - mlx_dir = checkout(SUCCESS_STUB) - asyncio.run( - sa3.generate( - "vinyl spinback", 3.0, "sfx", lora_dirs=["/adapters/small/crackle"] - ) + ) + argv = (selection.runtime_dir / "argv.txt").read_text().splitlines() + assert _option(argv, "--steps") == "12" + assert _option(argv, "--inpaint-range") == "0.1,0.4" + assert _option(argv, "--negative-prompt") == "vocals" + assert _option(argv, "--cfg") == "4.5" + assert _option(argv, "--apg") == "0.75" + assert (selection.runtime_dir / "init.wav").read_bytes() == source + + +def test_missing_pinned_asset_fails_before_spawn(tflite_runtime): + selection = tflite_runtime(assets=False) + with pytest.raises(sa3.GenerationUnavailable, match="bundle is incomplete"): + asyncio.run(sa3.generate("anything", 0.5, "sfx")) + assert not (selection.runtime_dir / "argv.txt").exists() + + +def test_cli_failure_is_bounded_and_redacts_the_prompt(tflite_runtime): + tflite_runtime(FAILURE_STUB) + with pytest.raises(sa3.GenerationFailed) as caught: + asyncio.run(sa3.generate("super secret user prompt", 0.5, "sfx")) + assert "no DiT weights" in str(caught.value) + assert "super secret" not in str(caught.value) + + +@pytest.mark.parametrize("stub", [SILENT_STUB, CORRUPT_STUB, WRONG_DURATION_STUB]) +def test_missing_corrupt_or_wrong_duration_output_fails(tflite_runtime, stub): + tflite_runtime(stub) + with pytest.raises(sa3.GenerationFailed): + asyncio.run(sa3.generate("anything", 0.5, "sfx")) + + +def test_timeout_stops_the_worker(tflite_runtime, monkeypatch): + tflite_runtime(TIMEOUT_STUB) + monkeypatch.setattr(sa3, "TIMEOUT_SECONDS", 0.05) + with pytest.raises(sa3.GenerationFailed, match="timed out"): + asyncio.run(sa3.generate("anything", 0.5, "sfx")) + assert sa3.status()["generation"]["state"] == "idle" + + +def test_explicit_cancellation_stops_the_worker(tflite_runtime): + tflite_runtime(TIMEOUT_STUB) + + async def run(): + cancelled = asyncio.Event() + task = asyncio.create_task( + sa3.generate("anything", 0.5, "sfx", cancel_event=cancelled) ) - argv = (mlx_dir / ".venv" / "bin" / "argv.txt").read_text().splitlines() - lora_index = argv.index("--lora") - assert argv[lora_index + 1] == "/adapters/small/crackle" - assert not any(arg.startswith("strength=") for arg in argv) - assert "--lora-strength" not in argv - - def test_tracks_run_the_medium_dit_with_its_decoder(self, checkout): - # M19 (ADR-0013): tracks pair the medium DiT with SAME-L; the - # pad kinds keep the small DiTs with SAME-S. - mlx_dir = checkout(SUCCESS_STUB) - asyncio.run(sa3.generate("late night dub techno", 120.0, "track")) - argv = (mlx_dir / ".venv" / "bin" / "argv.txt").read_text().splitlines() - assert argv[argv.index("--dit") + 1] == "medium" - assert argv[argv.index("--decoder") + 1] == "same-l" - assert argv[argv.index("--seconds") + 1] == "120" - - def test_timeout_scales_with_the_requested_length(self): - assert sa3.timeout_for(3.0) == sa3.TIMEOUT_SECONDS + 3.0 - assert sa3.timeout_for(380.0) == sa3.TIMEOUT_SECONDS + 380.0 - - def test_no_checkout_raises_unavailable(self, monkeypatch, tmp_path): - monkeypatch.delenv("SA3_MLX_HOME", raising=False) - monkeypatch.setenv("LSDJ_ASSETS_HOME", str(tmp_path / "assets")) - with pytest.raises(sa3.GenerationUnavailable): - asyncio.run(sa3.generate("anything", 3.0, "sfx")) - - def test_cli_failure_raises_with_output_tail(self, checkout): - checkout(FAILURE_STUB) - with pytest.raises(sa3.GenerationFailed, match="no DiT weights"): - asyncio.run(sa3.generate("anything", 3.0, "sfx")) - - def test_clean_exit_without_wav_is_a_failure(self, checkout): - checkout(SILENT_STUB) - with pytest.raises(sa3.GenerationFailed): - asyncio.run(sa3.generate("anything", 3.0, "sfx")) - - def test_timeout_kills_and_raises(self, checkout, monkeypatch): - # The deadline is base + seconds (timeout_for), so a short clip - # keeps the test fast while exercising the real kill path. - checkout(TIMEOUT_STUB) - monkeypatch.setattr(sa3, "TIMEOUT_SECONDS", 0.2) - with pytest.raises(sa3.GenerationFailed, match="timed out"): - asyncio.run(sa3.generate("anything", 0.5, "sfx")) + await asyncio.sleep(0.1) + cancelled.set() + await task + + with pytest.raises(sa3.GenerationCancelled, match="cancelled"): + asyncio.run(run()) + assert sa3.status()["generation"]["state"] == "idle" + + +def test_progress_is_normalized_from_the_official_text_stream(tflite_runtime): + tflite_runtime(PROGRESS_STUB) + events = [] + asyncio.run(sa3.generate("anything", 0.5, "sfx", on_progress=events.append)) + assert [(event.stage, event.current, event.total) for event in events] == [ + ("sampling", 1, 2), + ("sampling", 2, 2), + ] + + +def test_no_runtime_raises_unavailable(monkeypatch, tmp_path): + monkeypatch.setenv("LSDJ_SA3_BACKEND", "tflite") + monkeypatch.setenv("SA3_HOME", str(tmp_path / "missing")) + with pytest.raises(sa3.GenerationUnavailable, match="tflite"): + asyncio.run(sa3.generate("anything", 0.5, "sfx")) diff --git a/backend/tests/test_sa3_audio.py b/backend/tests/test_sa3_audio.py new file mode 100644 index 0000000..41483e0 --- /dev/null +++ b/backend/tests/test_sa3_audio.py @@ -0,0 +1,119 @@ +"""Deterministic, model-free Stable Audio WAV boundary tests.""" + +import io +import struct +import wave + +import pytest + +from lsdj import sa3_audio + + +def pcm_wav( + raw: bytes, + *, + sample_rate: int, + channels: int, + sample_width: int, +) -> bytes: + output = io.BytesIO() + with wave.open(output, "wb") as target: + target.setnchannels(channels) + target.setsampwidth(sample_width) + target.setframerate(sample_rate) + target.writeframes(raw) + return output.getvalue() + + +def test_canonical_wav_is_validated_without_reencoding(): + source = pcm_wav( + b"\0" * 100 * 4, + sample_rate=44_100, + channels=2, + sample_width=2, + ) + normalized = sa3_audio.normalize_wav(source) + assert normalized.wav is source + assert normalized.frames == 100 + + +def test_mono_8khz_pcm8_is_resampled_and_duplicated_to_stereo(): + source = pcm_wav( + bytes([128]) * 8_000, + sample_rate=8_000, + channels=1, + sample_width=1, + ) + normalized = sa3_audio.normalize_wav(source) + with wave.open(io.BytesIO(normalized.wav), "rb") as result: + assert result.getframerate() == 44_100 + assert result.getnchannels() == 2 + assert result.getsampwidth() == 2 + assert result.getnframes() == 44_100 + assert result.readframes(1) == b"\0\0\0\0" + + +def test_multichannel_input_keeps_the_first_two_channels(): + frame = struct.pack(" dict: + return json.loads(MANIFEST.read_text()) + + +def test_runtime_and_models_use_immutable_official_revisions(): + manifest = load_manifest() + runtime = manifest["runtime"] + models = manifest["models"] + assert runtime["repo"] == "https://github.com/Stability-AI/stable-audio-3" + assert re.fullmatch(r"[0-9a-f]{40}", runtime["revision"]) + assert models["repo"] == "stabilityai/stable-audio-3-optimized" + assert re.fullmatch(r"[0-9a-f]{40}", models["revision"]) + assert runtime["repo"] == sa3.TFLITE_RUNTIME_REPO + assert runtime["revision"] == sa3.TFLITE_RUNTIME_REVISION + assert models["repo"] == sa3.TFLITE_MODELS_REPO + assert models["revision"] == sa3.TFLITE_MODELS_REVISION + + +def test_every_model_asset_has_a_safe_path_exact_size_and_sha256(): + models = load_manifest()["models"] + assets = [*models["shared"]] + for bundle in models["bundles"].values(): + assets.extend(bundle) + for asset in assets: + assert asset["size"] > 0 + assert re.fullmatch(r"[0-9a-f]{64}", asset["sha256"]) + install_path = pathlib.PurePosixPath(asset["installPath"]) + assert not install_path.is_absolute() + assert ".." not in install_path.parts + assert asset["path"].startswith("tflite/") + + +def test_adapter_preflight_paths_match_the_pinned_manifest(): + manifest = load_manifest()["models"] + installed = {entry["installPath"] for entry in manifest["shared"]} + for entries in manifest["bundles"].values(): + installed.update(entry["installPath"] for entry in entries) + required = set() + for kind in ("sfx", "music", "track"): + request = GenerationRequest("fixture", 0.5, kind, init_audio=b"wav") + required.update(str(path) for path in sa3._required_tflite_assets(request)) + required.remove("models/tokenizer.model") + assert required == installed + + +def test_measured_bundle_storage_totals_are_stable(): + models = load_manifest()["models"] + shared = sum(entry["size"] for entry in models["shared"]) + totals = { + name: shared + sum(entry["size"] for entry in entries) + for name, entries in models["bundles"].items() + } + assert totals == { + "sm-music": 2_836_149_512, + "sm-sfx": 2_836_149_512, + "medium": 10_027_905_456, + } + + +def test_runtime_lock_is_hash_pinned_and_covers_official_direct_dependencies(): + lock = LOCK.read_text() + for package in ( + "ai-edge-litert", + "numpy", + "sentencepiece", + "soundfile", + "huggingface-hub", + ): + assert re.search(rf"(?m)^{re.escape(package)}==", lock) + assert "--hash=sha256:" in lock + requirements = [ + line + for line in lock.splitlines() + if line and not line[0].isspace() and not line.startswith("#") + ] + assert requirements + assert all("==" in requirement for requirement in requirements) diff --git a/docs/stable-audio-backends.md b/docs/stable-audio-backends.md new file mode 100644 index 0000000..b99daa8 --- /dev/null +++ b/docs/stable-audio-backends.md @@ -0,0 +1,95 @@ +# Stable Audio 3 backend contract + +LSDJ selects one Stable Audio backend explicitly: + +- Apple Silicon macOS uses the existing MLX runtime. +- Linux and Windows use the official LiteRT/TFLite CPU runtime. +- Unsupported platforms fail with a diagnostic. They do not silently select a + different runtime. +- `LSDJ_SA3_BACKEND=mlx|tflite` is a diagnostic/developer override. The MLX + override remains restricted to Apple Silicon. + +Both adapters consume the same `GenerationRequest` contract and share one +argument translator. A populated control is either forwarded or rejected; it +is never silently discarded. + +## Feature matrix + +| Capability | MLX | TFLite | Notes | +| --- | --- | --- | --- | +| Music and SFX | Yes | Yes | Official small Music/SFX DiTs | +| Medium / 380 seconds | Yes | Yes | Runtime correctness is model-free tested; Windows/Linux performance still needs hardware evidence | +| Audio-to-audio | Yes | Yes | LSDJ normalizes input before either CLI sees it | +| Inpainting | Yes | Yes | Shared `inpaint_range` control | +| Continuation | Yes | Yes | The official continuation primitive is an inpaint range from source duration to requested duration | +| Positive/negative prompt | Yes | Yes | Negative prompt requires CFG other than 1 | +| Seed, duration, steps, CFG, APG | Yes | Yes | Shared validation and CLI spelling | +| Stacked LoRA with strength | Yes | Yes | TFLite runs fp32 because upstream cannot merge LoRA into quantized graphs | +| Per-step LoRA gating | Yes upstream | No | Not exposed by LSDJ; the TFLite CLI explicitly rejects it | +| Progress | Text stream | Text stream | LSDJ normalizes sampling/decode messages; upstream has no structured progress protocol | +| Cancellation | Process stop | Process stop | A cancelled request stops the isolated generation process | +| Partial audio preview | No | No | Neither pinned CLI exposes audio before the final WAV is written | + +The `/api/sa3/status` endpoint reports the selected backend, readiness, +capabilities, real limitations, and current queued/running state. + +## Audio boundary + +LSDJ accepts bounded, uncompressed integer PCM WAV input (8/16/24/32 bit, +8–384 kHz, mono through 32 channels). It converts internally to the official +runtime format: 44.1 kHz, stereo, PCM16. Mono is duplicated; multichannel input +uses its first two channels, matching the official TFLite path. No system +`ffmpeg`, shell, or media executable is invoked. + +Generation remains outside the audio callback and is serialized across both +backends. The TFLite adapter caps XNNPACK and common numeric runtimes at four +threads by default (configurable from 1–8) and launches at background priority. +Timeouts and output bounds stop a wedged or runaway request. Platform hardware +runs still need to establish practical RAM/CPU admission thresholds while both +MRT2 decks are active. + +Every generated file must be a non-empty 44.1 kHz stereo PCM16 WAV with exactly +`round(seconds * 44100)` frames before it can enter LSDJ's library/player. +Corrupt, truncated, oversized, or wrong-duration output fails the request. + +## Pinned upstream and storage + +The machine-readable trust handoff is +[`sa3-tflite-pin.json`](../sa3-tflite-pin.json): + +- code: `Stability-AI/stable-audio-3` at + `a0b57f5483c4588f827f3552b7d5c6ca2a9687be`; +- models: `stabilityai/stable-audio-3-optimized` at + `6736003cb57d06b7b1fdc36fad31b2a3709e4774`; +- eight fp32 model artifacts carry exact byte counts and SHA-256 digests; +- the official runtime dependency surface is resolved into the universal, + hash-locked `scripts/sa3-tflite-requirements.lock`. + +Measured download totals, including the shared T5Gemma encoder, are: + +- Small Music: 2,836,149,512 bytes; +- Small SFX: 2,836,149,512 bytes; +- both Small models together (shared files deduplicated): 4,674,908,056 bytes; +- Medium: 10,027,905,456 bytes; +- all three models (shared files deduplicated): 14,138,994,904 bytes. + +The app installer must download these pinned artifacts, verify them, and write +the warm/readiness stamp plus `.lsdj-provenance.json` before generation. That +stamp records the exact runtime and model repositories/revisions above; a +missing or mismatched stamp is a failed state. The runtime process receives +`HF_HUB_OFFLINE=1` and no Hugging Face token, so a missing file fails closed +instead of using upstream's mutable first-run downloader. Licensing, +attribution, acknowledgement, and credential UX remain owned by issue #108. + +## Evidence and remaining gates + +The repository suite exercises backend selection, argument parity, every LSDJ +control, PCM normalization, exact output validation, corrupt output, progress, +cancellation, timeouts, missing assets, and the 380-second command contract +without loading model weights. + +This does **not** claim a real model run. Before release, issue #107's secure +installer must consume the new manifest, and Ubuntu plus Windows hardware runs +must verify Small/Medium generation, LoRA, cancellation, storage, RAM/CPU use, +and coexistence with both active MRT2 decks. Partial preview and structured +progress require a future upstream API; they are reported as limitations today. diff --git a/sa3-tflite-pin.json b/sa3-tflite-pin.json new file mode 100644 index 0000000..7a0db6d --- /dev/null +++ b/sa3-tflite-pin.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "runtime": { + "repo": "https://github.com/Stability-AI/stable-audio-3", + "revision": "a0b57f5483c4588f827f3552b7d5c6ca2a9687be", + "subdirectory": "optimized/tflite", + "entrypoint": "scripts/sa3_tflite.py", + "requirementsInput": "scripts/sa3-tflite-requirements.in", + "requirementsLock": "scripts/sa3-tflite-requirements.lock" + }, + "models": { + "repo": "stabilityai/stable-audio-3-optimized", + "revision": "6736003cb57d06b7b1fdc36fad31b2a3709e4774", + "precision": "fp32", + "shared": [ + { + "path": "tflite/t5gemma/encoder_fp16.tflite", + "installPath": "models/tflite/t5gemma/encoder_fp16.tflite", + "size": 563818608, + "sha256": "8530d0b3e6b9b9dcf1239145c2a853fb749708eaddbb472ff8f0802b50059372" + } + ], + "bundles": { + "sm-music": [ + { + "path": "tflite/sa3-sm-music/dit_fp32.tflite", + "installPath": "models/tflite/sa3-sm-music/dit_fp32.tflite", + "size": 1838758544, + "sha256": "d388700a2ca439c11e9a53506e964e93231386a2beb8173c6eec6d95f676ce09" + }, + { + "path": "tflite/same-s/enc_fp32.tflite", + "installPath": "models/tflite/same-s/enc_fp32.tflite", + "size": 215195204, + "sha256": "35ce38ea9f56e116036c683e37bf96c954d4fe0a435606ded0f62595b91f52a3" + }, + { + "path": "tflite/same-s/dec_fp32.tflite", + "installPath": "models/tflite/same-s/dec_fp32.tflite", + "size": 218377156, + "sha256": "cd87fa6686b24a56dc3497e05fbb26a34cf9604afe49c6631e829c9e70fccf21" + } + ], + "sm-sfx": [ + { + "path": "tflite/sa3-sm-sfx/dit_fp32.tflite", + "installPath": "models/tflite/sa3-sm-sfx/dit_fp32.tflite", + "size": 1838758544, + "sha256": "6060ecfeca34c4ab35bc1912a37e680e8cd7aab6c4bd9de1bc2655414891b8d8" + }, + { + "path": "tflite/same-s/enc_fp32.tflite", + "installPath": "models/tflite/same-s/enc_fp32.tflite", + "size": 215195204, + "sha256": "35ce38ea9f56e116036c683e37bf96c954d4fe0a435606ded0f62595b91f52a3" + }, + { + "path": "tflite/same-s/dec_fp32.tflite", + "installPath": "models/tflite/same-s/dec_fp32.tflite", + "size": 218377156, + "sha256": "cd87fa6686b24a56dc3497e05fbb26a34cf9604afe49c6631e829c9e70fccf21" + } + ], + "medium": [ + { + "path": "tflite/sa3-m/dit_fp32.tflite", + "installPath": "models/tflite/sa3-m/dit_fp32.tflite", + "size": 5816313104, + "sha256": "b811dc7d0135ca48afbc7a7bb7d19bdaaad13cbcb592418b8aa169e0c149daba" + }, + { + "path": "tflite/same-l/enc_fp32.tflite", + "installPath": "models/tflite/same-l/enc_fp32.tflite", + "size": 1823872896, + "sha256": "f8b5e95a7073e3b59e4a1c2b07836d86d514cc7eaaff05b3c7cbdd1620f141d5" + }, + { + "path": "tflite/same-l/dec_fp32.tflite", + "installPath": "models/tflite/same-l/dec_fp32.tflite", + "size": 1823900848, + "sha256": "3af34d35939ce6fc74d9f7b9d9bd6b99bc9568b614bcfe57da4a781bf40c8c6c" + } + ] + } + } +} diff --git a/scripts/sa3-tflite-requirements.in b/scripts/sa3-tflite-requirements.in new file mode 100644 index 0000000..9adc59b --- /dev/null +++ b/scripts/sa3-tflite-requirements.in @@ -0,0 +1,8 @@ +# Official optimized/tflite/requirements.txt dependency surface at +# Stability-AI/stable-audio-3@a0b57f5483c4588f827f3552b7d5c6ca2a9687be. +# The generated lock is consumed by issue #107's standalone runtime installer. +ai-edge-litert>=1.0 +numpy>=1.24 +sentencepiece>=0.2 +soundfile>=0.12 +huggingface-hub>=0.20 diff --git a/scripts/sa3-tflite-requirements.lock b/scripts/sa3-tflite-requirements.lock new file mode 100644 index 0000000..dca8864 --- /dev/null +++ b/scripts/sa3-tflite-requirements.lock @@ -0,0 +1,505 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --python-version 3.11 --generate-hashes scripts/sa3-tflite-requirements.in --output-file scripts/sa3-tflite-requirements.lock +ai-edge-litert==2.1.6 \ + --hash=sha256:0156d9051c9a2500cd4c4ecedb77676eff0b45bd8efb1001e8e730a8cd0db4ee \ + --hash=sha256:1a278957648d0aff7b38c03e6f92ca4ad8c1414bb085d505dc97b59b8b768cfa \ + --hash=sha256:2057f8996666e8e2a52ed45fe16d8cc237434a02ed7845d2febedceb1811f66d \ + --hash=sha256:282f3521e31d9d1cb4f60143f6b64fa07a8ac8488b098c37c809b574fa3dc264 \ + --hash=sha256:2e8a3f92fa407690189533bea8b64d49bb1b8a9e96f707ba27e1a64a9c3cc8cf \ + --hash=sha256:303cb32ca33e8d12a9360dd8a130c2a7c94b7e75727da34be2eacab65f0da8c6 \ + --hash=sha256:3349289e114cd1f8396632c93d77f58a94212d2567e64dcdb1e2eca6220d67e6 \ + --hash=sha256:5819dcafe62005483744636dfe429fcd429a29ba382187eeb156dda2ada2f82c \ + --hash=sha256:5adf0c9afde6151dc7f2989d039c800f3060d98d40bb5dfc95e426ad4eb3680b \ + --hash=sha256:8475d18c73698d9244380ad72f67caf540e5c72d9a80c66d17c4b81ca81a21c8 \ + --hash=sha256:af4f2ba681fa2c688746cbd7ddd71a2bbbdd9e6a51aa609d382bfad77d1c695e \ + --hash=sha256:b20f4c8cdbbf6f64e3baa77853e55a1b29515a45d6dfbe6b2ced9b3a1efb5807 \ + --hash=sha256:c7c93fb0dc2a1d45750443731d0193974737f9f1ffb36eb6c1889bdfb61bf091 \ + --hash=sha256:e037e41a15c3285302da8ebf32ff285b085c5616e929ed93cd2f563531d7d998 \ + --hash=sha256:e8aa6393e1293fac837f764b5a9ba3ef81bb16068c94a2068d9dfb109a24e3b9 \ + --hash=sha256:edf598814004e594b40c888f52cae59e950dbeffd821e83ba45d28db0a0aa3f5 \ + --hash=sha256:f5be19734dce243c141106c3699395b62d770a61fedd097b3a10af82006bf57f \ + --hash=sha256:f722298b070343e24634ab51a55b7185d32d8f84d4df8659f9d2989e76d7d63f \ + --hash=sha256:fa58e1ddf39d8d6c190db808bf8289f22987fbf412ed0107a12617108c51bc94 \ + --hash=sha256:fc361114c68c194ce9ee9e6b2748fa40d23aea9e12173225ef0affe823353099 + # via -r scripts/sa3-tflite-requirements.in +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via httpx +backports-strenum==1.3.1 \ + --hash=sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414 \ + --hash=sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83 + # via ai-edge-litert +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via soundfile +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via huggingface-hub +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # tqdm +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 + # via huggingface-hub +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + # via ai-edge-litert +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \ + --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88 + # via huggingface-hub +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +hf-xet==1.6.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' \ + --hash=sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7 \ + --hash=sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675 \ + --hash=sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef \ + --hash=sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9 \ + --hash=sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3 \ + --hash=sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb \ + --hash=sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e \ + --hash=sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338 \ + --hash=sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d \ + --hash=sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d \ + --hash=sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765 \ + --hash=sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c \ + --hash=sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522 \ + --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f \ + --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a \ + --hash=sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b \ + --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b + # via huggingface-hub +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via huggingface-hub +huggingface-hub==1.27.0 \ + --hash=sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d \ + --hash=sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df + # via -r scripts/sa3-tflite-requirements.in +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +numpy==2.4.6 ; python_full_version < '3.12' \ + --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ + --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ + --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ + --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ + --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ + --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ + --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ + --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ + --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ + --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ + --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ + --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ + --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ + --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ + --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ + --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ + --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ + --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ + --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ + --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ + --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ + --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ + --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ + --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ + --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ + --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ + --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ + --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ + --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ + --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ + --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ + --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ + --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ + --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ + --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ + --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ + --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ + --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ + --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ + --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ + --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ + --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ + --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ + --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ + --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ + --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ + --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ + --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ + --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ + --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ + --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ + --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ + --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ + --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ + --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ + --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ + --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ + --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ + --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ + --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ + --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ + --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ + --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ + --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ + --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ + --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ + --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ + --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ + --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ + --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ + --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ + --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 + # via + # -r scripts/sa3-tflite-requirements.in + # ai-edge-litert + # soundfile +numpy==2.5.1 ; python_full_version >= '3.12' \ + --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \ + --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \ + --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \ + --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \ + --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \ + --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \ + --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \ + --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \ + --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \ + --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \ + --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \ + --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \ + --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \ + --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \ + --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \ + --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \ + --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \ + --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \ + --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \ + --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \ + --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \ + --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \ + --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \ + --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \ + --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \ + --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \ + --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \ + --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \ + --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \ + --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \ + --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \ + --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \ + --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \ + --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \ + --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \ + --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \ + --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \ + --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \ + --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \ + --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \ + --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \ + --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \ + --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \ + --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107 + # via + # -r scripts/sa3-tflite-requirements.in + # ai-edge-litert + # soundfile +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via huggingface-hub +protobuf==7.35.1 \ + --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ + --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ + --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ + --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ + --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ + --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ + --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ + --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a + # via ai-edge-litert +pycparser==3.0 ; implementation_name != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via huggingface-hub +sentencepiece==0.2.2 \ + --hash=sha256:046b15ea22d8042e2e173561d464ec3b64a9c2081324df70ebce7bf7ebb3e497 \ + --hash=sha256:0e2aae42960392d6dcb9a72d8e1e65a97294c965071b43c7b3429a42f350250e \ + --hash=sha256:1120e0791540615e650b2e9bea835bf38a7362455d8ab62dee7968219c2d79a0 \ + --hash=sha256:1402d8ee36f0d851cea8eee4dbb85fea14643b7503cf4d00d102eec0fe3ca719 \ + --hash=sha256:1416b92f2f010333786fe6306ed2631121d5ea492219b0841e967b6765e64107 \ + --hash=sha256:16c84ddef8d3084a8af37208acd365b08092ca089080f1a71fbfdd911adda9b3 \ + --hash=sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b \ + --hash=sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a \ + --hash=sha256:252908153eeec06c3ca3a32077e64a49d572e3d89881475b4e0f02d99d9fcc7c \ + --hash=sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838 \ + --hash=sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5 \ + --hash=sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6 \ + --hash=sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d \ + --hash=sha256:3f5851441ab1ef8634963a5100b733a8bbeefe623e0c5c005b1f1f3880e574cf \ + --hash=sha256:3fd9ce2ab4460c713cfdeb4aca693ca6732a11538e05fb332d5af42e3d7fde25 \ + --hash=sha256:44284adc6fbe9d5bdd480541431a3d93f674fa44736714d3ad4bcee8283ace7d \ + --hash=sha256:443ac618c7a2a1377cf5c82581fbb849591d14e656d5e5a3e4682d4e36a34e4e \ + --hash=sha256:46ba07b543add034de0ff47ac5f907e9a06682f91d85121a972764628933be6b \ + --hash=sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd \ + --hash=sha256:524e2a85c028a0d2f9935191fa751e5ef9d9bcc39616f70ab14b28d0369c9936 \ + --hash=sha256:54a83df9260a89c1734256e620fe1f1a6bfedd7547139d4dc1384efac11a3a85 \ + --hash=sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b \ + --hash=sha256:63250cfab8b80a1ef82a614eb2b3cadfec2c405f870cedc139d08e2f063eb708 \ + --hash=sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb \ + --hash=sha256:65d84ec36888de4a848eee5f910e67fbc79b064685ef1e10a502e14520ead9c9 \ + --hash=sha256:69e9dc8078e128286ed3b975e37c837ba96e215a50c3ef9f3f8b7ab9e5a832a0 \ + --hash=sha256:6dd76f3e5c8b2eb8a3a3efee787bbf5b9a66e52a048fe09cab85eca33fec6790 \ + --hash=sha256:70d4ca6f4d06df7f0ccab6fe4f49c8a712c8c8b6847b4f0af9a0e1dbb0e0337e \ + --hash=sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78 \ + --hash=sha256:741b4b367140e9b5c36b5a14c72179f2c946d991ea9a7c031a2a1ee6ad097b99 \ + --hash=sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9 \ + --hash=sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d \ + --hash=sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8 \ + --hash=sha256:79bac5a251f23a7341e28fda9ce0d5319edf45328239ce037c0682936f137906 \ + --hash=sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383 \ + --hash=sha256:7fc14c1585139fa6b68775e616a6b90cf622ebf219f9558c0aeaf5d253ee6c9b \ + --hash=sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53 \ + --hash=sha256:8b2db2056c97224e122054fd794543cde5d24b7cae28424f6e3eb79bbe08e42b \ + --hash=sha256:8d44b20234905ff022b7d535f79d1f823ad7670c9851cc4f03cdc34787cdb3ab \ + --hash=sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0 \ + --hash=sha256:8f1f61592e7cabd45d49ce8cc0ef42ca655c091e037153754fb3fa59725b5914 \ + --hash=sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91 \ + --hash=sha256:bc7b0b1da20f856bfac5f84b2673fe534b167e41980b27442ca8f78c2b7eb77e \ + --hash=sha256:c62bd361cec1f5b556eb8210264ecfff37486cd990c3386cc00310f26c54090a \ + --hash=sha256:c76c9b3324efd79029eeb0fd2ced1964bdbeca7d45e030b46fa3ef3cf74f8032 \ + --hash=sha256:c798f0b327bac10dc95cdac77b9a197ab2bd7dd1e60ebd7586a12d918d4be711 \ + --hash=sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da \ + --hash=sha256:caad9566e2ef0e5640d36032c69b0edc7ac6028277b93d93815898804fac450c \ + --hash=sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a \ + --hash=sha256:cd810878180a52950e5a61f25ada5248a453bbdbafe474f89514135fbc1f633d \ + --hash=sha256:d254c98ca6387655400b3959c33c83efd807f5edeb608e3aca45800ceaa77151 \ + --hash=sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563 \ + --hash=sha256:df88b0c34f2fa909d322f7b06b1398e1e81af4b2f42a7b8e3556f928b25d1811 \ + --hash=sha256:eb8da9d9a9b418422c21a07fd19b9d9228692b7a7468a45eec6b11642d3c808b \ + --hash=sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820 \ + --hash=sha256:fa9f5ef0e2a82233dd0b8b32ea3f5710e0c44afbc07ed3620219f32601e56090 \ + --hash=sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c + # via -r scripts/sa3-tflite-requirements.in +soundfile==0.14.0 \ + --hash=sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849 \ + --hash=sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4 \ + --hash=sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d \ + --hash=sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e \ + --hash=sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8 \ + --hash=sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11 \ + --hash=sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c \ + --hash=sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98 \ + --hash=sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377 + # via -r scripts/sa3-tflite-requirements.in +tqdm==4.70.0 \ + --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 + # via + # ai-edge-litert + # huggingface-hub +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # ai-edge-litert + # anyio + # huggingface-hub + # soundfile From b1095bbe4dacabffb530c2171aeee227ebef4eca Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 16:02:20 -0700 Subject: [PATCH 2/7] feat: add gated Windows SA3 CUDA foundation --- backend/lsdj/gpu_broker.py | 316 ++++++++ backend/lsdj/mrt2_pytorch.py | 64 +- backend/lsdj/sa3.py | 78 +- backend/lsdj/sa3_contract.py | 23 +- backend/lsdj/sa3_cuda.py | 318 ++++++++ backend/lsdj/sa3_cuda_worker.py | 543 +++++++++++++ backend/runtime-locks/windows-gpu-pytorch.in | 22 + backend/runtime-locks/windows-gpu-pytorch.txt | 756 ++++++++++++++++++ backend/tests/test_gpu_broker.py | 137 ++++ backend/tests/test_mrt2_pytorch.py | 32 +- backend/tests/test_sa3.py | 30 + backend/tests/test_sa3_cuda.py | 124 +++ backend/tests/test_sa3_cuda_pins.py | 64 ++ backend/tests/test_sa3_cuda_worker.py | 248 ++++++ ...038-windows-sa3-cuda-qualification-gate.md | 72 ++ docs/adr/README.md | 1 + docs/issue-114-windows-sa3-cuda-checklist.md | 126 +++ docs/stable-audio-backends.md | 64 +- sa3-pytorch-cuda-pin.json | 93 +++ scripts/audit-sa3-cuda-pin.py | 102 +++ src-tauri/src/models.rs | 107 +++ 21 files changed, 3286 insertions(+), 34 deletions(-) create mode 100644 backend/lsdj/gpu_broker.py create mode 100644 backend/lsdj/sa3_cuda.py create mode 100644 backend/lsdj/sa3_cuda_worker.py create mode 100644 backend/runtime-locks/windows-gpu-pytorch.in create mode 100644 backend/runtime-locks/windows-gpu-pytorch.txt create mode 100644 backend/tests/test_gpu_broker.py create mode 100644 backend/tests/test_sa3_cuda.py create mode 100644 backend/tests/test_sa3_cuda_pins.py create mode 100644 backend/tests/test_sa3_cuda_worker.py create mode 100644 docs/adr/0038-windows-sa3-cuda-qualification-gate.md create mode 100644 docs/issue-114-windows-sa3-cuda-checklist.md create mode 100644 sa3-pytorch-cuda-pin.json create mode 100644 scripts/audit-sa3-cuda-pin.py diff --git a/backend/lsdj/gpu_broker.py b/backend/lsdj/gpu_broker.py new file mode 100644 index 0000000..6458386 --- /dev/null +++ b/backend/lsdj/gpu_broker.py @@ -0,0 +1,316 @@ +"""Cross-process NVIDIA work admission for MRT2 and Stable Audio 3. + +The audio decks have strict priority. A Stable Audio lease is admitted only +when no MRT2 generation is running or waiting and its measured reservation fits +inside the caller-provided VRAM budget. If MRT2 arrives while Stable Audio is +sampling, the SA3 callback observes the waiter and cancels its disposable child +process before MRT2 is admitted. + +State lives below the app-owned cache root and is guarded with an OS file lock; +no daemon, shell command, system Python, or third-party lock package is needed. +Dead-process records are pruned on every operation, so a killed worker cannot +leave the GPU permanently reserved. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import enum +import json +import os +import pathlib +import tempfile +import time +import uuid +from collections.abc import Callable, Iterator +from typing import Any + + +SCHEMA_VERSION = 1 +MAX_RECORDS = 32 +DEFAULT_POLL_SECONDS = 0.05 + + +class Priority(enum.IntEnum): + SA3_BACKGROUND = 10 + MRT2_REALTIME = 100 + + +class BrokerError(RuntimeError): + """The broker state is invalid or work cannot be admitted safely.""" + + +class BrokerCancelled(BrokerError): + """The caller cancelled while waiting for the GPU.""" + + +class BrokerTimeout(BrokerError): + """The caller's bounded admission deadline expired.""" + + +@dataclasses.dataclass(frozen=True) +class Lease: + token: str + service: str + priority: Priority + reservation_bytes: int + pid: int + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + if pid == os.getpid(): + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + # Windows can reject signal 0 for an otherwise-live process. Retaining + # the record is safer than admitting overlapping GPU work. + return True + return True + + +@contextlib.contextmanager +def _os_file_lock(path: pathlib.Path) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink(): + raise BrokerError("GPU broker lock path must not be a symlink") + with path.open("a+b") as handle: + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +class GpuBroker: + def __init__( + self, + root: pathlib.Path, + *, + poll_seconds: float = DEFAULT_POLL_SECONDS, + clock: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, + pid_alive: Callable[[int], bool] = _pid_alive, + ) -> None: + if poll_seconds <= 0: + raise ValueError("poll_seconds must be positive") + self.root = root + self.state_path = root / "state.json" + self.lock_path = root / "state.lock" + self.poll_seconds = poll_seconds + self._clock = clock + self._sleep = sleeper + self._pid_alive = pid_alive + + def _empty_state(self) -> dict[str, Any]: + return {"schema_version": SCHEMA_VERSION, "waiters": [], "leases": []} + + def _read_state(self) -> dict[str, Any]: + if not self.state_path.exists(): + return self._empty_state() + if self.state_path.is_symlink(): + raise BrokerError("GPU broker state path must not be a symlink") + try: + parsed = json.loads(self.state_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise BrokerError("GPU broker state is unreadable") from error + if ( + not isinstance(parsed, dict) + or parsed.get("schema_version") != SCHEMA_VERSION + ): + raise BrokerError("GPU broker state has an unsupported schema") + for field in ("waiters", "leases"): + records = parsed.get(field) + if not isinstance(records, list) or len(records) > MAX_RECORDS: + raise BrokerError(f"GPU broker {field} are invalid") + for record in records: + if not self._valid_record(record): + raise BrokerError(f"GPU broker {field} contain an invalid record") + return parsed + + @staticmethod + def _valid_record(record: Any) -> bool: + return ( + isinstance(record, dict) + and isinstance(record.get("token"), str) + and 1 <= len(record["token"]) <= 64 + and isinstance(record.get("service"), str) + and 1 <= len(record["service"]) <= 64 + and isinstance(record.get("priority"), int) + and not isinstance(record["priority"], bool) + and record["priority"] in {int(item) for item in Priority} + and isinstance(record.get("reservation_bytes"), int) + and not isinstance(record["reservation_bytes"], bool) + and record["reservation_bytes"] >= 0 + and isinstance(record.get("pid"), int) + and record["pid"] > 0 + ) + + def _write_state(self, state: dict[str, Any]) -> None: + self.root.mkdir(parents=True, exist_ok=True) + if self.state_path.is_symlink(): + raise BrokerError("GPU broker state path must not be a symlink") + descriptor, temporary_name = tempfile.mkstemp( + prefix="state.", suffix=".tmp", dir=self.root + ) + temporary = pathlib.Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(state, handle, sort_keys=True, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, self.state_path) + finally: + with contextlib.suppress(FileNotFoundError): + temporary.unlink() + + def _prune(self, state: dict[str, Any]) -> None: + for field in ("waiters", "leases"): + state[field] = [ + record for record in state[field] if self._pid_alive(record["pid"]) + ] + + @contextlib.contextmanager + def _locked_state(self) -> Iterator[dict[str, Any]]: + with _os_file_lock(self.lock_path): + state = self._read_state() + self._prune(state) + yield state + self._write_state(state) + + @staticmethod + def _record(lease: Lease) -> dict[str, Any]: + return { + "token": lease.token, + "service": lease.service, + "priority": int(lease.priority), + "reservation_bytes": lease.reservation_bytes, + "pid": lease.pid, + } + + def acquire( + self, + service: str, + priority: Priority, + *, + reservation_bytes: int, + capacity_bytes: int, + timeout_seconds: float, + cancelled: Callable[[], bool] = lambda: False, + ) -> Lease: + if not service or len(service) > 64: + raise ValueError("service must contain 1-64 characters") + if reservation_bytes < 0 or capacity_bytes < 0: + raise ValueError("GPU byte counts must not be negative") + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + lease = Lease( + token=uuid.uuid4().hex, + service=service, + priority=priority, + reservation_bytes=reservation_bytes, + pid=os.getpid(), + ) + record = self._record(lease) + deadline = self._clock() + timeout_seconds + registered = False + try: + while True: + if cancelled(): + raise BrokerCancelled(f"{service} GPU request was cancelled") + if self._clock() >= deadline: + raise BrokerTimeout(f"{service} timed out waiting for the GPU") + with self._locked_state() as state: + if not registered: + if len(state["waiters"]) + len(state["leases"]) >= MAX_RECORDS: + raise BrokerError( + "GPU broker is at its bounded record limit" + ) + state["waiters"].append(record) + registered = True + higher_waiting = any( + item["token"] != lease.token + and item["priority"] > int(priority) + for item in state["waiters"] + ) + active_higher = any( + item["priority"] > int(priority) for item in state["leases"] + ) + active_lower = any( + item["priority"] < int(priority) for item in state["leases"] + ) + reserved = sum( + item["reservation_bytes"] for item in state["leases"] + ) + fits = reservation_bytes <= max(0, capacity_bytes - reserved) + if ( + not higher_waiting + and not active_higher + and not active_lower + and fits + ): + state["waiters"] = [ + item + for item in state["waiters"] + if item["token"] != lease.token + ] + state["leases"].append(record) + return lease + self._sleep(self.poll_seconds) + except Exception: + if registered: + self._remove(lease.token) + raise + + def _remove(self, token: str) -> None: + with self._locked_state() as state: + for field in ("waiters", "leases"): + state[field] = [item for item in state[field] if item["token"] != token] + + def release(self, lease: Lease) -> None: + self._remove(lease.token) + + @contextlib.contextmanager + def hold(self, *args: Any, **kwargs: Any) -> Iterator[Lease]: + lease = self.acquire(*args, **kwargs) + try: + yield lease + finally: + self.release(lease) + + def should_yield(self, lease: Lease) -> bool: + with self._locked_state() as state: + live = any(item["token"] == lease.token for item in state["leases"]) + if not live: + raise BrokerError("GPU lease is no longer live") + return any( + item["priority"] > int(lease.priority) for item in state["waiters"] + ) + + def diagnostics(self) -> dict[str, Any]: + with self._locked_state() as state: + return json.loads(json.dumps(state)) diff --git a/backend/lsdj/mrt2_pytorch.py b/backend/lsdj/mrt2_pytorch.py index 8551ccb..b64d9b1 100644 --- a/backend/lsdj/mrt2_pytorch.py +++ b/backend/lsdj/mrt2_pytorch.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import importlib.metadata import math import threading @@ -17,6 +18,7 @@ import numpy as np from . import runtime_paths +from .gpu_broker import GpuBroker, Priority from .engine import ( CFG_MUSICCOCA, CFG_NOTES, @@ -121,6 +123,7 @@ def __init__( selection: RuntimeSelection, bindings: PytorchBindings | None = None, cache_root: Path | None = None, + gpu_broker: GpuBroker | None = None, ) -> None: if selection.name != PYTORCH_CUDA_RUNTIME: raise RuntimeUnavailable( @@ -189,6 +192,14 @@ def __init__( self._model = model self._model_pin = model_pin self._model_lock = threading.RLock() + broker_root = runtime_paths.cache_home() + self._gpu_broker = ( + gpu_broker + if gpu_broker is not None + else None + if broker_root is None + else GpuBroker(broker_root / "gpu-broker") + ) self._warmup_owner = True self._init_deck_state() @@ -217,6 +228,7 @@ def shared_deck(self) -> "PytorchMrt2Engine": deck._model = self._model deck._model_pin = self._model_pin deck._model_lock = self._model_lock + deck._gpu_broker = self._gpu_broker deck._warmup_owner = False deck._init_deck_state() return deck @@ -353,21 +365,40 @@ def _generate( ) -> tuple[np.ndarray, Any]: notes = self._notes if stream_conditioning else None drums = self._drums if stream_conditioning else None - with self._model_lock: - audio, state = self._system.generate( - style=style, - notes=notes, - drums=None if drums is None else [drums], - cfg_drums=self._drums_cfg if stream_conditioning else None, - temperature=self._temperature, - top_k=self._top_k, - cfg_musiccoca=self._cfg_musiccoca, - cfg_notes=self._cfg_notes, - frames=frames, - seed=self._seed, - state=state, - guidance=True, + broker_hold = ( + contextlib.nullcontext() + if self._gpu_broker is None + else self._gpu_broker.hold( + "mrt2", + priority=Priority.MRT2_REALTIME, + reservation_bytes=0, + capacity_bytes=int( + self._bindings.torch.cuda.get_device_properties( + self._bindings.torch.cuda.current_device() + ).total_memory + ), + timeout_seconds=max(10.0, frames * FRAME_SECONDS), ) + ) + # Acquire the cross-process priority lease before the in-process model + # lock. A waiting MRT2 lease makes a background SA3 callback cancel its + # disposable process, while the two deck states remain serialized here. + with broker_hold: + with self._model_lock: + audio, state = self._system.generate( + style=style, + notes=notes, + drums=None if drums is None else [drums], + cfg_drums=self._drums_cfg if stream_conditioning else None, + temperature=self._temperature, + top_k=self._top_k, + cfg_musiccoca=self._cfg_musiccoca, + cfg_notes=self._cfg_notes, + frames=frames, + seed=self._seed, + state=state, + guidance=True, + ) samples = np.asarray(audio) expected = frames * round(SAMPLE_RATE * FRAME_SECONDS) if samples.ndim != 2 or samples.shape != (expected, CHANNELS): @@ -433,6 +464,11 @@ def diagnostics(self) -> dict[str, object]: "accelerator": "cuda", "acceleration_mode": "eager-guidance", "topology": "shared-worker-two-state", + "gpu_broker": { + "enabled": self._gpu_broker is not None, + "priority": int(Priority.MRT2_REALTIME), + "preempts": "sa3-background", + }, "hardware_qualified": self._selection.hardware_qualified, "experimental": self._selection.experimental, "model": self._model, diff --git a/backend/lsdj/sa3.py b/backend/lsdj/sa3.py index d611ebf..c46cd2c 100644 --- a/backend/lsdj/sa3.py +++ b/backend/lsdj/sa3.py @@ -22,7 +22,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass -from . import runtime_paths +from . import runtime_paths, sa3_cuda from .sa3_audio import AudioFormatError, inspect_canonical_wav, normalize_wav from .sa3_audio import validate_output_wav as _validate_output_wav from .sa3_contract import ( @@ -58,6 +58,7 @@ TIMEOUT_SECONDS = 120 TFLITE_THREADS_DEFAULT = 4 TFLITE_THREADS_MAX = 8 +SA3_PREFERENCE_ENV = "LSDJ_SA3_PREFERENCE" STATE_MISSING = "missing" STATE_VENV_MISSING = "venv_missing" @@ -362,6 +363,7 @@ def status( platform_name: str | None = None, machine: str | None = None, ) -> dict: + env = os.environ if env is None else env ready = readiness(env, platform_name=platform_name, machine=machine) backend_value = ready["backend"] capabilities = ( @@ -369,8 +371,35 @@ def status( if backend_value is None else capabilities_for(BackendName(backend_value)).as_dict() ) + platform_value = sys.platform if platform_name is None else platform_name + machine_value = host_platform.machine() if machine is None else machine + preference = env.get(SA3_PREFERENCE_ENV, sa3_cuda.BackendPreference.AUTO.value) + cuda = None + if platform_value == "win32" and _normalise_arch(machine_value) == "x86_64": + evidence = sa3_cuda.CudaEvidence( + platform=platform_value, + machine=machine_value, + runtime_ready=False, + provenance_complete=False, + packages={}, + cuda_available=False, + cuda_runtime=None, + driver=None, + device=None, + compute_capability=None, + total_vram_bytes=None, + free_vram_bytes=None, + estimated_vram_bytes={"music": None, "sfx": None, "track": None}, + ) + cuda = sa3_cuda.diagnostic_manifest( + evidence, tflite_ready=ready["state"] == STATE_READY, env=env + ) return { **ready, + "activeBackend": backend_value, + "preference": preference, + "preferenceChoices": [item.value for item in sa3_cuda.BackendPreference], + "cuda": cuda, "capabilities": capabilities, "generation": dict(_generation_state), "maxSeconds": dict(MAX_SECONDS_FOR), @@ -683,6 +712,53 @@ async def generate( lora_dirs=lora_dirs, lora_strengths=lora_strengths, ) + preference_value = os.environ.get( + SA3_PREFERENCE_ENV, sa3_cuda.BackendPreference.AUTO.value + ) + try: + preference = sa3_cuda.BackendPreference(preference_value) + except ValueError: + raise GenerationUnavailable( + f"{SA3_PREFERENCE_ENV} must be auto, gpu, or cpu_tflite" + ) from None + if preference is sa3_cuda.BackendPreference.GPU: + platform_value, machine_value = sa3_cuda.host_identity() + evidence = sa3_cuda.CudaEvidence( + platform=platform_value, + machine=machine_value, + runtime_ready=False, + provenance_complete=False, + packages={}, + cuda_available=False, + cuda_runtime=None, + driver=None, + device=None, + compute_capability=None, + total_vram_bytes=None, + free_vram_bytes=None, + estimated_vram_bytes={kind: None}, + ) + try: + sa3_cuda.choose_backend( + preference, + kind=kind, + cuda=evidence, + tflite_ready=resolve_runtime() is not None, + ) + except sa3_cuda.CudaUnavailable as error: + fallback = ( + " Choose CPU/TFLite to confirm the fallback." + if error.fallback_available + else "" + ) + raise GenerationUnavailable(f"{error}{fallback}") from None + # The release gate currently makes this unreachable. Do not start the + # experimental worker through the public endpoint until the installer + # can produce a complete provenance stamp and hardware evidence flips + # HARDWARE_QUALIFIED in the same reviewed change. + raise GenerationUnavailable( + "the CUDA worker is not exposed until its release gates are complete" + ) try: selection = resolve_runtime() except GenerationUnavailable: diff --git a/backend/lsdj/sa3_contract.py b/backend/lsdj/sa3_contract.py index 1e1c773..e4704ca 100644 --- a/backend/lsdj/sa3_contract.py +++ b/backend/lsdj/sa3_contract.py @@ -15,6 +15,7 @@ class BackendName(StrEnum): MLX = "mlx" TFLITE = "tflite" + PYTORCH_CUDA = "pytorch_cuda" class GenerationMode(StrEnum): @@ -138,6 +139,26 @@ def as_dict(self) -> dict: ), ) +PYTORCH_CUDA_CAPABILITIES = BackendCapabilities( + backend=BackendName.PYTORCH_CUDA, + modes=COMMON_MODES, + controls=COMMON_CONTROLS, + models=("small_music", "small_sfx"), + progress=True, + cancellation=True, + preview=False, + limitations=( + "Windows x64 with a qualified NVIDIA GPU, driver, runtime, and measured VRAM is required.", + "Stable Audio Medium remains on TFLite until an official Windows FlashAttention build is qualified.", + "There is no PyTorch CPU fallback; CPU mode uses the separate TFLite runtime.", + "The disposable worker releases its CUDA context after every generation.", + ), +) + def capabilities_for(backend: BackendName) -> BackendCapabilities: - return MLX_CAPABILITIES if backend is BackendName.MLX else TFLITE_CAPABILITIES + return { + BackendName.MLX: MLX_CAPABILITIES, + BackendName.TFLITE: TFLITE_CAPABILITIES, + BackendName.PYTORCH_CUDA: PYTORCH_CUDA_CAPABILITIES, + }[backend] diff --git a/backend/lsdj/sa3_cuda.py b/backend/lsdj/sa3_cuda.py new file mode 100644 index 0000000..ee6e4dc --- /dev/null +++ b/backend/lsdj/sa3_cuda.py @@ -0,0 +1,318 @@ +"""Fail-closed policy for the optional Windows Stable Audio CUDA worker. + +The official TFLite backend remains the portable baseline. This module only +selects PyTorch when its immutable shared runtime, gated model provenance, +Windows NVIDIA driver, measured VRAM reservation, and release qualification are +all present. An explicit qualification opt-in may exercise unmeasured hardware, +but it never enables automatic selection or a PyTorch CPU fallback. +""" + +from __future__ import annotations + +import enum +import os +import platform as host_platform +import re +import sys +from dataclasses import asdict, dataclass +from typing import Mapping + + +BACKEND_NAME = "pytorch_cuda" +UNVERIFIED_OPT_IN = "LSDJ_ALLOW_UNVERIFIED_SA3_CUDA" +CUDA_RUNTIME = "12.6" +MIN_WINDOWS_DRIVER = (560, 76) +VRAM_HEADROOM_BYTES = 1024**3 + +# Flipped only in a PR that carries the completed physical-hardware evidence. +HARDWARE_QUALIFIED = False + +EXPECTED_PACKAGES = { + "torch": "2.7.1+cu126", + "torchaudio": "2.7.1+cu126", + "transformers": "5.8.0", + "huggingface-hub": "1.7.1", + "numpy": "2.3.5", + "safetensors": "0.7.0", + "sentencepiece": "0.2.1", + "resampy": "0.4.3", +} +SOURCE_REVISION = "a0b57f5483c4588f827f3552b7d5c6ca2a9687be" +RUNTIME_LOCK_SHA256 = "3c9bf7d79c3848ebe1da40fd14b26708b55d8157f008cb3a1944ddfb1cd597c4" +MODEL_PINS = { + "music": { + "repository": "stabilityai/stable-audio-3-small-music", + "revision": "0fef1392cd842149a2b6d445e181c97608faac06", + }, + "sfx": { + "repository": "stabilityai/stable-audio-3-small-sfx", + "revision": "ae12755283df9d62ca39a9b050a39a0b607b8c20", + }, +} + + +class BackendPreference(enum.StrEnum): + AUTO = "auto" + GPU = "gpu" + CPU_TFLITE = "cpu_tflite" + + +class CudaUnavailable(RuntimeError): + def __init__( + self, + message: str, + *, + reason: str, + fallback_available: bool, + ) -> None: + super().__init__(message) + self.reason = reason + self.fallback_available = fallback_available + + +@dataclass(frozen=True) +class CudaEvidence: + platform: str + machine: str + runtime_ready: bool + provenance_complete: bool + packages: Mapping[str, str] + cuda_available: bool + cuda_runtime: str | None + driver: str | None + device: str | None + compute_capability: tuple[int, int] | None + total_vram_bytes: int | None + free_vram_bytes: int | None + estimated_vram_bytes: Mapping[str, int | None] + source_revision: str | None = None + model_revision: str | None = None + + def as_dict(self) -> dict[str, object]: + value = asdict(self) + if self.compute_capability is not None: + value["compute_capability"] = list(self.compute_capability) + return value + + +@dataclass(frozen=True) +class BackendDecision: + backend: str + preference: BackendPreference + reason: str + fallback: bool + + def as_dict(self) -> dict[str, object]: + value = asdict(self) + value["preference"] = self.preference.value + return value + + +def _normalise_platform(platform_name: str) -> str: + value = platform_name.lower() + if value.startswith(("win32", "cygwin", "msys")): + return "windows" + return value + + +def _normalise_machine(machine: str) -> str: + value = machine.lower() + return "x86_64" if value in {"amd64", "x86_64"} else value + + +def _truthy(value: str | None) -> bool: + return value is not None and value.strip().lower() in {"1", "true", "yes", "on"} + + +def parse_driver_version(version: str | None) -> tuple[int, int] | None: + if version is None: + return None + match = re.fullmatch(r"\s*(\d{3,4})\.(\d{1,3})(?:\.\d+)?\s*", version) + if match is None: + return None + return int(match.group(1)), int(match.group(2)) + + +def runtime_errors( + evidence: CudaEvidence, + *, + kind: str, + allow_unmeasured_vram: bool = False, +) -> list[str]: + errors = [] + if ( + _normalise_platform(evidence.platform) != "windows" + or _normalise_machine(evidence.machine) != "x86_64" + ): + errors.append("the CUDA Stable Audio backend supports Windows x64 only") + if not evidence.runtime_ready: + errors.append("the shared app-owned PyTorch runtime is not ready") + if not evidence.provenance_complete: + errors.append("the gated Stable Audio model provenance is incomplete") + if evidence.source_revision != SOURCE_REVISION: + errors.append( + "the installed Stable Audio source revision does not match the pin" + ) + expected_model = MODEL_PINS.get(kind) + if ( + expected_model is not None + and evidence.model_revision != expected_model["revision"] + ): + errors.append( + "the installed Stable Audio model revision does not match the pin" + ) + mismatched = { + name: (evidence.packages.get(name), expected) + for name, expected in EXPECTED_PACKAGES.items() + if evidence.packages.get(name) != expected + } + if mismatched: + errors.append( + "the installed shared PyTorch dependency versions do not match the pin" + ) + if not evidence.cuda_available: + errors.append( + "PyTorch reports no CUDA device; there is no PyTorch CPU fallback" + ) + if evidence.cuda_runtime != CUDA_RUNTIME: + errors.append( + f"the installed PyTorch CUDA runtime is {evidence.cuda_runtime or 'unknown'}, " + f"not the pinned {CUDA_RUNTIME} runtime" + ) + driver = parse_driver_version(evidence.driver) + if driver is None: + errors.append("the NVIDIA display driver version could not be verified") + elif driver < MIN_WINDOWS_DRIVER: + errors.append( + "the NVIDIA driver is older than the provisional CUDA 12.6 floor " + f"{MIN_WINDOWS_DRIVER[0]}.{MIN_WINDOWS_DRIVER[1]}" + ) + if kind == "track": + errors.append( + "Stable Audio Medium requires FlashAttention 2; no official Windows " + "wheel has been qualified, so Medium remains on TFLite" + ) + estimate = evidence.estimated_vram_bytes.get(kind) + if estimate is None: + if not allow_unmeasured_vram: + errors.append(f"{kind} has no qualified VRAM reservation yet") + elif evidence.free_vram_bytes is None: + errors.append("free CUDA memory could not be measured") + elif evidence.free_vram_bytes < estimate + VRAM_HEADROOM_BYTES: + errors.append( + f"{kind} needs an estimated {estimate} bytes plus " + f"{VRAM_HEADROOM_BYTES} bytes headroom, but only " + f"{evidence.free_vram_bytes} bytes are free" + ) + return errors + + +def choose_backend( + preference: BackendPreference | str, + *, + kind: str, + cuda: CudaEvidence, + tflite_ready: bool, + env: Mapping[str, str] | None = None, +) -> BackendDecision: + try: + preference = BackendPreference(preference) + except ValueError: + raise CudaUnavailable( + "Stable Audio preference must be auto, gpu, or cpu_tflite", + reason="invalid_preference", + fallback_available=tflite_ready, + ) from None + environment = os.environ if env is None else env + + if preference is BackendPreference.CPU_TFLITE: + if not tflite_ready: + raise CudaUnavailable( + "the requested TFLite backend is not installed and ready", + reason="tflite_not_ready", + fallback_available=False, + ) + return BackendDecision("tflite", preference, "CPU/TFLite was selected", False) + + experimental = _truthy(environment.get(UNVERIFIED_OPT_IN)) + errors = runtime_errors( + cuda, + kind=kind, + allow_unmeasured_vram=(preference is BackendPreference.GPU and experimental), + ) + release_ready = HARDWARE_QUALIFIED and not errors + qualification_ready = experimental and not errors + + if preference is BackendPreference.GPU: + if not (release_ready or qualification_ready): + if not HARDWARE_QUALIFIED and not experimental: + errors.insert( + 0, + "the Windows CUDA backend is implemented but not release-qualified; " + f"{UNVERIFIED_OPT_IN}=1 is reserved for hardware qualification", + ) + raise CudaUnavailable( + "; ".join(errors) if errors else "the CUDA backend is unavailable", + reason="cuda_not_eligible", + fallback_available=tflite_ready, + ) + return BackendDecision( + BACKEND_NAME, + preference, + "explicit experimental GPU qualification" + if not HARDWARE_QUALIFIED + else "explicit GPU selection", + False, + ) + + if release_ready: + return BackendDecision( + BACKEND_NAME, preference, "qualified CUDA backend", False + ) + if tflite_ready: + return BackendDecision( + "tflite", + preference, + "; ".join(errors) + if errors + else "CUDA hardware qualification is incomplete", + True, + ) + raise CudaUnavailable( + "; ".join(errors + ["the TFLite fallback is not ready"]), + reason="no_ready_backend", + fallback_available=False, + ) + + +def diagnostic_manifest( + cuda: CudaEvidence, + *, + tflite_ready: bool, + env: Mapping[str, str] | None = None, +) -> dict[str, object]: + environment = os.environ if env is None else env + return { + "backend": BACKEND_NAME, + "release_ready": HARDWARE_QUALIFIED, + "qualification_opt_in": _truthy(environment.get(UNVERIFIED_OPT_IN)), + "cpu_fallback": False, + "tflite_fallback_ready": tflite_ready, + "cuda_runtime_pin": CUDA_RUNTIME, + "minimum_windows_driver_provisional": ( + f"{MIN_WINDOWS_DRIVER[0]}.{MIN_WINDOWS_DRIVER[1]}" + ), + "vram_headroom_bytes": VRAM_HEADROOM_BYTES, + "evidence": cuda.as_dict(), + "qualification_blockers": [ + "authenticated hashes for gated Stable Audio and T5Gemma artifacts", + "MRT2 parity on the shared torch 2.7.1/CUDA 12.6 runtime", + "measured Small Music and Small SFX VRAM reservations", + "Windows NVIDIA cancellation/OOM/crash/VRAM-release evidence", + "two active MRT2 decks for ten minutes at 25- and 5-frame scheduling", + ], + } + + +def host_identity() -> tuple[str, str]: + return sys.platform, host_platform.machine() diff --git a/backend/lsdj/sa3_cuda_worker.py b/backend/lsdj/sa3_cuda_worker.py new file mode 100644 index 0000000..ecf9c76 --- /dev/null +++ b/backend/lsdj/sa3_cuda_worker.py @@ -0,0 +1,543 @@ +"""Disposable Stable Audio 3 PyTorch/CUDA worker. + +The controller writes one bounded JSON request and starts this module with the +app-owned shared PyTorch interpreter. Heavyweight imports and model allocation +occur only in this child. Cancellation, an MRT2 priority waiter, CUDA OOM, a +driver reset, or any other failure ends the process, releasing its CUDA context +without affecting the deck workers or native audio callback. +""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib.metadata +import json +import os +import pathlib +import platform as host_platform +import sys +import wave +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Protocol + +import numpy as np + +from . import sa3_cuda +from .gpu_broker import GpuBroker, Lease, Priority + + +SCHEMA_VERSION = 1 +SAMPLE_RATE = 44_100 +CHANNELS = 2 +MAX_JSON_BYTES = 64 * 1024 +MODEL_FOR_KIND = {"music": "small-music", "sfx": "small-sfx"} + + +class WorkerError(RuntimeError): + pass + + +class WorkerCancelled(WorkerError): + pass + + +class ModelProtocol(Protocol): + def load_lora(self, paths: Sequence[str]) -> None: ... + + def set_lora_strength( + self, strength: float, lora_index: int | None = None + ) -> None: ... + + def generate(self, **kwargs: Any) -> Any: ... + + +@dataclass(frozen=True) +class WorkerRequest: + prompt: str + seconds: float + kind: str + steps: int + cfg: float | None + apg: float | None + seed: int | None + negative_prompt: str | None + init_noise_level: float | None + inpaint_range: tuple[float, float] | None + init_audio: pathlib.Path | None + lora_files: tuple[pathlib.Path, ...] + lora_strengths: tuple[float, ...] + model_dir: pathlib.Path + output: pathlib.Path + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "WorkerRequest": + if value.get("schema_version") != SCHEMA_VERSION: + raise WorkerError("unsupported CUDA worker request schema") + prompt = value.get("prompt") + kind = value.get("kind") + seconds = value.get("seconds") + steps = value.get("steps") + if not isinstance(prompt, str) or not prompt or len(prompt) > 32_000: + raise WorkerError("prompt is invalid") + if kind not in MODEL_FOR_KIND: + raise WorkerError("CUDA supports only Small Music and Small SFX") + if ( + isinstance(seconds, bool) + or not isinstance(seconds, (int, float)) + or not 0.5 <= float(seconds) <= 32.0 + ): + raise WorkerError("seconds is invalid") + if ( + isinstance(steps, bool) + or not isinstance(steps, int) + or not 1 <= steps <= 100 + ): + raise WorkerError("steps is invalid") + inpaint = value.get("inpaint_range") + if inpaint is not None: + if ( + not isinstance(inpaint, list) + or len(inpaint) != 2 + or any( + isinstance(item, bool) or not isinstance(item, (int, float)) + for item in inpaint + ) + or not 0 <= float(inpaint[0]) < float(inpaint[1]) <= float(seconds) + ): + raise WorkerError("inpaint_range is invalid") + inpaint = (float(inpaint[0]), float(inpaint[1])) + lora_files = value.get("lora_files", []) + lora_strengths = value.get("lora_strengths", []) + if ( + not isinstance(lora_files, list) + or not isinstance(lora_strengths, list) + or len(lora_files) != len(lora_strengths) + or len(lora_files) > 4 + or any(not isinstance(item, str) for item in lora_files) + or any( + isinstance(item, bool) + or not isinstance(item, (int, float)) + or not 0 <= float(item) <= 4 + for item in lora_strengths + ) + ): + raise WorkerError("LoRA stack is invalid") + init_audio = value.get("init_audio") + if init_audio is not None and not isinstance(init_audio, str): + raise WorkerError("init_audio is invalid") + if inpaint is not None and init_audio is None: + raise WorkerError("inpainting requires init_audio") + return cls( + prompt=prompt, + seconds=float(seconds), + kind=kind, + steps=steps, + cfg=_optional_float(value, "cfg"), + apg=_optional_float(value, "apg"), + seed=_optional_int(value, "seed"), + negative_prompt=_optional_string(value, "negative_prompt"), + init_noise_level=_optional_float(value, "init_noise_level"), + inpaint_range=inpaint, + init_audio=None if init_audio is None else pathlib.Path(init_audio), + lora_files=tuple(pathlib.Path(item) for item in lora_files), + lora_strengths=tuple(float(item) for item in lora_strengths), + model_dir=pathlib.Path(_required_string(value, "model_dir")), + output=pathlib.Path(_required_string(value, "output")), + ) + + +def _required_string(value: Mapping[str, Any], field: str) -> str: + item = value.get(field) + if not isinstance(item, str) or not item: + raise WorkerError(f"{field} is invalid") + return item + + +def _optional_string(value: Mapping[str, Any], field: str) -> str | None: + item = value.get(field) + if item is None: + return None + if not isinstance(item, str) or not item or len(item) > 32_000: + raise WorkerError(f"{field} is invalid") + return item + + +def _optional_float(value: Mapping[str, Any], field: str) -> float | None: + item = value.get(field) + if item is None: + return None + if isinstance(item, bool) or not isinstance(item, (int, float)): + raise WorkerError(f"{field} is invalid") + result = float(item) + if not np.isfinite(result): + raise WorkerError(f"{field} is invalid") + return result + + +def _optional_int(value: Mapping[str, Any], field: str) -> int | None: + item = value.get(field) + if item is None: + return None + if isinstance(item, bool) or not isinstance(item, int): + raise WorkerError(f"{field} is invalid") + return item + + +def read_request(path: pathlib.Path) -> WorkerRequest: + if path.is_symlink() or not path.is_file(): + raise WorkerError("CUDA worker request must be a regular file") + if path.stat().st_size > MAX_JSON_BYTES: + raise WorkerError("CUDA worker request is too large") + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise WorkerError("CUDA worker request is unreadable") from error + if not isinstance(parsed, dict): + raise WorkerError("CUDA worker request must be an object") + return WorkerRequest.from_dict(parsed) + + +def verify_provenance(path: pathlib.Path, request: WorkerRequest) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_JSON_BYTES: + raise WorkerError("CUDA provenance must be a bounded regular file") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise WorkerError("CUDA provenance is unreadable") from error + expected = { + "schema_version": 1, + "backend": sa3_cuda.BACKEND_NAME, + "gated_artifacts_complete": True, + "source_revision": sa3_cuda.SOURCE_REVISION, + "runtime_lock_sha256": sa3_cuda.RUNTIME_LOCK_SHA256, + "packages": sa3_cuda.EXPECTED_PACKAGES, + "model": sa3_cuda.MODEL_PINS[request.kind], + } + if value != expected: + raise WorkerError( + "CUDA provenance does not match the immutable source/runtime/model pin" + ) + root = path.parent.resolve(strict=True) + model_dir = request.model_dir.resolve(strict=True) + expected_model_dir = (root / "models" / MODEL_FOR_KIND[request.kind]).resolve() + if model_dir != expected_model_dir: + raise WorkerError("CUDA model path is outside its verified runtime bundle") + return value + + +def _single_safetensors(path: pathlib.Path) -> pathlib.Path: + if path.suffix == ".safetensors" and path.is_file() and not path.is_symlink(): + return path + if path.is_dir() and not path.is_symlink(): + hits = [ + item + for item in path.iterdir() + if item.is_file() + and not item.is_symlink() + and item.suffix == ".safetensors" + ] + if len(hits) == 1: + return hits[0] + raise WorkerError("each LoRA must resolve to exactly one regular safetensors file") + + +def _load_pcm16(path: pathlib.Path) -> Any: + try: + with wave.open(str(path), "rb") as source: + if ( + source.getnchannels() != CHANNELS + or source.getsampwidth() != 2 + or source.getframerate() != SAMPLE_RATE + or source.getcomptype() != "NONE" + ): + raise WorkerError("init audio is not canonical PCM16") + frames = source.getnframes() + raw = source.readframes(frames) + except (EOFError, OSError, wave.Error) as error: + raise WorkerError("init audio is unreadable") from error + if frames < 1 or len(raw) != frames * CHANNELS * 2: + raise WorkerError("init audio is empty or truncated") + return ( + np.frombuffer(raw, dtype=" np.ndarray: + if isinstance(audio, np.ndarray): + return audio + value = audio.detach().to("cpu").float().numpy() + return np.asarray(value) + + +def write_pcm16(path: pathlib.Path, audio: Any, seconds: float) -> None: + samples = _to_numpy(audio) + if samples.ndim == 3 and samples.shape[0] == 1: + samples = samples[0] + if samples.ndim != 2 or samples.shape[0] != CHANNELS: + raise WorkerError(f"upstream returned invalid audio shape {samples.shape!r}") + frames = round(seconds * SAMPLE_RATE) + if samples.shape[1] < frames or not np.isfinite(samples[:, :frames]).all(): + raise WorkerError("upstream returned short or non-finite audio") + clipped = np.clip(samples[:, :frames], -1.0, 1.0) + pcm = np.where(clipped <= -1, -32768, np.rint(clipped * 32767)).astype(" dict[str, Any]: + init = None + if request.init_audio is not None: + waveform = torch_module.from_numpy(_load_pcm16(request.init_audio)) + init = (SAMPLE_RATE, waveform) + kwargs: dict[str, Any] = { + "prompt": request.prompt, + "negative_prompt": request.negative_prompt, + "duration": request.seconds, + "steps": request.steps, + "cfg_scale": 1.0 if request.cfg is None else request.cfg, + "apg_scale": 1.0 if request.apg is None else request.apg, + "seed": -1 if request.seed is None else request.seed, + "batch_size": 1, + "chunked_decode": True, + "callback": lambda info: progress(int(info["i"]) + 1, request.steps), + "disable_tqdm": True, + } + if request.inpaint_range is not None: + kwargs.update( + { + "inpaint_audio": init, + "inpaint_mask_start_seconds": request.inpaint_range[0], + "inpaint_mask_end_seconds": request.inpaint_range[1], + "init_audio": None, + } + ) + else: + kwargs.update( + { + "init_audio": init, + "init_noise_level": ( + 0.9 + if request.init_noise_level is None + else request.init_noise_level + ), + "inpaint_audio": None, + } + ) + return kwargs + + +def run_generation( + request: WorkerRequest, + *, + model: ModelProtocol, + torch_module: Any, + cancelled: Callable[[], bool], + broker: GpuBroker | None = None, + lease: Lease | None = None, + emit: Callable[[dict[str, object]], None] = lambda event: None, +) -> None: + lora_files = [_single_safetensors(path) for path in request.lora_files] + if lora_files: + model.load_lora([str(path) for path in lora_files]) + for index, strength in enumerate(request.lora_strengths): + model.set_lora_strength(strength, lora_index=index) + + def progress(current: int, total: int) -> None: + if cancelled(): + raise WorkerCancelled("Stable Audio generation was cancelled") + if broker is not None and lease is not None and broker.should_yield(lease): + raise WorkerCancelled("Stable Audio yielded to realtime MRT2 generation") + emit( + { + "event": "progress", + "stage": "sampling", + "current": current, + "total": total, + } + ) + + kwargs = generation_kwargs(request, torch_module=torch_module, progress=progress) + audio = model.generate(**kwargs) + emit({"event": "progress", "stage": "decoding", "current": None, "total": None}) + write_pcm16(request.output, audio, request.seconds) + emit({"event": "done"}) + + +def _package_versions() -> dict[str, str]: + versions = {} + for name in sa3_cuda.EXPECTED_PACKAGES: + try: + versions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + versions[name] = "missing" + return versions + + +def _nvml_driver_version() -> str | None: + if os.name != "nt": + return None + import ctypes + + library = None + try: + library = ctypes.WinDLL("nvml.dll") + if library.nvmlInit_v2() != 0: + return None + buffer = ctypes.create_string_buffer(96) + if library.nvmlSystemGetDriverVersion(buffer, len(buffer)) != 0: + return None + return buffer.value.decode("ascii", "strict") + except (AttributeError, OSError, UnicodeDecodeError): + return None + finally: + if library is not None: + with contextlib.suppress(Exception): + library.nvmlShutdown() + + +def _load_production_runtime() -> tuple[Any, Callable[[WorkerRequest], ModelProtocol]]: + try: + import torch + from stable_audio_3.loading_utils import load_diffusion_cond + from stable_audio_3.model import StableAudioModel + except ImportError as error: + raise WorkerError( + "the pinned Stable Audio PyTorch dependency is missing" + ) from error + + def load(request: WorkerRequest) -> ModelProtocol: + config = request.model_dir / "model_config.json" + checkpoint = request.model_dir / "model.safetensors" + if any( + path.is_symlink() or not path.is_file() for path in (config, checkpoint) + ): + raise WorkerError("the verified Stable Audio model bundle is incomplete") + try: + model_config = json.loads(config.read_text(encoding="utf-8")) + upstream = load_diffusion_cond( + model_config, str(checkpoint), device="cuda", model_half=True + ) + upstream.use_lora = False + upstream.lora_names = [] + return StableAudioModel(upstream, model_config, "cuda", True) + except Exception as error: + raise WorkerError( + "the pinned Stable Audio model could not initialize" + ) from error + + return torch, load + + +def _emit(event: dict[str, object]) -> None: + sys.stdout.write(json.dumps(event, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="LSDJ disposable SA3 CUDA worker") + parser.add_argument("--request", required=True) + parser.add_argument("--cancel-file", required=True) + parser.add_argument("--broker-root", required=True) + parser.add_argument("--provenance", required=True) + parser.add_argument("--reservation-bytes", required=True, type=int) + args = parser.parse_args(argv) + request = read_request(pathlib.Path(args.request)) + provenance = verify_provenance(pathlib.Path(args.provenance), request) + cancel_file = pathlib.Path(args.cancel_file) + model: ModelProtocol | None = None + torch: Any | None = None + try: + torch, load_model = _load_production_runtime() + versions = _package_versions() + if not torch.cuda.is_available(): + raise WorkerError( + "PyTorch reports no CUDA device; there is no PyTorch CPU fallback" + ) + free_bytes, total_bytes = torch.cuda.mem_get_info() + properties = torch.cuda.get_device_properties(torch.cuda.current_device()) + evidence = sa3_cuda.CudaEvidence( + platform=sys.platform, + machine=os.environ.get("PROCESSOR_ARCHITECTURE") or host_platform.machine(), + runtime_ready=True, + provenance_complete=True, + packages=versions, + cuda_available=torch.cuda.is_available(), + cuda_runtime=torch.version.cuda, + driver=_nvml_driver_version(), + device=properties.name, + compute_capability=tuple(torch.cuda.get_device_capability()), + total_vram_bytes=int(total_bytes), + free_vram_bytes=int(free_bytes), + estimated_vram_bytes={request.kind: args.reservation_bytes}, + source_revision=provenance["source_revision"], + model_revision=provenance["model"]["revision"], + ) + errors = sa3_cuda.runtime_errors(evidence, kind=request.kind) + if errors: + raise WorkerError("; ".join(errors)) + broker = GpuBroker(pathlib.Path(args.broker_root)) + capacity = max(0, int(free_bytes) - sa3_cuda.VRAM_HEADROOM_BYTES) + with broker.hold( + "sa3", + priority=Priority.SA3_BACKGROUND, + reservation_bytes=args.reservation_bytes, + capacity_bytes=capacity, + timeout_seconds=120, + cancelled=cancel_file.exists, + ) as lease: + _emit( + { + "event": "progress", + "stage": "loading", + "current": None, + "total": None, + } + ) + model = load_model(request) + run_generation( + request, + model=model, + torch_module=torch, + cancelled=cancel_file.exists, + broker=broker, + lease=lease, + emit=_emit, + ) + return 0 + except WorkerCancelled as error: + _emit({"event": "cancelled", "message": str(error)}) + return 2 + except Exception as error: + # Only our bounded, path-free errors cross the worker boundary. Unknown + # upstream/OS errors are intentionally reduced to their class name so a + # prompt, token, or app-owned filesystem path cannot leak into logs. + message = ( + str(error)[:512] + if isinstance(error, WorkerError) + else f"CUDA worker failed ({type(error).__name__})" + ) + _emit({"event": "error", "message": message}) + return 1 + finally: + model = None + if torch is not None: + with contextlib.suppress(Exception): + torch.cuda.empty_cache() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/runtime-locks/windows-gpu-pytorch.in b/backend/runtime-locks/windows-gpu-pytorch.in new file mode 100644 index 0000000..4f3c7f8 --- /dev/null +++ b/backend/runtime-locks/windows-gpu-pytorch.in @@ -0,0 +1,22 @@ +# Candidate shared Windows NVIDIA runtime for MRT2 and Stable Audio 3. +# +# Stable Audio 3's immutable upstream source pins torch/torchaudio 2.7.1 and +# requires huggingface-hub >=1.7.1. MRT2's adapter surface is model-free tested +# against this one environment, but the candidate remains release-blocked until +# both models complete the physical-GPU qualification matrix in issue #114. +--index-url https://pypi.org/simple +--extra-index-url https://download.pytorch.org/whl/cu126 + +einops==0.8.2 +einops-exts==0.0.4 +huggingface-hub==1.7.1 +numpy==2.3.5 +packaging==26.0 +resampy==0.4.3 +safetensors==0.7.0 +sentencepiece==0.2.1 +soundfile==0.13.1 +torch==2.7.1+cu126 +torchaudio==2.7.1+cu126 +tqdm==4.67.3 +transformers==5.8.0 diff --git a/backend/runtime-locks/windows-gpu-pytorch.txt b/backend/runtime-locks/windows-gpu-pytorch.txt new file mode 100644 index 0000000..4fc4ed5 --- /dev/null +++ b/backend/runtime-locks/windows-gpu-pytorch.txt @@ -0,0 +1,756 @@ +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + # via typer +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via httpx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via soundfile +colorama==0.4.6 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # tqdm + # typer +einops==0.8.2 \ + --hash=sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193 \ + --hash=sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827 + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # einops-exts +einops-exts==0.0.4 \ + --hash=sha256:616f145b3411f8e9e3be5da5c968bbe372e55c249de11faa909c7a4b74580a6c \ + --hash=sha256:6d310a4c858e459ebff8288580f90255d354cfa3bde22a53b59baae64b48cb95 + # via -r backend/runtime-locks/windows-gpu-pytorch.in +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 + # via + # huggingface-hub + # torch +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \ + --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88 + # via + # huggingface-hub + # torch +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +hf-xet==1.6.0 \ + --hash=sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7 \ + --hash=sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675 \ + --hash=sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef \ + --hash=sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9 \ + --hash=sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3 \ + --hash=sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb \ + --hash=sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e \ + --hash=sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338 \ + --hash=sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d \ + --hash=sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d \ + --hash=sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765 \ + --hash=sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c \ + --hash=sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522 \ + --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f \ + --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a \ + --hash=sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b \ + --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b + # via huggingface-hub +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via huggingface-hub +huggingface-hub==1.7.1 \ + --hash=sha256:38c6cce7419bbde8caac26a45ed22b0cea24152a8961565d70ec21f88752bfaa \ + --hash=sha256:be38fe66e9b03c027ad755cb9e4b87ff0303c98acf515b5d579690beb0bf3048 + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # tokenizers + # transformers +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +jinja2==3.1.6 \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via torch +llvmlite==0.48.0 \ + --hash=sha256:02853fe4214acb3780fc920c3fee10564b61d58a35e1b78afcc8a546c2deaba3 \ + --hash=sha256:054aa7d46595935565f276cf0c1659b4f10929c996dd4a606875fae26fba2a23 \ + --hash=sha256:05f0103c8f2f96a37441337e3643863c01b8e83e530aff38960dcb383c54a065 \ + --hash=sha256:13532f248960ba888ad5ab8150494e2f3a3d20e5f59f264e63741ea5b0ba844c \ + --hash=sha256:1d9f952dff6c350c529997423d4fa43abae9722a884ac7ffafc37a3af676e7db \ + --hash=sha256:2fe5cb59b2063bfa039dcb8ca6481c0181bf552f340d10dcf61d7996a665556e \ + --hash=sha256:321f1ac39b462603f0b589751aecf2d237d056f6d005749c1752b6f23ec3f074 \ + --hash=sha256:37d66fae72802175b0bfe1ea06e624b51e2d7aee6c3c34bbd09739b8f88e8e0b \ + --hash=sha256:3ee0c77685a18f5fca994ae21d0763007fca5c5c64b41de37accc78b69079176 \ + --hash=sha256:416fa4c2c66c2c6dc6d0a402648c19206e548efa0aa1eff01ad5cdad0af8217d \ + --hash=sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2 \ + --hash=sha256:56a7e24607d3f02d7b1bae8d29c7e1e423d53143d68b072999777f19678fe77b \ + --hash=sha256:614aad57df707e3172efd5165f2aa7da6a0c6897e40dce590bf756396815ba76 \ + --hash=sha256:6fa532d6bb3fd3f0803567c736401c54aecfe1a396d3ad25d2440d220e09f0e7 \ + --hash=sha256:7a5c413317050a1d67c34708bde97707f9b2257ef1017f7532d21fe7d9a9ff30 \ + --hash=sha256:8761b9e522f55207e24424fcd98370289eec2710bf8e915c82d1053f642450dc \ + --hash=sha256:91c7e24e74cde3f02b88aa5acca678373f9e069f3b98531b3dbb3a142d9d10bb \ + --hash=sha256:966dcab0a598e2bd8fb5f2cc082cf7b07bae564fc485a3a8692393caf986facf \ + --hash=sha256:979a66a3f28a02565383ff463527dce78e9b856298872a361283132488e83591 \ + --hash=sha256:a83a99ef0c05b4ccddf9b6218ed9fe84b653a0caf7c1d9dbe148d6d16c67f518 \ + --hash=sha256:d0b3c61aac83b42fb48cc96bffbf57c81b82b2aa92276b7ed6420c814629a99a \ + --hash=sha256:d45c7541a80934ec6d8ab0defe67439494ecd2193cbf852a44ba827808976ac1 \ + --hash=sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e \ + --hash=sha256:efaee0276e5e17c2b99b92e0c974bd484ef5977cf5dbc9168e82b71578edb47f \ + --hash=sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98 + # via numba +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +markupsafe==3.0.3 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c + # via jinja2 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via sympy +networkx==3.6.1 \ + --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ + --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 + # via torch +numba==0.66.0 \ + --hash=sha256:0999e3ee1b18c48e1fb51d11af35ef59852c7f4f50569c9550c25faef0616ad1 \ + --hash=sha256:2338cc0d43609fe448930848fd35a5bc688761b986f81b597a6f45cc0f8c9577 \ + --hash=sha256:27951c47e0def9bf8afe580eb961102902e2fd23cb77924b7d9d7cc0f8b444cb \ + --hash=sha256:380b2556a2019ccd1e956ae77dd257eaa39403f7520768b626d44b755112785e \ + --hash=sha256:46ae5f2b19e2af3c33c2df100306a90ea2f981c8158b0390f8bf6c20eee7357e \ + --hash=sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4 \ + --hash=sha256:63b943eb2c9ba371908ce2cd6dfc643db51fc40f7966993376a1701bc922f537 \ + --hash=sha256:651a2b53298340956db26ecbe7ab043106b50a40c2807e66d617a4917245a4ab \ + --hash=sha256:7c14c044c06b453ec3fa7715dfe75425e2ba72c73377a7ffde6d9ec511dfd94c \ + --hash=sha256:8c1144ba1720ea59ad79f4f488ed54d149b2613b357f7e445678b7d0739c70e9 \ + --hash=sha256:939316d5d8619751207b8972a67852b5a7646665298cb4de693cd6bf135152f4 \ + --hash=sha256:aac69f3ccb8af100f5913c1241edc9692bad1cdd2508721713f426eb06c9a659 \ + --hash=sha256:b075a4e7ebc43dc6294f223e2821659656209fd5e0ce53245877c23d66d6e1a9 \ + --hash=sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363 \ + --hash=sha256:bbd531c327557a9004507fa6bff06c53ab51a7a5776b75261bb9cef1efe2b2ea \ + --hash=sha256:bd57790acd20f6a468e0ad333ef6b82355e309a92310fb7dff80e919f01a21a9 \ + --hash=sha256:c5bfe5350284509ab0474390321454c3a8627a188af5b68c910e83df3e2db4a7 \ + --hash=sha256:cc408c54b450f41582f4be1608f8981c1dcc44c7f40355cc150dd93015753407 \ + --hash=sha256:cdf506775d9f02eb92a87bf5c5b1e0d25506fd18cafd769f4ed914a8feac73e7 \ + --hash=sha256:d426178fb991a85714c43112a8ea7b9d9579ea856ad8dcdb9c1c3941903ba5be \ + --hash=sha256:db7735d15ea17a283d6485b9fa3504769f78fd86e5146638ad5e8da57c031b9e \ + --hash=sha256:e2b101f23b8b63978d334574d2039f27f0dccfe1d891756f33a2e2f3e4c88cf4 \ + --hash=sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d \ + --hash=sha256:fb601841d9e02e6237bb6522e36d0741614be3cfe2b482a6f00a41b5ba209443 \ + --hash=sha256:fc6629becb21a867d85401ec89f426dd24c484a4193ade8a38309debfd1529ca + # via resampy +numpy==2.3.5 \ + --hash=sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b \ + --hash=sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae \ + --hash=sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3 \ + --hash=sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0 \ + --hash=sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b \ + --hash=sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa \ + --hash=sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28 \ + --hash=sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e \ + --hash=sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017 \ + --hash=sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41 \ + --hash=sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e \ + --hash=sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63 \ + --hash=sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9 \ + --hash=sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8 \ + --hash=sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff \ + --hash=sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7 \ + --hash=sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139 \ + --hash=sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4 \ + --hash=sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748 \ + --hash=sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952 \ + --hash=sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd \ + --hash=sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b \ + --hash=sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce \ + --hash=sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f \ + --hash=sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5 \ + --hash=sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42 \ + --hash=sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7 \ + --hash=sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248 \ + --hash=sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e \ + --hash=sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3 \ + --hash=sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b \ + --hash=sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e \ + --hash=sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0 \ + --hash=sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa \ + --hash=sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a \ + --hash=sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5 \ + --hash=sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d \ + --hash=sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4 \ + --hash=sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c \ + --hash=sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52 \ + --hash=sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5 \ + --hash=sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d \ + --hash=sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1 \ + --hash=sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c \ + --hash=sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18 \ + --hash=sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7 \ + --hash=sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 \ + --hash=sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218 \ + --hash=sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2 \ + --hash=sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903 \ + --hash=sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c \ + --hash=sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c \ + --hash=sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234 \ + --hash=sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82 \ + --hash=sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39 \ + --hash=sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf \ + --hash=sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20 \ + --hash=sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946 \ + --hash=sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0 \ + --hash=sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9 \ + --hash=sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff \ + --hash=sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad \ + --hash=sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227 \ + --hash=sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10 \ + --hash=sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e \ + --hash=sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf \ + --hash=sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769 \ + --hash=sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310 \ + --hash=sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425 \ + --hash=sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013 \ + --hash=sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c \ + --hash=sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb \ + --hash=sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d \ + --hash=sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520 + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # numba + # resampy + # soundfile + # transformers +packaging==26.0 \ + --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # huggingface-hub + # transformers +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via rich +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # huggingface-hub + # transformers +regex==2026.7.19 \ + --hash=sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0 \ + --hash=sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6 \ + --hash=sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62 \ + --hash=sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af \ + --hash=sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc \ + --hash=sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13 \ + --hash=sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd \ + --hash=sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951 \ + --hash=sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc \ + --hash=sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511 \ + --hash=sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12 \ + --hash=sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518 \ + --hash=sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db \ + --hash=sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae \ + --hash=sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009 \ + --hash=sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986 \ + --hash=sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1 \ + --hash=sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a \ + --hash=sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2 \ + --hash=sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0 \ + --hash=sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78 \ + --hash=sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d \ + --hash=sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4 \ + --hash=sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0 \ + --hash=sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11 \ + --hash=sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52 \ + --hash=sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e \ + --hash=sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902 \ + --hash=sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11 \ + --hash=sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6 \ + --hash=sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba \ + --hash=sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e \ + --hash=sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac \ + --hash=sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939 \ + --hash=sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb \ + --hash=sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc \ + --hash=sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095 \ + --hash=sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b \ + --hash=sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b \ + --hash=sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220 \ + --hash=sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c \ + --hash=sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae \ + --hash=sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3 \ + --hash=sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44 \ + --hash=sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665 \ + --hash=sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5 \ + --hash=sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97 \ + --hash=sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218 \ + --hash=sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864 \ + --hash=sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e \ + --hash=sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4 \ + --hash=sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda \ + --hash=sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459 \ + --hash=sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18 \ + --hash=sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3 \ + --hash=sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5 \ + --hash=sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a \ + --hash=sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035 \ + --hash=sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa \ + --hash=sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5 \ + --hash=sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78 \ + --hash=sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20 \ + --hash=sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a \ + --hash=sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a \ + --hash=sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a \ + --hash=sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965 \ + --hash=sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5 \ + --hash=sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797 \ + --hash=sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276 \ + --hash=sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c \ + --hash=sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547 \ + --hash=sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9 \ + --hash=sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d \ + --hash=sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1 \ + --hash=sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68 \ + --hash=sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a \ + --hash=sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd \ + --hash=sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c \ + --hash=sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6 \ + --hash=sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82 \ + --hash=sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7 \ + --hash=sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15 \ + --hash=sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e \ + --hash=sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38 \ + --hash=sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96 \ + --hash=sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2 \ + --hash=sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8 \ + --hash=sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732 \ + --hash=sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966 \ + --hash=sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053 \ + --hash=sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3 \ + --hash=sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0 \ + --hash=sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f \ + --hash=sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e \ + --hash=sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327 \ + --hash=sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac \ + --hash=sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6 \ + --hash=sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2 \ + --hash=sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a \ + --hash=sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435 \ + --hash=sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5 \ + --hash=sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d \ + --hash=sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312 \ + --hash=sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b \ + --hash=sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40 \ + --hash=sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974 \ + --hash=sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404 \ + --hash=sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff \ + --hash=sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf \ + --hash=sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175 \ + --hash=sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da \ + --hash=sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d \ + --hash=sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1 \ + --hash=sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2 + # via transformers +resampy==0.4.3 \ + --hash=sha256:a0d1c28398f0e55994b739650afef4e3974115edbe96cd4bb81968425e916e47 \ + --hash=sha256:ad2ed64516b140a122d96704e32bc0f92b23f45419e8b8f478e5a05f83edcebd + # via -r backend/runtime-locks/windows-gpu-pytorch.in +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via typer +safetensors==0.7.0 \ + --hash=sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2 \ + --hash=sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0 \ + --hash=sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd \ + --hash=sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981 \ + --hash=sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a \ + --hash=sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3 \ + --hash=sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d \ + --hash=sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0 \ + --hash=sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85 \ + --hash=sha256:6999421eb8ba9df4450a16d9184fcb7bef26240b9f98e95401f17af6c2210b71 \ + --hash=sha256:7b95a3fa7b3abb9b5b0e07668e808364d0d40f6bbbf9ae0faa8b5b210c97b140 \ + --hash=sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104 \ + --hash=sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57 \ + --hash=sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4 \ + --hash=sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba \ + --hash=sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517 \ + --hash=sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b \ + --hash=sha256:cfdead2f57330d76aa7234051dadfa7d4eedc0e5a27fd08e6f96714a92b00f09 \ + --hash=sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755 \ + --hash=sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48 \ + --hash=sha256:dc92bc2db7b45bda4510e4f51c59b00fe80b2d6be88928346e4294ce1c2abe7c \ + --hash=sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542 \ + --hash=sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737 + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # transformers +sentencepiece==0.2.1 \ + --hash=sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c \ + --hash=sha256:017f97b274d4b0baa84b2dc743bf4517be81156f413bb24f12aacacde378e5ab \ + --hash=sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b \ + --hash=sha256:02593eca45440ef39247cee8c47322a34bdcc1d8ae83ad28ba5a899a2cf8d79a \ + --hash=sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff \ + --hash=sha256:0a0d15781a171d188b661ae4bde1d998c303f6bd8621498c50c671bd45a4798e \ + --hash=sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6 \ + --hash=sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07 \ + --hash=sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b \ + --hash=sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751 \ + --hash=sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b \ + --hash=sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b \ + --hash=sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8 \ + --hash=sha256:22c4ebcb3c6ab1496ab1c37c79ef7bb563b8726f29548c30773b7a4cb152df1a \ + --hash=sha256:251874d720ac7f28024a168501f3c7bb15d1802245f6e66de565f18bbb9b5eaa \ + --hash=sha256:27e38eee653abc3d387862e67bc5c8b6f428cd604e688b85d29170b7e725c26c \ + --hash=sha256:2af5a1fb05013332ad94343b8b5f3973e006a2dde2dfba55a819549e054e2f0f \ + --hash=sha256:2f27ae6deea72efdb6f361750c92f6c21fd0ad087445082770cc34015213c526 \ + --hash=sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484 \ + --hash=sha256:39f8651bd10974eafb9834ce30d9bcf5b73e1fc798a7f7d2528f9820ca86e119 \ + --hash=sha256:3d165fbb9bf8fba35f1946ba2617c3f9995679f07438325f07c026d53f33e746 \ + --hash=sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec \ + --hash=sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de \ + --hash=sha256:4f5a3e0d9f445ed9d66c0fec47d4b23d12cfc858b407a03c194c1b26c2ac2a63 \ + --hash=sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6 \ + --hash=sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133 \ + --hash=sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719 \ + --hash=sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d \ + --hash=sha256:60937c959e6f44159fdd9f56fbdd302501f96114a5ba436829496d5f32d8de3f \ + --hash=sha256:6356d0986b8b8dc351b943150fcd81a1c6e6e4d439772e8584c64230e58ca987 \ + --hash=sha256:6d297a1748d429ba8534eebe5535448d78b8acc32d00a29b49acf28102eeb094 \ + --hash=sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab \ + --hash=sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad \ + --hash=sha256:814978ac05130dd5812b4b03215c766bc6abaef13e7bd72bc534e4d1e12e9a4c \ + --hash=sha256:82d9ead6591015f009cb1be1cb1c015d5e6f04046dbb8c9588b931e869a29728 \ + --hash=sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd \ + --hash=sha256:891ade6503dd93d418c03993f7d6a8aa20260c422cefff5096b9068185e67642 \ + --hash=sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b \ + --hash=sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94 \ + --hash=sha256:8f8ba89a3acb3dc1ae90f65ec1894b0b9596fdb98ab003ff38e058f898b39bc7 \ + --hash=sha256:9076430ac25dfa7147d9d05751dbc66a04bc1aaac371c07f84952979ea59f0d0 \ + --hash=sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f \ + --hash=sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167 \ + --hash=sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b \ + --hash=sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068 \ + --hash=sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd \ + --hash=sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c \ + --hash=sha256:afefe50a0cdcb4f2fd9733cb52001a2c164181ee2d82c32d38f5b1b326a8528c \ + --hash=sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0 \ + --hash=sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596 \ + --hash=sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f \ + --hash=sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062 \ + --hash=sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47 \ + --hash=sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33 \ + --hash=sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1 \ + --hash=sha256:caa4e560c72c151da80036aecc2159e51a7fd8ae9efebefd96860460ce6bd025 \ + --hash=sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0 \ + --hash=sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820 \ + --hash=sha256:d8b1d91545578852f128650b8cce4ec20f93d39b378ff554ebe66290f2dabb92 \ + --hash=sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76 \ + --hash=sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4 \ + --hash=sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706 \ + --hash=sha256:e10fa50bdbaa5e2445dbd387979980d391760faf0ec99a09bd7780ff37eaec44 \ + --hash=sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb \ + --hash=sha256:e52144670738b4b477fade6c2a9b6af71a8d0094514c9853ac9f6fc1fcfabae7 + # via -r backend/runtime-locks/windows-gpu-pytorch.in +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + # via torch +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +soundfile==0.13.1 \ + --hash=sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618 \ + --hash=sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9 \ + --hash=sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593 \ + --hash=sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33 \ + --hash=sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb \ + --hash=sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445 \ + --hash=sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b \ + --hash=sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5 + # via -r backend/runtime-locks/windows-gpu-pytorch.in +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via torch +tokenizers==0.22.2 \ + --hash=sha256:143b999bdc46d10febb15cbffb4207ddd1f410e2c755857b5a0797961bbdc113 \ + --hash=sha256:1a62ba2c5faa2dd175aaeed7b15abf18d20266189fb3406c5d0550dd34dd5f37 \ + --hash=sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e \ + --hash=sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 \ + --hash=sha256:1e50f8554d504f617d9e9d6e4c2c2884a12b388a97c5c77f0bc6cf4cd032feee \ + --hash=sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7 \ + --hash=sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd \ + --hash=sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4 \ + --hash=sha256:319f659ee992222f04e58f84cbf407cfa66a65fe3a8de44e8ad2bc53e7d99012 \ + --hash=sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67 \ + --hash=sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a \ + --hash=sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5 \ + --hash=sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917 \ + --hash=sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c \ + --hash=sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195 \ + --hash=sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4 \ + --hash=sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a \ + --hash=sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc \ + --hash=sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92 \ + --hash=sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5 \ + --hash=sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48 \ + --hash=sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b \ + --hash=sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c \ + --hash=sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5 + # via transformers +torch==2.7.1+cu126 \ + --hash=sha256:03b83a0f2c1e90afafd7a5728b956e211bb3e6c56ea3d7d8c7638a659e448d5f \ + --hash=sha256:27d396231f33dc6103ba26ec6ec2ec5939d9850b599e32da711b038af272954e \ + --hash=sha256:30119a54e1b4ccefe20dfe5d4b13f6aef76c17ec605b40e26d39789db00906f2 \ + --hash=sha256:49692cc24edb72ba247a6f37345572cb2371f125eda132bc2834fd842f16bb7e \ + --hash=sha256:63bce0590bc540fc16139e2be0177847585182b8c5e68d7f9213789d1d96c978 \ + --hash=sha256:7d897b5ff67e778de4a2a05d4528377003105e29854fd73ecbe965287533f08b \ + --hash=sha256:a05c0001fd1d0ceae9cda8c8c1b8a16ed5def858fe996c9237a28016559dad52 \ + --hash=sha256:a38a903c9b55cea1217100e0851b25659765b6bb8cd75e6de6bbf0063a2cd51e \ + --hash=sha256:d4e68a1aeb2a6272d0234b7575089fc70757a93d24dccde8e962a3b18aef77d1 \ + --hash=sha256:e1a8465165708c2e2e90786ade8a3e1b1d01eca1f022792cd397caad9d8c21bc \ + --hash=sha256:ef0d0b0bd96d2adb07a47da12426e60d91921dfcd7c1964eea309f41488c2462 \ + --hash=sha256:f3af23387ac106b5b01dbef0eb021883e0c00ff4073477b7ce1cbade5ef5038d + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # torchaudio +torchaudio==2.7.1+cu126 \ + --hash=sha256:0c306e9f5ae1204dc8cd998912d0ad4b13420d00428aa5f5fcab01b180abe386 \ + --hash=sha256:1e9231eb156400a53d8041688f7567ac92c8758332225e364c35d68603cae2da \ + --hash=sha256:2c5d2d639aa466cbb3cf3f32d20009b6a6d472df8e07a88238248b1bcf208023 \ + --hash=sha256:33e67c2da5da68c075a065062cc39115b7af29890120c4773cfd24a9ea05a428 \ + --hash=sha256:38484cd84566b96a12e7371ea0f5a91ce35708c8c2aaa223342da833e094ee6d \ + --hash=sha256:560692b35b4c0325b4b11e793574e04565769f462a9146ad254d6d0411b8c7f4 \ + --hash=sha256:759de378d1fe4f4a5a56d58f51b4a05a9aa9681ae7b9469e638d9ed321a73e95 \ + --hash=sha256:9c6e00c79c09572a65eb54652d12f00fa79df63847552f61f10b7705fd0aca0b \ + --hash=sha256:c293d7e8d5d86d855313a940862c8d1506536a80ea44eca1ab020579b4f8bb0b \ + --hash=sha256:d3a5f160336aa7ec262f1361755c5da4e451571ed69f3cb8435b09bd7a2a0227 \ + --hash=sha256:d9a65c7f7100748802030257efd22535decd74eba310d2b2b9c41489c77cbe1d \ + --hash=sha256:fb9d04a635ab9856bf70c47926e3d47976c36aeb60779e6c4639ac2a630f9b0f + # via -r backend/runtime-locks/windows-gpu-pytorch.in +tqdm==4.67.3 \ + --hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \ + --hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf + # via + # -r backend/runtime-locks/windows-gpu-pytorch.in + # huggingface-hub + # transformers +transformers==5.8.0 \ + --hash=sha256:6cc9a1f0291d16b1c1b735bad775e78ebefff7722701d4e28f98aaaa2bd6fb91 \ + --hash=sha256:e9d2cae6d195a7e1e05164c5ebf26142a7044e4dc4267274f4809204f92827e4 + # via -r backend/runtime-locks/windows-gpu-pytorch.in +typer==0.27.1 \ + --hash=sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56 \ + --hash=sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df + # via + # huggingface-hub + # transformers +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # huggingface-hub + # torch diff --git a/backend/tests/test_gpu_broker.py b/backend/tests/test_gpu_broker.py new file mode 100644 index 0000000..c33366f --- /dev/null +++ b/backend/tests/test_gpu_broker.py @@ -0,0 +1,137 @@ +import json +import pathlib + +import pytest + +from lsdj.gpu_broker import ( + BrokerCancelled, + BrokerError, + BrokerTimeout, + GpuBroker, + Priority, +) + + +def broker(tmp_path: pathlib.Path) -> GpuBroker: + return GpuBroker(tmp_path / "gpu-broker", poll_seconds=0.001) + + +def test_sa3_lease_is_bounded_by_measured_capacity(tmp_path): + service = broker(tmp_path) + with pytest.raises(BrokerTimeout): + service.acquire( + "sa3", + Priority.SA3_BACKGROUND, + reservation_bytes=8, + capacity_bytes=7, + timeout_seconds=0.01, + ) + assert service.diagnostics()["waiters"] == [] + + +def test_mrt2_waiter_preempts_sa3_at_the_next_callback(tmp_path): + service = broker(tmp_path) + sa3 = service.acquire( + "sa3", + Priority.SA3_BACKGROUND, + reservation_bytes=8, + capacity_bytes=16, + timeout_seconds=1, + ) + state = service.diagnostics() + state["waiters"].append( + { + "token": "mrt2-waiter", + "service": "mrt2", + "priority": int(Priority.MRT2_REALTIME), + "reservation_bytes": 0, + "pid": sa3.pid, + } + ) + service._write_state(state) + + assert service.should_yield(sa3) is True + service.release(sa3) + assert service.diagnostics()["leases"] == [] + + +def test_active_mrt2_blocks_background_generation(tmp_path): + service = broker(tmp_path) + realtime = service.acquire( + "mrt2", + Priority.MRT2_REALTIME, + reservation_bytes=0, + capacity_bytes=0, + timeout_seconds=1, + ) + with pytest.raises(BrokerTimeout): + service.acquire( + "sa3", + Priority.SA3_BACKGROUND, + reservation_bytes=1, + capacity_bytes=16, + timeout_seconds=0.01, + ) + service.release(realtime) + + +def test_cancelled_waiter_is_removed(tmp_path): + service = broker(tmp_path) + realtime = service.acquire( + "mrt2", + Priority.MRT2_REALTIME, + reservation_bytes=0, + capacity_bytes=0, + timeout_seconds=1, + ) + with pytest.raises(BrokerCancelled): + service.acquire( + "sa3", + Priority.SA3_BACKGROUND, + reservation_bytes=1, + capacity_bytes=16, + timeout_seconds=1, + cancelled=lambda: True, + ) + assert service.diagnostics()["waiters"] == [] + service.release(realtime) + + +def test_dead_process_records_are_recovered(tmp_path): + service = GpuBroker( + tmp_path / "gpu-broker", poll_seconds=0.001, pid_alive=lambda pid: pid != 99 + ) + service.root.mkdir(parents=True) + service.state_path.write_text( + json.dumps( + { + "schema_version": 1, + "waiters": [], + "leases": [ + { + "token": "dead", + "service": "sa3", + "priority": int(Priority.SA3_BACKGROUND), + "reservation_bytes": 8, + "pid": 99, + } + ], + } + ) + ) + lease = service.acquire( + "mrt2", + Priority.MRT2_REALTIME, + reservation_bytes=0, + capacity_bytes=0, + timeout_seconds=1, + ) + assert [item["token"] for item in service.diagnostics()["leases"]] == [lease.token] + + +def test_tampered_or_unbounded_state_fails_closed(tmp_path): + service = broker(tmp_path) + service.root.mkdir(parents=True) + service.state_path.write_text("{}") + with pytest.raises(BrokerError, match="unsupported schema"): + service.diagnostics() diff --git a/backend/tests/test_mrt2_pytorch.py b/backend/tests/test_mrt2_pytorch.py index 92f6323..0908e40 100644 --- a/backend/tests/test_mrt2_pytorch.py +++ b/backend/tests/test_mrt2_pytorch.py @@ -1,5 +1,6 @@ from pathlib import Path from types import SimpleNamespace +from contextlib import contextmanager import numpy as np import pytest @@ -7,6 +8,7 @@ from lsdj.engine import CHANNELS, FRAME_SECONDS, NOTE_SUSTAIN, SAMPLE_RATE from lsdj.mrt2 import RuntimeSelection, RuntimeUnavailable from lsdj.mrt2_pytorch import PytorchBindings, PytorchMrt2Engine +from lsdj.gpu_broker import Priority class FakeCuda: @@ -87,7 +89,7 @@ def from_pretrained(self, path, **kwargs): return self.model -def make_engine(*, cuda=True): +def make_engine(*, cuda=True, gpu_broker=None): model = FakeModel() auto_model = FakeAutoModel(model) snapshots = [] @@ -111,6 +113,7 @@ def snapshot_download(**kwargs): selection=selection, bindings=bindings, cache_root=Path("/cache"), + gpu_broker=gpu_broker, ) return engine, model, auto_model, snapshots @@ -197,6 +200,7 @@ def test_shared_deck_reuses_one_model_with_independent_continuation_state(): second = first.shared_deck() assert first._system is second._system assert first._model_lock is second._model_lock + assert first._gpu_broker is second._gpu_broker first.generate_chunk() second.generate_chunk() @@ -223,3 +227,29 @@ def test_diagnostics_disclose_unqualified_runtime_and_cuda_versions(): assert diagnostics["nvidia_driver"] == "13.2" assert diagnostics["cuda_device"] == "Fake NVIDIA" assert diagnostics["capabilities"]["negative_prompt"] is False + + +def test_mrt2_generation_takes_realtime_priority_over_background_sa3(): + class FakeBroker: + def __init__(self): + self.calls = [] + + @contextmanager + def hold(self, service, **kwargs): + self.calls.append((service, kwargs)) + yield object() + + broker = FakeBroker() + engine, _, _, _ = make_engine(gpu_broker=broker) + engine.generate_chunk() + + service, values = broker.calls[-1] + assert service == "mrt2" + assert values["priority"] is Priority.MRT2_REALTIME + assert values["reservation_bytes"] == 0 + assert values["capacity_bytes"] == 12 * 1024**3 + assert engine.diagnostics()["gpu_broker"] == { + "enabled": True, + "priority": 100, + "preempts": "sa3-background", + } diff --git a/backend/tests/test_sa3.py b/backend/tests/test_sa3.py index ea35033..f3ffba2 100644 --- a/backend/tests/test_sa3.py +++ b/backend/tests/test_sa3.py @@ -238,6 +238,36 @@ def test_status_exposes_backend_capabilities_and_real_limitations(tmp_path): ) +def test_windows_status_exposes_conservative_backend_choices_and_cuda_blockers( + tmp_path, +): + selection = make_runtime( + tmp_path / "sa3", BackendName.TFLITE, platform_name="win32" + ) + result = sa3.status( + {"SA3_HOME": str(selection.checkout)}, + platform_name="win32", + machine="AMD64", + ) + + assert result["preference"] == "auto" + assert result["preferenceChoices"] == ["auto", "gpu", "cpu_tflite"] + assert result["activeBackend"] == "tflite" + assert result["cuda"]["release_ready"] is False + assert result["cuda"]["tflite_fallback_ready"] is True + assert any("gated" in item for item in result["cuda"]["qualification_blockers"]) + + +def test_explicit_gpu_fails_before_start_and_requires_confirmed_tflite_fallback( + tflite_runtime, monkeypatch +): + tflite_runtime() + monkeypatch.setenv(sa3.SA3_PREFERENCE_ENV, "gpu") + + with pytest.raises(sa3.GenerationUnavailable, match="Choose CPU/TFLite"): + asyncio.run(sa3.generate("kick", 0.5, "sfx")) + + def test_status_fails_closed_for_unverified_tflite_provenance(tmp_path): selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) (selection.runtime_dir / sa3.TFLITE_PROVENANCE_STAMP).write_text("{}") diff --git a/backend/tests/test_sa3_cuda.py b/backend/tests/test_sa3_cuda.py new file mode 100644 index 0000000..c183a00 --- /dev/null +++ b/backend/tests/test_sa3_cuda.py @@ -0,0 +1,124 @@ +import pytest + +from lsdj import sa3_cuda + + +def evidence(**updates): + values = { + "platform": "win32", + "machine": "AMD64", + "runtime_ready": True, + "provenance_complete": True, + "packages": dict(sa3_cuda.EXPECTED_PACKAGES), + "cuda_available": True, + "cuda_runtime": "12.6", + "driver": "560.76", + "device": "NVIDIA test device", + "compute_capability": (8, 9), + "total_vram_bytes": 24 * 1024**3, + "free_vram_bytes": 16 * 1024**3, + "estimated_vram_bytes": {"music": 6 * 1024**3, "sfx": 6 * 1024**3}, + "source_revision": sa3_cuda.SOURCE_REVISION, + "model_revision": sa3_cuda.MODEL_PINS["music"]["revision"], + } + values.update(updates) + return sa3_cuda.CudaEvidence(**values) + + +def test_auto_keeps_tflite_until_hardware_is_release_qualified(): + decision = sa3_cuda.choose_backend( + "auto", kind="music", cuda=evidence(), tflite_ready=True, env={} + ) + assert decision.backend == "tflite" + assert decision.fallback is True + + +def test_explicit_gpu_never_silently_falls_back(): + with pytest.raises(sa3_cuda.CudaUnavailable) as caught: + sa3_cuda.choose_backend( + "gpu", kind="music", cuda=evidence(), tflite_ready=True, env={} + ) + assert caught.value.fallback_available is True + assert caught.value.reason == "cuda_not_eligible" + + +def test_qualification_opt_in_allows_small_models_but_not_auto(): + explicit = sa3_cuda.choose_backend( + "gpu", + kind="music", + cuda=evidence(estimated_vram_bytes={"music": None}), + tflite_ready=True, + env={sa3_cuda.UNVERIFIED_OPT_IN: "1"}, + ) + automatic = sa3_cuda.choose_backend( + "auto", + kind="music", + cuda=evidence(estimated_vram_bytes={"music": None}), + tflite_ready=True, + env={sa3_cuda.UNVERIFIED_OPT_IN: "1"}, + ) + assert explicit.backend == "pytorch_cuda" + assert automatic.backend == "tflite" + + +@pytest.mark.parametrize( + "updates, expected", + [ + ({"provenance_complete": False}, "provenance is incomplete"), + ({"source_revision": "0" * 40}, "source revision does not match"), + ({"model_revision": "0" * 40}, "model revision does not match"), + ({"cuda_available": False}, "no CUDA device"), + ({"cuda_runtime": "13.0"}, "not the pinned 12.6"), + ({"driver": None}, "driver version could not be verified"), + ({"driver": "528.33"}, "older than the provisional"), + ( + {"packages": {**sa3_cuda.EXPECTED_PACKAGES, "torch": "2.12.1+cu130"}}, + "dependency versions do not match", + ), + ], +) +def test_explicit_gpu_fails_closed_on_runtime_mismatch(updates, expected): + with pytest.raises(sa3_cuda.CudaUnavailable, match=expected): + sa3_cuda.choose_backend( + "gpu", + kind="music", + cuda=evidence(**updates), + tflite_ready=True, + env={sa3_cuda.UNVERIFIED_OPT_IN: "1"}, + ) + + +def test_medium_stays_on_tflite_without_an_official_windows_flashattention_build(): + with pytest.raises( + sa3_cuda.CudaUnavailable, match="Medium requires FlashAttention" + ): + sa3_cuda.choose_backend( + "gpu", + kind="track", + cuda=evidence(estimated_vram_bytes={"track": 12 * 1024**3}), + tflite_ready=True, + env={sa3_cuda.UNVERIFIED_OPT_IN: "1"}, + ) + + +def test_free_vram_is_advisory_but_still_a_conservative_admission_gate(): + errors = sa3_cuda.runtime_errors( + evidence(free_vram_bytes=6 * 1024**3), kind="music" + ) + assert any("headroom" in error for error in errors) + + +def test_cpu_choice_requires_the_portable_baseline(): + with pytest.raises( + sa3_cuda.CudaUnavailable, match="TFLite backend is not installed" + ): + sa3_cuda.choose_backend( + "cpu_tflite", kind="music", cuda=evidence(), tflite_ready=False + ) + + +def test_diagnostics_are_honest_about_hardware_and_gated_hashes(): + status = sa3_cuda.diagnostic_manifest(evidence(), tflite_ready=True) + assert status["release_ready"] is False + assert status["cpu_fallback"] is False + assert any("gated" in item for item in status["qualification_blockers"]) diff --git a/backend/tests/test_sa3_cuda_pins.py b/backend/tests/test_sa3_cuda_pins.py new file mode 100644 index 0000000..5403f99 --- /dev/null +++ b/backend/tests/test_sa3_cuda_pins.py @@ -0,0 +1,64 @@ +import hashlib +import json +import re +from pathlib import Path + +from lsdj import sa3_cuda + + +ROOT = Path(__file__).parents[2] +PIN_PATH = ROOT / "sa3-pytorch-cuda-pin.json" +LOCK_PATH = ROOT / "backend/runtime-locks/windows-gpu-pytorch.txt" +REQUIREMENT = re.compile(r"^([a-z0-9][a-z0-9_.-]*)==([^ \\]+) \\$", re.MULTILINE) + + +def test_shared_windows_runtime_is_hash_locked_and_matches_executable_policy(): + pin = json.loads(PIN_PATH.read_text()) + lock = LOCK_PATH.read_bytes() + runtime = pin["sharedRuntime"] + requirements = dict(REQUIREMENT.findall(lock.decode())) + + assert len(requirements) == 44 + assert runtime["packages"] == sa3_cuda.EXPECTED_PACKAGES + assert runtime["packages"].items() <= requirements.items() + assert runtime["requirementsLockSize"] == len(lock) + assert runtime["requirementsLockSha256"] == hashlib.sha256(lock).hexdigest() + assert runtime["requirementsLockSha256"] == sa3_cuda.RUNTIME_LOCK_SHA256 + assert lock.count(b"--hash=sha256:") >= len(requirements) + assert not any(token in lock for token in (b"git+", b"http://", b" @ ")) + + +def test_candidate_cannot_be_released_with_missing_gated_hashes(): + pin = json.loads(PIN_PATH.read_text()) + missing = [ + f"{model_name}/{artifact['path']}" + for model_name, model in pin["models"].items() + if model.get("required") or model.get("enabled") + for artifact in (model["weight"], model["config"]) + if artifact["sha256"] is None + ] + + assert missing == [ + "small-music/model_config.json", + "small-sfx/model_config.json", + ] + assert pin["gatedArtifactsComplete"] is False + assert pin["releaseReady"] is False + assert pin["releaseBlockers"] + + +def test_cuda_manifest_uses_upstream_as_an_external_immutable_dependency(): + pin = json.loads(PIN_PATH.read_text()) + source = pin["source"] + + assert source["repository"] == "https://github.com/Stability-AI/stable-audio-3" + assert source["revision"] == sa3_cuda.SOURCE_REVISION + assert source["revision"] in source["archiveUrl"] + assert source["license"] == "MIT" + pinned_models = { + (model["repository"], model["revision"]) for model in pin["models"].values() + } + assert { + (model["repository"], model["revision"]) + for model in sa3_cuda.MODEL_PINS.values() + } <= pinned_models diff --git a/backend/tests/test_sa3_cuda_worker.py b/backend/tests/test_sa3_cuda_worker.py new file mode 100644 index 0000000..0155ee7 --- /dev/null +++ b/backend/tests/test_sa3_cuda_worker.py @@ -0,0 +1,248 @@ +import json +import pathlib +import wave + +import numpy as np +import pytest + +from lsdj import sa3_cuda, sa3_cuda_worker as worker + + +class FakeTorch: + @staticmethod + def from_numpy(value): + return value + + +class FakeModel: + def __init__(self, seconds=0.5): + self.seconds = seconds + self.kwargs = None + self.loras = [] + self.strengths = [] + + def load_lora(self, paths): + self.loras = paths + + def set_lora_strength(self, strength, lora_index=None): + self.strengths.append((strength, lora_index)) + + def generate(self, **kwargs): + self.kwargs = kwargs + for index in range(kwargs["steps"]): + kwargs["callback"]({"i": index}) + return np.zeros( + (1, 2, round(self.seconds * worker.SAMPLE_RATE)), dtype=np.float32 + ) + + +def pcm16_wav(path, seconds=0.5): + frames = round(seconds * worker.SAMPLE_RATE) + with wave.open(str(path), "wb") as output: + output.setnchannels(2) + output.setsampwidth(2) + output.setframerate(worker.SAMPLE_RATE) + output.writeframes(b"\0" * frames * 4) + + +def request(tmp_path, **updates): + value = { + "schema_version": 1, + "prompt": "warm dub loop", + "seconds": 0.5, + "kind": "music", + "steps": 8, + "cfg": 4.5, + "apg": 0.75, + "seed": 123, + "negative_prompt": "vocals", + "init_noise_level": 0.6, + "inpaint_range": None, + "init_audio": None, + "lora_files": [], + "lora_strengths": [], + "model_dir": str(tmp_path / "model"), + "output": str(tmp_path / "out.wav"), + } + value.update(updates) + return worker.WorkerRequest.from_dict(value) + + +def test_maps_every_shared_control_to_the_pinned_python_api(tmp_path): + model = FakeModel() + events = [] + item = request(tmp_path) + worker.run_generation( + item, + model=model, + torch_module=FakeTorch, + cancelled=lambda: False, + emit=events.append, + ) + assert model.kwargs | {"callback": None} == { + "prompt": "warm dub loop", + "negative_prompt": "vocals", + "duration": 0.5, + "steps": 8, + "cfg_scale": 4.5, + "apg_scale": 0.75, + "seed": 123, + "batch_size": 1, + "chunked_decode": True, + "callback": None, + "disable_tqdm": True, + "init_audio": None, + "init_noise_level": 0.6, + "inpaint_audio": None, + } + assert events[-1] == {"event": "done"} + with wave.open(str(item.output), "rb") as output: + assert output.getparams()[:4] == (2, 2, 44_100, 22_050) + + +def test_maps_inpainting_and_continuation_to_upstream_inpaint_api(tmp_path): + init = tmp_path / "init.wav" + pcm16_wav(init) + item = request(tmp_path, init_audio=str(init), inpaint_range=[0.25, 0.5]) + model = FakeModel() + worker.run_generation( + item, + model=model, + torch_module=FakeTorch, + cancelled=lambda: False, + ) + assert model.kwargs["init_audio"] is None + assert model.kwargs["inpaint_audio"][0] == 44_100 + assert model.kwargs["inpaint_audio"][1].shape == (2, 22_050) + assert model.kwargs["inpaint_mask_start_seconds"] == 0.25 + assert model.kwargs["inpaint_mask_end_seconds"] == 0.5 + + +def test_stacked_lora_strengths_are_set_per_index(tmp_path): + adapters = [] + for name in ("one", "two"): + directory = tmp_path / name + directory.mkdir() + (directory / f"{name}.safetensors").write_bytes(b"fixture") + adapters.append(str(directory)) + item = request(tmp_path, lora_files=adapters, lora_strengths=[0.75, 1.5]) + model = FakeModel() + worker.run_generation( + item, + model=model, + torch_module=FakeTorch, + cancelled=lambda: False, + ) + assert [pathlib.Path(path).name for path in model.loras] == [ + "one.safetensors", + "two.safetensors", + ] + assert model.strengths == [(0.75, 0), (1.5, 1)] + + +def test_cancellation_is_observed_between_sampling_steps(tmp_path): + calls = 0 + + def cancelled(): + nonlocal calls + calls += 1 + return calls >= 2 + + with pytest.raises(worker.WorkerCancelled, match="cancelled"): + worker.run_generation( + request(tmp_path), + model=FakeModel(), + torch_module=FakeTorch, + cancelled=cancelled, + ) + assert not (tmp_path / "out.wav").exists() + + +def test_priority_waiter_cancels_the_disposable_worker(tmp_path): + class Broker: + @staticmethod + def should_yield(_lease): + return True + + with pytest.raises(worker.WorkerCancelled, match="yielded"): + worker.run_generation( + request(tmp_path), + model=FakeModel(), + torch_module=FakeTorch, + cancelled=lambda: False, + broker=Broker(), + lease=object(), + ) + + +@pytest.mark.parametrize( + "updates, message", + [ + ({"kind": "track"}, "Small Music and Small SFX"), + ({"steps": 0}, "steps"), + ({"inpaint_range": [0.1, 0.2]}, "requires init_audio"), + ({"lora_files": ["one"], "lora_strengths": []}, "LoRA stack"), + ], +) +def test_request_contract_fails_closed(updates, message, tmp_path): + with pytest.raises(worker.WorkerError, match=message): + request(tmp_path, **updates) + + +def test_request_file_is_bounded_and_rejects_symlinks(tmp_path): + real = tmp_path / "request.json" + real.write_text(json.dumps({"schema_version": 1})) + link = tmp_path / "link.json" + link.symlink_to(real) + with pytest.raises(worker.WorkerError, match="regular file"): + worker.read_request(link) + + +def test_provenance_matches_exact_source_runtime_model_and_bundle_path(tmp_path): + root = tmp_path / "runtime" + model_dir = root / "models" / "small-music" + model_dir.mkdir(parents=True) + item = request(tmp_path, model_dir=str(model_dir)) + stamp = root / "provenance.json" + value = { + "schema_version": 1, + "backend": sa3_cuda.BACKEND_NAME, + "gated_artifacts_complete": True, + "source_revision": sa3_cuda.SOURCE_REVISION, + "runtime_lock_sha256": sa3_cuda.RUNTIME_LOCK_SHA256, + "packages": sa3_cuda.EXPECTED_PACKAGES, + "model": sa3_cuda.MODEL_PINS["music"], + } + stamp.write_text(json.dumps(value)) + + assert worker.verify_provenance(stamp, item) == value + + value["source_revision"] = "0" * 40 + stamp.write_text(json.dumps(value)) + with pytest.raises(worker.WorkerError, match="immutable"): + worker.verify_provenance(stamp, item) + + +def test_provenance_rejects_model_path_outside_verified_runtime(tmp_path): + root = tmp_path / "runtime" + model_dir = tmp_path / "other" / "small-music" + model_dir.mkdir(parents=True) + root.mkdir() + item = request(tmp_path, model_dir=str(model_dir)) + stamp = root / "provenance.json" + stamp.write_text( + json.dumps( + { + "schema_version": 1, + "backend": sa3_cuda.BACKEND_NAME, + "gated_artifacts_complete": True, + "source_revision": sa3_cuda.SOURCE_REVISION, + "runtime_lock_sha256": sa3_cuda.RUNTIME_LOCK_SHA256, + "packages": sa3_cuda.EXPECTED_PACKAGES, + "model": sa3_cuda.MODEL_PINS["music"], + } + ) + ) + + with pytest.raises(worker.WorkerError, match="outside"): + worker.verify_provenance(stamp, item) diff --git a/docs/adr/0038-windows-sa3-cuda-qualification-gate.md b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md new file mode 100644 index 0000000..750282a --- /dev/null +++ b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md @@ -0,0 +1,72 @@ +# ADR-0038: Windows SA3 CUDA is a gated shared-runtime backend + +Status: proposed, implemented behind a release gate + +## Context + +Windows already has the official LiteRT/TFLite Stable Audio 3 backend. CUDA can +make Small Music and Small SFX practical on a capable NVIDIA GPU, but it adds a +second large model family beside the realtime MRT2 decks. The two projects' +original dependency pins do not match: the pinned SA3 source requires PyTorch +2.7.1 and Hugging Face Hub 1.7.1 or newer, while the MRT2 candidate from #110 +used PyTorch 2.12.1 and Hugging Face Hub 1.5.0. + +The SA3 model repositories are gated. Public Hugging Face metadata establishes +immutable revisions and the root weight hashes, but cannot establish all +configuration and nested T5Gemma hashes without the authenticated terms flow +owned by #108. No Windows NVIDIA qualification host was available for this +change. + +## Decision + +- Consume `Stability-AI/stable-audio-3` at the immutable upstream commit in + `sa3-pytorch-cuda-pin.json`. LSDJ owns a thin adapter; it does not fork or + vendor upstream runtime code. +- Test one shared Windows PyTorch environment for MRT2 and SA3. The resolved + candidate uses PyTorch/torchaudio 2.7.1+cu126 and Hugging Face Hub 1.7.1 in a + fully hashed 44-package lock. This is a candidate, not a supported upgrade: + MRT2 must be requalified on it. +- Keep the TFLite runtime separately installed and available. It is not placed + inside the PyTorch environment and remains the release/default backend. +- Run SA3 in a new disposable child for every generation. Heavy imports, model + allocation, and CUDA context creation occur only in that child. Completion, + cancellation, broker yield, OOM, crash, backend switch, and app teardown end + the process. +- Coordinate CUDA with a cross-process file-locked broker. MRT2 uses realtime + priority; background SA3 checks for a higher-priority waiter at every sampler + callback and exits so the MRT2 request can proceed. +- Expose Auto, GPU, and CPU/TFLite policy in the backend status contract. Auto + remains on TFLite until the hardware gate is flipped with evidence. Explicit + GPU fails before launch and asks for a confirmed TFLite fallback while the + candidate is blocked; it never silently changes backend or runs PyTorch on + CPU. +- Enable only Small Music and Small SFX in the CUDA capability contract. Medium + remains on TFLite unless an official Windows FlashAttention path and a + measured hardware tier are qualified. Unofficial wheels, custom extensions, + and private model forks are forbidden. + +## Trust and release gate + +The native installer compiles the candidate manifest but rejects it unless all +of these are true in one reviewed change: + +1. `releaseReady` and `gatedArtifactsComplete` are true and the blocker list is + empty. +2. Every required Small model artifact has an exact SHA-256 and byte count. +3. The embedded shared lock matches the manifest's exact size and SHA-256. +4. A worker provenance stamp matches the source revision, lock digest, package + versions, model repository, and model revision exactly. +5. The Windows NVIDIA matrix in the issue #114 checklist is complete. + +The worker independently rechecks package, CUDA runtime, driver, free-memory, +reservation, provenance, and model path facts before importing a model. A +reported free-memory value is only an admission snapshot; the process boundary +is still the recovery mechanism for unrelated VRAM pressure and CUDA failure. + +## Consequences + +The design and model-free failure behavior can merge without delaying the +TFLite Windows release. CUDA is not advertised as installable or selected by +Auto in this state. Completing #108's authenticated audit, resolving measured +VRAM tiers, and running both SA3 parity and MRT2 realtime qualification are +mandatory follow-ups, not release notes that can be waived. diff --git a/docs/adr/README.md b/docs/adr/README.md index b9dbd6c..0973cfb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -59,3 +59,4 @@ scaffolds the file from the template. | [0032](0032-standalone-midi-keyboard-window.md) | Standalone MIDI keyboard window, routing decoupled from steering | Accepted | | [0035](0035-dual-envelope-beat-detection-with-fast-change-invalidation.md) | Dual-envelope beat detection with fast change invalidation | Accepted | | [0037](0037-platform-mrt2-runtime-contract.md) | Platform MRT2 runtimes behind one worker contract | Accepted for implementation | +| [0038](0038-windows-sa3-cuda-qualification-gate.md) | Windows SA3 CUDA is a gated shared-runtime backend | Proposed | diff --git a/docs/issue-114-windows-sa3-cuda-checklist.md b/docs/issue-114-windows-sa3-cuda-checklist.md new file mode 100644 index 0000000..6269e94 --- /dev/null +++ b/docs/issue-114-windows-sa3-cuda-checklist.md @@ -0,0 +1,126 @@ +# Issue #114 — Windows Stable Audio 3 CUDA qualification + +This checklist is intentionally unchecked. Unit tests prove policy, mapping, +broker behavior, and failure containment without weights; they are not NVIDIA +performance evidence. Until every release gate is complete, Auto and the model +manager continue to use the supported TFLite backend. + +Set `LSDJ_ALLOW_UNVERIFIED_SA3_CUDA=1` only on a dedicated qualification host. +It permits explicit GPU probes; it does not enable Auto or make a build +release-ready. + +## Immutable inputs and shared runtime + +- [x] Pin the official upstream source commit without an LSDJ fork. +- [x] Resolve one 44-package, fully hash-locked Windows x64 candidate shared by + SA3 and MRT2: Python 3.12, PyTorch/torchaudio 2.7.1+cu126, Transformers 5.8.0, + Hugging Face Hub 1.7.1. +- [x] Record the source archive, Small Music/SFX root weights, optional Medium + root weight, exact immutable revisions, known hashes, and incomplete gates in + `sa3-pytorch-cuda-pin.json`. +- [ ] Through #108's authenticated terms flow, record SHA-256 and byte count for + every required Small config and nested T5Gemma artifact. +- [ ] Regenerate the lock with the release uv version on a clean Windows x64 + host and prove `--require-hashes --only-binary :all:` installation. +- [ ] Re-run all MRT2 functional fixtures on the shared PyTorch 2.7.1/CUDA 12.6 + runtime. The #110 PyTorch 2.12.1/CUDA 13.0 results do not transfer. +- [ ] Confirm the source/runtime/model provenance shown by diagnostics matches + the compiled manifest and that an altered stamp fails before model import. +- [ ] Complete #108 acknowledgement, attribution, and terms UX before exposing + the CUDA download. + +Run the local candidate audit with: + +```console +python3 scripts/audit-sa3-cuda-pin.py --allow-incomplete +``` + +The same command without `--allow-incomplete` is the release gate and must fail +until the required gated hashes exist. + +## Required host inventory + +For each row, save the LSDJ version, Windows build, GPU, VRAM, NVIDIA driver, +PyTorch version, CUDA runtime, source revision, model revision, peak VRAM, and +generated WAV hash. CUDA 12.6's provisional minimum Windows driver is 560.76; +replace that value with the measured support floor before release. + +| Host tier | GPU / VRAM | driver | Small Music reserve | Small SFX reserve | result | +| --- | --- | --- | ---: | ---: | --- | +| proposed minimum | | | | | [ ] | +| mid-range | | | | | [ ] | +| high-end | | | | | [ ] | +| insufficient VRAM | | | n/a | n/a | [ ] clean failure | + +- [ ] Measure cold-load, sampling, decode, peak allocated/reserved VRAM, and + post-exit VRAM for Small Music. +- [ ] Repeat for Small SFX. +- [ ] Choose conservative per-model reservations and at least 1 GiB headroom + from results; never infer them from marketed card capacity. +- [ ] Add an unrelated VRAM consumer before and during admission. Free VRAM must + be treated as advisory and unsafe work must fail before start. +- [ ] Unsupported GPU, old driver, CUDA mismatch, and no GPU fail without a + PyTorch CPU attempt. + +## Shared-contract parity + +For both Small models, compare the same pinned fixtures against TFLite: + +- [ ] text-to-audio; +- [ ] audio-to-audio and init-noise level; +- [ ] inpainting and continuation; +- [ ] positive and negative prompt; +- [ ] fixed seed, duration, sampling steps, CFG, and APG; +- [ ] one LoRA and a stacked LoRA with independent strengths; +- [ ] normalized progress and cancellation; +- [ ] exact 44.1 kHz stereo PCM16 duration/output boundary. + +Record intentional numerical/performance differences. A populated control may +not be ignored. If the pinned API cannot represent it, coordinate upstream or +route it explicitly to TFLite. + +## Broker, isolation, and lifecycle + +- [ ] Start SA3, then request MRT2 work. At the next sampler callback SA3 exits, + releases its lease/context, and MRT2 proceeds. +- [ ] Queue SA3 while MRT2 holds a lease. SA3 waits without disturbing either + deck or the native audio callback. +- [ ] Cancel while waiting, loading, sampling, and decoding; no child or CUDA + allocation remains. +- [ ] Force CUDA OOM, worker exception, invalid output, and abrupt worker death; + MRT2 and deck audio continue. +- [ ] Switch to CPU/TFLite during/after generation and exit the app at every + worker stage; the Windows Job Object removes every descendant. +- [ ] Corrupt the broker state and provenance stamp; both fail closed with a + bounded, non-sensitive diagnostic. + +## Dual-deck realtime acceptance + +Run two active `mrt2_small` decks for at least ten minutes per row while +repeatedly queueing/running/cancelling alternating Small Music and Small SFX +jobs. Save native engine underrun telemetry; silence or ring occupancy is not a +substitute. + +| frames/chunk | duration | SA3 workload | engine underruns | MRT2 p50/p95/p99 | SA3 p50/p95 | peak VRAM | result | +| ---: | ---: | --- | ---: | --- | --- | ---: | --- | +| 25 | 10 min | alternating Small Music/SFX | | | | | [ ] | +| 5 | 10 min | alternating Small Music/SFX | | | | | [ ] | + +- [ ] Both rows have zero engine-reported underruns. +- [ ] Exercise weighted prompts, notes, drums, seed/reset, and both deck states + during the 5-frame run. +- [ ] Verify broker yield does not kill or reset the shared MRT2 worker. + +## Selection and release + +- [ ] Auto selects CUDA only on a fully qualified configuration and explains + why it chose TFLite otherwise. +- [ ] Explicit GPU shows requirements and fails or offers a user-confirmed + TFLite fallback before generation; it never silently falls back after start. +- [ ] CPU/TFLite always selects the independent verified portable runtime. +- [ ] Active backend, worker state, fallback reason, estimate/reservation, GPU, + VRAM, driver, CUDA, PyTorch, and immutable revisions are visible. +- [ ] Keep Medium on TFLite. Enable it only in a later evidence-bearing change + with an official Windows FlashAttention path; never ship an unofficial wheel. +- [ ] Flip `HARDWARE_QUALIFIED` and the manifest release gate only in the PR that + links all evidence above. diff --git a/docs/stable-audio-backends.md b/docs/stable-audio-backends.md index 7be85d9..eeb4fdc 100644 --- a/docs/stable-audio-backends.md +++ b/docs/stable-audio-backends.md @@ -4,11 +4,17 @@ LSDJ selects one Stable Audio backend explicitly: - Apple Silicon macOS uses the existing MLX runtime. - Linux and Windows use the official LiteRT/TFLite CPU runtime. +- Windows x64 has an optional PyTorch/CUDA candidate for Small Music and Small + SFX. It remains behind a fail-closed release gate; Auto therefore continues + to select TFLite until the issue #114 hardware and provenance matrix is done. - Unsupported platforms fail with a diagnostic. They do not silently select a different runtime. - `LSDJ_SA3_BACKEND=mlx|tflite` is a diagnostic/developer override. The MLX override remains restricted to Apple Silicon; TFLite remains restricted to supported Linux/Windows x64 targets. +- `LSDJ_SA3_PREFERENCE=auto|gpu|cpu_tflite` is the backend-policy seam. An + explicit GPU request never silently falls back; while the release gate is + incomplete it fails before launch and asks the caller to confirm TFLite. Both adapters consume the same `GenerationRequest` contract and share one argument translator. A populated control is either forwarded or rejected; it @@ -16,23 +22,47 @@ is never silently discarded. ## Feature matrix -| Capability | MLX | TFLite | Notes | -| --- | --- | --- | --- | -| Music and SFX | Yes | Yes | Official small Music/SFX DiTs | -| Medium / 380 seconds | Yes | Yes | Runtime correctness is model-free tested; Windows/Linux performance still needs hardware evidence | -| Audio-to-audio | Yes | Yes | LSDJ normalizes input before either CLI sees it | -| Inpainting | Yes | Yes | Shared `inpaint_range` control | -| Continuation | Yes | Yes | The official continuation primitive is an inpaint range from source duration to requested duration | -| Positive/negative prompt | Yes | Yes | Negative prompt requires CFG other than 1 | -| Seed, duration, steps, CFG, APG | Yes | Yes | Shared validation and CLI spelling | -| Stacked LoRA with strength | Yes | Yes | TFLite runs fp32 because upstream cannot merge LoRA into quantized graphs | -| Per-step LoRA gating | Yes upstream | No | Not exposed by LSDJ; the TFLite CLI explicitly rejects it | -| Progress | Text stream | Text stream | LSDJ normalizes sampling/decode messages; upstream has no structured progress protocol | -| Cancellation | Process stop | Process stop | A cancelled request stops the isolated generation process | -| Partial audio preview | No | No | Neither pinned CLI exposes audio before the final WAV is written | - -The `/api/sa3/status` endpoint reports the selected backend, readiness, -capabilities, real limitations, and current queued/running state. +| Capability | MLX | TFLite | Windows CUDA candidate | Notes | +| --- | --- | --- | --- | --- | +| Music and SFX | Yes | Yes | Gated | Official Small Music/SFX models | +| Medium / 380 seconds | Yes | Yes | No | TFLite fallback; no unofficial FlashAttention build | +| Audio-to-audio | Yes | Yes | Gated | LSDJ normalizes input before every backend | +| Inpainting | Yes | Yes | Gated | Shared `inpaint_range` control | +| Continuation | Yes | Yes | Gated | Inpaint range from source duration to requested duration | +| Positive/negative prompt | Yes | Yes | Gated | Negative prompt requires CFG other than 1 | +| Seed, duration, steps, CFG, APG | Yes | Yes | Gated | CUDA maps directly to the pinned upstream Python API | +| Stacked LoRA with strength | Yes | Yes | Gated | Independent strength per adapter | +| Per-step LoRA gating | Yes upstream | No | No | Not exposed by LSDJ | +| Progress | Text stream | Text stream | Sampler callback | Normalized by LSDJ | +| Cancellation | Process stop | Process stop | Callback + process stop | CUDA also yields to realtime MRT2 | +| Partial audio preview | No | No | No | No pinned backend returns partial audio | + +The `/api/sa3/status` endpoint reports the preference choices, active backend, +readiness, capabilities, real limitations, current queued/running state, and on +Windows the CUDA release gate and qualification blockers. + +## Windows CUDA process and scheduling model + +The CUDA adapter calls the official pinned Python API; no upstream code is +copied into LSDJ. Each request runs in a disposable child and checks an exact +provenance stamp before heavyweight imports. It then verifies the shared +package versions, CUDA runtime, NVIDIA driver, device, reported memory, and the +measured reservation before loading Small Music or Small SFX. It never invokes +the Hub downloader and never falls back to PyTorch CPU. + +The file-locked GPU broker is shared with MRT2 across processes. MRT2 leases +have realtime priority. SA3 is admitted only when no MRT2 lease or waiter is +present and its measured reservation fits the conservative budget. If MRT2 +arrives during sampling, the next upstream callback cancels the SA3 child; +process exit releases the CUDA context. OOM, driver reset, worker crash, and app +exit are contained by the same process boundary and the native process-tree +supervisor. + +The candidate lock resolves the pinned SA3 requirements as PyTorch/torchaudio +2.7.1+cu126 and Hugging Face Hub 1.7.1. This differs from #110's MRT2 candidate, +so it is a shared-runtime hypothesis rather than a production upgrade. MRT2 +must pass its parity and dual-deck matrix on this exact lock. LSDJ will not ship +a second multi-gigabyte PyTorch environment if that qualification fails. ## Audio boundary diff --git a/sa3-pytorch-cuda-pin.json b/sa3-pytorch-cuda-pin.json new file mode 100644 index 0000000..1c63ead --- /dev/null +++ b/sa3-pytorch-cuda-pin.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": 1, + "releaseReady": false, + "backend": "pytorch_cuda", + "platform": "windows-x86_64", + "source": { + "repository": "https://github.com/Stability-AI/stable-audio-3", + "revision": "a0b57f5483c4588f827f3552b7d5c6ca2a9687be", + "archiveUrl": "https://github.com/Stability-AI/stable-audio-3/archive/a0b57f5483c4588f827f3552b7d5c6ca2a9687be.tar.gz", + "size": 50494239, + "sha256": "98e206e061a3b64a4e65f50b2802bdb6965910ac1fab65da919808dfb4497e9f", + "license": "MIT" + }, + "sharedRuntime": { + "python": "3.12", + "cuda": "12.6", + "requirementsInput": "backend/runtime-locks/windows-gpu-pytorch.in", + "requirementsLock": "backend/runtime-locks/windows-gpu-pytorch.txt", + "requirementsLockSize": 56238, + "requirementsLockSha256": "3c9bf7d79c3848ebe1da40fd14b26708b55d8157f008cb3a1944ddfb1cd597c4", + "packages": { + "torch": "2.7.1+cu126", + "torchaudio": "2.7.1+cu126", + "transformers": "5.8.0", + "huggingface-hub": "1.7.1", + "numpy": "2.3.5", + "safetensors": "0.7.0", + "sentencepiece": "0.2.1", + "resampy": "0.4.3" + }, + "mrt2RequalificationRequired": true + }, + "models": { + "small-music": { + "repository": "stabilityai/stable-audio-3-small-music", + "revision": "0fef1392cd842149a2b6d445e181c97608faac06", + "required": true, + "weight": { + "path": "model.safetensors", + "size": 2270384940, + "sha256": "da85866b11b01d0694d990785f6abbd79c8064df1b0e6f8aea52935e0ef84b64" + }, + "config": { + "path": "model_config.json", + "size": 10341, + "gitBlobSha1": "29ba617d6556c9e3c94bea19f07ea66d0c895e9e", + "sha256": null + } + }, + "small-sfx": { + "repository": "stabilityai/stable-audio-3-small-sfx", + "revision": "ae12755283df9d62ca39a9b050a39a0b607b8c20", + "required": true, + "weight": { + "path": "model.safetensors", + "size": 2270384940, + "sha256": "ed9cf1b6172f1a8c2921a9560c21109ff3239524563ced9dce6dcdef41e2f515" + }, + "config": { + "path": "model_config.json", + "size": 10454, + "gitBlobSha1": null, + "sha256": null + } + }, + "medium": { + "repository": "stabilityai/stable-audio-3-medium", + "revision": "27b5a21b791b1b033d193a9e1e3ce78493f102f9", + "required": false, + "enabled": false, + "blockedBy": "No official Windows FlashAttention 2 build has been qualified", + "weight": { + "path": "model.safetensors", + "size": 9222116660, + "sha256": "48d9c65e290e7bcd5194e0633bfc2424a59ee9683f5c2d58762d997b7d8ce0b5" + }, + "config": { + "path": "model_config.json", + "size": 10360, + "gitBlobSha1": null, + "sha256": null + } + } + }, + "gatedArtifactsComplete": false, + "releaseBlockers": [ + "Authenticated SHA-256 audit of all model configs and nested T5Gemma artifacts after the terms flow in issue #108", + "Small Music and Small SFX measured VRAM reservations with conservative headroom", + "MRT2 functional and real-time parity on the shared torch 2.7.1/CUDA 12.6 runtime", + "Windows NVIDIA OOM, cancellation, crash, exit, and VRAM-release evidence", + "Ten-minute dual-deck 25-frame and 5-frame runs with zero engine-reported underruns" + ] +} diff --git a/scripts/audit-sa3-cuda-pin.py b/scripts/audit-sa3-cuda-pin.py new file mode 100644 index 0000000..c21bf60 --- /dev/null +++ b/scripts/audit-sa3-cuda-pin.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Audit the fail-closed Windows SA3/CUDA candidate manifest. + +This audit deliberately fails for a release while any gated artifact lacks an +application-controlled SHA-256. ``--allow-incomplete`` is only for reviewing +the public, immutable metadata before issue #108 supplies an authenticated +terms/download flow; it does not make the runtime installable. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +PIN_PATH = ROOT / "sa3-pytorch-cuda-pin.json" +REQUIREMENT = re.compile(r"(?m)^([A-Za-z0-9_.-]+)==([^ \\\n]+) \\") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def audit_lock(pin: dict) -> None: + runtime = pin["sharedRuntime"] + lock_path = ROOT / runtime["requirementsLock"] + if (lock_path.stat().st_size, sha256(lock_path)) != ( + runtime["requirementsLockSize"], + runtime["requirementsLockSha256"], + ): + raise RuntimeError("shared runtime lock size or SHA-256 does not match the pin") + text = lock_path.read_text(encoding="utf-8") + requirements = dict(REQUIREMENT.findall(text)) + if runtime["packages"].items() > requirements.items(): + raise RuntimeError("shared runtime direct package pins do not match the lock") + if any(value in text for value in ("git+", "http://", " @ ", "--editable")): + raise RuntimeError("shared runtime lock contains a mutable dependency") + blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", text) + if any("==" in block and "--hash=sha256:" not in block for block in blocks): + raise RuntimeError("shared runtime lock contains an unhashed dependency") + + +def missing_artifact_hashes(pin: dict) -> list[str]: + missing = [] + for model_name, model in pin["models"].items(): + if not (model.get("required") or model.get("enabled")): + continue + for artifact_name in ("weight", "config"): + artifact = model[artifact_name] + if artifact.get("sha256") is None: + missing.append(f"{model_name}/{artifact['path']}") + return missing + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--allow-incomplete", + action="store_true", + help="review the non-release candidate even though gated hashes are missing", + ) + args = parser.parse_args() + pin = json.loads(PIN_PATH.read_text(encoding="utf-8")) + if pin.get("schemaVersion") != 1 or pin.get("backend") != "pytorch_cuda": + raise RuntimeError("unsupported Windows SA3/CUDA pin schema") + if pin.get("platform") != "windows-x86_64": + raise RuntimeError("the CUDA candidate must be Windows x64 only") + if pin["source"]["revision"] not in pin["source"]["archiveUrl"]: + raise RuntimeError("source archive URL is not tied to the immutable revision") + audit_lock(pin) + missing = missing_artifact_hashes(pin) + complete = not missing + if pin.get("gatedArtifactsComplete") is not complete: + raise RuntimeError("gatedArtifactsComplete disagrees with artifact hashes") + if pin.get("releaseReady") and (not complete or pin["releaseBlockers"]): + raise RuntimeError("releaseReady cannot be true while gates remain") + if missing and not args.allow_incomplete: + raise RuntimeError( + "gated artifact SHA-256 values are missing: " + ", ".join(missing) + ) + print( + "SA3/CUDA pin audit complete" + + (" (candidate remains release-blocked)" if missing else "") + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + print(f"SA3/CUDA pin audit failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 34e223c..c66a20b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -638,6 +638,10 @@ struct Sa3Pin { const SA3_PIN_JSON: &str = include_str!("../../sa3-pin.json"); const TFLITE_PIN_JSON: &str = include_str!("../../sa3-tflite-pin.json"); +#[allow(dead_code)] +const SA3_CUDA_PIN_JSON: &str = include_str!("../../sa3-pytorch-cuda-pin.json"); +#[allow(dead_code)] +const SA3_CUDA_LOCK: &str = include_str!("../../backend/runtime-locks/windows-gpu-pytorch.txt"); fn sa3_pin() -> Sa3Pin { serde_json::from_str(SA3_PIN_JSON).expect("sa3-pin.json is valid JSON") @@ -696,6 +700,82 @@ fn tflite_pin() -> TflitePin { serde_json::from_str(TFLITE_PIN_JSON).expect("sa3-tflite-pin.json is valid JSON") } +/// Release/install gate for the optional Windows CUDA bundle. The public +/// model metadata is intentionally compiled into the app for review, but the +/// installer must never expose a download until issue #108 has supplied every +/// required gated hash and the physical qualification blockers are cleared. +#[allow(dead_code)] +fn validate_sa3_cuda_install_gate(json: &str, lock: &[u8]) -> Result<(), String> { + use sha2::{Digest, Sha256}; + + let pin: serde_json::Value = serde_json::from_str(json) + .map_err(|error| format!("SA3 CUDA pin is invalid JSON: {error}"))?; + if pin.get("schemaVersion").and_then(|value| value.as_u64()) != Some(1) + || pin.get("backend").and_then(|value| value.as_str()) != Some("pytorch_cuda") + || pin.get("platform").and_then(|value| value.as_str()) != Some("windows-x86_64") + { + return Err("SA3 CUDA pin has an unsupported schema or target".into()); + } + if pin.get("releaseReady").and_then(|value| value.as_bool()) != Some(true) + || pin + .get("gatedArtifactsComplete") + .and_then(|value| value.as_bool()) + != Some(true) + || pin + .get("releaseBlockers") + .and_then(|value| value.as_array()) + .is_none_or(|blockers| !blockers.is_empty()) + { + return Err("SA3 CUDA candidate is not release-ready; TFLite remains active".into()); + } + let runtime = pin + .get("sharedRuntime") + .ok_or("SA3 CUDA shared runtime pin is missing")?; + let expected_size = runtime + .get("requirementsLockSize") + .and_then(|value| value.as_u64()) + .ok_or("SA3 CUDA lock size is missing")?; + let expected_hash = runtime + .get("requirementsLockSha256") + .and_then(|value| value.as_str()) + .ok_or("SA3 CUDA lock hash is missing")?; + if lock.len() as u64 != expected_size || hex::encode(Sha256::digest(lock)) != expected_hash { + return Err("SA3 CUDA shared runtime lock does not match its pin".into()); + } + let models = pin + .get("models") + .and_then(|value| value.as_object()) + .ok_or("SA3 CUDA model pins are missing")?; + for (name, model) in models { + let required = model + .get("required") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + || model + .get("enabled") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if !required { + continue; + } + for artifact_name in ["weight", "config"] { + let artifact = model + .get(artifact_name) + .ok_or_else(|| format!("SA3 CUDA {name} {artifact_name} pin is missing"))?; + let hash = artifact + .get("sha256") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!( + "SA3 CUDA {name} {artifact_name} SHA-256 is incomplete" + )); + } + } + } + Ok(()) +} + /// Shared install state: at most one install runs at a time; the running stage's /// child is parked here so [`InstallManager::cancel`] / shutdown can reach it. /// `active` names the in-flight job so `model_status` can report it — the manager @@ -1859,6 +1939,33 @@ mod tests { ); } + #[test] + fn sa3_cuda_pin_is_compiled_in_but_fails_closed_until_every_gate_is_complete() { + let error = validate_sa3_cuda_install_gate(SA3_CUDA_PIN_JSON, SA3_CUDA_LOCK.as_bytes()) + .unwrap_err(); + assert!(error.contains("not release-ready")); + + let mut pin: serde_json::Value = serde_json::from_str(SA3_CUDA_PIN_JSON).unwrap(); + pin["releaseReady"] = serde_json::json!(true); + pin["gatedArtifactsComplete"] = serde_json::json!(true); + pin["releaseBlockers"] = serde_json::json!([]); + let error = validate_sa3_cuda_install_gate( + &serde_json::to_string(&pin).unwrap(), + SA3_CUDA_LOCK.as_bytes(), + ) + .unwrap_err(); + assert!(error.contains("config SHA-256 is incomplete")); + + for model in ["small-music", "small-sfx"] { + pin["models"][model]["config"]["sha256"] = serde_json::json!("0".repeat(64)); + } + validate_sa3_cuda_install_gate( + &serde_json::to_string(&pin).unwrap(), + SA3_CUDA_LOCK.as_bytes(), + ) + .unwrap(); + } + #[test] fn sa3_backend_mapping_is_explicit_and_fail_closed() { assert_eq!( From 992266f9bc151f476983735f8b634708c8a59fb2 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:44:21 -0700 Subject: [PATCH 3/7] fix: make SA3 contract tests portable --- backend/lsdj/sa3.py | 4 ++++ backend/tests/test_models.py | 9 +++++++-- backend/tests/test_sa3.py | 12 ++++++++++-- backend/tests/test_sa3_manifest.py | 4 +++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/lsdj/sa3.py b/backend/lsdj/sa3.py index c46cd2c..6c25ccf 100644 --- a/backend/lsdj/sa3.py +++ b/backend/lsdj/sa3.py @@ -605,6 +605,10 @@ def _child_environment(selection: RuntimeSelection) -> dict[str, str]: env["HF_HUB_OFFLINE"] = "1" env["HF_HUB_DISABLE_TELEMETRY"] = "1" env["DO_NOT_TRACK"] = "1" + # Windows otherwise inherits a legacy console/filesystem encoding (often + # cp1252), which can make valid Unicode asset paths fail before inference. + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" env.pop("HF_TOKEN", None) env.pop("HUGGING_FACE_HUB_TOKEN", None) if selection.backend is BackendName.TFLITE: diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 8df6154..3165a06 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -45,7 +45,7 @@ def _checkout(root: pathlib.Path, *, venv: bool, warmed: bool) -> None: mlx = root / "optimized" / "mlx" mlx.mkdir(parents=True) if venv: - python = runtime_paths.venv_python(mlx / ".venv") + python = runtime_paths.venv_python(mlx / ".venv", platform="darwin") python.parent.mkdir(parents=True) python.write_text("") (mlx / "scripts").mkdir() @@ -65,7 +65,12 @@ def _checkout(root: pathlib.Path, *, venv: bool, warmed: bool) -> None: def test_readiness_classifies_a_checkout(tmp_path, venv, warmed, expected): root = tmp_path / "co" _checkout(root, venv=venv, warmed=warmed) - result = sa3.readiness(env={"SA3_MLX_HOME": str(root)}, home=tmp_path / "home") + result = sa3.readiness( + env={"SA3_MLX_HOME": str(root)}, + home=tmp_path / "home", + platform_name="darwin", + machine="arm64", + ) assert result["state"] == expected assert result["checkout"] == str(root) diff --git a/backend/tests/test_sa3.py b/backend/tests/test_sa3.py index f3ffba2..dcaebbc 100644 --- a/backend/tests/test_sa3.py +++ b/backend/tests/test_sa3.py @@ -27,6 +27,8 @@ f"offline={os.environ.get('HF_HUB_OFFLINE')}\n" f"token={os.environ.get('HF_TOKEN')}\n" f"threads={os.environ.get('OMP_NUM_THREADS')}\n" + f"pythonutf8={os.environ.get('PYTHONUTF8')}\n" + f"stdio={os.environ.get('PYTHONIOENCODING')}\n" ) if "--init-audio" in sys.argv: source = pathlib.Path(sys.argv[sys.argv.index("--init-audio") + 1]) @@ -223,7 +225,9 @@ def test_runtime_resolution_uses_windows_venv_layout(tmp_path): def test_status_exposes_backend_capabilities_and_real_limitations(tmp_path): - selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) + selection = make_runtime( + tmp_path / "sa3", BackendName.TFLITE, platform_name="linux" + ) result = sa3.status( {"SA3_HOME": str(selection.checkout)}, platform_name="linux", @@ -269,7 +273,9 @@ def test_explicit_gpu_fails_before_start_and_requires_confirmed_tflite_fallback( def test_status_fails_closed_for_unverified_tflite_provenance(tmp_path): - selection = make_runtime(tmp_path / "sa3", BackendName.TFLITE) + selection = make_runtime( + tmp_path / "sa3", BackendName.TFLITE, platform_name="linux" + ) (selection.runtime_dir / sa3.TFLITE_PROVENANCE_STAMP).write_text("{}") result = sa3.status( {"SA3_HOME": str(selection.checkout)}, @@ -378,6 +384,8 @@ def test_generate_returns_a_validated_wav_and_runs_offline(tflite_runtime): assert "offline=1" in env assert "token=None" in env assert "threads=4" in env + assert "pythonutf8=1" in env + assert "stdio=utf-8" in env def test_generate_passes_full_control_surface_and_normalized_input(tflite_runtime): diff --git a/backend/tests/test_sa3_manifest.py b/backend/tests/test_sa3_manifest.py index d21c667..ca18567 100644 --- a/backend/tests/test_sa3_manifest.py +++ b/backend/tests/test_sa3_manifest.py @@ -52,7 +52,9 @@ def test_adapter_preflight_paths_match_the_pinned_manifest(): required = set() for kind in ("sfx", "music", "track"): request = GenerationRequest("fixture", 0.5, kind, init_audio=b"wav") - required.update(str(path) for path in sa3._required_tflite_assets(request)) + required.update( + path.as_posix() for path in sa3._required_tflite_assets(request) + ) required.remove("models/tokenizer.model") assert required == installed From 469e2916d6669a5049bb8b385ddec19512b0bce5 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:49:23 -0700 Subject: [PATCH 4/7] fix: gate Unix-only process helpers --- src-tauri/src/analysis/live.rs | 2 +- src-tauri/src/child_process.rs | 8 ++++++-- src-tauri/src/models.rs | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/analysis/live.rs b/src-tauri/src/analysis/live.rs index 22f2ad9..1a361da 100644 --- a/src-tauri/src/analysis/live.rs +++ b/src-tauri/src/analysis/live.rs @@ -133,7 +133,7 @@ pub struct AnalysisFeed { impl AnalysisFeed { /// A feed whose receivers are dropped — every send is a silent no-op. For /// tests that need the tee wiring without analysis threads (no `AppHandle`). - #[cfg(test)] + #[cfg(all(test, unix))] pub fn disconnected(deck_count: usize) -> Self { AnalysisFeed { senders: Arc::new((0..deck_count).map(|_| sync_channel(1).0).collect()), diff --git a/src-tauri/src/child_process.rs b/src-tauri/src/child_process.rs index a138fe0..0116f7e 100644 --- a/src-tauri/src/child_process.rs +++ b/src-tauri/src/child_process.rs @@ -23,6 +23,7 @@ use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(20); const FORCE_WAIT: Duration = Duration::from_secs(2); +#[cfg(unix)] const TREE_REAP_SWEEPS: usize = 100; const DIAGNOSTIC_BYTES: usize = 16 * 1024; const DIAGNOSTIC_LINES: usize = 128; @@ -155,6 +156,7 @@ fn scrub_child_environment(command: &mut Command) { } impl SupervisedChild { + #[cfg(unix)] pub(crate) fn id(&self) -> u32 { self.child.id() } @@ -576,8 +578,10 @@ fn resume_windows_process(process_id: u32) -> io::Result<()> { } // SAFETY: ownership of the snapshot handle transfers here. let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot as _) }; - let mut entry = THREADENTRY32::default(); - entry.dwSize = std::mem::size_of::() as u32; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; // SAFETY: snapshot and entry pointers are valid. let mut has_entry = unsafe { Thread32First(snapshot.as_raw_handle() as _, &mut entry) } != 0; while has_entry { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index c66a20b..bca0bcd 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -2125,6 +2125,7 @@ mod tests { // ONLY on the child Command (never process-global), so they can't race the // sidecar tests that share this binary's environment. + #[cfg(unix)] fn shared() -> InstallShared { InstallShared { busy: AtomicBool::new(false), From 4afbfc1d581f717c6f0a062cd499497ee9999e82 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 16:11:03 -0700 Subject: [PATCH 5/7] fix: authenticate and preempt CUDA worker jobs --- backend/lsdj/sa3_cuda_worker.py | 123 +++++++++++++++--- backend/tests/test_sa3_cuda_worker.py | 44 +++++++ ...038-windows-sa3-cuda-qualification-gate.md | 11 +- docs/issue-114-windows-sa3-cuda-checklist.md | 7 +- docs/stable-audio-backends.md | 15 ++- 5 files changed, 176 insertions(+), 24 deletions(-) diff --git a/backend/lsdj/sa3_cuda_worker.py b/backend/lsdj/sa3_cuda_worker.py index ecf9c76..24a0284 100644 --- a/backend/lsdj/sa3_cuda_worker.py +++ b/backend/lsdj/sa3_cuda_worker.py @@ -11,12 +11,16 @@ import argparse import contextlib +import hashlib +import hmac import importlib.metadata import json import os import pathlib import platform as host_platform +import re import sys +import threading import wave from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass @@ -32,7 +36,11 @@ SAMPLE_RATE = 44_100 CHANNELS = 2 MAX_JSON_BYTES = 64 * 1024 +MAX_LAUNCH_TOKEN_BYTES = 512 +LAUNCH_TOKEN_ENV = "LSDJ_WORKER_LAUNCH_TOKEN" MODEL_FOR_KIND = {"music": "small-music", "sfx": "small-sfx"} +_JOB_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}") +_SHA256 = re.compile(r"[0-9a-f]{64}") class WorkerError(RuntimeError): @@ -55,6 +63,8 @@ def generate(self, **kwargs: Any) -> Any: ... @dataclass(frozen=True) class WorkerRequest: + job_id: str + launch_token_sha256: str prompt: str seconds: float kind: str @@ -76,11 +86,20 @@ def from_dict(cls, value: Mapping[str, Any]) -> "WorkerRequest": if value.get("schema_version") != SCHEMA_VERSION: raise WorkerError("unsupported CUDA worker request schema") prompt = value.get("prompt") + job_id = value.get("job_id") + launch_token_sha256 = value.get("launch_token_sha256") kind = value.get("kind") seconds = value.get("seconds") steps = value.get("steps") if not isinstance(prompt, str) or not prompt or len(prompt) > 32_000: raise WorkerError("prompt is invalid") + if not isinstance(job_id, str) or _JOB_ID.fullmatch(job_id) is None: + raise WorkerError("job_id is invalid") + if ( + not isinstance(launch_token_sha256, str) + or _SHA256.fullmatch(launch_token_sha256) is None + ): + raise WorkerError("launch_token_sha256 is invalid") if kind not in MODEL_FOR_KIND: raise WorkerError("CUDA supports only Small Music and Small SFX") if ( @@ -130,6 +149,8 @@ def from_dict(cls, value: Mapping[str, Any]) -> "WorkerRequest": if inpaint is not None and init_audio is None: raise WorkerError("inpainting requires init_audio") return cls( + job_id=job_id, + launch_token_sha256=launch_token_sha256, prompt=prompt, seconds=float(seconds), kind=kind, @@ -199,6 +220,18 @@ def read_request(path: pathlib.Path) -> WorkerRequest: return WorkerRequest.from_dict(parsed) +def verify_launch_token( + request: WorkerRequest, env: Mapping[str, str] | None = None +) -> None: + environment = os.environ if env is None else env + token = environment.get(LAUNCH_TOKEN_ENV) + if token is None or not 32 <= len(token.encode("utf-8")) <= MAX_LAUNCH_TOKEN_BYTES: + raise WorkerError("CUDA worker launch authorization is missing or invalid") + actual = hashlib.sha256(token.encode("utf-8")).hexdigest() + if not hmac.compare_digest(actual, request.launch_token_sha256): + raise WorkerError("CUDA worker launch authorization does not match the request") + + def verify_provenance(path: pathlib.Path, request: WorkerRequest) -> dict[str, Any]: if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_JSON_BYTES: raise WorkerError("CUDA provenance must be a bounded regular file") @@ -447,6 +480,52 @@ def _emit(event: dict[str, object]) -> None: sys.stdout.flush() +def _job_emitter(job_id: str) -> Callable[[dict[str, object]], None]: + def emit(event: dict[str, object]) -> None: + _emit({"jobId": job_id, **event}) + + return emit + + +def start_broker_watchdog( + broker: GpuBroker, + lease: Lease, + emit: Callable[[dict[str, object]], None], + *, + poll_seconds: float = 0.05, + exit_process: Callable[[int], None] = os._exit, +) -> tuple[threading.Event, threading.Thread]: + """Hard-stop model loading/decoding when realtime MRT2 needs the GPU. + + The sampler callback provides graceful yield during diffusion. Loading and + decoding are upstream calls with no cancellation callback, so a daemon + watchdog terminates only this disposable worker. Process exit is the + reliable CUDA-context/VRAM release boundary. + """ + + stop = threading.Event() + + def watch() -> None: + while not stop.wait(poll_seconds): + try: + should_yield = broker.should_yield(lease) + except Exception: + should_yield = True + if should_yield: + emit( + { + "event": "cancelled", + "message": "Stable Audio yielded to realtime MRT2 generation", + } + ) + exit_process(2) + return + + thread = threading.Thread(target=watch, name="sa3-gpu-yield", daemon=True) + thread.start() + return stop, thread + + def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="LSDJ disposable SA3 CUDA worker") parser.add_argument("--request", required=True) @@ -455,12 +534,19 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("--provenance", required=True) parser.add_argument("--reservation-bytes", required=True, type=int) args = parser.parse_args(argv) - request = read_request(pathlib.Path(args.request)) - provenance = verify_provenance(pathlib.Path(args.provenance), request) - cancel_file = pathlib.Path(args.cancel_file) + request: WorkerRequest | None = None + emit = _emit model: ModelProtocol | None = None torch: Any | None = None try: + request = read_request(pathlib.Path(args.request)) + emit = _job_emitter(request.job_id) + verify_launch_token(request) + # The token authenticates this one request/child pairing. Upstream + # imports and any grandchildren must never inherit it. + os.environ.pop(LAUNCH_TOKEN_ENV, None) + provenance = verify_provenance(pathlib.Path(args.provenance), request) + cancel_file = pathlib.Path(args.cancel_file) torch, load_model = _load_production_runtime() versions = _package_versions() if not torch.cuda.is_available(): @@ -499,7 +585,7 @@ def main(argv: Sequence[str] | None = None) -> int: timeout_seconds=120, cancelled=cancel_file.exists, ) as lease: - _emit( + emit( { "event": "progress", "stage": "loading", @@ -507,19 +593,24 @@ def main(argv: Sequence[str] | None = None) -> int: "total": None, } ) - model = load_model(request) - run_generation( - request, - model=model, - torch_module=torch, - cancelled=cancel_file.exists, - broker=broker, - lease=lease, - emit=_emit, - ) + watchdog_stop, watchdog = start_broker_watchdog(broker, lease, emit) + try: + model = load_model(request) + run_generation( + request, + model=model, + torch_module=torch, + cancelled=cancel_file.exists, + broker=broker, + lease=lease, + emit=emit, + ) + finally: + watchdog_stop.set() + watchdog.join(timeout=1) return 0 except WorkerCancelled as error: - _emit({"event": "cancelled", "message": str(error)}) + emit({"event": "cancelled", "message": str(error)}) return 2 except Exception as error: # Only our bounded, path-free errors cross the worker boundary. Unknown @@ -530,7 +621,7 @@ def main(argv: Sequence[str] | None = None) -> int: if isinstance(error, WorkerError) else f"CUDA worker failed ({type(error).__name__})" ) - _emit({"event": "error", "message": message}) + emit({"event": "error", "message": message}) return 1 finally: model = None diff --git a/backend/tests/test_sa3_cuda_worker.py b/backend/tests/test_sa3_cuda_worker.py index 0155ee7..5d827de 100644 --- a/backend/tests/test_sa3_cuda_worker.py +++ b/backend/tests/test_sa3_cuda_worker.py @@ -1,5 +1,7 @@ import json +import hashlib import pathlib +import threading import wave import numpy as np @@ -46,8 +48,11 @@ def pcm16_wav(path, seconds=0.5): def request(tmp_path, **updates): + launch_token = "qualification-token-with-32-bytes-minimum" value = { "schema_version": 1, + "job_id": "sa3-job-123", + "launch_token_sha256": hashlib.sha256(launch_token.encode()).hexdigest(), "prompt": "warm dub loop", "seconds": 0.5, "kind": "music", @@ -189,6 +194,45 @@ def test_request_contract_fails_closed(updates, message, tmp_path): request(tmp_path, **updates) +def test_launch_token_authenticates_one_private_request_without_storing_secret( + tmp_path, +): + item = request(tmp_path) + token = "qualification-token-with-32-bytes-minimum" + worker.verify_launch_token(item, {worker.LAUNCH_TOKEN_ENV: token}) + with pytest.raises(worker.WorkerError, match="does not match"): + worker.verify_launch_token(item, {worker.LAUNCH_TOKEN_ENV: "x" * 40}) + with pytest.raises(worker.WorkerError, match="missing or invalid"): + worker.verify_launch_token(item, {}) + + +def test_broker_watchdog_hard_stops_disposable_worker_during_model_load(): + exited = threading.Event() + events = [] + + class Broker: + @staticmethod + def should_yield(_lease): + return True + + stop, thread = worker.start_broker_watchdog( + Broker(), + object(), + events.append, + poll_seconds=0.001, + exit_process=lambda code: exited.set() if code == 2 else None, + ) + assert exited.wait(1) + thread.join(timeout=1) + stop.set() + assert events == [ + { + "event": "cancelled", + "message": "Stable Audio yielded to realtime MRT2 generation", + } + ] + + def test_request_file_is_bounded_and_rejects_symlinks(tmp_path): real = tmp_path / "request.json" real.write_text(json.dumps({"schema_version": 1})) diff --git a/docs/adr/0038-windows-sa3-cuda-qualification-gate.md b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md index 750282a..57d7ea4 100644 --- a/docs/adr/0038-windows-sa3-cuda-qualification-gate.md +++ b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md @@ -32,9 +32,16 @@ change. allocation, and CUDA context creation occur only in that child. Completion, cancellation, broker yield, OOM, crash, backend switch, and app teardown end the process. +- Bind each private request to its supervised child with a per-launch secret: + only its SHA-256 enters the bounded request file, the secret arrives through + an allowlisted inherited environment value, and the worker removes it before + upstream imports. A bounded job ID is attached to every progress/terminal + event; neither value enters argv or diagnostics. - Coordinate CUDA with a cross-process file-locked broker. MRT2 uses realtime - priority; background SA3 checks for a higher-priority waiter at every sampler - callback and exits so the MRT2 request can proceed. +priority; background SA3 checks for a higher-priority waiter at every sampler +callback. A daemon watchdog covers model loading and decoding, whose upstream +calls have no callback, and hard-stops only the disposable SA3 process so the +MRT2 request can proceed. - Expose Auto, GPU, and CPU/TFLite policy in the backend status contract. Auto remains on TFLite until the hardware gate is flipped with evidence. Explicit GPU fails before launch and asks for a confirmed TFLite fallback while the diff --git a/docs/issue-114-windows-sa3-cuda-checklist.md b/docs/issue-114-windows-sa3-cuda-checklist.md index 6269e94..80096bf 100644 --- a/docs/issue-114-windows-sa3-cuda-checklist.md +++ b/docs/issue-114-windows-sa3-cuda-checklist.md @@ -26,6 +26,9 @@ release-ready. runtime. The #110 PyTorch 2.12.1/CUDA 13.0 results do not transfer. - [ ] Confirm the source/runtime/model provenance shown by diagnostics matches the compiled manifest and that an altered stamp fails before model import. +- [x] Require a per-launch secret and bounded job ID before imports; echo only + the job ID on progress/terminal events and scrub the secret before upstream + code loads. - [ ] Complete #108 acknowledgement, attribution, and terms UX before exposing the CUDA download. @@ -81,8 +84,8 @@ route it explicitly to TFLite. ## Broker, isolation, and lifecycle -- [ ] Start SA3, then request MRT2 work. At the next sampler callback SA3 exits, - releases its lease/context, and MRT2 proceeds. +- [ ] Start SA3, then request MRT2 work during loading, sampling, and decoding. + The watchdog/callback exits SA3, releases its lease/context, and MRT2 proceeds. - [ ] Queue SA3 while MRT2 holds a lease. SA3 waits without disturbing either deck or the native audio callback. - [ ] Cancel while waiting, loading, sampling, and decoding; no child or CUDA diff --git a/docs/stable-audio-backends.md b/docs/stable-audio-backends.md index eeb4fdc..c57e936 100644 --- a/docs/stable-audio-backends.md +++ b/docs/stable-audio-backends.md @@ -50,13 +50,20 @@ package versions, CUDA runtime, NVIDIA driver, device, reported memory, and the measured reservation before loading Small Music or Small SFX. It never invokes the Hub downloader and never falls back to PyTorch CPU. +The managed launcher binds the private request to that child with an ephemeral +secret inherited through an allowlisted environment entry; the request contains +only its SHA-256. The worker verifies and removes the secret before importing +upstream code. Every structured event carries a bounded job ID, while secrets, +prompts, paths, and ambient credentials are excluded from diagnostics. + The file-locked GPU broker is shared with MRT2 across processes. MRT2 leases have realtime priority. SA3 is admitted only when no MRT2 lease or waiter is present and its measured reservation fits the conservative budget. If MRT2 -arrives during sampling, the next upstream callback cancels the SA3 child; -process exit releases the CUDA context. OOM, driver reset, worker crash, and app -exit are contained by the same process boundary and the native process-tree -supervisor. +arrives during sampling, the next upstream callback cancels the SA3 child. A +daemon watchdog provides the same hard process boundary while upstream model +loading or decoding offers no callback. Process exit releases the CUDA context. +OOM, driver reset, worker crash, and app exit are contained by the same process +boundary and the native process-tree supervisor. The candidate lock resolves the pinned SA3 requirements as PyTorch/torchaudio 2.7.1+cu126 and Hugging Face Hub 1.7.1. This differs from #110's MRT2 candidate, From 2a73a101ee10b46be4d3f225c5f22c0d4efdb412 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 16:21:06 -0700 Subject: [PATCH 6/7] fix: serialize shared MRT2 worker switching --- docs/issue-110-hardware-checklist.md | 9 + src-tauri/src/sidecar.rs | 344 ++++++++++++++++++++++++--- 2 files changed, 315 insertions(+), 38 deletions(-) diff --git a/docs/issue-110-hardware-checklist.md b/docs/issue-110-hardware-checklist.md index 13bbca3..fb80d69 100644 --- a/docs/issue-110-hardware-checklist.md +++ b/docs/issue-110-hardware-checklist.md @@ -65,6 +65,15 @@ is not an underrun measurement. process remains. - [ ] Prove the production Rust host owns one model worker with independent deck continuation states, as selected by #109. +- [ ] On the minimum-VRAM Windows and Linux hosts, switch the shared worker from + equal models to different models and back while both decks are active. Verify + both decks enter loading/unavailable together, the old process and CUDA + allocation are fully reaped before replacement allocation starts, and no + transient second generation appears in process/VRAM telemetry. +- [ ] Force the replacement launch and model load to fail after the old shared + worker is reaped. Verify both decks remain clearly unavailable and a later + valid selection recovers them. This serialized minimum-VRAM transition does + not promise live hardware rollback to the old worker. ## Release gate diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 509c7da..987129f 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -162,6 +162,7 @@ type StatusSink = Box; type PcmSink = Box; type DeckStatusSinks = [StatusSink; lsdj_engine::DECK_COUNT]; type DeckPcmSinks = [PcmSink; lsdj_engine::DECK_COUNT]; +type SharedStatusSinks = [Arc>; lsdj_engine::DECK_COUNT]; /// The read loop: drain frames from the sidecar until EOF/error. PCM frames are /// posted to the deck's ring (the non-RT producer side) and then TEED to `on_pcm` @@ -259,7 +260,6 @@ struct ReaderParts { struct SharedReaderExit { handles: [DeckHandle; lsdj_engine::DECK_COUNT], - on_status: DeckStatusSinks, } struct SharedReaderParts { @@ -403,7 +403,7 @@ fn start_shared_reader( listener: TcpListener, child: SupervisedChild, handles: [DeckHandle; lsdj_engine::DECK_COUNT], - mut on_status: DeckStatusSinks, + on_status: SharedStatusSinks, mut on_pcm: DeckPcmSinks, ) -> SharedReaderParts { let control: Arc>> = Arc::new(Mutex::new(None)); @@ -413,11 +413,20 @@ fn start_shared_reader( let reader = thread::Builder::new() .name("lsdj-sidecar-shared".to_string()) .spawn(move || { + let mut reader_status: DeckStatusSinks = std::array::from_fn(|deck| { + let sink = on_status[deck].clone(); + let status_stop = stop_for_reader.clone(); + Box::new(move |message| { + if !status_stop.load(Ordering::Acquire) { + (sink.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))(message) + } + }) as StatusSink + }); let stream = match accept_with_timeout(&listener, &stop_for_reader, ACCEPT_TIMEOUT) { Some(stream) => stream, None => { eprintln!("lsdj-sidecar-shared: sidecar never connected"); - return SharedReaderExit { handles, on_status }; + return SharedReaderExit { handles }; } }; stream.set_nodelay(true).ok(); @@ -427,20 +436,20 @@ fn start_shared_reader( } Err(error) => { eprintln!("lsdj-sidecar-shared: cannot split socket: {error}"); - return SharedReaderExit { handles, on_status }; + return SharedReaderExit { handles }; } } - let handles = run_shared_reader(stream, handles, &mut on_status, &mut on_pcm); + let handles = run_shared_reader(stream, handles, &mut reader_status, &mut on_pcm); *control_for_reader.lock().unwrap_or_else(|p| p.into_inner()) = None; if !stop_for_reader.load(Ordering::Acquire) { - for (deck, sink) in on_status.iter_mut().enumerate() { - sink(format!( + for (deck, sink) in on_status.iter().enumerate() { + (sink.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))(format!( "{{\"event\":\"worker_died\",\"deck\":\"{}\"}}", ["a", "b"][deck] )); } } - SharedReaderExit { handles, on_status } + SharedReaderExit { handles } }) .expect("failed to spawn shared LSDJ sidecar reader thread"); SharedReaderParts { @@ -601,10 +610,14 @@ pub struct SharedSidecar { models: [String; lsdj_engine::DECK_COUNT], taps: PcmTaps, feed: AnalysisFeed, + on_status: SharedStatusSinks, control: Arc>>, child: Arc>>, stop: Arc, reader: Option>, + /// Reclaimed ring producers parked after a replacement launch failure. A + /// later selection can recover without reconstructing the native engine. + parked: Option, } impl SharedSidecar { @@ -619,19 +632,22 @@ impl SharedSidecar { Ok(launch) => launch, Err(error) => return Err((error, handles)), }; + let on_status = on_status.map(|sink| Arc::new(Mutex::new(sink))); let on_pcm: DeckPcmSinks = [ Box::new(pcm_tee(taps.clone(), feed.clone(), 0)), Box::new(pcm_tee(taps.clone(), feed.clone(), 1)), ]; - let parts = start_shared_reader(listener, child, handles, on_status, on_pcm); + let parts = start_shared_reader(listener, child, handles, on_status.clone(), on_pcm); Ok(Self { models, taps, feed, + on_status, control: parts.control, child: parts.child, stop: parts.stop, reader: Some(parts.reader), + parked: None, }) } @@ -675,7 +691,65 @@ impl SharedSidecar { } let mut models = self.models.clone(); models[deck] = model.to_string(); - let (listener, child) = bind_and_launch_shared(&models)?; + + // A single shared CUDA worker owns both deck states, so both become + // unavailable together. Publish that fact before stopping the old + // generation and before any replacement model allocation can begin. + // Gate the old reader first so a final stale `ready` cannot race after + // these loading events; the process itself is stopped below. + self.stop.store(true, Ordering::Release); + for (index, sink) in self.on_status.iter().enumerate() { + let deck_label = ["a", "b"][index]; + (sink.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))( + serde_json::json!({ + "event": "model_loading", + "deck": deck_label, + "model": &models[index], + }) + .to_string(), + ); + } + + let exit = self.stop_and_reclaim()?; + // Stop-and-reap is intentional for shared CUDA. Launch-first remains the + // per-deck policy above, but would temporarily require two resident model + // generations here and can OOM a minimum-VRAM host. + let (listener, child) = match bind_and_launch_shared(&models) { + Ok(launch) => launch, + Err(error) => { + self.parked = Some(exit); + return Err(io::Error::new( + error.kind(), + format!( + "shared CUDA replacement failed after the old worker was stopped; reselect a model to retry: {error}" + ), + )); + } + }; + + let on_pcm: DeckPcmSinks = [ + Box::new(pcm_tee(self.taps.clone(), self.feed.clone(), 0)), + Box::new(pcm_tee(self.taps.clone(), self.feed.clone(), 1)), + ]; + let parts = start_shared_reader( + listener, + child, + exit.handles, + self.on_status.clone(), + on_pcm, + ); + self.models = models; + self.control = parts.control; + self.child = parts.child; + self.stop = parts.stop; + self.reader = Some(parts.reader); + Ok(()) + } + + fn stop_and_reclaim(&mut self) -> io::Result { + if let Some(exit) = self.parked.take() { + return Ok(exit); + } self.stop.store(true, Ordering::Release); if let Some(writer) = self @@ -686,11 +760,20 @@ impl SharedSidecar { { let _ = writer.shutdown(std::net::Shutdown::Both); } + let mut shutdown_error = None; if let Some(mut old) = self.child.lock().unwrap_or_else(|p| p.into_inner()).take() { - crate::child_process::log_shutdown( - "shared sidecar restart", - old.shutdown(Duration::from_millis(500)), - ); + match old.shutdown(Duration::from_millis(500)) { + Ok(report) => { + crate::child_process::log_shutdown("shared sidecar restart", Ok(report)) + } + Err(error) => { + if let Err(force_error) = old.force_kill() { + shutdown_error = Some(io::Error::other(format!( + "cannot reap old shared CUDA worker ({error}); forced teardown also failed ({force_error})" + ))); + } + } + } } let exit = self .reader @@ -698,31 +781,11 @@ impl SharedSidecar { .ok_or_else(|| io::Error::other("shared sidecar has no reader to reclaim"))? .join() .map_err(|_| io::Error::other("shared sidecar reader thread panicked"))?; - - let mut on_status = exit.on_status; - for (index, sink) in on_status.iter_mut().enumerate() { - let deck = ["a", "b"][index]; - let model = &models[index]; - sink( - serde_json::json!({ - "event": "model_loading", - "deck": deck, - "model": model, - }) - .to_string(), - ); + if let Some(error) = shutdown_error { + self.parked = Some(exit); + return Err(error); } - let on_pcm: DeckPcmSinks = [ - Box::new(pcm_tee(self.taps.clone(), self.feed.clone(), 0)), - Box::new(pcm_tee(self.taps.clone(), self.feed.clone(), 1)), - ]; - let parts = start_shared_reader(listener, child, exit.handles, on_status, on_pcm); - self.models = models; - self.control = parts.control; - self.child = parts.child; - self.stop = parts.stop; - self.reader = Some(parts.reader); - Ok(()) + Ok(exit) } } @@ -1038,6 +1101,9 @@ mod tests { #[cfg(unix)] use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + static SIDECAR_ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn native_platform_selects_one_explicit_mrt2_runtime() { let runtime = mrt2_runtime_for_platform().expect("supported build target"); @@ -1212,6 +1278,7 @@ mod tests { #[cfg(unix)] #[test] fn restart_switches_model_without_a_worker_died() { + let _env_guard = SIDECAR_ENV_LOCK.lock().unwrap(); // A stand-in sidecar: connect to --port, announce ready with --model, then // deliberately ignore socket EOF. Teardown must kill it as the wrapper's // process-group child; killing only the wrapper leaves this process and @@ -1354,4 +1421,205 @@ while True: std::env::remove_var("LSDJ_SIDECAR_CMD"); let _ = std::fs::remove_dir_all(&tmp); } + + /// A shared CUDA model switch cannot use the per-deck launch-first policy: + /// two generations resident at once can OOM the minimum supported card. + /// This model-free process test proves stop/reap-before-spawn, both-deck + /// loading state, a failed replacement parked for retry, and recovery. + #[cfg(unix)] + #[test] + fn shared_restart_serializes_cuda_generations_and_recovers_after_launch_failure() { + let _env_guard = SIDECAR_ENV_LOCK.lock().unwrap(); + let tmp = + std::env::temp_dir().join(format!("lsdj-shared-sidecar-switch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let python = tmp.join("shared-sidecar.py"); + let wrapper = tmp.join("shared-sidecar-wrapper.sh"); + let pidfile = tmp.join("python-pids"); + let overlap = tmp.join("overlap-detected"); + std::fs::write( + &python, + r#"import argparse, json, os, pathlib, socket, struct, sys, time +p = argparse.ArgumentParser() +p.add_argument('--port', type=int) +p.add_argument('--model-a') +p.add_argument('--model-b') +p.add_argument('--shared', action='store_true') +a, _ = p.parse_known_args() +pidfile = pathlib.Path(os.environ['LSDJ_TEST_PIDFILE']) +overlap = pathlib.Path(os.environ['LSDJ_TEST_OVERLAP']) +for line in pidfile.read_text().splitlines(): + pid = int(line) + if pid == os.getpid(): + continue + try: + os.kill(pid, 0) + except ProcessLookupError: + continue + overlap.write_text(f'{pid} still alive when {os.getpid()} started') +s = socket.create_connection(('127.0.0.1', a.port)) +if 'load_fail' in (a.model_a, a.model_b): + for deck, model in enumerate((a.model_a, a.model_b)): + body = bytes([deck]) + json.dumps({'event': 'startup_failed', 'model': model}).encode() + s.sendall(struct.pack('> \"{}\"\nwait \"$child\"\n", + python.display(), + pidfile.display() + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&wrapper).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&wrapper, permissions).unwrap(); + std::env::set_var("LSDJ_SIDECAR_CMD", wrapper.as_os_str()); + std::env::set_var("LSDJ_MRT2_RUNTIME", "pytorch-cuda"); + std::env::set_var("LSDJ_TEST_PIDFILE", pidfile.as_os_str()); + std::env::set_var("LSDJ_TEST_OVERLAP", overlap.as_os_str()); + + let mut engine = Engine::new(); + let handles = [engine.create_deck(0), engine.create_deck(1)]; + let statuses = Arc::new(Mutex::new(Vec::::new())); + let sinks: DeckStatusSinks = std::array::from_fn(|_| { + let statuses = statuses.clone(); + Box::new(move |message| statuses.lock().unwrap().push(message)) as StatusSink + }); + let taps = PcmTaps::new(2); + let feed = AnalysisFeed::disconnected(2); + let mut shared = SharedSidecar::spawn( + ["model_a".into(), "model_b".into()], + handles, + sinks, + taps, + feed, + ) + .map_err(|(error, _)| error) + .expect("spawn shared stand-in"); + + let saw_ready = |model: &str| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if statuses + .lock() + .unwrap() + .iter() + .any(|status| status.contains("ready") && status.contains(model)) + { + return true; + } + thread::sleep(Duration::from_millis(20)); + } + false + }; + assert!(saw_ready("model_a")); + assert!(saw_ready("model_b")); + + shared.restart(0, "model_c").expect("serialized switch"); + assert!(saw_ready("model_c")); + assert!(!overlap.exists(), "old and replacement workers overlapped"); + + std::env::set_var("LSDJ_SIDECAR_CMD", tmp.join("missing-sidecar")); + let error = shared.restart(1, "model_x").unwrap_err(); + assert!(error.to_string().contains("reselect a model to retry")); + std::env::set_var("LSDJ_SIDECAR_CMD", wrapper.as_os_str()); + shared + .restart(0, "model_d") + .expect("retry from parked handles"); + assert!(saw_ready("model_d")); + assert!(!overlap.exists(), "recovery overlapped CUDA generations"); + + assert!( + !statuses + .lock() + .unwrap() + .iter() + .any(|status| status.contains("worker_died")), + "deliberate switches and launch failure must suppress worker_died" + ); + shared + .restart(1, "load_fail") + .expect("replacement process launched before model-load failure"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while std::time::Instant::now() < deadline + && !statuses + .lock() + .unwrap() + .iter() + .any(|status| status.contains("startup_failed")) + { + thread::sleep(Duration::from_millis(20)); + } + assert!( + statuses + .lock() + .unwrap() + .iter() + .any(|status| status.contains("startup_failed")) + ); + shared + .restart(1, "model_e") + .expect("recover after replacement model-load failure"); + assert!(saw_ready("model_e")); + assert!(!overlap.exists(), "load recovery overlapped CUDA generations"); + + let log = statuses.lock().unwrap(); + for model in [ + "model_c", + "model_b", + "model_x", + "model_d", + "load_fail", + "model_e", + ] { + assert!( + log.iter() + .any(|status| status.contains("model_loading") && status.contains(model)), + "missing both-deck loading state for {model}" + ); + } + assert!(log.iter().any(|status| status.contains("worker_died"))); + drop(log); + + drop(shared); + let pids: Vec = std::fs::read_to_string(&pidfile) + .unwrap() + .lines() + .map(|line| line.parse().unwrap()) + .collect(); + assert_eq!(pids.len(), 5, "failed launch must not create a child"); + for pid in pids { + let mut gone = false; + for _ in 0..1000 { + if unsafe { libc::kill(pid, 0) } == -1 { + gone = true; + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert!(gone, "shared worker {pid} survived serialized transition"); + } + + for name in [ + "LSDJ_SIDECAR_CMD", + "LSDJ_MRT2_RUNTIME", + "LSDJ_TEST_PIDFILE", + "LSDJ_TEST_OVERLAP", + ] { + std::env::remove_var(name); + } + let _ = std::fs::remove_dir_all(&tmp); + } } From c7cb4aeab9361b14e4fe4bc1452be10cf4485fa0 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 16:54:45 -0700 Subject: [PATCH 7/7] fix: preserve hashed runtime lock bytes --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 7597f9e..d8886d4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ backend/spike_corpus/*.wav filter=lfs diff=lfs merge=lfs -text +backend/runtime-locks/*.txt text eol=lf