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 @@
Vocals
+
+
+ All
+ Lead + Backing
+
Drums
@@ -418,6 +425,31 @@
+
+
+
+
+
+ Lead Vocals
+
+ M
+ S
+
+
+
+
+
+
+
+ Backing Vocals
+
+ M
+ S
+
+
+
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 `
${s.name} ${on ? ICON.check : ""} `;
}).join("")}
+ ${state.selected.vocals ? `
+
+
Vocals
+
+ All
+ Lead + Backing
+
+
` : ""}
${ICON.scissors}Split stems
${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"