From 3d5a3cf8bc6c5075d6c66233fa3bdbf764ea2aff Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 5 Aug 2026 12:23:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20OGG=20and=20Opus=20support=20=E2=80=94?= =?UTF-8?q?=20import=20upload=20and=20OGG=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import: accept .ogg (Vorbis or Opus in Ogg) and .opus uploads. The pipeline already transcodes every local upload to 16-bit/44.1 kHz WAV via ffmpeg before Demucs, so only the extension allow-lists change: the API gate, the web file picker/drop validation, and the mobile accept list (which already advertised .ogg but got a server 422). Export: add OGG (Vorbis VBR q6, ~192 kbps — the quality tier matching the MP3 setting) to the mixdown, region, and stems-zip endpoints plus the export format toggle in the player. Tests: the unsupported-extension fixtures used .ogg and now use .aiff; new upload tests for .ogg/.opus and an ffmpeg-gated OGG zip transcode test asserting real OggS output. Closes #330 --- README.md | 10 +++++----- app/api/jobs.py | 4 ++-- app/api/stems.py | 23 +++++++++++++++-------- static/index.html | 3 ++- static/js/main.js | 9 ++++++--- static/mobile/app.js | 2 +- tests/test_jobs_api.py | 24 ++++++++++++++++++++++-- tests/test_stems_api.py | 39 +++++++++++++++++++++++++++++++++++++-- 8 files changed, 90 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 66bc018..535ff8e 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@
-Drop in an MP3, WAV, or FLAC file, or paste a YouTube URL, and StemDeck splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine. +Drop in an MP3, WAV, FLAC, OGG/Opus, MP4, or M4A file, or paste a YouTube URL, and StemDeck splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine. -> **What is this?** StemDeck is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, or FLAC onto the import bar and go. YouTube support is a convenience for content you have the right to process. StemDeck does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine. +> **What is this?** StemDeck is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, FLAC, OGG, or M4A onto the import bar and go. YouTube support is a convenience for content you have the right to process. StemDeck does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine. > StemDeck is a free, open alternative to cloud stem-splitters like Moises and LALAL.AI: no account, no quota, no uploads, no subscription. If you want stems for personal study and prefer to keep things local and free, StemDeck has you covered. If you need the polish, a mobile app, or deeper musician tooling, the commercial products are a better fit. @@ -58,7 +58,7 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no **6-stem separation** via Demucs `htdemucs_6s`, with auto-detection of the best Torch device (CUDA on NVIDIA, MPS on Apple Silicon, CPU fallback). -**YouTube and local file import.** Paste a YouTube URL or drop an MP3 or WAV directly onto the import bar. +**YouTube and local file import.** Paste a YouTube URL or drop an MP3, WAV, FLAC, OGG/Opus, MP4, or M4A directly onto the import bar. **DAW-style waveform editor** with min/max sample rendering across all stems, shared normalization, zoom in/out/Fit, loop drag on the ruler, gold playhead overlay, and stem-aligned lanes. @@ -94,7 +94,7 @@ StemDeck is not trying to compete with commercial stem-separation products. It c | **Data retention** | You control it; delete anytime | Governed by their privacy policy and retention period | | **Stem model** | Demucs `htdemucs_6s` (open source, Meta AI) | Proprietary models, regularly updated, generally higher quality | | **Stem count** | 6 (vocals, drums, bass, guitar, piano, other) | Up to 10 depending on service and plan | -| **Input formats** | YouTube URL, MP3, WAV | MP3, WAV, FLAC, M4A, and more depending on service | +| **Input formats** | YouTube URL, MP3, WAV, FLAC, OGG/Opus, MP4, M4A | MP3, WAV, FLAC, M4A, and more depending on service | | **Processing speed** | Depends on your hardware; fast with a GPU, slow on CPU only | Fast regardless of your hardware (runs on their servers) | | **Batch processing** | One job at a time | Yes, on paid plans | | **Mobile app** | No | iOS and Android | @@ -284,7 +284,7 @@ The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks a ## How to Use 1. On the import bar, click stem chips to choose which stems to extract (defaults to all 6). -2. Paste a YouTube URL **or** drop an MP3/WAV file, then click **Process**. +2. Paste a YouTube URL **or** drop an audio file (MP3, WAV, FLAC, OGG, MP4, M4A), then click **Process**. 3. Wait through `Uploading...` / `Downloading...` → `Analyzing...` → `Separating...` → `Mixing tracks...`. 4. When done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (full song minus your selection); the rest are your isolated stems. 5. Mix: **Play/Pause/Stop** controls the master transport. **M** mutes a stem, **S** solos it (additive; multiple solos stay audible), **Monitor** solos only that stem and clears others. The volume fader moves 1:1 with drag; double-click resets to 0 dB; `Shift+wheel` gives coarse adjustment and plain wheel gives fine. The **Reset**, **Mute**, and **Solo** toolbar buttons act on all stems at once. diff --git a/app/api/jobs.py b/app/api/jobs.py index db17a2c..0c8d00b 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -27,7 +27,7 @@ router = APIRouter(tags=["jobs"]) logger = logging.getLogger("stemdeck.api") -_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a")) +_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus")) _MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB _WS_RE = re.compile(r"\s+") @@ -171,7 +171,7 @@ async def _create_local_job(request: Request) -> dict[str, str]: if ext not in _ALLOWED_EXTS: raise HTTPException( status_code=422, - detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, and .m4a", + detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, .m4a, .ogg, and .opus", ) # Validate stems list from form field diff --git a/app/api/stems.py b/app/api/stems.py index 8f1351d..eaed1f6 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -45,14 +45,21 @@ _MIXDOWN_MAX_GAIN = 4.0 # 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. +# stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps, +# OGG is Vorbis VBR q6 (~192 kbps) — the quality tier matching the MP3 setting. _ENCODE_ARGS = { "wav": ["-c:a", "pcm_s16le"], "mp3": ["-q:a", "2"], "flac": ["-c:a", "flac"], + "ogg": ["-c:a", "libvorbis", "-q:a", "6"], } MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()} -MIXDOWN_MEDIA_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac"} +MIXDOWN_MEDIA_TYPES = { + "wav": "audio/wav", + "mp3": "audio/mpeg", + "flac": "audio/flac", + "ogg": "audio/ogg", +} # Mixdown render cache (#290): identical render params re-run the full ffmpeg # graph on every request today. On a shared server, repeat downloads of the @@ -403,7 +410,7 @@ async def get_mixdown( end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), ) -> FileResponse | StreamingResponse: """Render a mixdown of the given lanes at the given gains, streamed as WAV, - MP3, or FLAC. Mirrors the studio mixer (per-stem volume, mute, solo) so the + MP3, FLAC, or OGG. Mirrors the studio mixer (per-stem volume, mute, solo) so the exported file matches what is heard. The master fader is intentionally not applied -- it is a monitoring level, not part of the mix. Optional ?start=&end= trims to a loop region. @@ -411,7 +418,7 @@ async def get_mixdown( Identical params (including start/end and the current export sample rate) hit a render cache instead of re-running ffmpeg (#290) -- a cheap win on a shared server where the same export gets re-downloaded.""" - if ext not in ("wav", "mp3", "flac"): + if ext not in ("wav", "mp3", "flac", "ogg"): raise HTTPException(status_code=404, detail="not found") names, parsed_gains = _parse_lane_gains(stems, gains) @@ -559,8 +566,8 @@ async def get_video_mixdown( def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> None: - """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3 and - FLAC are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't + """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3, + FLAC, and OGG are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't meaningfully compress, and STORED keeps the build fast. Runs in a thread.""" if fmt == "wav": with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: @@ -608,8 +615,8 @@ async def get_all_stems_zip( every available stem is included.""" if not JOB_ID_RE.match(job_id): raise HTTPException(status_code=404, detail="job not found") - if fmt not in ("wav", "mp3", "flac"): - raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', or 'flac'") + if fmt not in ("wav", "mp3", "flac", "ogg"): + raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', 'flac', or 'ogg'") job = registry_get(job_id) if job is None or job.status != "done": raise HTTPException(status_code=404, detail="job not ready") diff --git a/static/index.html b/static/index.html index 7d68fb3..3424ff0 100644 --- a/static/index.html +++ b/static/index.html @@ -57,7 +57,7 @@ - + @@ -613,6 +613,7 @@ + - +
STEMS TO EXTRACT
${EXTRACT_STEMS.map((s) => { const on = !!state.selected[s.id]; diff --git a/tests/test_jobs_api.py b/tests/test_jobs_api.py index e492581..00b0e1e 100644 --- a/tests/test_jobs_api.py +++ b/tests/test_jobs_api.py @@ -134,10 +134,10 @@ def test_upload_503_when_queue_full(upload_client): def test_upload_rejects_unsupported_extension(upload_client): - data = io.BytesIO(b"OGG data") + data = io.BytesIO(b"FORM\x00\x00\x00\x00AIFF") r = upload_client.post( "/api/jobs", - files={"file": ("track.ogg", data, "audio/ogg")}, + files={"file": ("track.aiff", data, "audio/aiff")}, ) assert r.status_code == 422 assert "Unsupported file type" in r.json()["detail"] @@ -183,6 +183,26 @@ def test_upload_flac_returns_job_id(upload_client): assert "job_id" in r.json() +def test_upload_ogg_returns_job_id(upload_client): + data = io.BytesIO(b"OggS" + b"\x00" * 128) + r = upload_client.post( + "/api/jobs", + files={"file": ("my_track.ogg", data, "audio/ogg")}, + ) + assert r.status_code == 200 + assert "job_id" in r.json() + + +def test_upload_opus_returns_job_id(upload_client): + data = io.BytesIO(b"OggS" + b"\x00" * 128) + r = upload_client.post( + "/api/jobs", + files={"file": ("my_track.opus", data, "audio/opus")}, + ) + assert r.status_code == 200 + assert "job_id" in r.json() + + # ─── Sections endpoint ──────────────────────────────────────────────────────── diff --git a/tests/test_stems_api.py b/tests/test_stems_api.py index decc73a..8d7f604 100644 --- a/tests/test_stems_api.py +++ b/tests/test_stems_api.py @@ -177,7 +177,7 @@ def test_all_stems_zip_rejects_bad_format(client, tmp_path): job.status = "done" _jobs[job.id] = job _make_stem_file(tmp_path, job.id, "vocals") - r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=ogg") + r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=aiff") assert r.status_code == 422 @@ -236,6 +236,41 @@ def test_all_stems_zip_mp3(client, tmp_path): assert len(zf.read("vocals.mp3")) > 0 +def test_all_stems_zip_ogg(client, tmp_path): + """OGG zip transcodes via ffmpeg (libvorbis); skip if ffmpeg isn't available.""" + import io + import shutil + import zipfile + + if shutil.which("ffmpeg") is None: + import pytest + + pytest.skip("ffmpeg not available") + + import struct + + sr = 8000 + nframes = sr // 10 + data = b"\x00\x00" * nframes + hdr = b"RIFF" + struct.pack(" Job: def test_mixdown_rejects_bad_ext(client): - r = client.get("/api/jobs/abcdef000001/mixdown.ogg?stems=vocals&gains=1") + r = client.get("/api/jobs/abcdef000001/mixdown.aiff?stems=vocals&gains=1") assert r.status_code == 404