Files
stemify/stemroller/worker/backends.py
T
ClericalErrorandClaude Opus 5.5 1845f03c41 StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems
- android/: Plex music player that plays per-stem mixes, with Android Auto support,
  a graphic + parametric (Equalizer APO-style) EQ, and offline downloads
- server/: stemserve (serves stems to the app) and stemd (separates the library on
  the server's GPU, with "Stem Now" priority requests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:17:17 -07:00

252 lines
9.3 KiB
Python

"""Separation backends. Each returns ({stem: float32 (channels, samples)}, samplerate)."""
import gc
import logging
import os
import random
import numpy as np
import soundfile as sf
import torch
from .audio_io import load_audio
class Cancelled(BaseException):
"""Raised from progress hooks to abort a running separation."""
class DemucsBackend:
"""Demucs with batched segments.
demucs.apply.apply_model runs one segment per forward pass, which leaves an
RX 9070 XT mostly idle waiting on MIOpen host overhead. Batching 8 segments
under fp16 autocast measured ~110x realtime vs ~31x for the stock loop.
"""
BATCH = 8
def __init__(self):
self._model = None
self._model_name = None
def _get(self, name):
if self._model_name != name:
from demucs.pretrained import get_model
self._model = None
torch.cuda.empty_cache()
self._model = get_model(name).eval()
self._model_name = name
return self._model
def release(self):
self._model = self._model_name = None
gc.collect()
torch.cuda.empty_cache()
def run(self, job, device):
from demucs.apply import BagOfModels
job.report("Loading model", 0.01)
bag = self._get(job.model["id"])
sub_models = bag.models if isinstance(bag, BagOfModels) else [bag]
weights = bag.weights if isinstance(bag, BagOfModels) else [[1.0] * len(bag.sources)]
job.check()
job.report("Decoding audio", 0.03)
wav = torch.from_numpy(load_audio(job.file, bag.samplerate, bag.audio_channels))
job.check()
ref = wav.mean(0)
mean, std = ref.mean(), ref.std() + 1e-8
mix = ((wav - mean) / std).to(device)
length = mix.shape[-1]
passes = max(1, job.shifts)
max_shift = int(0.5 * bag.samplerate) if job.shifts else 0
padded = torch.nn.functional.pad(mix, (max_shift, max_shift)) if max_shift else mix
# Work units for progress: one per segment per model per shift.
plans = []
for sub in sub_models:
seg_len = int(min(job.segment, float(sub.segment)) * bag.samplerate) if job.segment else int(float(sub.segment) * bag.samplerate)
stride = max(1, int((1 - job.overlap) * seg_len))
plans.append((sub, seg_len, stride))
total_units = sum(passes * len(range(0, length + max_shift, stride)) for _, _, stride in plans)
done_units = 0
def tick(n):
nonlocal done_units
done_units += n
job.check()
job.report("Separating", 0.05 + 0.88 * min(1.0, done_units / total_units))
self.fp16 = job.fp16
job.report("Separating", 0.05)
estimates = torch.zeros(len(bag.sources), mix.shape[0], length, device=device)
totals = [0.0] * len(bag.sources)
for (sub, seg_len, stride), w in zip(plans, weights):
sub.to(device)
out = torch.zeros_like(estimates)
for _ in range(passes):
offset = random.randint(0, max_shift) if max_shift else 0
piece = padded[..., offset: offset + length + max_shift - offset] if max_shift else padded
res = self._split(sub, piece, seg_len, stride, job.overlap, device, tick)
out += res[..., max_shift - offset: max_shift - offset + length]
out /= passes
for k, wk in enumerate(w):
estimates[k] += out[k] * wk
totals[k] += wk
sub.cpu()
for k in range(len(totals)):
estimates[k] /= totals[k]
out = (estimates * std.to(device) + mean.to(device)).cpu().numpy()
return dict(zip(bag.sources, out)), bag.samplerate
def _split(self, model, mix, seg_len, stride, overlap, device, tick):
"""Overlap-add over fixed-length segments, BATCH segments per forward pass."""
channels, length = mix.shape
n_src = len(model.sources)
weight = torch.cat([torch.arange(1, seg_len // 2 + 1, device=device),
torch.arange(seg_len - seg_len // 2, 0, -1, device=device)]).float()
weight /= weight.max()
out = torch.zeros(n_src, channels, length, device=device)
sum_weight = torch.zeros(length, device=device)
offsets = list(range(0, length, stride))
use_amp = device.type == "cuda" and self.fp16
batch = self.BATCH
i = 0
while i < len(offsets):
group = offsets[i:i + batch]
chunks = torch.zeros(len(group), channels, seg_len, device=device)
for j, off in enumerate(group):
piece = mix[:, off:off + seg_len]
chunks[j, :, :piece.shape[-1]] = piece
try:
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16, enabled=use_amp):
res = model(chunks).float()
except torch.OutOfMemoryError:
if batch == 1:
raise
torch.cuda.empty_cache()
batch = max(1, batch // 2)
continue
for j, off in enumerate(group):
n = min(seg_len, length - off)
out[..., off:off + n] += weight[:n] * res[j, ..., :n]
sum_weight[off:off + n] += weight[:n]
i += len(group)
tick(len(group))
return out / sum_weight
def _progress_iter_factory(job, lo, hi):
"""Stand-in for tqdm inside audio-separator: reports progress and checks cancel."""
class ProgressIter:
def __init__(self, iterable=None, *args, total=None, **kwargs):
self.iterable = iterable
self.total = total if total is not None else (len(iterable) if hasattr(iterable, "__len__") else None)
self.n = 0
def __iter__(self):
for item in self.iterable:
self.update(0)
yield item
self.n += 1
def update(self, n=1):
self.n += n
job.check()
if self.total:
job.report("Separating", lo + (hi - lo) * min(1.0, self.n / self.total))
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
return ProgressIter
class RoformerBackend:
def __init__(self, model_dir):
self.model_dir = model_dir
self._sep = None
self._key = None
def _get(self, job, device):
key = (job.model["file"], str(device), job.overlap_int, job.fp16)
if self._key != key:
from audio_separator.separator import Separator
self._sep = None
torch.cuda.empty_cache()
if not os.path.exists(os.path.join(self.model_dir, job.model["file"])):
job.report("Downloading model", 0.01)
sep = Separator(
log_level=logging.WARNING,
model_file_dir=self.model_dir,
output_dir=job.out_dir,
normalization_threshold=1.0,
amplification_threshold=0.0,
use_autocast=device.type == "cuda" and job.fp16,
mdxc_params={"segment_size": 256, "override_model_segment_size": False,
"batch_size": 1, "overlap": job.overlap_int, "pitch_shift": 0},
)
# Separator picks "cuda" (current device) on its own; honour an explicit choice.
if device.type == "cpu":
sep.torch_device = torch.device("cpu")
elif device.index is not None:
torch.cuda.set_device(device.index)
sep.load_model(model_filename=job.model["file"])
self._sep, self._key = sep, key
return self._sep
def release(self):
self._sep = self._key = None
gc.collect()
torch.cuda.empty_cache()
def run(self, job, device):
from audio_separator.separator.architectures import mdxc_separator
job.report("Loading model", 0.02)
sep = self._get(job, device)
job.check()
captured = {}
def capture(stem_path, source, stem_name):
captured[stem_name] = source
return {stem_name: source}
inst = sep.model_instance
inst.final_process = capture
inst.output_dir = job.out_dir
# audio-separator reads with libsndfile, which rejects many MP3s (big ID3 tags) and
# FLACs with one damaged frame; ffmpeg decodes both, so hand it a clean WAV instead.
job.report("Decoding audio", 0.03)
os.makedirs(job.out_dir, exist_ok=True)
mix_path = os.path.join(job.out_dir, "mix.wav")
sf.write(mix_path, load_audio(job.file, inst.sample_rate, 2).T, inst.sample_rate, subtype="FLOAT")
job.check()
mdxc_separator.tqdm = _progress_iter_factory(job, 0.05, 0.93)
job.report("Separating", 0.05)
try:
sep.separate(mix_path)
finally:
os.remove(mix_path)
two_stem_model = len(job.model["stems"]) == 2
stems = {}
for name, source in captured.items():
key = name.lower()
if two_stem_model and key != "vocals":
key = "instrumental"
stems[key] = np.ascontiguousarray(source.T, dtype=np.float32)
return stems, inst.sample_rate