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>
This commit is contained in:
ClericalErrorandClaude Opus 5.5 committed 2026-10-14 21:17:17 -07:00
1 parent 716c5499ff
commit 1845f03c41
184 files changed
+24648

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
"""QML image providers. Everything here renders off the UI thread and is
uploaded once as a texture: no per-frame CPU work, no live blur.
image://icon/<name>/<RRGGBB> Material icon tinted, sized by sourceSize
image://fx/backdrop/<W>x<H>/<radius> window background (aurora)
image://fx/glass/<W>x<H>/<radius>/<blur> blurred + tinted snapshot of the backdrop
image://fx/glasscrop/<W>x<H>/<r>/<blur>/<x>,<y>,<w>,<h>/<sheet r> rounded glass sheet
image://fx/shadow/<sigma>/<alpha>/<r> 9-patch elevation shadow
image://fx/glow/<RRGGBB>/<sigma>/<r> 9-patch hover glow
image://fx/stripes/<h> progress stripe tile
image://fx/noise dither tile
image://wave/<job>/<stem>/<W>x<H>/<t0>/<t1>/<RRGGBB>[/<rev>]
"""
import threading
import numpy as np
from PySide6.QtCore import QByteArray, QRectF, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtQuick import QQuickImageProvider
from PySide6.QtSvg import QSvgRenderer
from scipy.ndimage import gaussian_filter
# Material Icons (Apache 2.0), 24x24 viewBox.
ICONS = {
"play": "M8 5v14l11-7z",
"pause": "M6 19h4V5H6v14zm8-14v14h4V5h-4z",
"stop": "M6 6h12v12H6z",
"skip_start": "M6 6h2v12H6zm3.5 6l8.5 6V6z",
"close": "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
"minimize": "M5 11h14v2H5z",
"maximize": "M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H6V6h12v12z",
"restore": "M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z",
"settings": "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z",
"folder": "M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z",
"download": "M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z",
"check": "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
"error": "M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z",
"refresh": "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z",
"add": "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
"music": "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z",
"volume": "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
"volume_off": "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z",
"headphones": "M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z",
"chip": "M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z",
"delete": "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z",
"upload": "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z",
"equalizer": "M7 18h2V6H7v12zm4 4h2V2h-2v20zm-8-8h2v-4H3v4zm12 4h2V6h-2v12zm4-8v4h2v-4h-2z",
"expand": "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z",
"layers": "M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z",
"zoom_out": "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zM7 9h5v1H7z",
"speed": "M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z",
}
def _hex(c):
c = c.lstrip("#")
return np.array([int(c[i:i + 2], 16) for i in (0, 2, 4)], np.float32) / 255
def _to_qimage(rgba: np.ndarray) -> QImage:
"""float RGBA [0,1] (H, W, 4), straight alpha -> premultiplied QImage (copies)."""
a = np.clip(rgba[..., 3:4], 0, 1)
out = np.empty(rgba.shape, np.uint8)
out[..., :3] = np.clip(rgba[..., :3] * a * 255 + 0.5, 0, 255)
out[..., 3:] = np.clip(a * 255 + 0.5, 0, 255)
h, w = out.shape[:2]
return QImage(out.data, w, h, w * 4, QImage.Format.Format_RGBA8888_Premultiplied).copy()
class IconProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
def requestImage(self, id_, size, requested):
parts = id_.split("/")
name, color = parts[0], (parts[1] if len(parts) > 1 else "ECEDEF")
w = requested.width() if requested.width() > 0 else 24
h = requested.height() if requested.height() > 0 else w
svg = (f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>"
f"<path fill='#{color}' d='{ICONS.get(name, ICONS['music'])}'/></svg>")
img = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
QSvgRenderer(QByteArray(svg.encode())).render(p, QRectF(0, 0, w, h))
p.end()
return img
# ---- backdrop / glass ---------------------------------------------------
BG = _hex("1D1E21")
GLASS_TINT = _hex("242529")
# (color, cx, cy, sigma as fraction of the larger side, strength)
# Kept faint so the window reads as neutral dark gray with a hint of Aero colour.
BLOBS = [
(_hex("3BA8FF"), 0.10, 0.05, 0.30, 0.14),
(_hex("7C8CFF"), 0.62, 0.18, 0.22, 0.06),
(_hex("5FE3FF"), 0.92, 0.95, 0.26, 0.07),
(_hex("FF7AB6"), 0.02, 0.98, 0.18, 0.025),
]
# Aero-style diagonal light beams: (offset fraction, width fraction, strength)
BEAMS = [(0.18, 0.025, 0.035), (0.30, 0.06, 0.025), (0.74, 0.04, 0.02)]
def _backdrop(w, h, blur_px=0.0, crop=None):
"""Analytic aurora; blurring a gaussian blob is a wider gaussian, so the
'blurred snapshot' is exact and costs the same as the sharp one.
crop=(x, y, cw, ch) renders only that window-space rectangle."""
big = max(w, h)
cx0, cy0, cw, ch = crop or (0, 0, w, h)
x = np.arange(cx0, cx0 + cw, dtype=np.float32)
y = np.arange(cy0, cy0 + ch, dtype=np.float32)
img = np.empty((ch, cw, 3), np.float32)
img[:] = BG
for color, cx, cy, sig, strength in BLOBS:
s0 = sig * big
s = np.sqrt(s0 * s0 + blur_px * blur_px)
amp = strength * (s0 / s) ** 2
gx = np.exp(-((x - cx * w) ** 2) / (2 * s * s))
gy = np.exp(-((y - cy * h) ** 2) / (2 * s * s))
img += np.outer(gy * amp, gx)[..., None] * color
# beams run along x + 0.55 y = const
d = (x[None, :] + 0.55 * y[:, None]) / (big * 1.55)
for off, width, strength in BEAMS:
s0 = width
s = np.sqrt(s0 * s0 + (blur_px / (big * 1.55)) ** 2)
img += (strength * (s0 / s) * np.exp(-((d - off) ** 2) / (2 * s * s)))[..., None]
return img
def _round_alpha(h, w, r):
a = np.ones((h, w), np.float32)
if r <= 0:
return a
yy, xx = np.mgrid[0:r, 0:r].astype(np.float32) + 0.5
dist = np.sqrt((r - xx) ** 2 + (r - yy) ** 2)
corner = np.clip(r - dist + 0.5, 0, 1)
a[:r, :r] = corner
a[:r, -r:] = corner[:, ::-1]
a[-r:, :r] = corner[::-1, :]
a[-r:, -r:] = corner[::-1, ::-1]
return a
class FxProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
self._cache: dict[str, QImage] = {}
self._lock = threading.Lock()
def requestImage(self, id_, size, requested):
with self._lock:
hit = self._cache.get(id_)
if hit is not None:
return hit
img = self._make(id_.split("/"))
with self._lock:
if len(self._cache) > 48:
self._cache.clear()
self._cache[id_] = img
return img
def _make(self, p):
kind = p[0]
if kind in ("backdrop", "glass", "glasscrop"):
w, h = (max(1, int(v)) for v in p[1].split("x"))
r = int(p[2])
crop = None
if kind == "glasscrop": # <W>x<H>/<r>/<blur>/<x>,<y>,<cw>,<ch>: a rounded glass sheet (dialogs)
crop = tuple(max(0, int(float(v))) for v in p[4].split(","))
crop = (crop[0], crop[1], max(1, crop[2]), max(1, crop[3]))
if kind == "backdrop":
rgb = _backdrop(w, h)
noise = np.random.default_rng(1).random((h, w, 1), dtype=np.float32)
rgb += (noise - 0.5) / 255 # dither the dark gradients
else:
rgb = _backdrop(w, h, blur_px=float(p[3]), crop=crop)
rgb = rgb * 0.30 + GLASS_TINT * 0.70 # glass-tint rgba(36,37,41,0.70)
# glass sheen: one broad soft highlight along the top-left
ox, oy = (crop[0], crop[1]) if crop else (0, 0)
hh, ww = rgb.shape[:2]
d = (np.arange(ox, ox + ww)[None, :] + 1.4 * np.arange(oy, oy + hh)[:, None]) / max(w, h)
rgb += (0.035 * np.exp(-((d - 0.08) ** 2) / 0.004))[..., None]
if crop:
r = int(p[3 + 2]) if len(p) > 5 else 12
hh, ww = rgb.shape[:2]
rgba = np.concatenate([rgb, _round_alpha(hh, ww, r)[..., None]], axis=2)
return _to_qimage(rgba)
if kind in ("shadow", "glow"):
if kind == "shadow":
color, sigma, alpha, r = np.zeros(3, np.float32), float(p[1]), float(p[2]), int(p[3])
else:
color, sigma, alpha, r = _hex(p[1]), float(p[2]), 0.9, int(p[3])
m = int(np.ceil(sigma * 3)) + 1
n = 2 * m + 2 * r + 2
mask = np.zeros((n, n), np.float32)
core = _round_alpha(2 * r + 2, 2 * r + 2, r)
mask[m:m + 2 * r + 2, m:m + 2 * r + 2] = core
blurred = gaussian_filter(mask, sigma) * alpha
rgba = np.zeros((n, n, 4), np.float32)
rgba[..., :3] = color
rgba[..., 3] = blurred
return _to_qimage(rgba)
if kind == "stripes":
h = int(p[1])
period = 2 * h
xx, yy = np.meshgrid(np.arange(period) + 0.5, np.arange(h) + 0.5)
phase = ((xx + yy) % period) / period
band = np.clip(np.minimum(phase, 1 - phase) * period - h / 2 + 0.5, 0, 1)
rgba = np.zeros((h, period, 4), np.float32)
rgba[..., :3] = 1
rgba[..., 3] = band * 0.22
return _to_qimage(rgba)
if kind == "noise":
rng = np.random.default_rng(7)
v = rng.random((256, 256), dtype=np.float32)
rgba = np.zeros((256, 256, 4), np.float32)
rgba[..., :3] = v[..., None]
rgba[..., 3] = 0.025
return _to_qimage(rgba)
img = QImage(1, 1, QImage.Format.Format_RGBA8888_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
return img
# ---- waveforms ------------------------------------------------------------
class PeakStore:
"""Loaded peak pyramids keyed by job id. Shared with the controller."""
def __init__(self):
self._data: dict[str, dict] = {}
self._lock = threading.Lock()
def get(self, job_id, path):
with self._lock:
entry = self._data.get(job_id)
if entry is None or entry["path"] != path:
with np.load(path) as z:
levels: dict[str, list] = {}
for key in z.files:
if "/" in key:
stem, k = key.split("/")
levels.setdefault(stem, []).append((int(k), z[key].astype(np.float32)))
entry = {"path": path, "sr": int(z["samplerate"]), "base": int(z["base"]),
"levels": {s: [a for _, a in sorted(v)] for s, v in levels.items()}}
with self._lock:
self._data[job_id] = entry
return entry
def drop(self, job_id):
with self._lock:
self._data.pop(job_id, None)
class WaveformProvider(QQuickImageProvider):
def __init__(self, store: PeakStore, paths_for):
super().__init__(QQuickImageProvider.ImageType.Image)
self.store = store
self.paths_for = paths_for # job_id -> peaks_path (or None)
def requestImage(self, id_, size, requested):
p = id_.split("/")
job_id, stem = p[0], p[1]
w, h = (max(1, int(v)) for v in p[2].split("x"))
t0, t1, color = float(p[3]), float(p[4]), _hex(p[5])
path = self.paths_for(job_id)
rgba = np.zeros((h, w, 4), np.float32)
if path:
try:
entry = self.store.get(job_id, path)
levels = entry["levels"].get(stem)
if levels:
rgba = render_wave(levels, entry["base"], entry["sr"], t0, t1, w, h, color)
except Exception:
pass
return _to_qimage(rgba)
def render_wave(levels, base, sr, t0, t1, w, h, color):
s0, s1 = t0 * sr, max(t0 * sr + 1, t1 * sr)
spp = (s1 - s0) / w # samples per pixel
k = 0
while k + 1 < len(levels) and base * 2 ** (k + 1) <= spp:
k += 1
lvl = levels[k]
bucket = base * 2 ** k
edges = np.linspace(s0 / bucket, s1 / bucket, w + 1)
starts = np.clip(edges[:-1].astype(np.int64), 0, len(lvl) - 1)
ends = np.clip(np.ceil(edges[1:]).astype(np.int64), 1, len(lvl))
ends = np.maximum(ends, starts + 1)
# reduceat spans starts[i]..starts[i+1]; trim so the last pixel stops at its own edge
view = lvl[:ends[-1]]
lo = np.minimum.reduceat(view[:, 0], starts)
hi = np.maximum.reduceat(view[:, 1], starts)
valid = (edges[:-1] >= 0) & (edges[:-1] < len(lvl))
lo = np.where(valid, np.clip(lo, -1, 1), 0)
hi = np.where(valid, np.clip(hi, -1, 1), 0)
mid = h / 2
amp = mid - 1
top = mid - hi * amp
bot = mid - lo * amp
# at least 1px tall so silence reads as a line
short = bot - top < 1
top = np.where(short, mid - 0.5, top)
bot = np.where(short, mid + 0.5, bot)
rows = np.arange(h, dtype=np.float32)[:, None] + 0.5
cov = np.clip(np.minimum(rows - top[None, :] + 0.5, bot[None, :] - rows + 0.5), 0, 1)
# brighter toward the centre line, like a lit glass bar
shade = 0.55 + 0.45 * (1 - np.abs(rows - mid) / mid)
rgba = np.empty((h, w, 4), np.float32)
rgba[..., :3] = color
rgba[..., 3] = cov * shade
return rgba