Files
stemify/stemroller/player/player.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

602 lines
23 KiB
Python

"""The queue and the mix: what plays, where its audio comes from, and how loud each stem is."""
import hashlib
import json
import math
import os
import random
import shutil
import subprocess
import threading
import urllib.request
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
from . import config
from .engine import Source
from .stems import COLORS, sort_names
UNITY = 0.8 # stem slider position for 0 dB (the phone uses the same taper)
WAVE_BUCKETS = 240
def stem_taper(v):
return (max(0.0, min(1.0, v)) / UNITY) ** 2
# One-click mixes, as on the phone. A preset is a function of the stems a song has, or None
# when the song lacks what it needs.
PRESETS = [
("Full Mix", lambda n: {}),
("Karaoke", lambda n: {"vocals": {"muted": True}} if "vocals" in n else None),
("A Cappella", lambda n: {"vocals": {"solo": True}} if "vocals" in n else None),
("No Drums", lambda n: {"drums": {"muted": True}} if "drums" in n else None),
("Rhythm", lambda n: {"drums": {"solo": True}, "bass": {"solo": True}} if "drums" in n and "bass" in n else None),
("No Guitar", lambda n: {"guitar": {"muted": True}} if "guitar" in n else None),
("No Bass", lambda n: {"bass": {"muted": True}} if "bass" in n else None),
]
def _setting(mix, name):
s = mix.get(name, {})
return s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
def _normalized(mix):
out = {}
for name, s in mix.items():
level, muted, solo = s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
if abs(level - UNITY) > 1e-3 or muted or solo:
out[name] = (round(level, 3), muted, solo)
return out
class Player(QObject):
changed = Signal() # track, queue, play state
mixChanged = Signal()
waveChanged = Signal()
toast = Signal(str)
_loaded = Signal(int, object, object) # token, Source or error, track
_wave = Signal(int, object)
def __init__(self, cfg, plex, stems, engine, parent=None):
super().__init__(parent)
self.cfg, self.plex, self.server, self.engine = cfg, plex, stems, engine
self.library = None
self.queue: list[dict] = []
self.index = -1
self._unshuffled = None
self.shuffle = False
self.repeat = "off" # off | all | one
self.mix: dict[str, dict] = {}
self.names: list[str] = []
self.source_kind = "" # stems | original
self._token = 0
self._loading = False
self._wave_raw = None # {"stems": {name: [..]}} or {"db": [..]}
self._scrobbled = False
self._pending_seek = 0.0
self._queue_dirty = False
self._loaded.connect(self._on_loaded)
self._wave.connect(self._on_wave)
engine.ended.connect(self._on_ended)
engine.playingChanged.connect(self._on_play_state)
engine.bufferingChanged.connect(self.changed)
engine.setVolume(cfg.get("volume", 0.8))
engine.volumeChanged.connect(self._save_soon)
self._report = QTimer(self, interval=10_000)
self._report.timeout.connect(self._tick)
self._report.start()
self._saver = QTimer(self, interval=1500, singleShot=True)
self._saver.timeout.connect(self.save)
self._restore()
# ---- state -------------------------------------------------------------------------
# The queue can be the whole library, so it lives in its own file, written off the GUI
# thread and only when it changes; the small state (position, mix) saves often.
QUEUE_PATH = os.path.join(config.CONFIG_DIR, "player-queue.json")
QUEUE_KEYS = ("key", "title", "artist", "albumArtist", "album", "albumKey", "artistKey", "year", "index", "disc",
"durationMs", "thumb", "partFile", "partKey", "codec", "stemKey", "_cover")
def _restore(self):
st = self.cfg.get("state") or {}
try:
with open(self.QUEUE_PATH) as f:
q = json.load(f)
self.queue, self._unshuffled = q.get("queue") or [], q.get("unshuffled")
except (OSError, ValueError):
self.queue, self._unshuffled = st.get("queue") or [], st.get("unshuffled")
self.index = st.get("index", -1) if self.queue else -1
self.shuffle = bool(st.get("shuffle"))
self.repeat = st.get("repeat", "off")
self.mix = st.get("mix") or {}
self._pending_seek = float(st.get("position") or 0)
if 0 <= self.index < len(self.queue):
QTimer.singleShot(0, lambda: self._load(self.index, play=False, start=self._pending_seek))
def save(self, wait=False):
self.cfg["volume"] = round(self.engine.volume, 3)
self.cfg["state"] = {
"index": self.index, "shuffle": self.shuffle, "repeat": self.repeat, "mix": self.mix,
"position": round(self.engine.position() if self.engine.duration else self._pending_seek, 2),
}
config.save(self.cfg)
if self._queue_dirty:
self._queue_dirty = False
slim = lambda ts: [{k: t[k] for k in self.QUEUE_KEYS if k in t} for t in ts] if ts is not None else None
data = {"queue": slim(self.queue), "unshuffled": slim(self._unshuffled)}
def write():
tmp = self.QUEUE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, self.QUEUE_PATH)
t = threading.Thread(target=write, daemon=not wait)
t.start()
if wait:
t.join()
def _save_soon(self):
self._saver.start()
def current(self):
return self.queue[self.index] if 0 <= self.index < len(self.queue) else None
def current_key(self):
t = self.current()
return t["key"] if t else ""
# ---- queue ---------------------------------------------------------------------------
def play_tracks(self, tracks, start=0, shuffle=False):
tracks = [dict(t) for t in tracks]
if not tracks:
return
self._unshuffled = list(tracks) if shuffle else None
if shuffle:
first = tracks.pop(start)
random.shuffle(tracks)
tracks.insert(0, first)
start = 0
self.shuffle = shuffle
self.queue = tracks
if not self.cfg.get("keepMix"):
self.mix = {}
self._load(start, play=True)
self._queue_changed()
def add_to_queue(self, tracks):
self.queue += [dict(t) for t in tracks]
if self.index < 0:
self._load(0, play=True)
self._queue_changed()
def play_next(self, tracks):
at = self.index + 1
self.queue[at:at] = [dict(t) for t in tracks]
if self.index < 0:
self._load(0, play=True)
self._queue_changed()
@Slot(int)
def removeAt(self, i):
if not (0 <= i < len(self.queue)):
return
del self.queue[i]
if i < self.index:
self.index -= 1
elif i == self.index:
if self.queue:
self._load(min(i, len(self.queue) - 1), play=self.engine.playing)
else:
self.index = -1
self.engine.clear()
self._queue_changed()
@Slot()
def clearQueue(self):
cur = self.current()
self.queue = [cur] if cur else []
self.index = 0 if cur else -1
self._unshuffled = None
self._queue_changed()
@Slot(int)
def skipTo(self, i):
if 0 <= i < len(self.queue):
self._load(i, play=True)
def _queue_changed(self):
self._queue_dirty = True
if self.library:
self.library.update_queue()
self.changed.emit()
self._save_soon()
# ---- transport -------------------------------------------------------------------------
@Slot()
def toggle(self):
if self.engine.duration:
self.engine.toggle()
elif self.current() and not self._loading:
self._load(self.index, play=True, start=self._pending_seek)
@Slot()
def next(self):
if not self.queue:
return
i = self.index + 1
if i >= len(self.queue):
if self.repeat != "all":
return
i = 0
self._load(i, play=True)
@Slot()
def previous(self):
if not self.queue:
return
if self.engine.position() > 3 or self.index <= 0:
self.engine.seek(0)
return
self._load(self.index - 1, play=True)
@Slot(float)
def seek(self, seconds):
if self.engine.duration:
self.engine.seek(max(0.0, min(seconds, self.engine.duration - 0.25)))
else:
self._pending_seek = seconds
@Slot()
def toggleShuffle(self):
cur = self.current()
if not self.shuffle:
self._unshuffled = list(self.queue)
rest = [t for i, t in enumerate(self.queue) if i != self.index]
random.shuffle(rest)
self.queue = ([cur] if cur else []) + rest
self.index = 0 if cur else -1
self.shuffle = True
else:
if self._unshuffled:
self.queue = self._unshuffled
keys = [t["key"] for t in self.queue]
self.index = keys.index(cur["key"]) if cur and cur["key"] in keys else 0
self._unshuffled = None
self.shuffle = False
self._queue_changed()
@Slot()
def cycleRepeat(self):
self.repeat = {"off": "all", "all": "one", "one": "off"}[self.repeat]
self.changed.emit()
self._save_soon()
def _on_ended(self):
cur = self.current()
if cur and not self._scrobbled:
self._scrobbled = True
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
if self.repeat == "one":
self._load(self.index, play=True)
elif self.index + 1 < len(self.queue) or self.repeat == "all":
self.next()
else:
self.engine.seek(0)
self.changed.emit()
def _on_play_state(self):
self._report_state("playing" if self.engine.playing else "paused")
self.changed.emit()
self._save_soon()
# ---- loading ------------------------------------------------------------------------------
def _load(self, i, play=True, start=0.0):
"""Opens queue[i] off the GUI thread; a newer request makes older ones moot."""
if not (0 <= i < len(self.queue)):
return
if self.engine.playing and self.current():
self._report_state("stopped")
self._token += 1
token = self._token
self.index = i
self._loading = True
self._scrobbled = False
self._wave_raw = None
track = self.queue[i]
if not self.cfg.get("keepMix") and start == 0.0:
self.mix = {}
self.engine.pause()
if self.library:
self.library.model.set_playing(track["key"], i)
self.changed.emit()
self.waveChanged.emit()
self._pending_seek = start
def work():
try:
src, entry = self._open(track)
self._loaded.emit(token, (src, play, start, entry), track)
except Exception as e: # noqa: BLE001 - reported, then skipped
self._loaded.emit(token, e, track)
threading.Thread(target=work, daemon=True).start()
def _open(self, track):
entry = self.server.index.by_key.get(track.get("stemKey") or "") or self.server.find(track)
if entry and "_cover" not in track:
track["_cover"] = self._cover(entry)
if entry:
stems = {s["name"]: s for s in entry["stems"]}
names = sort_names(list(stems))
paths = [self.server.local_path(entry, stems[n]) or self._download(self.server.file_url(entry, stems[n]), stems[n]["file"])
for n in names]
return Source(names, paths), entry
return Source(["mix"], [self._original_path(track)]), None
def _cover(self, entry):
"""The album folder's own cover on the music share: art for songs Plex doesn't know."""
root = self.cfg.get("musicDir", "")
folder = os.path.join(root, os.path.dirname(entry.get("source", ""))) if root else ""
try:
pics = [f for f in os.listdir(folder) if f.lower().endswith((".jpg", ".jpeg", ".png"))]
except OSError:
return ""
if pics:
pics.sort(key=lambda f: (not f.lower().startswith(("cover", "folder", "front")), f.lower()))
return "file://" + urllib.request.pathname2url(os.path.join(folder, pics[0]))
# no picture file: the art embedded in the song itself, pulled out once by ffmpeg
src = os.path.join(root, entry["source"])
out = os.path.join(config.CACHE_DIR, "art", hashlib.sha1(folder.encode()).hexdigest() + ".jpg")
if not os.path.exists(out) and os.path.exists(src) and shutil.which("ffmpeg"):
os.makedirs(os.path.dirname(out), exist_ok=True)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-an", "-frames:v", "1", "-vf", "scale=600:-2", out],
stdin=subprocess.DEVNULL, capture_output=True, timeout=20)
return "file://" + urllib.request.pathname2url(out) if os.path.exists(out) else ""
def _original_path(self, track):
"""The file on the music share when it's mounted, else a download from Plex."""
pf = track.get("partFile") or ""
local = self.cfg.get("musicDir", "")
if pf and local and os.path.isdir(local):
for loc in self.cfg.get("sectionLocations") or []:
if pf.startswith(loc.rstrip("/") + "/"):
p = os.path.join(local, pf[len(loc.rstrip("/")) + 1:])
if os.path.exists(p):
return p
parts = pf.replace("\\", "/").split("/")
for i in range(1, len(parts)):
p = os.path.join(local, *parts[i:])
if os.path.exists(p):
return p
if not track.get("partKey"):
raise OSError(f"No file for {track.get('title')}")
return self._download(self.plex.part_url(track["partKey"]), os.path.basename(pf) or "track")
def _download(self, url, name):
d = os.path.join(config.CACHE_DIR, "files")
os.makedirs(d, exist_ok=True)
ext = os.path.splitext(name)[1] or ".bin"
path = os.path.join(d, hashlib.sha1(url.split("X-Plex-Token")[0].encode()).hexdigest() + ext)
if os.path.exists(path):
os.utime(path)
return path
tmp = path + ".part"
req = urllib.request.Request(url, headers={"X-Stem-Token": self.cfg["stemToken"]} if self.cfg.get("stemToken") else {})
with urllib.request.urlopen(req, timeout=30) as r, open(tmp, "wb") as f:
while chunk := r.read(1 << 20):
f.write(chunk)
os.replace(tmp, path)
self._trim_cache(d)
return path
@staticmethod
def _trim_cache(d, limit=4 << 30):
files = [os.path.join(d, f) for f in os.listdir(d) if not f.endswith(".part")]
files.sort(key=lambda p: os.path.getmtime(p))
total = sum(os.path.getsize(p) for p in files)
while files and total > limit:
p = files.pop(0)
total -= os.path.getsize(p)
os.remove(p)
def _on_loaded(self, token, result, track):
if token != self._token:
if not isinstance(result, Exception):
result[0].close()
return
self._loading = False
if isinstance(result, Exception):
self.toast.emit(f"Couldn't play “{track.get('title')}”: {result}")
self.changed.emit()
if self.index + 1 < len(self.queue):
QTimer.singleShot(800, lambda t=token: t == self._token and self.next())
return
src, play, start, entry = result
self.names = src.names
self.source_kind = "stems" if entry else "original"
self.engine.set_source(src, start, play=play)
self._push_mix()
self.changed.emit()
self._load_wave(token, track, entry)
self._save_soon()
def _load_wave(self, token, track, entry):
def work():
w = None
if entry:
st = self.server.peaks(entry, WAVE_BUCKETS)
w = {"stems": st} if st else None
if w is None:
try:
db = self.plex.loudness(track["key"], WAVE_BUCKETS)
w = {"db": db} if db else None
except Exception: # noqa: BLE001 - no waveform is fine
w = None
self._wave.emit(token, w)
threading.Thread(target=work, daemon=True).start()
def _on_wave(self, token, w):
if token == self._token:
self._wave_raw = w
self.waveChanged.emit()
# ---- mix -----------------------------------------------------------------------------------
def _push_mix(self):
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
gains = []
for n in self.names:
level, muted, solo = _setting(self.mix, n)
gains.append(0.0 if (any_solo and not solo) or muted else stem_taper(level))
self.engine.set_gains(gains)
self.mixChanged.emit()
self.waveChanged.emit()
self._save_soon()
def _edit(self, i, **kw):
if 0 <= i < len(self.names):
n = self.names[i]
self.mix[n] = {**self.mix.get(n, {}), **kw}
self._push_mix()
@Slot(int, float)
def setLevel(self, i, v):
self._edit(i, level=max(0.0, min(1.0, v)))
@Slot(int)
def toggleMute(self, i):
if 0 <= i < len(self.names):
self._edit(i, muted=not _setting(self.mix, self.names[i])[1])
@Slot(int)
def toggleSolo(self, i):
if 0 <= i < len(self.names):
self._edit(i, solo=not _setting(self.mix, self.names[i])[2])
@Slot(int)
def applyPreset(self, p):
m = PRESETS[p][1](self.names)
if m is not None:
self.mix = {k: dict(v) for k, v in m.items()}
self._push_mix()
@Slot()
def toggleVocals(self):
"""Karaoke switch: vocals off, or back to the full mix."""
if "vocals" not in self.names:
return
self.applyPreset(0 if self._preset_index() == 1 else 1)
def _preset_index(self):
m = _normalized(self.mix)
for i, (_, build) in enumerate(PRESETS):
b = build(self.names)
if b is not None and _normalized(b) == m:
return i
return -1
# ---- reporting to Plex (play counts, "now playing" on the server) -------------------------------
def _report_state(self, state):
cur = self.current()
if cur:
pos = self.engine.position() * 1000
threading.Thread(target=self.plex.timeline, args=(cur, state, pos), daemon=True).start()
def _tick(self):
if self.engine.playing:
self._report_state("playing")
cur, d = self.current(), self.engine.duration
if cur and d and not self._scrobbled and self.engine.position() > 0.9 * d:
self._scrobbled = True
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
self.save()
def shutdown(self):
self._report_state("stopped")
self.save(wait=True)
# ---- QML properties -------------------------------------------------------------------------
def _t(self, k, default=""):
t = self.current()
return t.get(k, default) if t else default
def _get_track(self):
t = self.current()
if not t:
return {}
return {"title": t["title"], "artist": t["artist"], "album": t["album"], "year": t.get("year") or 0,
"art": self.plex.art_url(t.get("thumb"), 600) or t.get("_cover", ""), "durationMs": t.get("durationMs", 0),
"codec": t.get("codec", ""), "key": t["key"]}
def _get_status(self):
if self._loading:
return "loading"
if self.engine.buffering:
return "buffering"
return "playing" if self.engine.playing else ("paused" if self.current() else "")
def _get_stems(self):
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
out = []
for n in self.names:
level, muted, solo = _setting(self.mix, n)
out.append({"name": n, "label": "Original mix" if n == "mix" else n.capitalize(),
"color": COLORS.get(n, "#8E8E93"), "level": level, "muted": muted, "solo": solo,
"audible": not muted and (solo or not any_solo), "db": 20 * math.log10(max(1e-6, stem_taper(level)))})
return out
def _get_presets(self):
cur = self._preset_index()
return [{"label": label, "usable": build(self.names) is not None and self.source_kind == "stems", "active": i == cur}
for i, (label, build) in enumerate(PRESETS)]
def _get_preset_label(self):
if self.source_kind != "stems":
return ""
i = self._preset_index()
return PRESETS[i][0] if i >= 0 else "Custom Mix"
def _get_wave(self):
"""0..1 loudness per bucket for the current mix: stems weighted by their faders."""
w = self._wave_raw
if not w:
return []
if "stems" in w:
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
gains = {}
for n in w["stems"]:
level, muted, solo = _setting(self.mix, n)
gains[n] = 0.0 if (any_solo and not solo) or muted else stem_taper(level)
size = max(len(a) for a in w["stems"].values())
out = []
for i in range(size):
e = sum((a[i] if i < len(a) else 0.0) ** 2 * gains.get(n, 1.0) ** 2 for n, a in w["stems"].items())
out.append(_norm_db(20 * math.log10(math.sqrt(e) + 1e-6)))
return out
return [_norm_db(v) for v in w["db"]]
def _get_queue_info(self):
return {"index": self.index, "count": len(self.queue)}
track = Property("QVariantMap", _get_track, notify=changed)
status = Property(str, _get_status, notify=changed)
stems = Property("QVariantList", _get_stems, notify=mixChanged)
presets = Property("QVariantList", _get_presets, notify=mixChanged)
presetLabel = Property(str, _get_preset_label, notify=mixChanged)
sourceKind = Property(str, lambda self: self.source_kind, notify=changed)
wave = Property("QVariantList", _get_wave, notify=waveChanged)
shuffleOn = Property(bool, lambda self: self.shuffle, notify=changed)
repeatMode = Property(str, lambda self: self.repeat, notify=changed)
queueInfo = Property("QVariantMap", _get_queue_info, notify=changed)
keepMix = Property(bool, lambda self: bool(self.cfg.get("keepMix")), notify=mixChanged)
@Slot(bool)
def setKeepMix(self, on):
self.cfg["keepMix"] = on
self.mixChanged.emit()
self._save_soon()
def _norm_db(db):
"""Loudness to bar height, as on the phone: -34 dB and below is a stub, -6 dB and up full."""
return max(0.06, min(1.0, (db + 34) / 28))