| """Bytley Demucs stem-separation backend (Hugging Face Space, free CPU tier). |
| |
| POST /separate (multipart: file; query: mode=stems|karaoke) |
| → application/zip of MP3 stems: |
| mode=stems → vocals.mp3, drums.mp3, bass.mp3, other.mp3 |
| mode=karaoke → instrumental.mp3, vocals.mp3 |
| |
| Uses the Demucs CLI (stable across versions) with the htdemucs model baked into the |
| image. Files are processed in a temp dir and deleted right after the response. |
| """ |
| import io |
| import os |
| import subprocess |
| import sys |
| import tempfile |
| import zipfile |
|
|
| from fastapi import FastAPI, File, Header, HTTPException, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import Response |
|
|
| MAX_MB = int(os.environ.get("MAX_MB", "30")) |
| TOKEN = os.environ.get("BYTLEY_TOKEN", "") |
|
|
| app = FastAPI(title="bytley-demucs") |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=[ |
| "https://bytley.co", |
| "https://www.bytley.co", |
| "http://localhost:4321", |
| "http://localhost:4322", |
| ], |
| allow_methods=["GET", "POST"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| @app.get("/") |
| def health(): |
| return {"ok": True, "service": "demucs-stems", "model": "htdemucs", "maxMb": MAX_MB} |
|
|
|
|
| def _run(cmd: list[str], timeout: int): |
| r = subprocess.run(cmd, capture_output=True, timeout=timeout) |
| if r.returncode != 0: |
| stderr_tail = r.stderr.decode(errors="replace")[-800:] |
| raise HTTPException( |
| 500, |
| detail=f"Separation failed. stderr tail: {stderr_tail}", |
| ) |
|
|
|
|
| @app.post("/separate") |
| async def separate( |
| file: UploadFile = File(...), |
| mode: str = "stems", |
| x_bytley_token: str | None = Header(default=None), |
| ): |
| if TOKEN and x_bytley_token != TOKEN: |
| raise HTTPException(401, detail="Unauthorized.") |
| data = await file.read() |
| if len(data) > MAX_MB * 1024 * 1024: |
| raise HTTPException(413, detail=f"File too large — max {MAX_MB} MB.") |
| if len(data) < 1024: |
| raise HTTPException(400, detail="Empty file.") |
|
|
| with tempfile.TemporaryDirectory() as td: |
| ext = os.path.splitext(file.filename or "a.mp3")[1] or ".mp3" |
| src = os.path.join(td, "input" + ext) |
| wav = os.path.join(td, "track.wav") |
| with open(src, "wb") as f: |
| f.write(data) |
|
|
| |
| r = subprocess.run( |
| ["ffmpeg", "-y", "-i", src, "-vn", "-ac", "2", "-ar", "44100", "-t", "600", "-c:a", "pcm_s16le", wav], |
| capture_output=True, |
| timeout=300, |
| ) |
| if r.returncode != 0 or not os.path.exists(wav): |
| raise HTTPException(400, detail="Couldn't read audio from this file.") |
|
|
| |
| outdir = os.path.join(td, "sep") |
| |
| cmd = [sys.executable, "-m", "demucs", "-n", "htdemucs", "-d", "cpu", "-o", outdir] |
| if mode == "karaoke": |
| cmd += ["--two-stems", "vocals"] |
| cmd.append(wav) |
| _run(cmd, timeout=1700) |
|
|
| stemdir = os.path.join(outdir, "htdemucs", "track") |
| if not os.path.isdir(stemdir): |
| raise HTTPException(500, detail="Separation produced no output.") |
|
|
| names = ["instrumental", "vocals"] if mode == "karaoke" else ["vocals", "drums", "bass", "other"] |
| src_names = {"instrumental": "no_vocals"} |
|
|
| buf = io.BytesIO() |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as z: |
| for name in names: |
| wpath = os.path.join(stemdir, f"{src_names.get(name, name)}.wav") |
| if not os.path.exists(wpath): |
| raise HTTPException(500, detail=f"Missing stem: {name}.") |
| mpath = os.path.join(td, f"{name}.mp3") |
| subprocess.run( |
| ["ffmpeg", "-y", "-i", wpath, "-c:a", "libmp3lame", "-b:a", "160k", mpath], |
| capture_output=True, |
| timeout=300, |
| check=True, |
| ) |
| z.write(mpath, arcname=f"{name}.mp3") |
|
|
| return Response( |
| content=buf.getvalue(), |
| media_type="application/zip", |
| headers={"Content-Disposition": 'attachment; filename="stems.zip"'}, |
| ) |
|
|