diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eae89d..c1496cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,13 @@ jobs: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - uses: actions/checkout@v7.0.1 + # diffq (a mandatory transitive dependency of audio-separator, #275) has + # no prebuilt wheel for Python 3.11+ on Linux -- its last release only + # ever shipped cp310 wheels -- so uv sync must compile it from source. + # Docker and the Linux desktop release build already install + # build-essential for the same reason; this container didn't need it + # before audio-separator existed. + - run: apt-get update && apt-get install -y --no-install-recommends build-essential - run: uv sync --frozen --all-extras - run: uv run ruff check app/ tests/ - run: uv run ruff format --check app/ tests/ @@ -37,7 +44,8 @@ jobs: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - uses: actions/checkout@v7.0.1 - - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg + # build-essential: see the matching comment in the lint job above. + - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg build-essential - run: uv sync --frozen --all-extras - run: uv run pytest tests/ -q diff --git a/.gitignore b/.gitignore index dbc488b..2691730 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ htmlcov/ data/ jobs/ cache/ +models/ settings.json .run/ .build diff --git a/README.md b/README.md index 9ff07f9..6587887 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,15 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no | Name | What they do | Link | |---|---|---| +| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | -| Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) | +| Empress Effects | Effects pedals | [empresseffects.com](https://empresseffects.com) | | Joao Gaspar | Producer/Film Scorer, Touring/Session Musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | | Kris Luthier | Luthier and Musical Instrument Repair, Lisboa | [@krisluthier](https://www.instagram.com/krisluthier) | -| Thomann | Online Music Store | [@thomann.music](https://www.instagram.com/thomann.music) | -| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | -| Empress Effects | Effects pedals | [empresseffects.com](https://empresseffects.com) | +| Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) | | More Notes Less Talk | Instruments and gear with personality, recorded raw to tape. No hype, no gatekeeping. | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Seratone | Turns any TV into a studio-grade karaoke stage | [seratone.audio](https://seratone.audio/) | +| Thomann | Online Music Store | [@thomann.music](https://www.instagram.com/thomann.music) | --- @@ -153,7 +154,7 @@ Extract the zip anywhere, run `StemDeck.exe`. FFmpeg, the Demucs model, config,
-StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `` using min/max sample rendering. +StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. The optional on-demand lead/backing vocal split runs the UVR-MDX-NET Karaoke 2 model via **[audio-separator](https://github.com/nomadkaraoke/python-audio-separator)**, trained as part of the **[Ultimate Vocal Remover](https://github.com/Anjok07/ultimatevocalremovergui)** project by Anjok07. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `` using min/max sample rendering. *Thanks to the creators and maintainers of all the open-source libraries that make StemDeck possible.* diff --git a/app/api/config.py b/app/api/config.py index 76eee2d..21cfe85 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -2,11 +2,16 @@ from fastapi import APIRouter -from app.core.config import STEM_NAMES +from app.core.config import EXTRA_STEM_NAMES, STEM_NAMES router = APIRouter() @router.get("/config") def get_config() -> dict: - return {"stem_names": list(STEM_NAMES)} + # extra_stem_names (#275) are produced only when a job's on-demand + # lead/backing vocal split has run -- kept separate from stem_names so + # existing clients that assume "every job produces exactly these stems" + # are unaffected; new clients merge it into their lane vocab (see + # syncStemNamesFromAPI in static/js/constants.js). + return {"stem_names": list(STEM_NAMES), "extra_stem_names": list(EXTRA_STEM_NAMES)} diff --git a/app/api/jobs.py b/app/api/jobs.py index 9002005..247c68f 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -33,7 +33,11 @@ from app.core.settings import get_max_duration_sec from app.core.stems_location import is_relocating from app.pipeline import jobqueue +from app.pipeline.collect import merge_stem_peaks from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url +from app.pipeline.errors import classify_failure +from app.pipeline.runner import _pipeline_lock +from app.pipeline.vocal_split import split_vocals router = APIRouter(tags=["jobs"]) logger = logging.getLogger("stemdeck.api") @@ -328,6 +332,63 @@ def cancel_job(job_id: str) -> dict: return job.to_state() +def _write_vocal_split_error(stems_dir: Path, cause: str, tail: list[str]) -> None: + """Best-effort error record for the on-demand vocal split (#275). The job + itself stays "done" -- this is diagnostic-only, not the quarantine path + (which would delete the job's base stems).""" + try: + lines = [f"cause: {cause}", "", "--- stderr tail ---", *tail] + (stems_dir / "vocal_split_error.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + except OSError: + logger.warning("could not write vocal_split_error.txt in %s", stems_dir, exc_info=True) + + +@router.post("/{job_id}/vocal-split") +async def start_vocal_split(job_id: str) -> Response: + """Trigger the on-demand lead/backing vocal split (#275) for a completed + job: a second model pass over the existing vocals.wav, producing + lead_vocals.wav + backing_vocals.wav. Idempotent once done -- calling + again returns 202 with the existing result rather than re-running the + (expensive) model.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not found") + if job.vocal_split == "running": + raise HTTPException(status_code=409, detail="vocal split already running") + if job.vocal_split == "done": + return JSONResponse(_job_state(job), status_code=202) + + stems_dir = (JOBS_DIR / job_id / "stems").resolve() + if not stems_dir.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="job not found") + + job.vocal_split = "running" + _set(job, stage="Splitting lead/backing vocals...") + try: + async with _pipeline_lock: + new_names = await asyncio.to_thread(split_vocals, job, stems_dir) + except Exception as e: + cause = classify_failure("\n".join([*(getattr(e, "tail", None) or []), str(e)])) + logger.warning("[%s] vocal split failed: %s", job_id, e, exc_info=True) + _write_vocal_split_error(stems_dir, cause, getattr(e, "tail", None) or [str(e)]) + job.vocal_split = "error" + _set(job, stage="Done") + registry_persist(JOBS_DIR) + raise HTTPException(status_code=500, detail="vocal split failed") from e + + existing = {s["name"] for s in job.stems} + for name in new_names: + if name not in existing: + job.stems.append({"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"}) + merge_stem_peaks(stems_dir, new_names) + job.vocal_split = "done" + _set(job, stage="Done") + registry_persist(JOBS_DIR) + return JSONResponse(_job_state(job)) + + _SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") _COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") diff --git a/app/api/stems.py b/app/api/stems.py index 403bb4a..974a83e 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -20,6 +20,7 @@ from app.core.config import ( CACHE_DIR, + EXTRA_STEM_NAMES, JOB_ID_RE, JOBS_DIR, STEM_NAMES, @@ -36,17 +37,23 @@ router = APIRouter(tags=["stems"]) # Stem files served by this endpoint: the 6 demucs stems + two -# pipeline-produced extras. "original" is the re-encoded source song -# (added when the user picked a strict subset), "mix" is the ffmpeg -# amix of the user's selected stems. -_ALLOWED_NAMES = frozenset(STEM_NAMES) | {"original", "mix"} +# pipeline-produced extras, plus the on-demand lead/backing vocal split +# (#275, EXTRA_STEM_NAMES) when a job has requested it. "original" is the +# re-encoded source song (added when the user picked a strict subset), "mix" +# is the ffmpeg amix of the user's selected stems. +_ALLOWED_NAMES = frozenset(STEM_NAMES) | frozenset(EXTRA_STEM_NAMES) | {"original", "mix"} # Lanes the dynamic mixdown may sum: the 6 stems plus "original" (the complement -# track shown when the user picked a subset). "mix" is excluded -- it is the -# static pre-render this endpoint replaces. Gains are linear; the studio caps a -# lane at 2.0, so this generous bound just rejects abusive values. -_MIXDOWN_NAMES = frozenset(STEM_NAMES) | {"original"} +# track shown when the user picked a subset) plus lead/backing vocals when a +# job has split them. "mix" is excluded -- it is the static pre-render this +# endpoint replaces. Gains are linear; the studio caps a lane at 2.0, so this +# generous bound just rejects abusive values. +_MIXDOWN_NAMES = frozenset(STEM_NAMES) | frozenset(EXTRA_STEM_NAMES) | {"original"} _MIXDOWN_MAX_GAIN = 4.0 +# lead_vocals/backing_vocals are a decomposition of vocals, not an independent +# signal -- summing vocals alongside either would double-count the vocal +# energy in the mix (#275). +_VOCAL_DECOMPOSITION_NAMES = frozenset(EXTRA_STEM_NAMES) # Output encoders by container/extension, shared by the dynamic mixdown and the # stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps, @@ -304,6 +311,11 @@ def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]: raise HTTPException(status_code=422, detail="gain out of range") if not set(names) <= _MIXDOWN_NAMES: raise HTTPException(status_code=422, detail="unknown stem requested") + if "vocals" in names and _VOCAL_DECOMPOSITION_NAMES & set(names): + raise HTTPException( + status_code=422, + detail="vocals cannot be combined with lead_vocals/backing_vocals (same signal)", + ) return names, parsed_gains @@ -910,13 +922,19 @@ async def get_all_stems_zip( raise HTTPException(status_code=404, detail="job not ready") # Resolve the requested subset (whitelisted) or fall back to all stems. + all_names = (*STEM_NAMES, *EXTRA_STEM_NAMES) if stems: requested = {s for s in stems.split(",") if s} - if not requested <= set(STEM_NAMES): + if not requested <= set(all_names): raise HTTPException(status_code=422, detail="unknown stem requested") - wanted = [name for name in STEM_NAMES if name in requested] + if "vocals" in requested and _VOCAL_DECOMPOSITION_NAMES & requested: + raise HTTPException( + status_code=422, + detail="vocals cannot be combined with lead_vocals/backing_vocals (same signal)", + ) + wanted = [name for name in all_names if name in requested] else: - wanted = list(STEM_NAMES) + wanted = list(all_names) jobs_root = JOBS_DIR.resolve() stems_dir = (JOBS_DIR / job_id / "stems").resolve() diff --git a/app/core/config.py b/app/core/config.py index 6569255..07cc2d1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -52,6 +52,12 @@ def detect_torch_device() -> str: ROOT = Path(__file__).resolve().parent.parent.parent STATIC_DIR = ROOT / "static" STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other") +# Produced only when a job opts into the lead/backing vocal split (best-effort, +# additive -- see app/pipeline/vocal_split.py). Kept out of STEM_NAMES itself: +# selected_stems defaults, the "Original" complement-track math, and the +# GET /api/config contract all assume "the 6 Demucs stems" and must not change +# just because a job happened to request this extra pass. +EXTRA_STEM_NAMES: tuple[str, ...] = ("lead_vocals", "backing_vocals") JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$") # Runtime knobs -- env-backed so Docker / desktop packaging / local dev can @@ -144,6 +150,15 @@ def _stored_jobs_dir() -> Path | None: TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300) TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120) TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800) +# On-demand lead/backing vocal split (#275). UVR-MDX-NET Karaoke 2 is an +# officially-distributed UVR-project model (MIT + credit-to-UVR per the +# audio-separator README) -- the default. STEMDECK_KARAOKE_MODEL lets a +# deployment swap the checkpoint (e.g. to a roformer model) without a code +# change; see docs/models.md for the license audit behind this default. +VOCAL_SPLIT_MODEL = os.environ.get("STEMDECK_KARAOKE_MODEL", "").strip() or "UVR_MDXNET_KARA_2.onnx" +# A first run downloads the checkpoint (hundreds of MB); generous default so a +# slow connection isn't mistaken for a stall. +TIMEOUT_VOCAL_SPLIT = _env_int("STEMDECK_TIMEOUT_VOCAL_SPLIT", 1800) # Beat-grid stage decodes the whole drums stem (not the 180 s analyze window), # so it gets its own, larger budget. TIMEOUT_BEATGRID = _env_int("STEMDECK_TIMEOUT_BEATGRID", 300) diff --git a/app/core/models.py b/app/core/models.py index fd6dc0c..6d5d6bc 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -76,6 +76,12 @@ class Job: # Wall-clock seconds per pipeline stage ({"download": 12.3, ...}); written # to metadata.json and the one-line completion summary in the log. stage_timings: dict[str, float] | None = None + # On-demand lead/backing vocal split (#275) -- a post-hoc action on an + # already-"done" job, not part of the main pipeline. "none" until the user + # asks for it; "error" leaves the job's base stems untouched (see + # app/pipeline/vocal_split.py) and is recorded in stems/vocal_split_error.txt, + # not job.error_detail, since the job itself did not fail. + vocal_split: Literal["none", "running", "done", "error"] = "none" # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. # Not surfaced via to_state() -- it's internal control state. cancel_requested: bool = False @@ -126,6 +132,7 @@ def to_state(self) -> dict[str, Any]: "compute_device": self.compute_device, "gpu_fallback": self.gpu_fallback, "stage_timings": self.stage_timings, + "vocal_split": self.vocal_split, "created_at": self.created_at, } diff --git a/app/core/registry.py b/app/core/registry.py index 05217de..82f6f75 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -8,7 +8,7 @@ import uuid from pathlib import Path -from app.core.config import DEMUCS_MODEL, JOB_ID_RE, STEM_NAMES +from app.core.config import DEMUCS_MODEL, EXTRA_STEM_NAMES, JOB_ID_RE, STEM_NAMES from app.core.models import Job logger = logging.getLogger("stemdeck.registry") @@ -237,7 +237,7 @@ def _recover_done_job(job_dir: Path) -> Job | None: return None stems = [ {"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"} - for name in ("original", *STEM_NAMES) + for name in ("original", *STEM_NAMES, *EXTRA_STEM_NAMES) if (stems_dir / f"{name}.wav").is_file() ] if not stems: @@ -246,6 +246,12 @@ def _recover_done_job(job_dir: Path) -> Job | None: if (stems_dir / "mix.wav").is_file(): mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav" selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES) + # A restart between the split finishing and its next registry persist + # would otherwise report the job as never split -- derive from disk the + # same way `stems` above does, so the recovered library entry doesn't + # regress (#275). + has_split = all((stems_dir / f"{name}.wav").is_file() for name in EXTRA_STEM_NAMES) + vocal_split = "done" if has_split else "none" meta_path = job_dir / "metadata.json" meta: dict = {} if meta_path.is_file(): @@ -287,6 +293,7 @@ def _recover_done_job(job_dir: Path) -> Job | None: stem_presence=meta.get("stem_presence"), sections=meta.get("sections"), tags=meta.get("tags"), + vocal_split=vocal_split, ) diff --git a/app/core/settings.py b/app/core/settings.py index 20efc2e..0b6c3aa 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -84,14 +84,20 @@ def _ensure() -> dict: return _state -def _save() -> None: +def _save() -> bool: + """Write settings.json. Best-effort for most settings (read-only FS, + permissions: the in-memory value still applies for this session, so a + caller here does not fail its request over it) -- but the write outcome + is still reported back, because one caller (set_jobs_dir) is coupled to + something irreversible enough that silently swallowing a failure there + would be actively misleading rather than merely inconvenient (#403).""" try: _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") + return True except Exception: - # Persistence is best-effort (read-only FS, permissions): the in-memory - # value still applies for this session, so don't fail the request. _log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True) + return False def _num(v: object) -> int | None: @@ -138,16 +144,24 @@ def get_jobs_dir() -> str | None: return value if isinstance(value, str) and value.strip() else None -def set_jobs_dir(value: str | None) -> str | None: +def set_jobs_dir(value: str | None) -> tuple[str | None, bool]: + """Persist the jobs folder choice. Returns (resolved_value, persisted). + + Unlike every other setting in this module, a failed persist here is not a + minor inconvenience: this is called only after move_library() has already + physically relocated the user's library (POST /api/settings/stems-location + in app/main.py), so quietly keeping the in-memory value "for this session" + and reporting success would mean the app comes back to the OLD (now-empty) + folder on the very next restart, with the real data sitting at a location + nothing points at any more (#403). The caller must check `persisted` and + tell the user the truth rather than assume a 200 means the choice stuck.""" with _LOCK: if value is None or not str(value).strip(): _ensure().pop("jobs_dir", None) - _save() - return None + return None, _save() resolved = str(Path(str(value)).expanduser().resolve()) _ensure()["jobs_dir"] = resolved - _save() - return resolved + return resolved, _save() # ── playlist_max_items ── diff --git a/app/core/stems_location.py b/app/core/stems_location.py index 3366eca..82a6e7b 100644 --- a/app/core/stems_location.py +++ b/app/core/stems_location.py @@ -104,7 +104,11 @@ def validate_target(target: Path, current: Path) -> Path: # An existing folder is fine only if it is empty or already ours: merging # a stem library into someone's Desktop is not a recoverable mistake. entries = list(target.iterdir()) - ours = {"registry.json", "failed"} + # user-data.json (#403): the library metadata store now lives inside + # the jobs folder too (see desktop/src-tauri/src/main.rs's + # documents_store_path), so re-selecting a folder StemDeck already + # used must recognize it as "ours", same as registry.json. + ours = {"registry.json", "failed", "user-data.json"} if entries and not all(e.name in ours or _looks_like_job_dir(e) for e in entries): raise StemsLocationError("Pick an empty folder, or one StemDeck already uses.") else: @@ -125,11 +129,13 @@ def _looks_like_job_dir(entry: Path) -> bool: def move_library(current: Path, target: Path) -> MoveResult: - """Move every stem directory from `current` into `target`. + """Move every stem directory (and top-level file, e.g. registry.json and + the desktop shell's user-data.json, #403) from `current` into `target`. Entry by entry rather than moving the folder itself: the folder may be one - the user picked in a native dialog and expects to keep, and on desktop the - parent (~/Documents/StemDeck) also holds the library index, which stays put. + the user picked in a native dialog and expects to keep. This also means + nothing here needs to know about user-data.json specifically -- it lives + inside the jobs folder like any other entry, so it comes along for free. On a failure partway, whatever has already moved stays moved and the error names the entry that stopped it. Rolling back a half-finished multi-gigabyte diff --git a/app/main.py b/app/main.py index 6cf2700..a584472 100644 --- a/app/main.py +++ b/app/main.py @@ -458,18 +458,31 @@ async def set_stems_location(request: Request) -> dict[str, object]: _log.exception("moving the stems library failed") raise HTTPException(status_code=500, detail="Could not move the stems folder") from None - set_jobs_dir(str(target)) + _, persisted = set_jobs_dir(str(target)) _log.info( - "stems library moved to %s (%d entries, %d bytes)", + "stems library moved to %s (%d entries, %d bytes, persisted=%s)", target, result.moved_entries, result.bytes_moved, + persisted, ) + if not persisted: + # The move itself genuinely succeeded (result is real) -- but + # settings.json didn't take the new path, so a restart would read + # JOBS_DIR back from the old default while the library sits at + # `target`. Loud, not a silent false-success (#403): the response + # still reports what physically happened, plus why it isn't safe yet. + _log.warning( + "stems library moved to %s but the jobs_dir setting did not persist " + "-- a restart right now would lose track of it", + target, + ) return { "path": str(target), "moved_entries": result.moved_entries, "bytes_moved": result.bytes_moved, "restart_required": True, + "persisted": persisted, } diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index 5c46742..f78e9e2 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -218,6 +218,38 @@ def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> dict[str, floa return rms_values +def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> None: + """Add peaks/RMS for newly-produced stems (e.g. the on-demand lead/backing + vocal split, #275) into the existing peaks.json instead of recomputing + every stem. Best-effort, same as compute_stem_peaks: a failure here only + costs client-side waveform decode for the new stems, never the job.""" + path = stems_dir / "peaks.json" + peaks: dict[str, list[list[float]]] = {} + if path.is_file(): + try: + peaks = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("could not read existing peaks.json in %s", stems_dir, exc_info=True) + + for name in new_names: + wav = stems_dir / f"{name}.wav" + if not wav.is_file(): + continue + try: + result, _rms = scan_stem(wav, _PEAK_POINTS) + if result: + peaks[name] = result + except Exception: + logger.warning("could not compute peaks for %s/%s", stems_dir.name, name, exc_info=True) + + try: + tmp = stems_dir / "peaks.json.tmp" + tmp.write_text(json.dumps(peaks), encoding="utf-8") + tmp.replace(path) + except Exception: + logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True) + + def sweep_old_jobs(jobs_dir: Path) -> None: """Delete job directories older than JOB_TTL_SECONDS and remove them from the in-memory registry. Called hourly from the background sweep loop diff --git a/app/pipeline/vocal_split.py b/app/pipeline/vocal_split.py new file mode 100644 index 0000000..e007a27 --- /dev/null +++ b/app/pipeline/vocal_split.py @@ -0,0 +1,139 @@ +"""On-demand lead/backing vocal split (#275). + +A post-hoc action on an already-"done" job (POST /api/jobs/{id}/vocal-split +in app/api/jobs.py), not part of the main separate/collect pipeline. Runs +UVR-MDX-NET Karaoke 2 (or STEMDECK_KARAOKE_MODEL's override) on the job's +existing stems/vocals.wav via a fresh subprocess per invocation -- +deliberately not a persistent worker like demucs_worker.py, since this is an +occasional user-triggered action, not the hot path every job takes (the +persistent-worker pattern in ml-pipeline.md exists specifically to amortize a +cost every job pays; that reasoning doesn't apply here, #309). +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +from app.core.config import TIMEOUT_VOCAL_SPLIT +from app.core.models import Job +from app.core.registry import set_proc +from app.core.settings import get_demucs_device +from app.pipeline.errors import SeparationError + +logger = logging.getLogger("stemdeck.pipeline") + + +def _spawn_cmd(device: str, vocals_path: Path, out_dir: Path) -> list[str]: + return [ + sys.executable, + "-m", + "app.pipeline.vocal_split_worker", + device, + str(vocals_path), + str(out_dir), + ] + + +def split_vocals(job: Job, stems_dir: Path) -> list[str]: + """Run the karaoke model on stems/vocals.wav, producing lead_vocals.wav + + backing_vocals.wav in stems_dir. Returns the two new stem names on + success. Raises SeparationError (carrying the stderr tail) on failure. + + Best-effort by design: the caller (the vocal-split API endpoint) is + responsible for catching failures and leaving the job's base stems + untouched -- this function never mutates anything but stems_dir's + contents, and never touches vocals.wav itself.""" + vocals_path = stems_dir / "vocals.wav" + if not vocals_path.is_file(): + raise SeparationError("vocals.wav not found -- job has no vocals stem to split") + + device = get_demucs_device() + env = os.environ.copy() + try: + import certifi + + env.setdefault("SSL_CERT_FILE", certifi.where()) + env.setdefault("REQUESTS_CA_BUNDLE", certifi.where()) + except ModuleNotFoundError: + pass + + proc = subprocess.Popen( + _spawn_cmd(device, vocals_path, stems_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env=env, + ) + set_proc(job.id, proc) + + tail: list[str] = [] + last_output = [time.monotonic()] + done_evt = threading.Event() + + def _watchdog() -> None: + while not done_evt.wait(timeout=30): + if proc.poll() is not None: + return + if time.monotonic() - last_output[0] > TIMEOUT_VOCAL_SPLIT: + logger.warning( + "vocal split stalled for %ss with no output, terminating job %s", + TIMEOUT_VOCAL_SPLIT, + job.id, + ) + proc.terminate() + return + + wt = threading.Thread(target=_watchdog, daemon=True) + wt.start() + job_ok = False + try: + assert proc.stderr is not None + for raw_line in proc.stderr: + last_output[0] = time.monotonic() + line = raw_line.strip() + if not line: + continue + if line == "@@DONE@@": + job_ok = True + break + if line.startswith("@@ERROR@@"): + msg = line[len("@@ERROR@@") :] + try: + msg = json.loads(msg) + except json.JSONDecodeError: + pass + tail.append(str(msg)) + break + tail.append(line) + if len(tail) > 40: + tail.pop(0) + finally: + done_evt.set() + set_proc(job.id, None) + wt.join(timeout=2) + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + + if not job_ok: + detail = "\n".join(tail[-15:]) if tail else "(no stderr captured)" + logger.warning("[%s] vocal split failed: %s", job.id, detail) + last = tail[-1] if tail else "vocal split failed" + raise SeparationError(f"vocal split failed: {last}", tail=tail[-40:], device=device) + + for name in ("lead_vocals", "backing_vocals"): + if not (stems_dir / f"{name}.wav").is_file(): + raise SeparationError(f"vocal split did not produce {name}.wav", device=device) + return ["lead_vocals", "backing_vocals"] diff --git a/app/pipeline/vocal_split_worker.py b/app/pipeline/vocal_split_worker.py new file mode 100644 index 0000000..116b9eb --- /dev/null +++ b/app/pipeline/vocal_split_worker.py @@ -0,0 +1,76 @@ +"""On-demand lead/backing vocal split worker (#275). + +Run as its own process: `python -m app.pipeline.vocal_split_worker + `. Unlike demucs_worker.py, this is **not** persistent -- +the split is an occasional, user-triggered action on an already-done job, not +the hot path every job takes, so there is no repeat-model-load cost worth +amortizing (see ml-pipeline.md / #309 for why that reasoning does not apply +here the way it does to Demucs). + +Runs UVR-MDX-NET Karaoke 2 (or STEMDECK_KARAOKE_MODEL's override) via the +`audio-separator` package on an already-isolated vocals stem, producing +lead_vocals.wav + backing_vocals.wav in out_dir. + +Protocol, matching demucs_worker.py's contract so the parent's stderr reader +can be reused: writes progress/log lines to stderr, then exactly one of: + "@@DONE@@" -- success, both files written + "@@ERROR@@" -- failure; process exits(1) +""" + +from __future__ import annotations + +import json +import os +import sys + +from app.core.config import VOCAL_SPLIT_MODEL + + +def _run(device: str, vocals_path: str, out_dir: str) -> None: + if device == "cpu": + # audio-separator/onnxruntime pick their execution provider mostly on + # their own; hiding CUDA devices is the reliable way to force the CPU + # provider when the caller explicitly resolved to "cpu" (matches the + # separation stage's own device policy in app.core.settings). + os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") + + from audio_separator.separator import Separator + + from app.core.config import MODELS_DIR + + separator = Separator( + log_level=40, # logging.ERROR -- only the lines we emit ourselves matter + model_file_dir=str(MODELS_DIR / "audio-separator"), + output_dir=out_dir, + output_format="WAV", + ) + separator.load_model(model_filename=VOCAL_SPLIT_MODEL) + # Karaoke-family models label their two outputs "Vocals" (the lead vocal + # that survived a second isolation pass) and "Instrumental" (everything in + # the input that wasn't lead vocal -- since the input here is already an + # isolated vocals stem, that's the backing vocals/harmonies). Renaming at + # the library boundary avoids depending on its default filename format. + separator.separate( + vocals_path, + {"Vocals": "lead_vocals", "Instrumental": "backing_vocals"}, + ) + + +def main() -> None: + if len(sys.argv) < 4: + sys.stderr.write("@@ERROR@@usage: vocal_split_worker \n") + sys.stderr.flush() + sys.exit(1) + device, vocals_path, out_dir = sys.argv[1], sys.argv[2], sys.argv[3] + try: + _run(device, vocals_path, out_dir) + except Exception as e: + sys.stderr.write(f"@@ERROR@@{json.dumps(str(e))}\n") + sys.stderr.flush() + sys.exit(1) + sys.stderr.write("@@DONE@@\n") + sys.stderr.flush() + + +if __name__ == "__main__": + main() diff --git a/app/pipeline/warmup.py b/app/pipeline/warmup.py new file mode 100644 index 0000000..571108b --- /dev/null +++ b/app/pipeline/warmup.py @@ -0,0 +1,66 @@ +"""Eager model pre-download for the desktop first-boot setup wizard (#275). + +Run as `python -m app.pipeline.warmup`. Downloads/caches the three ML +checkpoints StemDeck uses -- Demucs (htdemucs_6s), beat-this, and the +on-demand lead/backing vocal-split karaoke model -- so a user's first real +job doesn't pay for any of them mid-pipeline. Invoked by the Tauri +`warmup_models` command (desktop/src-tauri/src/main.rs) as one of the setup +steps; Docker has no equivalent step and keeps the pre-existing +lazy-download-on-first-use behavior (see docs/models.md). + +One line per model is printed to stdout so the caller can report per-model +status: "WARMUP_OK " on success, "WARMUP_FAILED " on +failure. A model failing to download here is never fatal to setup -- exactly +like the lazy path it replaces, a missing model degrades only that one +feature (e.g. no beat grid, or the karaoke split unavailable until it can +download later) rather than blocking the app from starting. +""" + +from __future__ import annotations + +import sys + +from app.core.config import BEAT_MODEL_CHECKPOINT, DEMUCS_MODEL, MODELS_DIR, VOCAL_SPLIT_MODEL + + +def _warm_demucs() -> None: + from demucs.pretrained import get_model + + get_model(DEMUCS_MODEL) + + +def _warm_beat_this() -> None: + from beat_this.inference import Audio2Beats + + Audio2Beats(checkpoint_path=BEAT_MODEL_CHECKPOINT, device="cpu", dbn=False) + + +def _warm_vocal_split() -> None: + from audio_separator.separator import Separator + + separator = Separator( + log_level=40, # logging.ERROR -- this is a one-shot download, not a job + model_file_dir=str(MODELS_DIR / "audio-separator"), + ) + separator.load_model(model_filename=VOCAL_SPLIT_MODEL) + + +_STEPS = ( + ("demucs", _warm_demucs), + ("beat_this", _warm_beat_this), + ("vocal_split", _warm_vocal_split), +) + + +def main() -> int: + for name, fn in _STEPS: + try: + fn() + print(f"WARMUP_OK {name}", flush=True) + except Exception as e: + print(f"WARMUP_FAILED {name} {e}", flush=True) + return 0 # best-effort throughout -- see module docstring + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index c90e772..c76b5fd 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -93,8 +93,9 @@ struct BackendStateInner { handles: Option, /// True while start_backend is executing; prevents concurrent starts (#145). starting: bool, - /// PID of an in-progress pip subprocess; killed by stop_backend on window close (#140). - pip_pid: Option, + /// PID of an in-progress setup-time subprocess (pip install, or the model + /// warmup download, #275); killed by stop_backend on window close (#140). + setup_child_pid: Option, /// Save destinations the user has picked but not yet downloaded to (#338). /// /// The export is two commands so the UI can tell "choosing a folder" apart @@ -119,7 +120,7 @@ impl Default for BackendStateInner { BackendStateInner { handles: None, starting: false, - pip_pid: None, + setup_child_pid: None, pending_saves: HashMap::new(), next_save_token: 0, } @@ -311,6 +312,7 @@ fn main() { extract_runtime_pack, ensure_external_assets, ensure_torch_device, + warmup_models, start_backend, local_ip, build_target, @@ -339,9 +341,12 @@ fn main() { }); } -/// Returns ~/Documents/StemDeck/, creating it if needed. -/// All user-facing content (library metadata + stem audio) lives here so it is -/// visible in Finder, eligible for iCloud backup, and survives app reinstalls. +/// Returns ~/Documents/StemDeck/, creating it if needed. The Documents +/// *default* for the jobs folder (documents_dir_for_jobs below) and the +/// source of a pre-#403 user-data.json for one-time migration +/// (documents_store_path) -- chosen so the library is visible in +/// Finder/Explorer, eligible for iCloud/OneDrive backup, and survives app +/// reinstalls, before the user ever relocates it via Settings. fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Result { let documents = app.path().document_dir().map_err(|e| e.to_string())?; let dir = documents.join("StemDeck"); @@ -349,9 +354,55 @@ fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Result { Ok(dir) } -/// Returns ~/Documents/StemDeck/user-data.json (library metadata store). +/// The stems/jobs folder as it exists right now: the backend's own +/// settings.json `jobs_dir` override if the user relocated it (#354) and that +/// folder still exists, otherwise the Documents default. Mirrors +/// app/core/config.py's `_stored_jobs_dir()` precedence exactly, read +/// directly from disk (not over IPC/HTTP) so this works even before the +/// backend process is up. +fn current_jobs_dir(app: &tauri::AppHandle) -> PathBuf { + if let Ok(data_dir) = local_data_dir() { + let settings_path = data_dir.join("settings.json"); + if let Ok(text) = fs::read_to_string(&settings_path) { + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(configured) = json.get("jobs_dir").and_then(|v| v.as_str()) { + let candidate = PathBuf::from(configured); + if candidate.is_dir() { + return candidate; + } + } + } + } + } + documents_dir_for_jobs(app) +} + +/// Returns /user-data.json (library metadata store). +/// +/// Lives *inside* the jobs folder (not its parent) so relocating stems via +/// Settings (#354) carries this along automatically -- move_library() +/// (app/core/stems_location.py) already moves every entry it finds inside +/// the jobs folder one by one, so a plain file sitting there (same as +/// registry.json) needs no special-casing on that side. Before #403 this +/// lived at the jobs folder's *parent* (~/Documents/StemDeck/user-data.json), +/// which relocation never touched -- a stems move would "forget" favorites, +/// folder layout, and per-job mixer state even though the audio moved fine. +/// +/// One-time migration: if the new location has nothing yet, copy (not move) +/// any pre-#403 file found at the old parent-folder path. Copy rather than +/// delete so a problem here can never lose the only copy of that data. fn documents_store_path(app: &tauri::AppHandle) -> Result { - Ok(documents_stemdeck_dir(app)?.join("user-data.json")) + let jobs_dir = current_jobs_dir(app); + fs::create_dir_all(&jobs_dir).map_err(|e| format!("failed to create {}: {e}", jobs_dir.display()))?; + let new_path = jobs_dir.join("user-data.json"); + if !new_path.is_file() { + if let Ok(old_path) = documents_stemdeck_dir(app).map(|d| d.join("user-data.json")) { + if old_path.is_file() && old_path != new_path { + let _ = fs::copy(&old_path, &new_path); + } + } + } + Ok(new_path) } /// The DEFAULT stems folder: ~/Documents/StemDeck/jobs/. Falls back to @@ -688,6 +739,87 @@ fn ensure_external_assets() -> Result { }) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelWarmupStatus { + demucs_ready: bool, + beat_this_ready: bool, + vocal_split_ready: bool, +} + +/// Eagerly downloads/caches the ML models StemDeck uses (Demucs, beat-this, +/// and the on-demand lead/backing vocal-split karaoke model, #275) via +/// `app/pipeline/warmup.py`, so a user's first real job doesn't pay for any +/// of them mid-pipeline. Best-effort per model: a single model failing to +/// download (e.g. no network) does not fail this command — the setup wizard +/// still proceeds, and that one feature falls back to its existing +/// lazy-download-on-first-use behavior, same as before this step existed. +#[tauri::command] +fn warmup_models(state: tauri::State) -> Result { + let root = app_root()?; + let data_dir = local_data_dir()?; + let backend_dir = backend_dir(&root)?; + let python = python_path(&root) + .filter(|p| p.is_file()) + .ok_or_else(|| "Python not found".to_string())?; + patch_pyvenv_cfg(&python); + + let mut command = Command::new(&python); + command + .args(["-m", "app.pipeline.warmup"]) + .current_dir(&backend_dir) + .env("STEMDECK_DATA_DIR", &data_dir) + .env("PYTHONUNBUFFERED", "1") + // Same cache locations start_backend uses, so a model downloaded here + // is found (not re-downloaded) by the real backend later. + .env("XDG_CACHE_HOME", data_dir.join("cache")) + .env("TORCH_HOME", data_dir.join("models").join("torch")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = command + .spawn() + .map_err(|e| format!("failed to start model warmup: {e}"))?; + + if let Ok(mut inner) = state.inner.lock() { + inner.setup_child_pid = Some(child.id()); + } + let output = child_output_with_timeout(child, Duration::from_secs(30 * 60), "model warmup"); + if let Ok(mut inner) = state.inner.lock() { + inner.setup_child_pid = None; + } + let output = output?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut status = ModelWarmupStatus { + demucs_ready: false, + beat_this_ready: false, + vocal_split_ready: false, + }; + for line in stdout.lines() { + match line { + "WARMUP_OK demucs" => status.demucs_ready = true, + "WARMUP_OK beat_this" => status.beat_this_ready = true, + "WARMUP_OK vocal_split" => status.vocal_split_ready = true, + _ if line.starts_with("WARMUP_FAILED") => { + append_to_setup_log(&data_dir, &format!("model warmup: {line}")); + } + _ => {} + } + } + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + append_to_setup_log( + &data_dir, + &format!( + "model warmup process exited non-zero. stderr:\n{}", + stderr.trim() + ), + ); + } + Ok(status) +} + /// Spawns the Python/uvicorn backend, waits for it to become healthy, and returns its URL. #[tauri::command] fn start_backend( @@ -1485,11 +1617,11 @@ fn run_pip_install( .map_err(|e| format!("failed to start {label}: {e}"))?; if let Ok(mut inner) = state.inner.lock() { - inner.pip_pid = Some(child.id()); + inner.setup_child_pid = Some(child.id()); } let output = child_output_with_timeout(child, Duration::from_secs(20 * 60), label); if let Ok(mut inner) = state.inner.lock() { - inner.pip_pid = None; + inner.setup_child_pid = None; } let output = output?; @@ -1842,15 +1974,15 @@ async fn save_audio_file( } fn stop_backend(state: &BackendState) { - let (handles, pip_pid) = match state.inner.lock() { - Ok(mut guard) => (guard.handles.take(), guard.pip_pid.take()), + let (handles, setup_child_pid) = match state.inner.lock() { + Ok(mut guard) => (guard.handles.take(), guard.setup_child_pid.take()), Err(_) => return, }; - // Kill any in-progress pip subprocess so it doesn't corrupt the venv - // if the window is closed during CUDA torch installation (#140). + // Kill any in-progress setup-time subprocess (pip install, model warmup) + // so it doesn't corrupt the venv/cache if the window is closed mid-setup (#140). #[cfg(unix)] - if let Some(pid) = pip_pid { + if let Some(pid) = setup_child_pid { // SAFETY: pid was stored immediately after spawn; we send SIGTERM best-effort. unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; } diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 59574c0..bb12444 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -20,7 +20,7 @@

StemDeck

  • Creating workspace
  • Checking FFmpeg
  • Configuring compute device
  • -
  • Checking AI separation model
  • +
  • Downloading AI models
  • Starting backend
  • diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js index b6cd708..a15842c 100644 --- a/desktop/ui/setup.js +++ b/desktop/ui/setup.js @@ -388,8 +388,34 @@ async function runSetup() { } }); - setStep("model", "done"); - setStatus("AI separation model will download on first use (~340 MB)."); + await runStep("model", async () => { + const stopProgress = startProgressStatus([ + { + afterSeconds: 0, + text: "Downloading AI models... this can take a few minutes on first run.", + }, + { + afterSeconds: 60, + text: "Still downloading AI models... slow networks can delay this.", + }, + { + afterSeconds: 300, + text: "Still working... large models can take a while on a slow connection.", + }, + ]); + try { + // Best-effort, per model: any model that doesn't download here just + // falls back to its existing lazy-download-on-first-use behavior -- + // never fails setup over this (warmup_models itself never throws for + // an individual model failure; this catch is only for the whole + // subprocess failing to run at all, e.g. a missing Python). + await invoke("warmup_models"); + } catch (err) { + console.warn("model warmup failed (will download lazily on first use):", err); + } finally { + stopProgress(); + } + }); await runStep("backend", async () => { setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting StemDeck backend..."); diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..1d1915a --- /dev/null +++ b/docs/models.md @@ -0,0 +1,53 @@ +# Model licensing notes + +Records the license basis for ML checkpoints StemDeck downloads at runtime. +Not exhaustive -- only entries where the decision wasn't a simple upstream +license file are documented here. + +## Demucs (htdemucs_6s) + +MIT, published by the `demucs` PyPI package (Meta/Facebook Research). No +audit needed -- an unambiguous upstream license. + +## UVR-MDX-NET Karaoke 2 (on-demand lead/backing vocal split, #275) + +- **File**: `UVR_MDXNET_KARA_2.onnx` +- **Distributed via**: `audio-separator` (PyPI, MIT, + `nomadkaraoke/python-audio-separator`), which bundles/downloads models + trained as part of the Ultimate Vocal Remover (UVR) project by Anjok07. +- **License**: MIT + attribution, per the `audio-separator` README: + > "If you choose to integrate this project into some other project using + > the default model or any other model trained as part of the UVR project, + > please honor the MIT license by providing credit to UVR and its + > developers." +- **Credit**: Ultimate Vocal Remover (Anjok07) -- https://github.com/Anjok07/ultimatevocalremovergui + +This is the shipped default (`VOCAL_SPLIT_MODEL` in `app/core/config.py`, +overridable via `STEMDECK_KARAOKE_MODEL`). + +### Rejected alternative: mel_band_roformer_karaoke (aufr33/viperx) + +`audio-separator`'s community-trained roformer checkpoint +(`mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt`) has meaningfully +better reported SDR than the MDX-Net Karaoke 2 model above, and was the +originally preferred choice while scoping this feature. It was rejected after +directly verifying: + +- No LICENSE file was ever published for this checkpoint, nor a stated + license anywhere in its distribution. +- It was originally released through UVR's Boosty supporter-paywall page, not + as a public open release. +- The public Hugging Face mirror (`jarredou/aufr33-viperx-karaoke-melroformer-model`) + now returns 401 (gated/removed) -- confirmed directly, not secondhand. + +A cleanly-licensed roformer alternative (Kimberley Jensen's +`Mel-Band-Roformer-Vocal-Model`) was also checked and found to have no +LICENSE file despite a claim to the contrary surfacing in a web search. + +Since StemDeck is free/non-commercial, the practical risk of using an +unlicensed-but-freely-shared community checkpoint is low -- but a +Boosty-paywall origin is a step past "unlicensed," suggesting the author did +not intend it for free redistribution at all, and its already-dead HF mirror +makes it an unreliable thing to depend on regardless of the licensing +question. `STEMDECK_KARAOKE_MODEL` remains available as an env override for a +deployment that wants to accept that risk itself. diff --git a/pyproject.toml b/pyproject.toml index 89a2065..a74d6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,19 @@ dependencies = [ # Pure-Python QR code generator used by the /api/qr endpoint (server # access QR codes in the desktop settings panel). "segno>=1.6", + # On-demand lead/backing vocal split (#275). Ships with UVR-MDX-NET + # Karaoke 2 (ONNX) as the default model -- see docs/models.md for why the + # torch-native alternative (a community roformer checkpoint) was rejected + # on licensing grounds. Excludes Intel macOS: requires numpy>=2/torch>=2.3, + # which conflicts with the numpy<2/torch<2.3 pins that platform is stuck on + # above; the feature gates itself off there rather than fighting the pins. + # CPU-only onnxruntime for now -- onnxruntime-gpu is a separate, mutually + # exclusive package requiring the same kind of CPU/GPU build-time split + # torch already gets via make-portable.{sh,ps1} (see versioning.md); wiring + # that up for this occasional, best-effort action is a follow-up, not a + # blocker, since the main separation already gets full GPU accel via torch. + "audio-separator>=0.24; sys_platform != 'darwin' or platform_machine != 'x86_64'", + "onnxruntime>=1.19; sys_platform != 'darwin' or platform_machine != 'x86_64'", ] [project.optional-dependencies] diff --git a/static/css/daw.css b/static/css/daw.css index 5d7111e..8e5f178 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -244,6 +244,33 @@ input, textarea { font-family: inherit; } color: var(--accent); } +/* Vocals: All / Lead + Backing (on-demand split, #275) */ +.vocal-mode-toggle { + display: inline-flex; + height: 30px; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.vocal-mode-toggle.hidden { display: none; } +.vocal-mode-btn { + height: 100%; + padding: 0 10px; + border: none; + background: none; + color: var(--muted-2); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} +.vocal-mode-btn + .vocal-mode-btn { border-left: 1px solid var(--border); } +.vocal-mode-btn[aria-pressed="true"] { + background: color-mix(in srgb, var(--vocals) 16%, transparent); + color: var(--vocals); +} + /* Process button */ .daw-process-btn { height: auto; diff --git a/static/index.html b/static/index.html index 895ed1c..0aec3fd 100644 --- a/static/index.html +++ b/static/index.html @@ -71,6 +71,13 @@ + + @@ -418,6 +425,31 @@ + + + diff --git a/static/js/catalog.js b/static/js/catalog.js index 2e07f03..e35a558 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -3,7 +3,7 @@ import { STEM_NAMES } from "./constants.js"; import { wireUpAudio, updateFooterTrack } from "./player.js"; import { initSections } from "./sections.js"; import { bpmChip, foregroundJobId, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js"; -import { showError, importFromUrl, detachForegroundJob } from "./job.js"; +import { showError, importFromUrl, detachForegroundJob, runVocalSplitIfWanted } from "./job.js"; import { cancelQueuedJob, getQueueSnapshot, isPaused, onJobSettled, onQueueChange, ordinal, queueCount, queueRowStates, reorderQueuedJob, runningLabel, startQueue, @@ -30,8 +30,20 @@ const DELETED_JOBS_KEY = "stemdeck.deleted_jobs"; // static/img/friends/ so they render offline. Links open externally via the // document-level a[target="_blank"] handler in main.js (Tauri open_url). const FRIENDS = [ + { + name: "Analog4Lyfe", + role: "Analog music gear", + url: "https://www.instagram.com/analog4lyfe", + logo: "/img/friends/analog4lyfe.jpg", + avatar: true, + }, { name: "Dlima Guitars", url: "https://www.instagram.com/dlimaguitars", logo: "/img/friends/dlima-guitars-ig.jpg", avatar: true }, - { name: "Lisbon Guitar Works", url: "https://dlimaguitars.com", logo: "/img/friends/lisbon-guitar-works.webp" }, + { + name: "Empress Effects", + role: "Effects pedals", + url: "https://empresseffects.com", + logo: "/img/friends/empress-effects.png", + }, { name: "Joao Gaspar", role: "Producer/Film Scorer, Touring/Session Musician", @@ -46,6 +58,17 @@ const FRIENDS = [ logo: "/img/friends/kris-luthier.jpg", avatar: true, }, + { name: "Lisbon Guitar Works", url: "https://dlimaguitars.com", logo: "/img/friends/lisbon-guitar-works.webp" }, + { + name: "More Notes Less Talk", + role: "YouTube channel", + url: "https://www.youtube.com/@morenoteslesstalk", + }, + { + name: "Seratone", + role: "Turns any TV into a studio-grade karaoke stage", + url: "https://seratone.audio/", + }, { name: "Thomann", role: "Online Music Store", @@ -53,24 +76,6 @@ const FRIENDS = [ logo: "/img/friends/thomann.jpg", avatar: true, }, - { - name: "Analog4Lyfe", - role: "Analog music gear", - url: "https://www.instagram.com/analog4lyfe", - logo: "/img/friends/analog4lyfe.jpg", - avatar: true, - }, - { - name: "Empress Effects", - role: "Effects pedals", - url: "https://empresseffects.com", - logo: "/img/friends/empress-effects.png", - }, - { - name: "More Notes Less Talk", - role: "YouTube channel", - url: "https://www.youtube.com/@morenoteslesstalk", - }, ]; // Instagram glyph (Simple Icons), shown under tiles that link to Instagram. @@ -1614,9 +1619,13 @@ async function completeSettledJob(jobId) { }); } - const track = stateMetadataToTrack(state, { ...existing, id: jobId }); + // Background jobs have no per-job stream (see the comment above), so this + // is the only place a background import's on-demand vocal split (#275) + // can be triggered -- runVocalSplitIfWanted no-ops if it wasn't requested. + const finalState = state.status === "done" ? await runVocalSplitIfWanted(state) : state; + const track = stateMetadataToTrack(finalState, { ...existing, id: jobId }); track.id = jobId; - track.channel = state.status === "done" ? "Extracted" : existing.channel; + track.channel = finalState.status === "done" ? "Extracted" : existing.channel; addTrackToLibrary(track); } catch (e) { console.warn("[catalog] could not finish background job", jobId, e); @@ -2733,11 +2742,24 @@ async function wireStemsLocation(overlay) { console.warn("[settings] refresh failed:", e); apply({ path: data.path, bytes: 0 }); } - setMessage( - `Moved ${data.moved_entries} item${data.moved_entries === 1 ? "" : "s"}. ` + - "Restart StemDeck to finish switching over.", - "ok", - ); + if (data.persisted === false) { + // The physical move genuinely succeeded (moved_entries is real) -- + // but settings.json didn't take the new path, so a restart right now + // would read the OLD default back while the library sits at the new + // folder (#403). Say so plainly rather than the usual "ok" message. + setMessage( + `Moved ${data.moved_entries} item${data.moved_entries === 1 ? "" : "s"}, but StemDeck ` + + "could not save this as your new location (check that the folder is writable). " + + "Restarting now would revert to the old location. Try setting it again.", + "error", + ); + } else { + setMessage( + `Moved ${data.moved_entries} item${data.moved_entries === 1 ? "" : "s"}. ` + + "Restart StemDeck to finish switching over.", + "ok", + ); + } } catch (e) { console.warn("[settings] move failed:", e); setMessage("Could not reach the server.", "error"); diff --git a/static/js/constants.js b/static/js/constants.js index 46000cd..3221c99 100644 --- a/static/js/constants.js +++ b/static/js/constants.js @@ -3,6 +3,12 @@ export let STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"]; export let TRACK_NAMES = ["original", ...STEM_NAMES]; +// Stems from the on-demand lead/backing vocal split (#275, EXTRA_STEM_NAMES +// in app/core/config.py). Not folded into STEM_NAMES/TRACK_NAMES -- most jobs +// never run this split -- but the player/mixer render them as real lanes, +// swapped in for "vocals", via effectiveStemOrder() below. +export let EXTRA_STEM_NAMES = ["lead_vocals", "backing_vocals"]; + export async function syncStemNamesFromAPI() { try { const res = await fetch("/api/config"); @@ -12,11 +18,24 @@ export async function syncStemNamesFromAPI() { STEM_NAMES = data.stem_names; TRACK_NAMES = ["original", ...STEM_NAMES]; } + if (Array.isArray(data.extra_stem_names) && data.extra_stem_names.length > 0) { + EXTRA_STEM_NAMES = data.extra_stem_names; + } } catch (e) { console.warn("[constants] failed to sync stem names from API:", e); } } +// The lane order for a specific job: STEM_NAMES with "vocals" replaced by +// lead_vocals + backing_vocals when a job's on-demand split (#275) has +// produced both. `presentNames` is the Set of stem names the job actually +// has (typically from job.stems). Order matters -- callers use this both to +// decide what to render and in what sequence (waveform stacking, mixer rows). +export function effectiveStemOrder(presentNames) { + const splitDone = presentNames.has("lead_vocals") && presentNames.has("backing_vocals"); + return STEM_NAMES.flatMap((n) => (n === "vocals" && splitDone ? EXTRA_STEM_NAMES : [n])); +} + export const STEM_DISPLAY = { vocals: "Vocals", drums: "Drums", @@ -25,6 +44,8 @@ export const STEM_DISPLAY = { piano: "Piano", other: "Other", original: "Original", + lead_vocals: "Lead Vocals", + backing_vocals: "Backing Vocals", }; // FL Studio-style channel palette: saturated but slightly dusty, designed @@ -37,6 +58,8 @@ export const STEM_COLORS = { piano: "#b88fe8", other: "#88a8c8", original: "#a8b0bd", + lead_vocals: "#e8748a", + backing_vocals: "#c98fe0", }; export const PROGRESS_COLOR = "#3a3a3a"; diff --git a/static/js/job.js b/static/js/job.js index 359185c..2cb8441 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -4,7 +4,7 @@ import { eventSource, setEventSource, setCurrentJobId, foregroundJobId, setForegroundJobId, audioEngine, multitrack, - selectedStems, + selectedStems, vocalSplitMode, } from "./state.js"; import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js"; import { notifyFailure, dismissFailuresByJobId } from "./notifications.js"; @@ -22,6 +22,66 @@ let lastStatus = null; let jobPollTimerId = null; const renderedJobs = new Set(); const jobSources = new Map(); +// On-demand lead/backing vocal split (#275): which jobs asked for it at +// submit time (via the Extract bar's Vocals "All / Lead + Backing" toggle). +// Exported so catalog.js's completeSettledJob (the background-job path -- +// there is no per-job SSE stream for those) can trigger it too, via +// runVocalSplitIfWanted below. Only covers the primary single-file/single-URL +// and batch-upload submit paths -- "Sync again" restores and playlist +// imports don't carry this choice. +export const jobVocalSplitModes = new Map(); +// Guards against double-firing the split POST if a job's "done" state is +// somehow observed twice (e.g. a late SSE frame after the queue already +// settled it) -- the split is expensive, so this must stay a hard one-shot. +const splitAutoTriggered = new Set(); + +// Fires the on-demand vocal split (#275) for a job that finished with the +// Extract bar's "Lead + Backing" toggle on, then refreshes the library cache +// (catalog.js's openTrack reads a locally cached stem list, which otherwise +// stays stale after a job's stems change post-completion). Returns the job's +// state after the split (or the original state, unchanged, if the split +// wasn't requested/needed/possible) -- callers use the return value rather +// than assuming their own `state` is still current. +export async function runVocalSplitIfWanted(state) { + if (state.status !== "done" || splitAutoTriggered.has(state.job_id)) return state; + const wantsSplit = jobVocalSplitModes.get(state.job_id) === "split"; + const hasVocals = (state.stems || []).some((s) => s.name === "vocals"); + const alreadySplit = state.vocal_split === "done"; + if (!wantsSplit || !hasVocals || alreadySplit) return state; + splitAutoTriggered.add(state.job_id); + if (state.job_id === foregroundJobId) setWaveformLoading(true, "Splitting lead/backing vocals…"); + try { + const res = await fetch(`/api/jobs/${state.job_id}/vocal-split`, { method: "POST" }); + if (!(res.ok || res.status === 202)) return state; + const r2 = await fetch(`/api/jobs/${state.job_id}`); + if (!r2.ok) return state; + const finalState = await r2.json(); + addTrackToLibrary({ + id: finalState.job_id, + title: finalState.title || "", + channel: "Extracted", + thumb: finalState.thumbnail, + stems: finalState.selected_stems || [...selectedStems], + selectedStems: finalState.selected_stems || [...selectedStems], + audioStems: finalState.stems || [], + status: "done", + duration: finalState.duration, + bpm: finalState.bpm, + key: finalState.key, + scale: finalState.scale, + keyConfidence: finalState.key_confidence, + lufs: finalState.lufs, + peakDb: finalState.peak_db, + stemPresence: finalState.stem_presence, + sourceUrl: jobSources.get(finalState.job_id) || "", + createdAt: finalState.created_at, + }); + return finalState; + } catch (e) { + console.warn("[job] vocal split failed:", e); + return state; + } +} const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); @@ -253,6 +313,26 @@ function applyStudioSummary(state) { } } +// Chains the on-demand vocal split onto a foreground job's completion (see +// runVocalSplitIfWanted above) -- from the user's perspective it's one +// action, even though the backend keeps it a separate, best-effort pass over +// the already-done job. Best-effort: any failure just proceeds with the base +// stems (the vocals lane), same as if the user had never asked for a split. +async function finishDoneJob(state) { + const finalState = await runVocalSplitIfWanted(state); + wireUpAudio( + finalState.job_id, + finalState.stems || [], + finalState.duration || 0, + finalState.thumbnail, + finalState.mix_url ?? null, + finalState.title || "", + null, + finalState.has_video ?? false, + ); + initSections(finalState.job_id, finalState.sections, finalState.duration || 0); +} + function applyState(state) { // Everything below the library update writes to DOM the studio owns, and // only the import the user is actually watching may touch it. A background @@ -359,18 +439,11 @@ function applyState(state) { jobBox.classList.add("hidden"); setForegroundJobId(null); if (!renderedJobs.has(state.job_id)) { + // Claimed before the (possibly async) finish below starts, so a second + // SSE/poll tick for the same job arriving mid-split can't re-enter this + // branch and fire the vocal-split call twice. renderedJobs.add(state.job_id); - wireUpAudio( - state.job_id, - state.stems || [], - state.duration || 0, - state.thumbnail, - state.mix_url ?? null, - state.title || "", - null, - state.has_video ?? false, - ); - initSections(state.job_id, state.sections, state.duration || 0); + finishDoneJob(state); } } } @@ -501,6 +574,7 @@ function registerUploadRow(jobId, file) { const title = sanitizeFilename(file.name); const sourceUrl = `local:${title}`; jobSources.set(jobId, sourceUrl); + jobVocalSplitModes.set(jobId, selectedStems.has("vocals") ? vocalSplitMode : "all"); addTrackToLibrary({ id: jobId, title, @@ -733,6 +807,7 @@ export function wireJobForm() { // silently import the same file a second time. if (file) fileInput._clear?.(); jobSources.set(jobId, sourceUrl); + jobVocalSplitModes.set(jobId, selectedStems.has("vocals") ? vocalSplitMode : "all"); addTrackToLibrary({ id: jobId, title: displayTitle, diff --git a/static/js/main.js b/static/js/main.js index d9af557..03f19fa 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,7 +1,7 @@ import { playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd, setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady, - currentJobId, + currentJobId, vocalSplitMode, vocalSplitModeReady, setVocalSplitMode, } from "./state.js"; import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js"; @@ -42,6 +42,28 @@ function refreshStemChoiceVisuals() { } } +// ─── Vocals: All / Lead + Backing toggle (on-demand split, #275) ─── +// +// Only meaningful while Vocals is actually selected above -- hidden +// otherwise so it can't imply a choice that has nothing to act on. + +function refreshVocalModeToggleVisibility() { + document.getElementById("vocalModeToggle")?.classList.toggle("hidden", !selectedStems.has("vocals")); +} + +function wireVocalModeToggle() { + const wrap = document.getElementById("vocalModeToggle"); + if (!wrap) return; + for (const btn of wrap.querySelectorAll(".vocal-mode-btn")) { + btn.addEventListener("click", () => { + setVocalSplitMode(btn.dataset.mode); + for (const b of wrap.querySelectorAll(".vocal-mode-btn")) { + b.setAttribute("aria-pressed", String(b.dataset.mode === btn.dataset.mode)); + } + }); + } +} + function handleStemChoiceClick(stem) { const allSelected = selectedStems.size === STEM_NAMES.length; if (allSelected) { @@ -59,11 +81,13 @@ function handleStemChoiceClick(stem) { } saveSelectedStems(); refreshStemChoiceVisuals(); + refreshVocalModeToggleVisibility(); buildStripStems(); } function wireStemChoiceButtons() { refreshStemChoiceVisuals(); + refreshVocalModeToggleVisibility(); for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) { btn.addEventListener("click", () => handleStemChoiceClick(btn.dataset.stem)); } @@ -86,6 +110,7 @@ function wireAllButton() { } saveSelectedStems(); refreshStemChoiceVisuals(); + refreshVocalModeToggleVisibility(); buildStripStems(); syncAllBtn(); }); @@ -110,6 +135,7 @@ wireStemListControls(); wireMixerToolbar(); wireStemChoiceButtons(); wireAllButton(); +wireVocalModeToggle(); wireFileDrop(); wireAppShellControls(); @@ -117,6 +143,11 @@ wireAppShellControls(); await runStoreMigrationIfNeeded(); await stemSelectionReady; refreshStemChoiceVisuals(); + refreshVocalModeToggleVisibility(); + await vocalSplitModeReady; + for (const b of document.querySelectorAll(".vocal-mode-btn")) { + b.setAttribute("aria-pressed", String(b.dataset.mode === vocalSplitMode)); + } // Before initCatalog: it runs the update check, which can itself notify. // collectDiagnostics is injected rather than imported by notifications.js, // which would make the two modules import each other. diff --git a/static/js/mixer.js b/static/js/mixer.js index f3b373f..63432bf 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -1,6 +1,17 @@ import { - STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX, + STEM_NAMES, TRACK_NAMES, EXTRA_STEM_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX, } from "./constants.js"; + +// Every lane name this session might ever need mixer state for, including +// the on-demand lead/backing vocal split (#275). Safe to iterate broadly: +// callers below all guard on the row/trackIndex entry actually existing, so +// touching state for a lane a given job doesn't have is a no-op. A function +// (not a snapshot) because STEM_NAMES/EXTRA_STEM_NAMES are reassigned once +// syncStemNamesFromAPI() resolves -- a const array here would freeze the +// fallback values from before that happens. +function allTrackNames() { + return [...TRACK_NAMES, ...EXTRA_STEM_NAMES]; +} import { mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex, masterVolume, audioEngine, @@ -12,7 +23,7 @@ function defaultMixerEntry() { } export function ensureMixerStateDefaults() { - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { if (!mixerState[name]) mixerState[name] = defaultMixerEntry(); } } @@ -23,7 +34,7 @@ export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) { const data = await storeGet(`stemdeck:mix:${jobId}`, {}); if (data && typeof data === "object") stored = data; } catch (e) { console.warn("[mixer] failed to load mix state:", e); } - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { Object.assign(mixerState[name], defaultMixerEntry(), stored[name] || {}); } // If all loaded stems are muted the session is unplayable -- unmute as recovery. @@ -34,7 +45,7 @@ export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) { } export function resetMixerState() { - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { Object.assign(mixerState[name], defaultMixerEntry()); } } @@ -46,8 +57,8 @@ function saveMix() { export function applyMix() { if (!multitrack) return; - const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed); - for (const name of TRACK_NAMES) { + const anySolo = allTrackNames().some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed); + for (const name of allTrackNames()) { const s = mixerState[name]; if (!s) continue; let effective = s.volume; @@ -108,7 +119,7 @@ export function setLaneVolume(name, v) { } export function refreshMixerVisuals() { - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { const state = mixerState[name]; if (!state) continue; // Mixer-column lane header @@ -143,7 +154,7 @@ export function refreshMixerVisuals() { } if (mon) { // Active when this stem is THE lone solo (the "monitor" target). - const others = TRACK_NAMES.filter((n) => n !== name); + const others = allTrackNames().filter((n) => n !== name); const lone = state.soloed && others.every((n) => !mixerState[n]?.soloed); mon.classList.toggle("active", lone); @@ -450,12 +461,12 @@ export function toggleStemSolo(name) { export function soloOnlyStem(name) { const state = mixerState[name]; if (!state) return; - const others = TRACK_NAMES.filter((n) => n !== name); + const others = allTrackNames().filter((n) => n !== name); const isAlreadyAlone = state.soloed && others.every((n) => !mixerState[n]?.soloed); if (isAlreadyAlone) { state.soloed = false; } else { - for (const n of TRACK_NAMES) { + for (const n of allTrackNames()) { if (!mixerState[n]) continue; mixerState[n].soloed = (n === name); } @@ -467,7 +478,7 @@ export function soloOnlyStem(name) { } export function resetMixer() { - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { const s = mixerState[name]; if (!s) continue; s.volume = 1; @@ -480,9 +491,13 @@ export function resetMixer() { } export function muteAll() { - // Toggle: if every stem is muted, un-mute all; otherwise mute all. - const allMuted = STEM_NAMES.every((n) => mixerState[n]?.muted); - for (const name of TRACK_NAMES) { + // Toggle direction reflects the lanes actually loaded for this job (base 6, + // or drums/bass/lead_vocals/backing_vocals/guitar/piano/other when the + // on-demand split, #275, is active) rather than always the fixed 6 -- + // trackIndex is the live "what's actually mounted" source of truth. + const loadedNames = Object.keys(trackIndex).filter((n) => n !== "original"); + const allMuted = loadedNames.length > 0 && loadedNames.every((n) => mixerState[n]?.muted); + for (const name of allTrackNames()) { const s = mixerState[name]; if (!s) continue; s.muted = !allMuted; @@ -493,7 +508,7 @@ export function muteAll() { } export function clearAllSolos() { - for (const name of TRACK_NAMES) { + for (const name of allTrackNames()) { const s = mixerState[name]; if (!s) continue; s.soloed = false; diff --git a/static/js/player.js b/static/js/player.js index 4b375d3..90d4534 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -6,8 +6,9 @@ import { fmtTime } from "./utils.js"; // job.js state. import { showPlaybackError, clearPlaybackError, resolvePlaybackSuccess } from "./job.js"; import { - STEM_NAMES, TRACK_NAMES, STEM_COLORS, PROGRESS_COLOR, + STEM_NAMES, TRACK_NAMES, EXTRA_STEM_NAMES, STEM_COLORS, PROGRESS_COLOR, LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX, + effectiveStemOrder, } from "./constants.js"; import { mixerEl, multitrackContainer, bpmChip, keyChip, stemsChip, timeEl, @@ -85,19 +86,32 @@ const _STEM_ROW_SELECTORS = [ ]; function applyStemSelectionFilter(presentNames) { - // Waveform rows: original hides if absent; STEM_NAMES rows always show, grayed if absent + // The order for THIS job: STEM_NAMES, with "vocals" swapped for + // lead_vocals + backing_vocals when the on-demand split (#275) produced + // both. Rows for a name outside `order` (the vocals-family row not in + // play for this job) are hidden outright, not just grayed "unavailable" -- + // unlike the base 6, lead_vocals/backing_vocals aren't part of every job's + // contract, so there's no "not selected this time" state for them to be in. + const order = effectiveStemOrder(presentNames); + const isVocalFamily = (s) => s === "vocals" || s === "lead_vocals" || s === "backing_vocals"; + + // Waveform rows: original hides if absent; order rows always show, grayed if absent for (const el of document.querySelectorAll(".stem-waveform-row[data-stem]")) { const stem = el.dataset.stem; if (stem === "original") { el.classList.toggle("hidden", !presentNames.has(stem)); el.classList.remove("unavailable"); + } else if (isVocalFamily(stem)) { + const inOrder = order.includes(stem); + el.classList.toggle("hidden", !inOrder); + if (inOrder) el.classList.toggle("unavailable", !presentNames.has(stem)); } else { el.classList.remove("hidden"); el.classList.toggle("unavailable", !presentNames.has(stem)); } } const originalRow = presentNames.has("original") ? 1 : 0; - const visibleTrackCount = originalRow + STEM_NAMES.length; + const visibleTrackCount = originalRow + order.length; const app = document.querySelector(".app"); app?.style.setProperty("--visible-track-count", String(visibleTrackCount)); app?.style.setProperty( @@ -108,16 +122,21 @@ function applyStemSelectionFilter(presentNames) { for (const el of document.querySelectorAll(sel)) { const stem = el.dataset.stem || el.classList[0]; // .presence-labels span has no data-stem, use class - el.classList.toggle("hidden", !presentNames.has(stem)); + // vocals/lead_vocals/backing_vocals are mutually exclusive rows for a + // given job (#275) -- whichever the split state doesn't call for stays + // hidden even if presentNames still has it (vocals.wav is never + // deleted by the split, so "present" alone can't decide this one). + const hidden = isVocalFamily(stem) ? !order.includes(stem) : !presentNames.has(stem); + el.classList.toggle("hidden", hidden); } } const visibleMixerNames = []; if (presentNames.has("original")) visibleMixerNames.push("original"); - for (const name of STEM_NAMES) { + for (const name of order) { if (presentNames.has(name)) visibleMixerNames.push(name); } - const mixerCap = STEM_NAMES.length + (presentNames.has("original") ? 1 : 0); - for (const name of STEM_NAMES) { + const mixerCap = order.length + (presentNames.has("original") ? 1 : 0); + for (const name of order) { if (visibleMixerNames.length >= mixerCap) break; if (!visibleMixerNames.includes(name)) visibleMixerNames.push(name); } @@ -125,8 +144,15 @@ function applyStemSelectionFilter(presentNames) { for (const row of document.querySelectorAll(".mixer-column .lane-header[data-stem]")) { const stem = row.dataset.stem; + const inOrder = stem === "original" ? presentNames.has("original") : order.includes(stem); const available = presentNames.has(stem); - row.classList.toggle("hidden", !visibleMixerSet.has(stem)); + // Mixer rows are built once in renderEmptyShell (original, the base 6, + // then lead_vocals/backing_vocals tacked on at the end) and reused across + // every job load -- DOM order alone would always put lead_vocals/ + // backing_vocals last. CSS order (.mixer-column is a column flexbox) + // repositions them to match `order` (right after "original") per job. + row.style.order = stem === "original" ? "-1" : String(order.indexOf(stem)); + row.classList.toggle("hidden", !inOrder || !visibleMixerSet.has(stem)); row.classList.toggle("unavailable", !available); row.setAttribute("aria-disabled", String(!available)); for (const el of row.querySelectorAll("button, .lane-knob, .lane-dl")) { @@ -143,9 +169,10 @@ function applyStemSelectionFilter(presentNames) { } } for (const row of document.querySelectorAll(".energy-row[data-stem]")) { - const available = presentNames.has(row.dataset.stem); + const stem = row.dataset.stem; + const available = presentNames.has(stem); row.classList.toggle("unavailable", !available); - row.classList.remove("hidden"); + row.classList.toggle("hidden", isVocalFamily(stem) ? !order.includes(stem) : false); } } @@ -353,7 +380,7 @@ function waveformPath(peaks) { return `${top.join(" ")} ${bottom.join(" ")} Z`; } -function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount) { +function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount, orderIndex) { const layer = ensureOverviewWaveformLayer(); let row = layer.querySelector(`[data-stem="${stemName}"]`); if (!row) { @@ -363,7 +390,7 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount) { layer.appendChild(row); } row.style.setProperty("--stem-color", color); - row.style.order = String(TRACK_NAMES.indexOf(stemName)); + row.style.order = String(orderIndex); // A row is created for every mixer lane, including stems with no audio (e.g. // a subset extraction). The rows are flex-distributed across the lane stack, // so they only stay 1:1 with the mixer lanes when their count matches; an @@ -385,9 +412,12 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount) { // The lane set must mirror the mixer/multitrack lanes (orderedNames in // wireUpAudio): "original" plus the stems when an original lane is present, // otherwise just the stems. Rendering a row for every lane keeps the overlay -// aligned even when only a subset of stems was extracted. +// aligned even when only a subset of stems was extracted. Swaps "vocals" for +// lead_vocals + backing_vocals when this job's on-demand split (#275) ran. function overviewLaneNames(stems) { - return stems.some((s) => s.name === "original") ? TRACK_NAMES : STEM_NAMES; + const present = new Set(stems.map((s) => s.name)); + const order = effectiveStemOrder(present); + return present.has("original") ? ["original", ...order] : order; } function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { @@ -409,10 +439,10 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) { } const norm = globalMax > 0 ? 1 / globalMax : 0; const bars = overviewBarCount(); - for (const name of laneNames) { + laneNames.forEach((name, i) => { const pts = present.has(name) ? peaksData[name] : null; - renderOverviewWaveformPath(name, pts, norm, STEM_COLORS[name] || "#a0a0a0", bars); - } + renderOverviewWaveformPath(name, pts, norm, STEM_COLORS[name] || "#a0a0a0", bars, i); + }); } // Normalize all stems to a single shared max so the overview waveforms @@ -435,9 +465,9 @@ function renderAllOverviewWaveforms(stems, decodedMap) { } const norm = globalMax > 0 ? 1 / globalMax : 0; const bars = overviewBarCount(); - for (const name of laneNames) { - renderOverviewWaveformPath(name, peaksByStem.get(name), norm, STEM_COLORS[name] || "#a0a0a0", bars); - } + laneNames.forEach((name, i) => { + renderOverviewWaveformPath(name, peaksByStem.get(name), norm, STEM_COLORS[name] || "#a0a0a0", bars, i); + }); } function renderDecodedStemVisuals(stemName, audioBuffer, color) { @@ -548,7 +578,10 @@ function buildStemVuEnvelope(audioBuffer) { function stemVuGain(stemName) { const state = mixerState[stemName]; if (!state) return 0; - const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed); + // Derived from trackIndex (the lanes actually mounted for THIS job) rather + // than the fixed TRACK_NAMES, so a solo on a lead_vocals/backing_vocals + // lane (#275) is honored the same as any of the base 6. + const anySolo = Object.keys(trackIndex).some((name) => mixerState[name]?.soloed); if (state.muted || (anySolo && !state.soloed)) return 0; return Math.max(0, state.volume); } @@ -812,7 +845,10 @@ export function renderEmptyShell() { stopStemVuLoop(); ensureMixerStateDefaults(); mixerEl.innerHTML = ""; - for (const name of ["original", ...STEM_NAMES]) { + // lead_vocals/backing_vocals (#275) get rows too, built once here like the + // base 6 -- applyStemSelectionFilter (below) hides them by default since no + // job is loaded yet, and shows them in place of "vocals" once one is. + for (const name of ["original", ...STEM_NAMES, ...EXTRA_STEM_NAMES]) { const { row } = renderMixerRow({ name, url: "#" }); mixerEl.appendChild(row); } @@ -961,7 +997,20 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // the user selected all 6 stems, the backend doesn't produce // original.wav, so it's simply not in `stems` and the mixer/sidebar // rows for it stay hidden.) - stems = stems.filter((s) => s.name === "original" || selectedStems.has(s.name)); + // + // vocals/lead_vocals/backing_vocals are mutually exclusive (#275): once a + // job's on-demand split has produced both, show those two in place of the + // plain Vocals lane rather than all three -- vocals.wav is never deleted by + // the split, so it would otherwise still show up here too. + const rawPresent = new Set(stems.map((s) => s.name)); + const splitDone = rawPresent.has("lead_vocals") && rawPresent.has("backing_vocals"); + const wantsVocals = selectedStems.has("vocals"); + stems = stems.filter((s) => { + if (s.name === "original") return true; + if (s.name === "vocals") return wantsVocals && !splitDone; + if (s.name === "lead_vocals" || s.name === "backing_vocals") return wantsVocals && splitDone; + return selectedStems.has(s.name); + }); _currentStems = stems; _mixUrl = mixUrl || null; _currentTitle = title || ""; @@ -1030,9 +1079,12 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti // "original" is prepended at row 0 only when it actually has a URL so it // appears at the top. Omitting it when absent avoids a phantom 70px gap. - // STEM_NAMES follow at the next consecutive rows so mixer lanes stay aligned. + // The (already-filtered) stem order follows at the next consecutive rows so + // mixer lanes stay aligned -- lead_vocals/backing_vocals in place of vocals + // when this job's on-demand split (#275) produced them. const stemsByName = Object.fromEntries(stems.map((s) => [s.name, s])); - const orderedNames = [...(stemsByName["original"] ? ["original"] : []), ...STEM_NAMES]; + const order = effectiveStemOrder(new Set(stems.map((s) => s.name))); + const orderedNames = [...(stemsByName["original"] ? ["original"] : []), ...order]; setTrackIndex(Object.fromEntries(orderedNames.map((name, i) => [name, i]))); multitrackContainer.innerHTML = ""; diff --git a/static/js/state.js b/static/js/state.js index f7f5b24..20c54c7 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -144,6 +144,25 @@ export function setStemSelected(name, selected) { saveSelectedStems(); } +// On-demand lead/backing vocal split (#275): "all" (default, plain Vocals +// lane) or "split" (auto-run the split once the next import finishes). +// A page-level setting like selectedStems, applied at whatever moment the +// user submits -- not stored per-job. +const _VOCAL_SPLIT_MODE_KEY = "stemdeck:vocal-split-mode"; +export let vocalSplitMode = "all"; +export const vocalSplitModeReady = (async () => { + try { + const v = await storeGet(_VOCAL_SPLIT_MODE_KEY, null); + if (v === "split") vocalSplitMode = v; + } catch (e) { console.warn("[state] failed to load vocal split mode:", e); } +})(); +export function setVocalSplitMode(mode) { + vocalSplitMode = mode === "split" ? "split" : "all"; + storeSet(_VOCAL_SPLIT_MODE_KEY, vocalSplitMode).catch((e) => + console.warn("[state] failed to save vocal split mode:", e) + ); +} + // Web Audio analysers for live VU meters. export let audioContext = null; export let masterVolume = 0.5; // mirrored from masterFader.value diff --git a/static/mobile/app.js b/static/mobile/app.js index a714a62..5ea7cdd 100644 --- a/static/mobile/app.js +++ b/static/mobile/app.js @@ -14,14 +14,21 @@ const STEM_META = { guitar: { label: "Guitar", color: "#3fcf6e" }, piano: { label: "Piano", color: "#9b6cf0" }, other: { label: "Other", color: "#4a9bf5" }, + // On-demand lead/backing vocal split (#275) -- not one of the Extract chips + // (see vocalSplitMode below), just lane metadata for once a job has one. + lead_vocals: { label: "Lead Vocals", color: "#f0506e" }, + backing_vocals: { label: "Backing Vocals", color: "#c44ad0" }, }; const FALLBACK_COLORS = ["#f0506e", "#f5862b", "#f5c518", "#3fcf6e", "#9b6cf0", "#4a9bf5", "#2bd4c4", "#c44ad0"]; function stemMeta(name, idx) { return STEM_META[name] || { label: name.charAt(0).toUpperCase() + name.slice(1), color: FALLBACK_COLORS[idx % FALLBACK_COLORS.length] }; } -// Stem chips shown on the (still-mock) Extract screen. -const EXTRACT_STEMS = Object.entries(STEM_META).map(([id, m]) => ({ id, name: m.label, color: m.color })); +// Stem chips shown on the Extract screen. lead_vocals/backing_vocals aren't +// separately selectable here -- they're an on-demand refinement of the +// Vocals chip (see the vocalSplitMode segmented control in extractScreen()). +const EXTRACT_BASE_STEMS = ["vocals", "drums", "bass", "guitar", "piano", "other"]; +const EXTRACT_STEMS = EXTRACT_BASE_STEMS.map((id) => ({ id, name: STEM_META[id].label, color: STEM_META[id].color })); const DEFAULT_GRADIENT = "linear-gradient(150deg,#3a3a42,#202026)"; const FILTERS = ["All", "Favorites"]; @@ -84,7 +91,10 @@ const state = { // Extract screen. extractUrl: "", extractFile: null, // File chosen for upload - extractJob: null, // { id, title, status, progress, stage } while a job runs + extractJob: null, // { id, title, status, progress, stage, vocalSplitMode } while a job runs + // "all" = plain Vocals lane (default); "split" = also run the on-demand + // lead/backing split (#275) once the base separation finishes. + vocalSplitMode: "all", }; let extractES = null; // EventSource for the active extraction @@ -235,8 +245,14 @@ async function openTrack(card, { autoplay = false } = {}) { // Use WAV stems with range-request chunking (chunkedAudioEngine.js): fetches // 10-second windows at a time, so the first audio starts after ~7 MB instead // of the full file, and peak RAM stays around 28 MB regardless of track length. + // Once the on-demand lead/backing split (#275) has run, show those two + // lanes in place of the plain Vocals lane rather than all three -- they're + // a decomposition of the same signal (mirrors the backend's mixdown guard + // in app/api/stems.py), not three independent tracks. + const stemNames = new Set((detail.stems || []).map((s) => s && s.name)); + const vocalSplitDone = stemNames.has("lead_vocals") && stemNames.has("backing_vocals"); const laneList = (detail.stems || []) - .filter((s) => s && s.name !== "original" && s.url) + .filter((s) => s && s.url && s.name !== "original" && !(vocalSplitDone && s.name === "vocals")) .map((s, i) => ({ name: s.name, url: s.url, ...stemMeta(s.name, i) })); state.vols = {}; @@ -565,6 +581,14 @@ function extractScreen() { const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : ""; return ``; }).join("")} + ${state.selected.vocals ? ` +
    + Vocals +
    + + +
    +
    ` : ""} ${extractProgressCard()} @@ -591,7 +615,8 @@ async function startExtraction() { title = url; } - state.extractJob = { id: null, title, status: "queued", progress: 0, stage: "Queued" }; + const vocalSplitMode = state.selected.vocals ? state.vocalSplitMode : "all"; + state.extractJob = { id: null, title, status: "queued", progress: 0, stage: "Queued", vocalSplitMode }; render(); let jobId; @@ -611,10 +636,42 @@ async function startExtraction() { state.extractJob.id = jobId; state.extractFile = null; state.extractUrl = ""; + state.vocalSplitMode = "all"; followExtraction(jobId); render(); } +function _finishExtractJob(jobId) { + const finishedId = jobId; + setTimeout(() => { + if (state.extractJob && state.extractJob.id === finishedId) { + state.extractJob = null; + if (state.tab === "extract") render(); + } + }, 4000); +} + +// Chains the on-demand lead/backing vocal split (#275) onto the base job's +// completion when the user picked "Lead + Backing" on the Extract screen -- +// from their perspective this is one action, even though the backend keeps +// it a separate, best-effort pass over the already-done job. +async function _runVocalSplitThenFinish(jobId) { + state.extractJob.stage = "Splitting lead/backing vocals…"; + if (state.tab === "extract") render(); + try { + const res = await fetch(`/api/jobs/${jobId}/vocal-split`, { method: "POST" }); + if (!res.ok && res.status !== 202) throw new Error(String(res.status)); + toast("Stems ready!"); + } catch (e) { + console.warn("[mobile] vocal split failed:", e); + // The base separation still succeeded -- the split is a best-effort + // extra, so this is a lesser notice, not a failure of the whole import. + toast("Stems ready (lead/backing split didn't work this time)."); + } + loadLibrary(); + _finishExtractJob(jobId); +} + function _onExtractState(jobId, s) { if (!state.extractJob || state.extractJob.id !== jobId) return false; state.extractJob.status = s.status; @@ -622,16 +679,14 @@ function _onExtractState(jobId, s) { state.extractJob.stage = s.stage || s.status; if (state.tab === "extract") render(); if (s.status === "done" || s.status === "error" || s.status === "cancelled") { - if (s.status === "done") { toast("Stems ready!"); loadLibrary(); } - else if (s.status === "error") toast("Extraction failed."); - const finishedId = jobId; - setTimeout(() => { - if (state.extractJob && state.extractJob.id === finishedId) { - state.extractJob = null; - if (state.tab === "extract") render(); - } - }, 4000); - return true; // terminal + if (s.status === "done" && state.extractJob.vocalSplitMode === "split") { + _runVocalSplitThenFinish(jobId); + } else { + if (s.status === "done") { toast("Stems ready!"); loadLibrary(); } + else if (s.status === "error") toast("Extraction failed."); + _finishExtractJob(jobId); + } + return true; // terminal for the base-job follow (SSE/poll can stop) } return false; } @@ -887,6 +942,9 @@ app.addEventListener("click", (e) => { case "chip": state.selected[t.dataset.id] = !state.selected[t.dataset.id]; break; + case "vocalmode": + state.vocalSplitMode = t.dataset.mode; + break; case "qual": state.quality = t.dataset.q; break; diff --git a/static/mobile/styles.css b/static/mobile/styles.css index fd79276..9544501 100644 --- a/static/mobile/styles.css +++ b/static/mobile/styles.css @@ -354,6 +354,31 @@ button { background: #26262c; color: #fff; } +.segmented.sm { + margin-top: 0; + padding: 3px; +} +.segmented.sm button { + padding: 7px 0; + font-size: 12px; +} + +/* ── vocal split mode (#275) ── */ +.vocal-mode-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} +.vocal-mode-label { + font-size: 12.5px; + font-weight: 600; + color: var(--muted-3); + flex-shrink: 0; +} +.vocal-mode-row .segmented { + flex: 1; +} /* ── stems grid ── */ .stems-grid { diff --git a/templates/stemdeck.xml b/templates/stemdeck.xml index e6b8a9f..cae392b 100644 --- a/templates/stemdeck.xml +++ b/templates/stemdeck.xml @@ -1,7 +1,7 @@ StemDeck - ghcr.io/stemdeckapp/stemdeck:0.11.2 + ghcr.io/stemdeckapp/stemdeck:0.12.0 https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck bridge sh diff --git a/tests/test_jobs_api.py b/tests/test_jobs_api.py index 10e5e33..7ad59e2 100644 --- a/tests/test_jobs_api.py +++ b/tests/test_jobs_api.py @@ -492,3 +492,99 @@ def test_get_failure_traceback_is_empty_list_when_absent(client, tmp_path, monke r = client.get("/api/jobs/abcdefabcdef/failure") assert r.status_code == 200 assert r.json()["traceback"] == [] + + +# ─── POST /jobs/{id}/vocal-split (#275) ────────────────────────────────────── + + +def _make_done_job_with_vocals(tmp_path, monkeypatch, job_id="abcdefabc275"): + import app.api.jobs as jobs_mod + + monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path) + job = Job(id=job_id, status="done", title="Test track") + _jobs[job.id] = job + stems_dir = tmp_path / job.id / "stems" + stems_dir.mkdir(parents=True) + (stems_dir / "vocals.wav").write_bytes(b"RIFF1234") + return job, stems_dir + + +def test_vocal_split_404_unknown_job(client): + r = client.post("/api/jobs/000000000000/vocal-split") + assert r.status_code == 404 + + +def test_vocal_split_404_job_not_done(client, tmp_path, monkeypatch): + import app.api.jobs as jobs_mod + + monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path) + job = Job(id="abcdefabc275", status="separating", title="In progress") + _jobs[job.id] = job + + r = client.post(f"/api/jobs/{job.id}/vocal-split") + assert r.status_code == 404 + + +def test_vocal_split_success(client, tmp_path, monkeypatch): + import app.api.jobs as jobs_mod + + job, stems_dir = _make_done_job_with_vocals(tmp_path, monkeypatch) + + def fake_split(job_arg, stems_dir_arg): + (stems_dir_arg / "lead_vocals.wav").write_bytes(b"RIFF") + (stems_dir_arg / "backing_vocals.wav").write_bytes(b"RIFF") + return ["lead_vocals", "backing_vocals"] + + monkeypatch.setattr(jobs_mod, "split_vocals", fake_split) + + r = client.post(f"/api/jobs/{job.id}/vocal-split") + assert r.status_code == 200 + body = r.json() + assert body["vocal_split"] == "done" + names = {s["name"] for s in body["stems"]} + assert {"lead_vocals", "backing_vocals"} <= names + assert job.vocal_split == "done" + + +def test_vocal_split_409_when_already_running(client, tmp_path, monkeypatch): + job, _ = _make_done_job_with_vocals(tmp_path, monkeypatch) + job.vocal_split = "running" + + r = client.post(f"/api/jobs/{job.id}/vocal-split") + assert r.status_code == 409 + + +def test_vocal_split_202_idempotent_when_already_done(client, tmp_path, monkeypatch): + import app.api.jobs as jobs_mod + + job, _ = _make_done_job_with_vocals(tmp_path, monkeypatch) + job.vocal_split = "done" + called = False + + def fake_split(*_args, **_kwargs): + nonlocal called + called = True + return ["lead_vocals", "backing_vocals"] + + monkeypatch.setattr(jobs_mod, "split_vocals", fake_split) + + r = client.post(f"/api/jobs/{job.id}/vocal-split") + assert r.status_code == 202 + assert called is False # idempotent: never re-runs the model + + +def test_vocal_split_failure_keeps_job_done_with_base_stems(client, tmp_path, monkeypatch): + import app.api.jobs as jobs_mod + + job, stems_dir = _make_done_job_with_vocals(tmp_path, monkeypatch) + + def fake_split(job_arg, stems_dir_arg): + raise RuntimeError("model download failed") + + monkeypatch.setattr(jobs_mod, "split_vocals", fake_split) + + r = client.post(f"/api/jobs/{job.id}/vocal-split") + assert r.status_code == 500 + assert job.status == "done" # the job itself is untouched + assert job.vocal_split == "error" + assert (stems_dir / "vocal_split_error.txt").is_file() diff --git a/tests/test_pipeline_vocal_split.py b/tests/test_pipeline_vocal_split.py new file mode 100644 index 0000000..682c9e9 --- /dev/null +++ b/tests/test_pipeline_vocal_split.py @@ -0,0 +1,105 @@ +"""Tests for the on-demand lead/backing vocal split (#275). + +Mirrors test_separate_fallback.py's approach: the real subprocess machinery +(Popen, stderr streaming, watchdog, cleanup) runs end-to-end against a stub +worker script swapped in via the _spawn_cmd seam, so no real ML model or +GPU is needed. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from app.core.models import Job +from app.pipeline import vocal_split as vs_mod +from app.pipeline.errors import SeparationError + +_SUCCESS_WORKER = """ +import sys, os +out_dir = sys.argv[3] +open(os.path.join(out_dir, "lead_vocals.wav"), "wb").write(b"RIFF") +open(os.path.join(out_dir, "backing_vocals.wav"), "wb").write(b"RIFF") +sys.stderr.write("@@DONE@@\\n") +sys.stderr.flush() +""" + +_FAILING_WORKER = """ +import sys, json +sys.stderr.write("@@ERROR@@" + json.dumps("model download failed: connection reset") + "\\n") +sys.stderr.flush() +sys.exit(1) +""" + +# Exits 0 / prints @@DONE@@ but never actually writes the two output files -- +# a worker-side bug this should still catch rather than trust blindly. +_LIAR_WORKER = """ +import sys +sys.stderr.write("@@DONE@@\\n") +sys.stderr.flush() +""" + + +def _stub(code: str): + def fake_spawn(device: str, vocals_path: Path, out_dir: Path) -> list[str]: + return [sys.executable, "-c", code, device, str(vocals_path), str(out_dir)] + + return fake_spawn + + +@pytest.fixture() +def stems_dir(tmp_path: Path) -> Path: + d = tmp_path / "stems" + d.mkdir() + (d / "vocals.wav").write_bytes(b"RIFF") + return d + + +@pytest.fixture() +def job() -> Job: + return Job(id="abcdefabc275") + + +def test_split_vocals_success(job, stems_dir, monkeypatch): + monkeypatch.setattr(vs_mod, "get_demucs_device", lambda: "cpu") + monkeypatch.setattr(vs_mod, "_spawn_cmd", _stub(_SUCCESS_WORKER)) + + result = vs_mod.split_vocals(job, stems_dir) + + assert result == ["lead_vocals", "backing_vocals"] + assert (stems_dir / "lead_vocals.wav").is_file() + assert (stems_dir / "backing_vocals.wav").is_file() + # vocals.wav itself must never be touched by the split. + assert (stems_dir / "vocals.wav").read_bytes() == b"RIFF" + + +def test_split_vocals_subprocess_failure_raises_with_tail(job, stems_dir, monkeypatch): + monkeypatch.setattr(vs_mod, "get_demucs_device", lambda: "cpu") + monkeypatch.setattr(vs_mod, "_spawn_cmd", _stub(_FAILING_WORKER)) + + with pytest.raises(SeparationError) as exc_info: + vs_mod.split_vocals(job, stems_dir) + + assert "connection reset" in "\n".join(exc_info.value.tail) + assert not (stems_dir / "lead_vocals.wav").exists() + + +def test_split_vocals_missing_output_raises(job, stems_dir, monkeypatch): + """A worker that reports success but didn't actually write the files is a + bug we catch rather than silently report a successful split.""" + monkeypatch.setattr(vs_mod, "get_demucs_device", lambda: "cpu") + monkeypatch.setattr(vs_mod, "_spawn_cmd", _stub(_LIAR_WORKER)) + + with pytest.raises(SeparationError): + vs_mod.split_vocals(job, stems_dir) + + +def test_split_vocals_requires_vocals_stem(job, tmp_path, monkeypatch): + empty_stems_dir = tmp_path / "no-vocals-stems" + empty_stems_dir.mkdir() + monkeypatch.setattr(vs_mod, "get_demucs_device", lambda: "cpu") + + with pytest.raises(SeparationError, match="vocals.wav"): + vs_mod.split_vocals(job, empty_stems_dir) diff --git a/tests/test_registry_persistence.py b/tests/test_registry_persistence.py index 10f4e9b..ea108ba 100644 --- a/tests/test_registry_persistence.py +++ b/tests/test_registry_persistence.py @@ -226,6 +226,40 @@ def test_restore_recovers_orphan_without_metadata(tmp_path: Path): assert meta["title"] == "Recovered track abcdef" +def test_restore_recovers_lead_backing_vocals_and_marks_split_done(tmp_path: Path): + """#275: a job whose on-demand vocal split finished must not regress to + "never split" just because the process restarted before its next + registry persist -- both are derived straight from disk, same as the + base stems above.""" + job_dir = tmp_path / "abcdefabc275" + stems_dir = job_dir / "stems" + stems_dir.mkdir(parents=True) + for name in ("vocals", "lead_vocals", "backing_vocals"): + (stems_dir / f"{name}.wav").write_bytes(b"RIFF") + (job_dir / "metadata.json").write_text(json.dumps({"title": "Split Song"}), encoding="utf-8") + + restore_registry(tmp_path) + + restored = _jobs["abcdefabc275"] + assert restored.vocal_split == "done" + assert {"lead_vocals", "backing_vocals"} <= {stem["name"] for stem in restored.stems} + # selected_stems (the mix-complement math) stays scoped to the base 6 -- + # lead/backing are a further decomposition of vocals, not independent. + assert "lead_vocals" not in restored.selected_stems + + +def test_restore_recovers_orphan_job_without_vocal_split(tmp_path: Path): + job_dir = tmp_path / "abcdefabc278" + stems_dir = job_dir / "stems" + stems_dir.mkdir(parents=True) + (stems_dir / "vocals.wav").write_bytes(b"RIFF") + (job_dir / "metadata.json").write_text(json.dumps({"title": "Unsplit Song"}), encoding="utf-8") + + restore_registry(tmp_path) + + assert _jobs["abcdefabc278"].vocal_split == "none" + + def test_restore_still_ignores_dir_without_stems(tmp_path: Path): """The stems requirement stays: an empty/partial job dir is not a track.""" (tmp_path / "abcdefabcde1" / "stems").mkdir(parents=True) # no WAVs diff --git a/tests/test_stems_api.py b/tests/test_stems_api.py index c3dbc62..86541fb 100644 --- a/tests/test_stems_api.py +++ b/tests/test_stems_api.py @@ -72,6 +72,28 @@ def test_serves_done_job_stem(client, tmp_path): assert r.headers["content-type"] == "audio/wav" +def test_serves_lead_and_backing_vocals_when_split_has_run(client, tmp_path): + """lead_vocals/backing_vocals (#275) are servable like any other stem once + the on-demand split has actually produced them.""" + job = Job(id="abcdefabc275") + job.status = "done" + _jobs[job.id] = job + for name in ("lead_vocals", "backing_vocals"): + _make_stem_file(tmp_path, job.id, name, b"RIFF1234") + r = client.get(f"/api/jobs/{job.id}/stems/{name}.wav") + assert r.status_code == 200 + assert r.content == b"RIFF1234" + + +def test_lead_vocals_404_before_split_has_run(client, tmp_path): + job = Job(id="abcdefabc276") + job.status = "done" + _jobs[job.id] = job + _make_stem_file(tmp_path, job.id, "vocals") # split never ran + r = client.get(f"/api/jobs/{job.id}/stems/lead_vocals.wav") + assert r.status_code == 404 + + def test_single_stem_download_is_named_after_the_song(client, tmp_path): """Content-Disposition beats an attribute for same-origin requests, so the prefix (#336) has to come from the server to be honoured.""" @@ -236,6 +258,24 @@ def test_all_stems_zip_only_active_subset(client, tmp_path): assert sorted(zf.namelist()) == ["bass.wav", "vocals.wav"] +def test_all_stems_zip_includes_lead_backing_when_present(client, tmp_path): + """Default "download all" naturally picks up lead_vocals/backing_vocals + (#275) once a job has split them -- no separate opt-in query param.""" + import io + import zipfile + + job = Job(id="abcdefabc277") + job.status = "done" + _jobs[job.id] = job + for name in ("vocals", "lead_vocals", "backing_vocals"): + _make_stem_file(tmp_path, job.id, name, f"RIFF{name}".encode()) + + r = client.get(f"/api/jobs/{job.id}/stems/all.zip") + assert r.status_code == 200 + zf = zipfile.ZipFile(io.BytesIO(r.content)) + assert sorted(zf.namelist()) == ["backing_vocals.wav", "lead_vocals.wav", "vocals.wav"] + + def test_all_stems_zip_prefixes_members_with_the_song(client, tmp_path): """Extracted stems land in whatever folder the user is working in, next to other songs' stems, so a bare "bass.wav" is ambiguous and collides (#336).""" @@ -450,6 +490,21 @@ def test_mixdown_rejects_unknown_stem(client): assert r.status_code == 422, f"stem={stem!r} should 422" +def test_mixdown_rejects_vocals_with_lead_or_backing_vocals(client): + # lead_vocals/backing_vocals (#275) are a decomposition of vocals, not an + # independent signal -- combining either with vocals would double-count it. + for extra in ("lead_vocals", "backing_vocals"): + r = client.get(f"/api/jobs/abcdef000001/mixdown.wav?stems=vocals,{extra}&gains=1,1") + assert r.status_code == 422, f"vocals+{extra} should 422" + + +def test_mixdown_allows_lead_vocals_alone(client, tmp_path): + _skip_without_ffmpeg() + job = _done_job_with_stems(tmp_path, "abcdef000011", ["lead_vocals"]) + r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=lead_vocals&gains=1") + assert r.status_code == 200 + + def test_mixdown_rejects_bad_region(client): r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=vocals&gains=1&start=5&end=2") assert r.status_code == 422 diff --git a/tests/test_stems_location.py b/tests/test_stems_location.py index a1b8211..d9278dc 100644 --- a/tests/test_stems_location.py +++ b/tests/test_stems_location.py @@ -94,6 +94,18 @@ def test_accepts_a_folder_stemdeck_already_uses(tmp_path): assert validate_target(target, current) == target.resolve() +def test_accepts_a_folder_with_the_desktop_shells_user_data_store(tmp_path): + """user-data.json (#403) now lives inside the jobs folder like + registry.json -- re-selecting a folder from a previous move must not be + rejected just because that file is already there.""" + current = tmp_path / "old" + current.mkdir() + target = tmp_path / "new" + _job_dir(target, "abcdefabcdef") + (target / "user-data.json").write_text("{}", encoding="utf-8") + assert validate_target(target, current) == target.resolve() + + def test_rejects_a_file(tmp_path): current = tmp_path / "old" current.mkdir() @@ -227,6 +239,7 @@ def test_moving_writes_the_setting_and_asks_for_a_restart(client, tmp_path): assert body["path"] == str(target.resolve()) assert body["moved_entries"] == 1 assert body["restart_required"] is True + assert body["persisted"] is True assert (target / "aaaaaaaaaaaa").is_dir() import app.core.settings as settings_mod @@ -234,6 +247,29 @@ def test_moving_writes_the_setting_and_asks_for_a_restart(client, tmp_path): assert settings_mod.get_jobs_dir() == str(target.resolve()) +def test_persist_failure_is_reported_not_swallowed(client, tmp_path, monkeypatch): + """#403: a settings.json write failure used to be silently swallowed, so + the endpoint reported success even though a restart would revert to the + old (now-empty) folder. The physical move still succeeds either way -- + only the persisted flag (and the message the UI shows) should change.""" + import app.core.settings as settings_mod + + _job_dir(client.jobs_dir, "aaaaaaaaaaaa") + target = tmp_path / "elsewhere" + monkeypatch.setattr(settings_mod, "_save", lambda: False) + + body = client.post("/api/settings/stems-location", json={"path": str(target)}).json() + + assert body["moved_entries"] == 1 + assert (target / "aaaaaaaaaaaa").is_dir(), "the move itself must still happen" + assert body["persisted"] is False + + # A restart re-reads settings.json from disk; the mocked failure means + # the write never actually landed, so the stored choice is gone too. + settings_mod._state = None + assert settings_mod.get_jobs_dir() is None + + def test_refuses_while_a_job_is_running(client, tmp_path): """Moving files out from under a separation would corrupt it.""" job = Job(id="aaaaaaaaaaaa") diff --git a/uv.lock b/uv.lock index 35b4955..e0c255f 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')", ] +[[package]] +name = "absl-py" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119, upload-time = "2026-07-03T10:57:48.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -49,6 +58,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "audio-separator" +version = "0.44.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts", marker = "(python_full_version >= '3.13' and platform_machine != 'x86_64') or (python_full_version >= '3.13' and sys_platform != 'darwin')" }, + { name = "beartype", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "diffq", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, + { name = "diffq-fixed", marker = "sys_platform == 'win32'" }, + { name = "einops", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "julius", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "librosa", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "ml-collections", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "onnx-weekly", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "onnx2torch-py313", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "pydub", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "pyyaml", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "requests", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "resampy", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "rotary-embedding-torch", version = "0.6.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "samplerate", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "six", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "soundfile", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "tqdm", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/a1/91884a9cbbace7d1eb480f6446e10fc2cdf0aa8d86da414f4593557b6340/audio_separator-0.44.5.tar.gz", hash = "sha256:58866bc61d0c692fff8a52cda67c284f7847a844048987135677a007bf0ae794", size = 344654, upload-time = "2026-07-20T22:02:44.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/44/7ff4a4a0f4b95af529b6d92132661e04199d8c10de049c96ec3691edb14d/audio_separator-0.44.5-py3-none-any.whl", hash = "sha256:9db7d8ded987a74aec9d96be949b49c9068def69823abb59cabb6e6f88679ae7", size = 415089, upload-time = "2026-07-20T22:02:43.327Z" }, +] + [[package]] name = "audioop-lts" version = "0.2.2" @@ -111,6 +155,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "beartype" +version = "0.18.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/15/4e623478a9628ad4cee2391f19aba0b16c1dd6fedcb2a399f0928097b597/beartype-0.18.5.tar.gz", hash = "sha256:264ddc2f1da9ec94ff639141fbe33d22e12a9f75aa863b83b7046ffff1381927", size = 1193506, upload-time = "2024-04-21T07:25:58.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/43/7a1259741bd989723272ac7d381a43be932422abcff09a1d9f7ba212cb74/beartype-0.18.5-py3-none-any.whl", hash = "sha256:5301a14f2a9a5540fe47ec6d34d758e9cd8331d36c4760fc7a5499ab86310089", size = 917762, upload-time = "2024-04-21T07:25:55.758Z" }, +] + [[package]] name = "beat-this" version = "1.1.0" @@ -120,7 +173,8 @@ dependencies = [ { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, - { name = "rotary-embedding-torch" }, + { name = "rotary-embedding-torch", version = "0.6.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "rotary-embedding-torch", version = "0.8.9", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "soxr" }, { name = "torch", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, @@ -304,6 +358,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cython" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/29/bc7e088201af74eacbb5799542d0f09d6e5e139cc55f19fd54c409f58109/cython-3.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2751b7c0cb13135aadcc1cc8fe223be98d458cd1132611d31675e768a5e4a2e9", size = 3000461, upload-time = "2026-07-24T06:21:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/81/e8/ac8266fa88b3a80009a2fa55b2729be1518c05d182888223f95b8e2a2b8e/cython-3.2.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccb0bc6b8437cfcb04459a02bc5ecc58bf161ea11659438945ab5a1392ee39fe", size = 3306728, upload-time = "2026-07-24T06:21:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/df/9b/b4ff8cdff357ba0f41791e044a2a95898d0768b20e9247823decf5eabb5e/cython-3.2.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c2974742433be5c36ae1df1e761ce0063753fc2702316d5a3062b0a4e94a1df", size = 3458732, upload-time = "2026-07-24T06:21:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/06ba035f8b845d2d7d67f533a58ebd885ceab1e48b8cc442f093ecf59201/cython-3.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:a3fc783d12202d1b064b6f125772d85f00e36e62eee6b2e415f56d8fd2d2e7b2", size = 2785625, upload-time = "2026-07-24T06:21:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/2f477d30fc6ca0ff333552233aa6dee0e57323a55913f84f24da9cac26fc/cython-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8ec2d5a7b798c84d6779e7ca5318fd928b8fe9dd021106d714dc2e77cdc7555", size = 2992696, upload-time = "2026-07-24T06:21:44.931Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/0f280f7960c129957d2ce650e7fd7dec8e706c26d61a428e93927f4c9904/cython-3.2.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cac792b0bde1c86d8f832e513cd061b7e682181cc5a6d843d487e6c8ae9d5fcb", size = 3307656, upload-time = "2026-07-24T06:21:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cd/6d7c35a1065f1a07c9fa5ec784ee562f32f36a8fd05748e5f10694887eb7/cython-3.2.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c843dd85857cd0d0da055489f448d032866120cfb094ce16205a1ff54d06353", size = 3459259, upload-time = "2026-07-24T06:21:48.881Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f6/c1ad54ec35fcd5c5a5808d8b5b6319d5ae96acc63f7b5c1f7812f8cc3f71/cython-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:efd54fe07f808e7e82f6a04f370457ca02e770c948f0e2f83345ccc7ec8bb829", size = 2789027, upload-time = "2026-07-24T06:21:50.551Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/7ffbaab88558e28411715ad35d913cac7d64ee965344c185325da84c4309/cython-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44b7fc417933d65bf31bce00337ff318efa3ea59daed21d10fe842d41e657c08", size = 2998816, upload-time = "2026-07-24T06:22:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/04/69/feed68904f389452494d9e0861c24228c0bda656f9fbd32f9e5b525b2ef4/cython-3.2.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41637c644d7224d2d3170ee312077c2173693f0d4a0da1c82a0cffa3680a42dc", size = 3185802, upload-time = "2026-07-24T06:22:02.831Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a7f094343e5c4925efac60e6dbdcf8602042e7f54c83ae14ccbc8033d2d9/cython-3.2.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f815cd3387bd88ca9eeabc357ce4bdf884eef0ab2949db0fbd5e0b69fe5ee422", size = 3360405, upload-time = "2026-07-24T06:22:04.645Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ba/a5c25226e29cb4f3a2c97d2b29de10e37c8a6b67b3c2f2a8b16ba713b218/cython-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:ddb43637b7749b88644df4319e1c36f767e7fb71a92bb9942558c8de2f1cc5f4", size = 2774714, upload-time = "2026-07-24T06:22:06.953Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -332,6 +420,43 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/87/38/55f835ebd9f443465087a6954ede19d4a41aebdf5e28567e89b99d6d2f57/demucs-4.0.1.tar.gz", hash = "sha256:e45a5a788bae79767c37bbf6e69aae03862ddcca05550fb79b926346a177d713", size = 1212924, upload-time = "2023-09-07T16:09:01.334Z" } +[[package]] +name = "diffq" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cython", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/fd/4c58807bf855c5929ffa6da55f26dd6b9ae462a4193f5e09cc49fbbfd451/diffq-0.2.4.tar.gz", hash = "sha256:049064861e974ebf00d0badab8b324c775037371419eda3150985b9d477b5bd2", size = 157139, upload-time = "2023-05-05T12:39:43.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/29/592009a585a22f6313e2950b38173975c2be979b18fe4454679f67102cb8/diffq-0.2.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d8e6d6b882dd93568b41a7da9ff9657845ec08c82e71460544d0d04ed112320", size = 175814, upload-time = "2023-05-05T12:38:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/99/21/83da39abd8080e4aa5d211c6d17c3eb1b7e6e90b35bf98394ed648c5a039/diffq-0.2.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:15d5055ebfc629914689d66fcfa36f6d751fd45b4b2331ba0d3390604e2b40fa", size = 106181, upload-time = "2023-05-05T12:38:55.868Z" }, + { url = "https://files.pythonhosted.org/packages/4f/45/b6d26c9d4c64c1f6481fa8a85b9920951349a786bcfa2615241387a118dc/diffq-0.2.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf990911fc2c932e505df9958ce50417f182fe9178b2dbe329173b29e6521727", size = 97366, upload-time = "2023-05-05T12:38:57.642Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/09ee34fd5756b5ebf96c63bcd82f4fabab4f1a95a350e491c1c8f92fdaad/diffq-0.2.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d62ffd89498611dbf32cde417f5fc8d222f450085b06afb4e8764307906ab2ca", size = 401116, upload-time = "2023-05-05T12:38:59.301Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/5ab58fda751ea353aa606260d78a9b58352aebf53d3a69183c593d14cd6d/diffq-0.2.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71a374573ec064227665208a5892d88032cb18736f68560d5522e0c48138ced1", size = 418777, upload-time = "2023-05-05T12:39:01.169Z" }, +] + +[[package]] +name = "diffq-fixed" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cython", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/96/8ca5acf5ecfd4108aa6f345cef171f2fda0c081cf0e7430671712586f172/diffq_fixed-0.2.4.tar.gz", hash = "sha256:cbc906b76fa23d1cf3c0ae517fbab744d9624980a068fe7fbb00dede1d83208d", size = 189695, upload-time = "2024-11-07T21:33:26.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/a9/b3bf2493447a7492c4208ee9003514017e9ee009798df34abada3666211b/diffq_fixed-0.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:d6fc81bd6ca17ebc04b8a163f9efae5afedf3274a4eee473f6742471a8d904bc", size = 106211, upload-time = "2024-11-12T18:19:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/4c/70/a11838773e1c2bd9fa47d5016a90b7584b4052179407fb934eb89499552e/diffq_fixed-0.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:67798dccc9b4f46196071953d52fafbef0751c5d0acb064e0883c7a8509d84f9", size = 106290, upload-time = "2024-11-12T18:22:14.423Z" }, + { url = "https://files.pythonhosted.org/packages/33/14/94327d99f551136ac95bdc9697a1738f78ab3bb63afe8ce58b2b9854cd3a/diffq_fixed-0.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:81087279979db570723ca0911265635d7c3132cc8b74bc52bdea94881440ce96", size = 106205, upload-time = "2024-11-07T21:33:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/28/fd/049873cb31ebd5c9d1d2fac9b3ff645513af1e8b98f295c3188b9c4fbce3/diffq_fixed-0.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:cd5a12f0da04394c8bd9d2d6bc69a120dce0848c2fb59ddae06b959fb5db8ae3", size = 106005, upload-time = "2025-03-03T21:26:54.469Z" }, +] + [[package]] name = "dora-search" version = "0.1.12" @@ -392,6 +517,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + [[package]] name = "fsspec" version = "2026.3.0" @@ -717,6 +850,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "ml-collections" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "pyyaml", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/f8/1a9ae6696dbb6bc9c44ddf5c5e84710d77fe9a35a57e8a06722e1836a4a6/ml_collections-1.1.0.tar.gz", hash = "sha256:0ac1ac6511b9f1566863e0bb0afad0c64e906ea278ad3f4d2144a55322671f6f", size = 61356, upload-time = "2025-04-17T08:25:02.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/8a/18d4ff2c7bd83f30d6924bd4ad97abf418488c3f908dea228d6f0961ad68/ml_collections-1.1.0-py3-none-any.whl", hash = "sha256:23b6fa4772aac1ae745a96044b925a5746145a70734f087eaca6626e92c05cbc", size = 76707, upload-time = "2025-04-17T08:24:59.038Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/72/307d7c4bd0600601c7133fba5cb78af7db968152951c1cd473abb1cda782/ml_dtypes-0.6.0.tar.gz", hash = "sha256:5e60251d32ced5598972e4d5e06a2f044341f9291402551a3f6f0ec44f9299b0", size = 3032327, upload-time = "2026-08-13T14:14:40.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/15/01285c64133ea38abf3b990a704d7d30e50daea2806d150bcc4163495d35/ml_dtypes-0.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bad8d1dd5bed060a29332b99d63d0e5c2969081e1c6ea54adfbccfdfa783be44", size = 566808, upload-time = "2026-08-13T14:13:50.012Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/850d9b8b35549182f7c7f2cf742ce75c853ee880101bbc51cca0d62732e3/ml_dtypes-0.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:008382aeab529df5d3f00501ad9a7dcd64494d4b5b1971fc4c79019e6c1f5010", size = 356865, upload-time = "2026-08-13T14:13:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/844f5402145ce73bec8eb3afeb9f41d2bf99e0c8617c93f9e9886f26b419/ml_dtypes-0.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ec0d244a5bba12239025389ad88bbfb45f9f10e25ab4f678e9a4768ebd47532", size = 412036, upload-time = "2026-08-13T14:13:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/f8/63/efc9257a1ef0f53dfc76dedfe70d7d35118fbcdb810bb48cb7323ebd0b87/ml_dtypes-0.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:03ce583adfce34ad33aa9e1fc7a8344dcf90ea776cc4ef0e5a48d4eae84e5d20", size = 433668, upload-time = "2026-08-13T14:13:53.668Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2c/318cd1a9014c63939ffe687e19559ae12831fcc37d66c71ad1f616f1ffd6/ml_dtypes-0.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4f59f83c82ab480e924b988e7b1b4eb4de836dfcf5390c6f59148d1a00e1d02", size = 566813, upload-time = "2026-08-13T14:13:55.053Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/706b8a39449f0d55a7d5f7d07a169da4decfafae8a1f4983a9236d4b49e8/ml_dtypes-0.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7728c0420ec1c338564fc8b01015ff2d58567e70f17fedce5a0a7c0308c0d5b9", size = 356864, upload-time = "2026-08-13T14:13:56.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b1/135a7bf47633f5b9184f0d0316af819884124d12b40965064bd216266514/ml_dtypes-0.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c8e39b53e90afda8ce52859c93de4dba3e02b76d85dcf091cc469f9184c6dae", size = 412043, upload-time = "2026-08-13T14:13:57.614Z" }, + { url = "https://files.pythonhosted.org/packages/07/23/8870bb62d6e499d6bcbc1242b9f11689bae00a3d39d3684a9aefad8b6ee6/ml_dtypes-0.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3035518e3e19add1a4cac9236ab22888b208a4074912514313ccb2d6d242cde8", size = 433670, upload-time = "2026-08-13T14:13:59.097Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7a/5d8fbe24d0bffd0d7cb5165a89f8ab7c3de000f26d6705242aeed99d583c/ml_dtypes-0.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:5a519c9e95a216fbcb8e759793ef7fb40793fc803ed839142d6dc5be9be5bc89", size = 551915, upload-time = "2026-08-13T14:14:00.368Z" }, + { url = "https://files.pythonhosted.org/packages/84/6a/441eb053b078954f7fea284dfb288701884d0a1404d39babb858e1649023/ml_dtypes-0.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5359c588cc62de6f78d7430f06b65853d884955494d86d6ad90b6dd64a3f3a08", size = 565447, upload-time = "2026-08-13T14:14:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cf/87e8a6c57eed63a91782a0d229856ddf73e138ce004dd71e2799a9dcdb33/ml_dtypes-0.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37da32aa97749251025666d62372775019594577b9c9e9cfda83bed48d778fdb", size = 360227, upload-time = "2026-08-13T14:14:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/7d76c1eae866f5d4636401b31b6d6dd90e4b4ced1fa7cfdfcca9c60e4bd3/ml_dtypes-0.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b4a480aa8fd54a1805b8ac10f3f91763926a74f73c0c364c10f9231854f4170", size = 409890, upload-time = "2026-08-13T14:14:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/9c61ec2760b5cbfb1c6558d5c991a6d8fd3271053c32db20506a9a90272b/ml_dtypes-0.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a3e9d53925597fbffafd2a37048dadeddd0bdaba58058f6ae0869ed709a184d", size = 439333, upload-time = "2026-08-13T14:14:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/6a/57/780ca3e5ab135b9fbdd8e5441abf5f801b30398371b691291e05ab9834c0/ml_dtypes-0.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:6eaed129a4afe90694b8685e2f9b6294849f5eda4af9a15be83a4326eeebd775", size = 552268, upload-time = "2026-08-13T14:14:06.866Z" }, + { url = "https://files.pythonhosted.org/packages/50/51/fd1582b8f5ed8a9e7be0e161a6ea0dff70cb280479a12178df0b3a72700e/ml_dtypes-0.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:084dfe51a7ad58b171f05115f8226ed4233a454a1611371947e806e76f0c638d", size = 565468, upload-time = "2026-08-13T14:14:08.5Z" }, + { url = "https://files.pythonhosted.org/packages/d2/22/20fd70ca6ed12446cb92d5b2a7745bd185f9d8b8cdeeadad976574398e6b/ml_dtypes-0.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28d676428b104bb9717b0928bc5c5129f2d6b51b6727587cc4289e7bf8713cb5", size = 360232, upload-time = "2026-08-13T14:14:09.873Z" }, + { url = "https://files.pythonhosted.org/packages/89/a5/da8ae6c6f1babe4b68e3e55d43d39b529e29774f10e0910671a6b8c86eb8/ml_dtypes-0.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b1f1fa4f0435a2946859823f6e2bf06796f1e9f10f5a05b08a5e3c8f46ff69", size = 410169, upload-time = "2026-08-13T14:14:11.036Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/4561acefa00fa4bcbfb82ca6a48578b41f372cd7dd7cdd6eb4720abc2e5f/ml_dtypes-0.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb87f46b4f7ad7b5d3ad8f4b452b024bd4229d44c8ff934798c1fe656210387a", size = 439357, upload-time = "2026-08-13T14:14:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/6a01538e507ef0ed5e879985b13a92467bf8960696fb1131f8b8cadc60ff/ml_dtypes-0.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:57ed0d6b4ac5e7868361303a9c57fbcf63b768236ee14456f585dfcf260d0292", size = 552278, upload-time = "2026-08-13T14:14:13.539Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1137,6 +1314,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, ] +[[package]] +name = "onnx-weekly" +version = "1.23.0.dev20260817" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "protobuf", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/44/f24ce74b129bbcbeac50e07e30ee807f3abaa711f81873fe2afe110e484d/onnx_weekly-1.23.0.dev20260817.tar.gz", hash = "sha256:860d42175f474562fe05236f168ba2340c66b566119750792caa8cada18c74da", size = 5983184, upload-time = "2026-08-17T00:28:03.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/9a/938c9f264ea90c049191dbba0002bcce27e36592446eb4176aaa0d7f5493/onnx_weekly-1.23.0.dev20260817-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:da975217cc014c3f6ce9c35137a2a75128e76fdc4bb51b5e33bfe50b11668e83", size = 9614934, upload-time = "2026-08-17T00:27:04.386Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/392b09f41964411bd2d63a4112ee6143e0bd04ab53044eac1f7625774542/onnx_weekly-1.23.0.dev20260817-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58910d4a6a1b996beed3d0615f3657ab2b5c91e6e8a6a51d9525c604a213336", size = 8569136, upload-time = "2026-08-17T00:27:07.371Z" }, + { url = "https://files.pythonhosted.org/packages/3f/67/4399c02e92132725e532b05f14d49e6738e0f87e848ba6e50fbe544409be/onnx_weekly-1.23.0.dev20260817-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b8d9d519841440fc52313de154fc8f38442e8d413ce151b86ba6aa19c8d07d7", size = 8808259, upload-time = "2026-08-17T00:27:09.933Z" }, + { url = "https://files.pythonhosted.org/packages/ed/08/dd1e86e66cfe5153e606665971068bf8e9d9abfa8675abd076f8f67e5bde/onnx_weekly-1.23.0.dev20260817-cp310-cp310-win32.whl", hash = "sha256:bb443eeb9164d94859e0072d15f73c8718507c781de8ebd862135985ec0db2a9", size = 7679782, upload-time = "2026-08-17T00:27:12.514Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2c/5b805377cf61d0b24a788315b7c2af3e26115eeccf68012d647abd6a1376/onnx_weekly-1.23.0.dev20260817-cp310-cp310-win_amd64.whl", hash = "sha256:dfb8ce2ddf125cef1415076ef52a81fd6b028f4dcc83cff7d2db49cd35217573", size = 7811958, upload-time = "2026-08-17T00:27:14.851Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7e/5cd2e7463a9ab9c62f68162bacef939764befd9f8c5f0db9d21dded649ce/onnx_weekly-1.23.0.dev20260817-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:1c7f546936d848fad40527a11edbb162da8c68e0fde2e6a0712f5798f0279353", size = 9614568, upload-time = "2026-08-17T00:27:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/7f4629aea125df1762c82d29bcc0838d8ba866c317359b105ca956adaf4c/onnx_weekly-1.23.0.dev20260817-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f530a618cdca5b6e087f86a65d86b48211138c3cf5356527846015441b9b63b", size = 8569339, upload-time = "2026-08-17T00:27:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/9f/19/10224b7963c0a4efd47d67df29203f187701f0ff66340598276f1b31774b/onnx_weekly-1.23.0.dev20260817-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a16c08ddc4b746d1814843c153e97200a8191aa17ed95d64ac9f128e528c0a71", size = 8808269, upload-time = "2026-08-17T00:27:22.967Z" }, + { url = "https://files.pythonhosted.org/packages/07/92/118a23beaf9df570f7de67a384f25e2991cc4b6a270f1a728e9d58c29044/onnx_weekly-1.23.0.dev20260817-cp311-cp311-win32.whl", hash = "sha256:5c961b8d2f5e5d11b807b1680c9657e1a0ac7193d5398db82a8994a207b03da6", size = 7679618, upload-time = "2026-08-17T00:27:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/5d2919289a6e1b6e7e769eb63c76f0de200bb27e09b4051b47ea266192f1/onnx_weekly-1.23.0.dev20260817-cp311-cp311-win_amd64.whl", hash = "sha256:b72ffc88692edda759412a34f1b993aabba522bc18eb02a718e9fcbca3a643fc", size = 7812360, upload-time = "2026-08-17T00:27:27.878Z" }, + { url = "https://files.pythonhosted.org/packages/86/bc/3702374ef5fd13c5dc6c61126a9c8f182bebd67cb94f2e6328648839ec8b/onnx_weekly-1.23.0.dev20260817-cp311-cp311-win_arm64.whl", hash = "sha256:bd18a2d52a09e99d2b591591062a4a5472e3932a05ea9e7b09868e22cb87c9f9", size = 7979340, upload-time = "2026-08-17T00:27:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/13/24/f457d73ad21d0bf875ecccf4ef75ca707ba2fbd28c3fe19dc3bce50426b5/onnx_weekly-1.23.0.dev20260817-cp312-abi3-macosx_13_0_universal2.whl", hash = "sha256:9390ed019355a233a0ae81b392579cf3c9a0cdd5edcbeef30ca52a535443e0a1", size = 9612871, upload-time = "2026-08-17T00:27:32.623Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/1bcc73642d295824d5df57489ca87ba0de3a0a6cc7e0ff6b2422bce10c74/onnx_weekly-1.23.0.dev20260817-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30ab14fd43b30c4f1ae36258bac0f552bd7949990fca168a55b5bd051980cacb", size = 8565037, upload-time = "2026-08-17T00:27:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/17ca27c34a47e8c167e509d844da585cbe8ea8245a48e580dab9b3d520a3/onnx_weekly-1.23.0.dev20260817-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d745b1467293256a8ab5862c325e553d8e164a7c89f20b3ef3cf9fa3a52ec741", size = 8804744, upload-time = "2026-08-17T00:27:38.278Z" }, + { url = "https://files.pythonhosted.org/packages/e9/11/dcb732ca671023278f15c9f939daa10b5321370f17d39f525ee7832c60b5/onnx_weekly-1.23.0.dev20260817-cp312-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eee895265f0cae798d94e034aec55a2919a6a889e5e22137e267f1f6b974f3de", size = 7263809, upload-time = "2026-08-17T00:27:40.881Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/b195f33771887717fd4573dc12d364a3821215ef21b9e2218a42c9eb9403/onnx_weekly-1.23.0.dev20260817-cp312-abi3-win32.whl", hash = "sha256:70eec883dac2022448b609c2870510d0d7b945d365c402fa4b72db8d4efbb05e", size = 7677868, upload-time = "2026-08-17T00:27:43.183Z" }, + { url = "https://files.pythonhosted.org/packages/57/c7/348267f6b47e534ffa359f21653fb69f361acfb9f28fd2033d0bb921baaf/onnx_weekly-1.23.0.dev20260817-cp312-abi3-win_amd64.whl", hash = "sha256:26c20262d3ffeca223f46a92476315c0065f3092a2f8cabfb48d4b3b591f363d", size = 7810316, upload-time = "2026-08-17T00:27:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/d79a1695cd4af96595c2ececdad4faf35cd0c4fd4d85c749c1d98221e1a8/onnx_weekly-1.23.0.dev20260817-cp312-abi3-win_arm64.whl", hash = "sha256:e25dfe10f476e815412dfc311edec90a155fb69be58122ba12e982903b49ba58", size = 7976860, upload-time = "2026-08-17T00:27:47.473Z" }, +] + +[[package]] +name = "onnx2torch-py313" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "onnx-weekly", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/bb/6301c1ea4c8f8859aff60d2d58a038fd68f9cdb507d1c60776d4e3b93b1f/onnx2torch_py313-1.6.0.tar.gz", hash = "sha256:d27b54c7e170f12ad252fe186e6cab8f3cade8ee4e596682acc5a3afddadb753", size = 49594, upload-time = "2025-04-11T03:52:32.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/14/bb49cfba5020c99ec37efcf64bb79f9793c1068992feffa81bcad9e10c92/onnx2torch_py313-1.6.0-py3-none-any.whl", hash = "sha256:03b1cc771f93941cc221635547e97dd7823dffc05f052057e51c6a45fbacba70", size = 81251, upload-time = "2025-04-11T03:52:31.179Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "protobuf", marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "sympy", marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c5/3af6b325f1492d691b23844d88ed26844c1164620860c5efe95c0e22782d/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978", size = 15130330, upload-time = "2026-03-05T16:34:53.831Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/f96b46c1866a293ed23ca2cf5e5a63d413ad3a951da60dd877e3c56cbbca/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d", size = 17213247, upload-time = "2026-03-05T17:17:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/36/13/27cf4d8df2578747584e8758aeb0b673b60274048510257f1f084b15e80e/onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39", size = 12595530, upload-time = "2026-03-05T17:18:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/6d9f31e6bae72a8079be12ed8ba36c4126a571fad38ded0a1b96f60f6896/onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723", size = 12261715, upload-time = "2026-03-05T17:18:39.699Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/dfdc4e52600fde4c02d59bfe98c4b057931c1114b701e175aee311a9bc11/onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91", size = 17342578, upload-time = "2026-03-05T17:19:02.403Z" }, + { url = "https://files.pythonhosted.org/packages/1c/dc/1f5489f7b21817d4ad352bf7a92a252bd5b438bcbaa7ad20ea50814edc79/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452", size = 15150105, upload-time = "2026-03-05T16:34:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/fd253da53594ab8efbefdc85b3638620ab1a6aab6eb7028a513c853559ce/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d", size = 17237101, upload-time = "2026-03-05T17:18:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/71/5f/eaabc5699eeed6a9188c5c055ac1948ae50138697a0428d562ac970d7db5/onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a", size = 12597638, upload-time = "2026-03-05T17:18:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/d8066c320b90610dbeb489a483b132c3b3879b2f93f949fb5d30cfa9b119/onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8", size = 12270943, upload-time = "2026-03-05T17:18:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/51/8d/487ece554119e2991242d4de55de7019ac6e47ee8dfafa69fcf41d37f8ed/onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7", size = 17342706, upload-time = "2026-03-05T16:35:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/25/8b444f463c1ac6106b889f6235c84f01eec001eaf689c3eff8c69cf48fae/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7", size = 15149956, upload-time = "2026-03-05T16:34:59.264Z" }, + { url = "https://files.pythonhosted.org/packages/34/fc/c9182a3e1ab46940dd4f30e61071f59eee8804c1f641f37ce6e173633fb6/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9", size = 17237370, upload-time = "2026-03-05T17:18:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/3b549e1f4538514118bff98a1bcd6481dd9a17067f8c9af77151621c9a5c/onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b", size = 12597939, upload-time = "2026-03-05T17:18:54.772Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/9696a5c4631a0caa75cc8bc4efd30938fd483694aa614898d087c3ee6d29/onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b", size = 12270705, upload-time = "2026-03-05T17:18:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/b7/65/a26c5e59e3b210852ee04248cf8843c81fe7d40d94cf95343b66efe7eec9/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34", size = 15161796, upload-time = "2026-03-05T16:35:02.871Z" }, + { url = "https://files.pythonhosted.org/packages/f3/25/2035b4aa2ccb5be6acf139397731ec507c5f09e199ab39d3262b22ffa1ac/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d", size = 17240936, upload-time = "2026-03-05T17:18:09.534Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.13' and platform_machine != 'x86_64') or (python_full_version >= '3.13' and sys_platform != 'darwin')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64') or (python_full_version == '3.12.*' and sys_platform != 'darwin')", + "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' and sys_platform != 'darwin')", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "protobuf", marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload-time = "2026-08-17T22:53:33.429Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload-time = "2026-08-17T22:53:36.47Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload-time = "2026-08-17T22:53:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload-time = "2026-08-17T22:53:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload-time = "2026-08-17T22:53:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, + { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, +] + [[package]] name = "openunmix" version = "1.3.0" @@ -1165,6 +1460,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1197,6 +1544,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1307,6 +1669,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, ] +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1443,6 +1814,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "resampy" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba", version = "0.65.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/f1/34be702a69a5d272e844c98cee82351f880985cfbca0cc86378011078497/resampy-0.4.3.tar.gz", hash = "sha256:a0d1c28398f0e55994b739650afef4e3974115edbe96cd4bb81968425e916e47", size = 3080604, upload-time = "2024-03-05T20:36:08.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b9/3b00ac340a1aab3389ebcc52c779914a44aadf7b0cb7a3bf053195735607/resampy-0.4.3-py3-none-any.whl", hash = "sha256:ad2ed64516b140a122d96704e32bc0f92b23f45419e8b8f478e5a05f83edcebd", size = 3076529, upload-time = "2024-03-05T20:36:02.439Z" }, +] + [[package]] name = "retrying" version = "1.4.2" @@ -1452,14 +1837,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, ] +[[package]] +name = "rotary-embedding-torch" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.13' and platform_machine != 'x86_64') or (python_full_version >= '3.13' and sys_platform != 'darwin')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64') or (python_full_version == '3.12.*' and sys_platform != 'darwin')", + "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' and sys_platform != 'darwin')", + "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')", +] +dependencies = [ + { name = "einops", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/7e/8dbb4a950a99ec2f459ec1eb73818c519b56a1fee8a9a98f7a57460a0d5a/rotary_embedding_torch-0.6.5.tar.gz", hash = "sha256:a3623274c559e0215922edc7cd14068d5a64dea1ca469e1ecd642b293ee8af78", size = 7114, upload-time = "2024-08-20T20:52:44.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/1a/d78ef65d18ed5b9b8d04e68c69ef393adecc36f189186e250089ed432d7d/rotary_embedding_torch-0.6.5-py3-none-any.whl", hash = "sha256:96c0b27b688820cbde652dd7869cf035b4f653eee53ddaf78133cf7cdd4aab19", size = 5454, upload-time = "2024-08-20T20:52:43.266Z" }, +] + [[package]] name = "rotary-embedding-torch" version = "0.8.9" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] dependencies = [ - { name = "einops" }, + { name = "einops", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "torch", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/38/74783585b1f0282fddd3faf1abd6dd20977255c27e31737eced2d7ec05f1/rotary_embedding_torch-0.8.9.tar.gz", hash = "sha256:b213f153cad1d108064d930544fb3af678d56515893d3f869a7a146f87997e3f", size = 7497, upload-time = "2025-07-27T01:26:14.675Z" } wheels = [ @@ -1491,6 +1899,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "samplerate" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/9c/6a13fbc59b1ceaef194a53d3aeafa983057133fb13660c423854184eafa7/samplerate-0.1.0.tar.gz", hash = "sha256:75ef725e6cd9c4545569caf4c47147beab7b53b2c36e5122e8c285d348f88847", size = 4044998, upload-time = "2017-02-24T00:01:56.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/3c/4c1aa376332d18e708dcc3289e4dbdd2c508bcac1b8eb1b35b73092fa00f/samplerate-0.1.0-py2.py3-none-any.whl", hash = "sha256:f55e5c9d0a8ba3c82a53b7d9c34a2d145439c61166a7f310efaec88f2781b8f8", size = 4028658, upload-time = "2017-02-24T00:01:44.947Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.2" @@ -1719,6 +2141,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soundfile" version = "0.13.1" @@ -1819,12 +2250,15 @@ wheels = [ name = "stemdeck" source = { editable = "." } dependencies = [ + { name = "audio-separator", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "beat-this" }, { name = "demucs" }, { name = "fastapi" }, { name = "librosa" }, { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "onnxruntime", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, { name = "pyloudnorm" }, { name = "python-multipart" }, { name = "segno" }, @@ -1848,6 +2282,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "audio-separator", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=0.24" }, { name = "beat-this", specifier = ">=1.1" }, { name = "demucs", specifier = ">=4.0.1,<4.1" }, { name = "fastapi", specifier = ">=0.115,!=0.136.3" }, @@ -1855,6 +2290,7 @@ requires-dist = [ { name = "librosa", specifier = ">=0.10" }, { name = "numba", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=0.61,<0.62" }, { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = "<2" }, + { name = "onnxruntime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=1.19" }, { name = "pyloudnorm", specifier = ">=0.1.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, @@ -2069,6 +2505,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/c8/3010878a5e7f15d89450e22769697173c6dc244a0647ddc5386c28b6dacc/torchaudio-2.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:393fa74ec40d167f0170728ea21c9b5e0f830648fd02df7db2bf7e62f64245ec", size = 2449350, upload-time = "2025-01-29T16:29:10.178Z" }, ] +[[package]] +name = "torchvision" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and sys_platform != 'darwin')" }, + { name = "pillow", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/20/72eb0b5b08fa293f20fc41c374e37cf899f0033076f0144d2cdc48f9faee/torchvision-0.21.0-1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5568c5a1ff1b2ec33127b629403adb530fab81378d9018ca4ed6508293f76e2b", size = 2327643, upload-time = "2025-03-18T17:25:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3d/b7241abfa3e6651c6e00796f5de2bd1ce4d500bf5159bcbfeea47e711b93/torchvision-0.21.0-1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ff96666b94a55e802ea6796cabe788541719e6f4905fc59c380fed3517b6a64d", size = 2329320, upload-time = "2025-03-18T17:25:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/52/5b/76ca113a853b19c7b1da761f8a72cb6429b3bd0bf932537d8df4657f47c3/torchvision-0.21.0-1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ffa2a16499508fe6798323e455f312c7c55f2a88901c9a7c0fb1efa86cf7e327", size = 2329878, upload-time = "2025-03-18T17:25:50.039Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fe/5e193353706dab96fe73ae100d5a633ff635ce310e0d92f3bc2958d075b1/torchvision-0.21.0-1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7e9e9afa150e40cd2a8f0701c43cb82a8d724f512896455c0918b987f94b84a4", size = 2280711, upload-time = "2025-03-18T17:25:46.012Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/143bd264876fad17c82096b6c2d433f1ac9b29cdc69ee45023096976ee3d/torchvision-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:044ea420b8c6c3162a234cada8e2025b9076fa82504758cd11ec5d0f8cd9fa37", size = 1784140, upload-time = "2025-01-29T16:28:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/44/32e2d2d174391374d5ff3c4691b802e8efda9ae27ab9062eca2255b006af/torchvision-0.21.0-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:b0c0b264b89ab572888244f2e0bad5b7eaf5b696068fc0b93e96f7c3c198953f", size = 7237187, upload-time = "2025-01-29T16:28:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/4fca9373eda42c1b04096758306b7bd55f7d8f78ba273446490855a0f25d/torchvision-0.21.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:54815e0a56dde95cc6ec952577f67e0dc151eadd928e8d9f6a7f821d69a4a734", size = 14699067, upload-time = "2025-01-29T16:28:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f7/799ddd538b21017cbf80294c92e9efbf6db08dff6efee37c3be114a81845/torchvision-0.21.0-cp310-cp310-win_amd64.whl", hash = "sha256:abbf1d7b9d52c00d2af4afa8dac1fb3e2356f662a4566bd98dfaaa3634f4eb34", size = 1560542, upload-time = "2025-01-29T16:28:52.608Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/00c69db213ee2443ada8886ec60789b227e06bb869d85ee324578221a7f7/torchvision-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110d115333524d60e9e474d53c7d20f096dbd8a080232f88dddb90566f90064c", size = 1784141, upload-time = "2025-01-29T16:28:51.207Z" }, + { url = "https://files.pythonhosted.org/packages/be/a2/b0cedf0a411f1a5d75cfc0b87cde56dd1ddc1878be46a42c905cd8580220/torchvision-0.21.0-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:3891cd086c5071bda6b4ee9d266bb2ac39c998c045c2ebcd1e818b8316fb5d41", size = 7237719, upload-time = "2025-01-29T16:28:20.724Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a1/ee962ef9d0b2bf7a6f8b14cb95acb70e05cd2101af521032a09e43f8582f/torchvision-0.21.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:54454923a50104c66a9ab6bd8b73a11c2fc218c964b1006d5d1fe5b442c3dcb6", size = 14700617, upload-time = "2025-01-29T16:28:30.247Z" }, + { url = "https://files.pythonhosted.org/packages/88/53/4ad334b9b1d8dd99836869fec139cb74a27781298360b91b9506c53f1d10/torchvision-0.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:49bcfad8cfe2c27dee116c45d4f866d7974bcf14a5a9fbef893635deae322f2f", size = 1560523, upload-time = "2025-01-29T16:28:48.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1b/28f527b22d5e8800184d0bc847f801ae92c7573a8c15979d92b7091c0751/torchvision-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:97a5814a93c793aaf0179cfc7f916024f4b63218929aee977b645633d074a49f", size = 1784140, upload-time = "2025-01-29T16:28:44.694Z" }, + { url = "https://files.pythonhosted.org/packages/36/63/0722e153fd27d64d5b0af45b5c8cb0e80b35a68cf0130303bc9a8bb095c7/torchvision-0.21.0-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:b578bcad8a4083b40d34f689b19ca9f7c63e511758d806510ea03c29ac568f7b", size = 7238673, upload-time = "2025-01-29T16:28:27.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ea/03541ed901cdc30b934f897060d09bbf7a98466a08ad1680320f9ce0cbe0/torchvision-0.21.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5083a5b1fec2351bf5ea9900a741d54086db75baec4b1d21e39451e00977f1b1", size = 14701186, upload-time = "2025-01-29T16:28:16.491Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6a/c7752603060d076dfed95135b78b047dc71792630cbcb022e3693d6f32ef/torchvision-0.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:6eb75d41e3bbfc2f7642d0abba9383cc9ae6c5a4ca8d6b00628c225e1eaa63b3", size = 1560520, upload-time = "2025-01-29T16:28:42.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/56/47d456b61c3bbce7bed4af3925c83d405bb87468e659fd3cf3d9840c3b51/torchvision-0.21.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:659b76c86757cb2ee4ca2db245e0740cfc3081fef46f0f1064d11adb4a8cee31", size = 1784141, upload-time = "2025-01-29T16:28:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4c/99880813aa50e64447fb1c4c6c804a793d2d78f7f7c53e99ddee7fa175fa/torchvision-0.21.0-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:084ac3f5a1f50c70d630a488d19bf62f323018eae1b1c1232f2b7047d3a7b76d", size = 7238714, upload-time = "2025-01-29T16:28:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2d/3c3ee10608310a395594aac7da8640372ed79c6585910ccae6919658dcdc/torchvision-0.21.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5045a3a5f21ec3eea6962fa5f2fa2d4283f854caec25ada493fcf4aab2925467", size = 2281252, upload-time = "2025-01-29T16:28:40.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b4/fc60e3bc003879d3de842baea258fffc3586f4b49cd435a5ba1e09c33315/torchvision-0.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:9147f5e096a9270684e3befdee350f3cacafd48e0c54ab195f45790a9c146d67", size = 1560519, upload-time = "2025-01-29T16:28:22.527Z" }, +] + [[package]] name = "tqdm" version = "4.67.3"