File size: 5,048 Bytes
a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 f3f8ae9 a11ed89 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | """
Sever — BASE Station's own stem separation engine.
Runs HTDemucs 6-source (htdemucs_6s) locally instead of paying a third-party
API per separation. Six stems: drums, bass, other, vocals, guitar, piano.
Design notes that are load-bearing:
* SEPARATION IS ASYNCHRONOUS. A 3-minute track on CPU takes minutes, far past
any sane HTTP timeout, so /separate returns a job id immediately and the
caller polls /status. Doing it inline would guarantee gateway timeouts.
* ONE JOB AT A TIME. max_workers=1 is deliberate. Concurrent separations each
hold a full model + a full audio tensor in RAM, which is exactly how the
Coda and Siren Song Spaces earn their 'meta tensor' / OOM crashes. Queueing
is slower but never fails; parallelism here would be a memory bomb.
* MODEL LOADED ONCE, LAZILY. Loading htdemucs_6s costs a weights download and
~2GB of RAM. At import time that would fail the Space boot health check; per
request it would multiply the cost by every job.
* LOW-LEVEL demucs API ON PURPOSE. `demucs.api.Separator` exists only on the
unreleased branch — on the pinned 4.0.1 release the real entry points are
get_model + apply_model, so that is what this uses.
* soundfile, NOT torchaudio.save. torchaudio's native backend (torchcodec)
does not load on the HF Space image — an already-paid-for lesson from Skye.
"""
import os
import urllib.request
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Dict, List, Optional
import soundfile as sf
import torch
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
OUT = Path("/tmp/outputs")
OUT.mkdir(parents=True, exist_ok=True)
MODEL_NAME = "htdemucs_6s"
app = FastAPI(title="Sever", description="HTDemucs-6s stem separation")
# Static mount so a finished stem is a plain downloadable URL. The backend
# copies these into Base44 storage — a /tmp file on a sleeping Space is gone.
app.mount("/outputs", StaticFiles(directory=str(OUT)), name="outputs")
JOBS: Dict[str, dict] = {}
POOL = ThreadPoolExecutor(max_workers=1)
_model = None
def get_model_once():
global _model
if _model is None:
from demucs.pretrained import get_model
m = get_model(MODEL_NAME)
m.cpu()
m.eval()
_model = m
return _model
class SeparateRequest(BaseModel):
audio_url: str
stems: Optional[List[str]] = None
def _run(job_id: str, audio_url: str, want: Optional[List[str]]):
from demucs.apply import apply_model
from demucs.audio import AudioFile
src = OUT / f"{job_id}_src"
try:
JOBS[job_id]["status"] = "processing"
urllib.request.urlretrieve(audio_url, src)
model = get_model_once()
wav = AudioFile(str(src)).read(
streams=0, samplerate=model.samplerate, channels=model.audio_channels
)
# Demucs expects the input normalized to zero mean / unit variance, then
# the same transform undone on the output. Skipping this does not error —
# it just quietly degrades separation quality.
ref = wav.mean(0)
wav = (wav - ref.mean()) / ref.std()
with torch.no_grad():
sources = apply_model(model, wav[None], device="cpu", progress=False)[0]
sources = sources * ref.std() + ref.mean()
files = {}
for name, tensor in zip(model.sources, sources):
if want and name not in want:
continue
path = OUT / f"{job_id}_{name}.wav"
# (channels, samples) -> (samples, channels) for soundfile
sf.write(str(path), tensor.t().cpu().numpy(), model.samplerate)
files[name] = f"/outputs/{path.name}"
JOBS[job_id].update(
status="completed",
stems=files,
sample_rate=model.samplerate,
model=MODEL_NAME,
)
except Exception as exc: # surfaced to the caller instead of a silent stall
JOBS[job_id].update(status="failed", error=f"{type(exc).__name__}: {exc}")
finally:
if src.exists():
try:
os.remove(src)
except OSError:
pass
@app.get("/health")
def health():
return {
"ok": True,
"model": MODEL_NAME,
"stems": list(_model.sources) if _model is not None else None,
"loaded": _model is not None,
"queued": len([j for j in JOBS.values() if j["status"] == "queued"]),
}
@app.post("/separate")
def separate(req: SeparateRequest):
if not req.audio_url:
return {"error": "audio_url is required"}
job_id = uuid.uuid4().hex
JOBS[job_id] = {"status": "queued", "stems": {}}
POOL.submit(_run, job_id, req.audio_url, req.stems)
return {"job_id": job_id, "status": "queued", "model": MODEL_NAME}
@app.get("/status/{job_id}")
def status(job_id: str):
job = JOBS.get(job_id)
if not job:
return {"error": "unknown job_id", "status": "failed"}
return {"job_id": job_id, **job} |