Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@

<br>

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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+")

Expand Down Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions app/api/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -403,15 +410,15 @@ 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.

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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
</svg>
</button>
<input id="fileInput" type="file" accept=".mp3,.wav,.flac,.mp4,.m4a,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4" style="display:none" aria-hidden="true" />
<input id="fileInput" type="file" accept=".mp3,.wav,.flac,.mp4,.m4a,.ogg,.opus,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4,audio/ogg,audio/opus" style="display:none" aria-hidden="true" />
</div>

<!-- Divider -->
Expand Down Expand Up @@ -613,6 +613,7 @@
<button class="export-fmt active" id="t-fmt-wav" type="button" role="radio" aria-checked="true">WAV</button>
<button class="export-fmt" id="t-fmt-mp3" type="button" role="radio" aria-checked="false">MP3</button>
<button class="export-fmt" id="t-fmt-flac" type="button" role="radio" aria-checked="false">FLAC</button>
<button class="export-fmt" id="t-fmt-ogg" type="button" role="radio" aria-checked="false">OGG</button>
<button class="export-fmt export-fmt-video" id="t-fmt-mp4" type="button" role="radio" aria-checked="false">MP4</button>
</div>
<button class="chip-panel-item export-item" id="t-export-mix" type="button" role="menuitem">
Expand Down
9 changes: 6 additions & 3 deletions static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ function wireFooterControls() {
const fmtWav = document.getElementById("t-fmt-wav");
const fmtMp3 = document.getElementById("t-fmt-mp3");
const fmtFlac = document.getElementById("t-fmt-flac");
const fmtOgg = document.getElementById("t-fmt-ogg");
const fmtMp4 = document.getElementById("t-fmt-mp4");
const exportWrap = document.getElementById("footer-export-wrap");
const itemMix = document.getElementById("t-export-mix");
Expand Down Expand Up @@ -163,7 +164,7 @@ function wireFooterControls() {

function setFormat(f) {
format = f;
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtMp4, "mp4"]]) {
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtOgg, "ogg"], [fmtMp4, "mp4"]]) {
btn?.classList.toggle("active", f === val);
btn?.setAttribute("aria-checked", String(f === val));
}
Expand All @@ -172,6 +173,7 @@ function wireFooterControls() {
fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); });
fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); });
fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); });
fmtOgg?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("ogg"); });
fmtMp4?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp4"); });

// MP4 exports the mix muxed with the source video. Stems and region have no
Expand Down Expand Up @@ -304,8 +306,9 @@ function wireFileDrop() {
if (!file) return;
const lower = file.name.toLowerCase();
if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") &&
!lower.endsWith(".mp4") && !lower.endsWith(".m4a")) {
showError("Only MP3, WAV, FLAC, MP4, and M4A files are supported.");
!lower.endsWith(".mp4") && !lower.endsWith(".m4a") &&
!lower.endsWith(".ogg") && !lower.endsWith(".opus")) {
showError("Only MP3, WAV, FLAC, MP4, M4A, OGG, and Opus files are supported.");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
Expand Down
2 changes: 1 addition & 1 deletion static/mobile/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ function extractScreen() {
<div class="sub">Paste a link or upload audio to split into stems.</div>
<div class="paste">${ICON.link}<input id="ext-url" class="ext-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Paste YouTube or audio URL" value="${esc(state.extractUrl || "")}"></div>
<button class="upload" data-action="pick-file">${ICON.upload}${fileName ? esc(fileName) : "Upload audio file"}</button>
<input id="ext-file" type="file" accept="audio/*,video/mp4,.mp3,.wav,.flac,.m4a,.ogg" style="display:none">
<input id="ext-file" type="file" accept="audio/*,video/mp4,.mp3,.wav,.flac,.m4a,.ogg,.opus" style="display:none">
<div class="eyebrow">STEMS TO EXTRACT</div>
<div class="chips">${EXTRACT_STEMS.map((s) => {
const on = !!state.selected[s.id];
Expand Down
24 changes: 22 additions & 2 deletions tests/test_jobs_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────


Expand Down
39 changes: 37 additions & 2 deletions tests/test_stems_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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("<I", 36 + len(data)) + b"WAVE"
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
hdr += b"data" + struct.pack("<I", len(data))
wav = hdr + data

job = Job(id="abcdefabcdb0")
job.status = "done"
job.title = "Track"
_jobs[job.id] = job
_make_stem_file(tmp_path, job.id, "vocals", wav)

r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=ogg")
assert r.status_code == 200
zf = zipfile.ZipFile(io.BytesIO(r.content))
assert zf.namelist() == ["vocals.ogg"]
ogg_bytes = zf.read("vocals.ogg")
assert ogg_bytes.startswith(b"OggS")


# --- dynamic mixdown endpoint (#183) ---


Expand All @@ -262,7 +297,7 @@ def _done_job_with_stems(tmp_path, job_id: str, names) -> 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


Expand Down