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 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/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/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..6c25ccf 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 . import runtime_paths +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass + +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 ( + 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,631 @@ 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 +SA3_PREFERENCE_ENV = "LSDJ_SA3_PREFERENCE" + +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() + if value in {"arm64", "aarch64"}: + return "arm64" + if value in {"amd64", "x86_64"}: + return "x86_64" + return 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 not ( + platform_name in {"linux", "win32"} and arch == "x86_64" + ): + 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"} and arch == "x86_64": + 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: + env = os.environ if env is None else env + 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() + ) + 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), + } + + +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" + # 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: + 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 +694,151 @@ 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, + """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, + ) + 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 "" ) - timeout = timeout_for(seconds) + 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: + 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..e4704ca --- /dev/null +++ b/backend/lsdj/sa3_contract.py @@ -0,0 +1,164 @@ +"""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" + PYTORCH_CUDA = "pytorch_cuda" + + +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.", + ), +) + +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 { + 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..24a0284 --- /dev/null +++ b/backend/lsdj/sa3_cuda_worker.py @@ -0,0 +1,634 @@ +"""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 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 +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 +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): + 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: + job_id: str + launch_token_sha256: str + 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") + 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 ( + 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( + job_id=job_id, + launch_token_sha256=launch_token_sha256, + 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_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") + 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 _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) + 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: 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(): + 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, + } + ) + 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)}) + 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_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_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_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_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_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 e2442b9..dcaebbc 100644 --- a/backend/tests/test_sa3.py +++ b/backend/tests/test_sa3.py @@ -1,256 +1,481 @@ -"""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 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" + f"pythonutf8={os.environ.get('PYTHONUTF8')}\n" + f"stdio={os.environ.get('PYTHONIOENCODING')}\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 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 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" - venv = mlx_dir / ".venv" - python = runtime_paths.venv_python(venv) - python.parent.mkdir(parents=True) - (mlx_dir / "scripts").mkdir() - (mlx_dir / "scripts" / "sa3_mlx.py").write_text(stub_body) - (venv / "pyvenv.cfg").write_text( +def _install_interpreter(runtime_dir: pathlib.Path, platform_name: str) -> pathlib.Path: + executable = runtime_paths.venv_python( + runtime_dir / ".venv", platform=platform_name + ) + executable.parent.mkdir(parents=True) + (runtime_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" ) - if os.name == "nt": - # Creating symlinks normally requires elevated Windows privileges. - # A copied interpreter plus pyvenv.cfg exercises the real Scripts layout. - shutil.copyfile(sys.executable, python) + 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) -def runtime_file(mlx_dir: pathlib.Path, name: str) -> pathlib.Path: - return runtime_paths.venv_python(mlx_dir / ".venv").parent / name +@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 + ) + monkeypatch.setenv("SA3_HOME", str(selection.checkout)) + monkeypatch.setenv("LSDJ_SA3_BACKEND", "tflite") + # The worker tests inject a fully resolved fake runtime so they remain + # model-free on every CI host without weakening production's target gate. + monkeypatch.setattr(sa3, "resolve_runtime", lambda: selection) + return selection + + return install + +@pytest.mark.parametrize( + ("platform_name", "machine", "expected"), + [ + ("darwin", "arm64", BackendName.MLX), + ("darwin", "aarch64", BackendName.MLX), + ("linux", "x86_64", 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 + ) -class TestResolveMlxDir: - def test_fixture_uses_the_platform_native_venv_interpreter(self, tmp_path): - mlx_dir = make_checkout(tmp_path / "checkout", SUCCESS_STUB) - assert runtime_paths.venv_python(mlx_dir / ".venv").is_file() - 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" +def test_backend_override_is_validated(): + assert ( + sa3.select_backend( + {"LSDJ_SA3_BACKEND": "tflite"}, + platform_name="linux", + machine="x86_64", + ) + is BackendName.TFLITE + ) + with pytest.raises(sa3.GenerationUnavailable, match="does not support"): + sa3.select_backend( + {"LSDJ_SA3_BACKEND": "tflite"}, + platform_name="darwin", + machine="x86_64", + ) + 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", ) - assert resolved == mlx_dir - 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 - 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_unsupported_platform_fails_instead_of_guessing(): + for platform_name, machine in (("freebsd", "x86_64"), ("linux", "aarch64")): + with pytest.raises(sa3.GenerationUnavailable, match="no Stable Audio backend"): + sa3.select_backend({}, platform_name=platform_name, machine=machine) - def test_nothing_resolves_to_none(self, tmp_path): - assert sa3.resolve_mlx_dir(env={}) is None +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" -@pytest.fixture -def checkout(tmp_path, monkeypatch): - """Install a stub checkout, point SA3_MLX_HOME at it, return mlx dir.""" - 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 +def test_status_exposes_backend_capabilities_and_real_limitations(tmp_path): + selection = make_runtime( + tmp_path / "sa3", BackendName.TFLITE, platform_name="linux" + ) + 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"] + ) - return install +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", + ) -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 = runtime_file(mlx_dir, "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, - ) + 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, platform_name="linux" + ) + (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 = runtime_file(mlx_dir, "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 runtime_file(mlx_dir, "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 + assert "pythonutf8=1" in env + assert "stdio=utf-8" 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 = runtime_file(mlx_dir, "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 = runtime_file(mlx_dir, "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 = runtime_file(mlx_dir, "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")) + monkeypatch.setattr(sa3, "resolve_runtime", lambda: None) + monkeypatch.setattr(sa3, "select_backend", lambda: BackendName.TFLITE) + 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("= 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..5d827de --- /dev/null +++ b/backend/tests/test_sa3_cuda_worker.py @@ -0,0 +1,292 @@ +import json +import hashlib +import pathlib +import threading +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): + 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", + "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_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})) + 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/backend/tests/test_sa3_manifest.py b/backend/tests/test_sa3_manifest.py new file mode 100644 index 0000000..ca18567 --- /dev/null +++ b/backend/tests/test_sa3_manifest.py @@ -0,0 +1,93 @@ +"""Fail-closed checks for the pinned official TFLite runtime manifest.""" + +import json +import pathlib +import re + +from lsdj import sa3 +from lsdj.sa3_contract import GenerationRequest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "sa3-tflite-pin.json" +LOCK = ROOT / "scripts" / "sa3-tflite-requirements.lock" + + +def load_manifest() -> 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( + path.as_posix() 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/adr/0038-windows-sa3-cuda-qualification-gate.md b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md new file mode 100644 index 0000000..57d7ea4 --- /dev/null +++ b/docs/adr/0038-windows-sa3-cuda-qualification-gate.md @@ -0,0 +1,79 @@ +# 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. +- 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. 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 + 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-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/docs/issue-114-windows-sa3-cuda-checklist.md b/docs/issue-114-windows-sa3-cuda-checklist.md new file mode 100644 index 0000000..80096bf --- /dev/null +++ b/docs/issue-114-windows-sa3-cuda-checklist.md @@ -0,0 +1,129 @@ +# 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. +- [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. + +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 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 + 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/sa3-pin-audit.md b/docs/sa3-pin-audit.md index e937075..8d4483c 100644 --- a/docs/sa3-pin-audit.md +++ b/docs/sa3-pin-audit.md @@ -1,19 +1,25 @@ # Stable Audio 3 pin provenance and audit -`sa3-pin.json` is executable supply-chain policy for the in-app installer, not -just version documentation. Every URL is HTTPS, every revision is immutable, -and every byte-bearing artifact has an application-controlled SHA-256 and exact -size. A pin change must update this record in the same pull request. +`sa3-pin.json` and `sa3-tflite-pin.json` are executable supply-chain policy for +the in-app installer, not just version documentation. Every URL is HTTPS, every +revision is immutable, and every byte-bearing artifact has an +application-controlled SHA-256 and exact size. A pin change must update this +record in the same pull request. ## Recorded provenance (2026-08-08) | Pin family | Immutable upstream evidence | How the manifest value was established | | --- | --- | --- | -| SA3 source | Stability AI Git commit `0385302ea26522f00c80392c4b708df5ebf1adf5` | Streamed the exact GitHub commit archive (8,436,657 bytes) and calculated SHA-256 `6991aeedd4e8f5509b7ce76b7d9dddc43e4c6f980e81ea9b5179890b518b906f`. GitHub does not publish a signed checksum for this generated archive, so a future archive-byte change must fail closed and receive explicit review. | -| uv runtime | Astral uv release `0.11.7`, target `aarch64-apple-darwin` | The official release archive and Astral release metadata agree on 20,839,135 bytes and SHA-256 `66e37d91f839e12481d7b932a1eccbfe732560f42c1cfb89faddfa2454534ba8`. | -| Python runtime | Astral python-build-standalone release `20251007`, CPython `3.11.13`, target `aarch64-apple-darwin` | The official release archive and the download metadata embedded in pinned uv `0.11.7` agree on 18,949,778 bytes and SHA-256 `78bc6defdc1dac5bf6765c8f938e6849383dbed831ea1e2d11576a4683fb1e8c`. | -| SA3 model weights | Hugging Face repository `stabilityai/stable-audio-3-optimized` at commit `6736003cb57d06b7b1fdc36fad31b2a3709e4774` | Each of the eight manifest size/hash pairs is the immutable revision's LFS object size and SHA-256. The audit script checks metadata without downloading roughly 9 GB; `--include-model-bytes` also streams and hashes every object. | -| Python dependencies | `scripts/sa3-requirements.in` compiled by uv `0.11.7` for Python 3.11 | The committed lock contains 19 exact package versions and 282 wheel/sdist SHA-256 hashes. Installer invocation also enforces `--require-hashes --only-binary :all:` against the public PyPI index with ambient config/index variables removed. | +| SA3 source | Stability AI Git commit `a0b57f5483c4588f827f3552b7d5c6ca2a9687be` | Streamed the exact GitHub commit archive: 50,494,239 bytes, SHA-256 `98e206e061a3b64a4e65f50b2802bdb6965910ac1fab65da919808dfb4497e9f`. The 442-entry archive expands to 83,845,120 bytes. GitHub does not publish a signed checksum for generated source archives, so any byte change fails closed and requires explicit review. | +| uv runtime | Astral uv release `0.11.7` | The publisher checksums and downloaded bytes agree for `aarch64-apple-darwin` (20,839,135 bytes, `66e37d91…34ba8`), `x86_64-unknown-linux-gnu` (24,249,861 bytes, `6681d691…ea868`), and `x86_64-pc-windows-msvc` (23,572,531 bytes, `fe0c7815…a8b29`). The Windows artifact is securely extracted from ZIP; the other two are tarballs. | +| Python runtime | python-build-standalone release `20251007`, CPython `3.11.13` | Publisher metadata and downloaded bytes agree for Apple arm64 (18,949,778 bytes, `78bc6def…e8c`), Linux x64 (30,157,215 bytes, `43bfc425…f0c3`), and Windows x64 (25,990,147 bytes, `cde5153f…8b29`). | +| MLX model weights | `stabilityai/stable-audio-3-optimized` at commit `6736003cb57d06b7b1fdc36fad31b2a3709e4774` | The eight manifest size/hash pairs match the immutable revision's LFS objects. The unique model payload is 9,154,794,562 bytes. | +| TFLite model weights | The same immutable model revision | `sa3-tflite-pin.json` selects the official fp32 CPU set. Its eight unique LFS objects total 14,138,994,904 bytes. Shared SAME-S artifacts are installed once even though both small bundles reference them. | +| Python dependencies | The two `.in` files compiled by uv `0.11.7` for Python 3.11 | The MLX lock contains 19 exact packages and 282 hashes; the portable LiteRT lock contains 26 exact packages and 433 hashes. Installer invocation enforces `--require-hashes --only-binary :all:` against public PyPI with ambient index/config variables removed. | + +The model manager discloses the selected backend's exact unique model payload +before installation. Source archives, Python, uv, and dependency wheels add +download and on-disk overhead beyond that displayed model-byte figure. ## Reproduce the audit @@ -23,25 +29,27 @@ From the repository root, with network access: python3 scripts/audit-sa3-pins.py ``` -This downloads and hashes about 50 MB of source/runtime archives, checks all -eight model objects against the pinned Hugging Face revision's LFS metadata, and -audits the lock structure. For a release-bound pin bump, also perform the full -model-byte audit: +This downloads and hashes the source and three host runtime pairs, checks both +sets of eight model objects against immutable Hugging Face LFS metadata, and +audits both dependency locks. For a release-bound pin bump, also perform the +full model-byte audit (roughly 23 GB): ```console python3 scripts/audit-sa3-pins.py --include-model-bytes ``` -Regenerate the dependency lock with the same pinned uv release and compare the -result rather than editing it by hand: +Regenerate each dependency lock with the same pinned uv release and compare the +result instead of editing it by hand: ```console uv pip compile --generate-hashes --python-version 3.11 \ --output-file scripts/sa3-requirements.lock scripts/sa3-requirements.in -git diff --exit-code -- scripts/sa3-requirements.lock +uv pip compile --generate-hashes --python-version 3.11 \ + --output-file scripts/sa3-tflite-requirements.lock scripts/sa3-tflite-requirements.in +git diff --exit-code -- scripts/sa3-requirements.lock scripts/sa3-tflite-requirements.lock ``` -Reviewers should reject any pin update whose immutable revision, exact size, -checksum, provenance source, and audit result are not all present. The runtime -installer independently rechecks the same sizes and hashes before extraction, -execution, promotion, recovery, and app-managed readiness. +Reviewers should reject a pin update unless its immutable revision, exact size, +checksum, provenance source, and audit result are all present. The installer +independently rechecks sizes and hashes before extraction, execution, promotion, +recovery, and app-managed readiness. diff --git a/docs/stable-audio-backends.md b/docs/stable-audio-backends.md new file mode 100644 index 0000000..c57e936 --- /dev/null +++ b/docs/stable-audio-backends.md @@ -0,0 +1,135 @@ +# 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. +- 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 +is never silently discarded. + +## Feature matrix + +| 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 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. 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, +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 + +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. The hardened #107 installer now +consumes both manifests, verifies every byte, builds the isolated environment, +warms all three model pairs, and atomically promotes or rolls back the candidate. +Before release, Ubuntu plus Windows hardware runs must still 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/frontend/src/audio/nativeEngine.ts b/frontend/src/audio/nativeEngine.ts index d554bff..9ac51cb 100644 --- a/frontend/src/audio/nativeEngine.ts +++ b/frontend/src/audio/nativeEngine.ts @@ -149,8 +149,8 @@ export type InstalledModel = { needsResources: boolean } -/** SA3's four readiness states (Rust `models`/`sa3.readiness`). */ -export type Sa3State = 'missing' | 'venv_missing' | 'not_warmed' | 'ready' +/** SA3 readiness states (Rust `models`/`sa3.readiness`). */ +export type Sa3State = 'missing' | 'venv_missing' | 'not_warmed' | 'ready' | 'failed' /** The source an SA3 checkout was installed from / is pinned to (Rust * `models::Sa3Source`): the `sa3-pin.json` repo + commit. */ @@ -183,7 +183,10 @@ export type ModelStatus = { } sa3: { state: Sa3State + backend: 'mlx' | 'tflite' | null sizeBytes: number + /** Exact model bytes from the selected backend's immutable manifest. */ + downloadBytes: number checkout: string | null /** What the installed checkout was fetched from (`null` when unstamped). */ installedSource: Sa3Source | null diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0a59884..dc8655b 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -400,6 +400,7 @@ "sa3": "Stable Audio 3", "none": "No models installed", "notInstalled": "Not installed", + "downloadSize": "{{size}} download", "needsResources": "Needs shared resources — repair to enable loading", "install": "Install", "repair": "Repair", @@ -413,7 +414,8 @@ "missing": "Not installed", "venv_missing": "Needs setup", "not_warmed": "Weights not downloaded", - "ready": "Ready" + "ready": "Ready", + "failed": "Verification failed — repair required" }, "stage": { "init": "Fetching shared resources…", diff --git a/frontend/src/models/LoraProvider.test.tsx b/frontend/src/models/LoraProvider.test.tsx index 33a464a..b787304 100644 --- a/frontend/src/models/LoraProvider.test.tsx +++ b/frontend/src/models/LoraProvider.test.tsx @@ -42,7 +42,9 @@ function status(overrides: Partial = {}): ModelStatus { }, sa3: { state: 'ready', + backend: 'mlx', sizeBytes: 5_000_000_000, + downloadBytes: 9_154_794_562, checkout: '/sa3', installedSource: null, pinnedSource: { repo: 'https://github.com/Stability-AI/stable-audio-3', commit: 'pin' }, diff --git a/frontend/src/models/ModelManager.test.tsx b/frontend/src/models/ModelManager.test.tsx index 1631058..eaa4173 100644 --- a/frontend/src/models/ModelManager.test.tsx +++ b/frontend/src/models/ModelManager.test.tsx @@ -40,7 +40,9 @@ function status(overrides: Partial = {}): ModelStatus { }, sa3: { state: 'missing', + backend: 'tflite', sizeBytes: 0, + downloadBytes: 14_138_994_904, checkout: null, installedSource: null, pinnedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, @@ -122,7 +124,9 @@ describe('ModelManager', () => { status({ sa3: { state: 'ready', + backend: 'mlx', sizeBytes: 5_000_000_000, + downloadBytes: 9_154_794_562, checkout: '/sa3', installedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, pinnedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, @@ -161,7 +165,9 @@ describe('ModelManager', () => { status({ sa3: { state: 'ready', + backend: 'mlx', sizeBytes: 5_000_000_000, + downloadBytes: 9_154_794_562, checkout: '/sa3', installedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'oldsha' }, pinnedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'newsha' }, @@ -180,7 +186,9 @@ describe('ModelManager', () => { status({ sa3: { state: 'ready', + backend: 'mlx', sizeBytes: 5_000_000_000, + downloadBytes: 9_154_794_562, checkout: '/sa3', installedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, pinnedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, @@ -203,4 +211,25 @@ describe('ModelManager', () => { expect(screen.getByRole('alert')).toHaveTextContent('no weights') }) + it('discloses the selected backend download and offers repair after verification failure', async () => { + modelStatus.mockResolvedValue( + status({ + sa3: { + state: 'failed', + backend: 'tflite', + sizeBytes: 0, + downloadBytes: 14_138_994_904, + checkout: '/sa3', + installedSource: null, + pinnedSource: { repo: 'https://github.com/Stability-AI/stable-audio-3', commit: 'pin' }, + updateAvailable: true, + }, + }), + ) + render() + await screen.findByText('Verification failed — repair required') + fireEvent.click(screen.getByText('Repair')) + expect(installModel).toHaveBeenCalledWith('sa3', undefined) + }) + }) diff --git a/frontend/src/models/ModelManager.tsx b/frontend/src/models/ModelManager.tsx index f759009..737c9a2 100644 --- a/frontend/src/models/ModelManager.tsx +++ b/frontend/src/models/ModelManager.tsx @@ -187,6 +187,9 @@ export function ModelManager() {
{t(`modelManager.sa3State.${sa3.state}`)} {sa3Present && sa3.sizeBytes > 0 ? ` · ${formatBytes(sa3.sizeBytes)}` : ''} + {!sa3Present && sa3.downloadBytes > 0 + ? ` · ${t('modelManager.downloadSize', { size: formatBytes(sa3.downloadBytes) })}` + : ''}
{sa3Ready && sa3.updateAvailable && (
{t('modelManager.updateAvailable')}
@@ -198,7 +201,7 @@ export function ModelManager() { sa3Label, !sa3Ready ? ( ) : sa3.updateAvailable ? (