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

154 lines
5.7 KiB
Python

"""stemserve's index, matched to Plex tracks by path (a port of the phone app's StemIndex)."""
import json
import os
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
# Display order and colours, matching the desktop separator and the phone.
ORDER = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
COLORS = {
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6", "mix": "#8E8E93",
}
def sort_names(names):
return sorted(names, key=lambda n: ORDER.index(n) if n in ORDER else 99)
def norm(path: str) -> str:
"""NFC, forward slashes, no extension, lower case."""
p = unicodedata.normalize("NFC", path).replace("\\", "/")
slash, dot = p.rfind("/"), p.rfind(".")
if dot > slash + 1:
p = p[:dot]
return p.lower()
class StemIndex:
"""Maps Plex file paths to stem folders.
The stems folder mirrors the music folder, so the source path relative to the music root
identifies a track. Plex reports paths as its container sees them, so the root is unknown
up front: every suffix of the Plex path is tried, longest first. A bare file name, or a
suffix whose duration disagrees, only counts under a root learned from a longer match.
"""
def __init__(self, entries=(), roots=()):
self.entries = list(entries)
self.by_source = {}
self.by_key = {}
for e in self.entries:
self.by_source.setdefault(norm(e["source"]), e)
self.by_key[e["key"]] = e
self.roots = list(dict.fromkeys(roots))
def __len__(self):
return len(self.entries)
def find(self, plex_path, duration_ms=0):
if not plex_path or not self.entries:
return None
path = norm(plex_path)
for root in self.roots:
if path.startswith(root):
hit = self.by_source.get(path[len(root):])
if hit:
return hit
parts = path.split("/")
for i in range(1, len(parts)):
hit = self.by_source.get("/".join(parts[i:]))
if hit is None:
continue
if len(parts) - i >= 2 and self._duration_ok(hit, duration_ms):
self.roots.append("/".join(parts[:i]) + "/")
return hit
return None
@staticmethod
def _duration_ok(e, duration_ms):
d = e.get("duration")
return d is None or duration_ms <= 0 or abs(d * 1000 - duration_ms) < 3000
class StemServer:
"""Client for server/stemserve.py. Files come from the local mount when it's there."""
def __init__(self, config, cache_dir):
self.cfg = config
self.cache_dir = cache_dir
self.index = StemIndex(roots=config.get("stemRoots", []))
self.online = None # not checked yet
self._index_file = os.path.join(cache_dir, "stem_index.json")
self._etag_file = os.path.join(cache_dir, "stem_index.etag")
try:
with open(self._index_file) as f:
self.index = StemIndex(json.load(f).get("tracks", []), self.index.roots)
except (OSError, ValueError):
pass
@property
def base(self):
return self.cfg["stemUrl"].rstrip("/")
def _open(self, url, headers=None, timeout=15):
h = dict(headers or {})
if self.cfg.get("stemToken"):
h["X-Stem-Token"] = self.cfg["stemToken"]
return urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout)
def refresh(self):
"""Fetches the index (ETag-cached). Returns True if it changed."""
headers = {}
if os.path.exists(self._etag_file) and os.path.exists(self._index_file):
with open(self._etag_file) as f:
headers["If-None-Match"] = f.read().strip()
try:
with self._open(self.base + "/api/index", headers) as r:
body = r.read()
etag = r.headers.get("ETag")
except urllib.error.HTTPError as e:
self.online = e.code == 304
return False
except (urllib.error.URLError, TimeoutError, OSError):
self.online = False
return False
self.online = True
data = json.loads(body)
self.index = StemIndex(data.get("tracks", []), self.index.roots)
os.makedirs(self.cache_dir, exist_ok=True)
with open(self._index_file, "wb") as f:
f.write(body)
if etag:
with open(self._etag_file, "w") as f:
f.write(etag)
return True
def find(self, track):
hit = self.index.find(track.get("partFile"), track.get("durationMs", 0))
if self.index.roots != self.cfg.get("stemRoots"):
self.cfg["stemRoots"] = list(self.index.roots)
return hit
def local_path(self, entry, stem):
root = self.cfg.get("stemsDir", "")
p = os.path.join(root, entry["key"], stem["file"]) if root else ""
return p if p and os.path.exists(p) else None
def file_url(self, entry, stem):
path = "/".join(urllib.parse.quote(p) for p in entry["key"].split("/"))
return f"{self.base}/file/{path}/{urllib.parse.quote(stem['file'])}"
def peaks(self, entry, buckets):
"""Per-stem RMS envelopes (0..1), or None."""
url = f"{self.base}/api/peaks?" + urllib.parse.urlencode({"key": entry["key"], "n": buckets})
try:
with self._open(url, timeout=30) as r:
stems = json.loads(r.read()).get("stems", {})
return {name: [v / 1000 for v in arr] for name, arr in stems.items()}
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None