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 @@
+