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:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user