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

+171
View File
@@ -0,0 +1,171 @@
"""MPRIS on the session bus: media keys, the desktop's media controls, and KDE Connect on the phone."""
import hashlib
from PySide6.QtCore import ClassInfo, Property, QObject, Slot
from PySide6.QtDBus import QDBusAbstractAdaptor, QDBusConnection, QDBusMessage, QDBusObjectPath
PATH = "/org/mpris/MediaPlayer2"
ROOT_IFACE = "org.mpris.MediaPlayer2"
PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
@ClassInfo({"D-Bus Interface": ROOT_IFACE})
class _Root(QDBusAbstractAdaptor):
def __init__(self, parent, window_raise, quit_):
super().__init__(parent)
self._raise, self._quit = window_raise, quit_
@Slot()
def Raise(self):
self._raise()
@Slot()
def Quit(self):
self._quit()
CanQuit = Property(bool, lambda self: True)
CanRaise = Property(bool, lambda self: True)
HasTrackList = Property(bool, lambda self: False)
Identity = Property(str, lambda self: "StemRoller Player")
DesktopEntry = Property(str, lambda self: "stemroller-player")
SupportedUriSchemes = Property("QStringList", lambda self: [])
SupportedMimeTypes = Property("QStringList", lambda self: [])
@ClassInfo({"D-Bus Interface": PLAYER_IFACE})
class _Player(QDBusAbstractAdaptor):
def __init__(self, parent, mpris):
super().__init__(parent)
self.m = mpris
@Slot()
def Next(self):
self.m.player.next()
@Slot()
def Previous(self):
self.m.player.previous()
@Slot()
def Pause(self):
self.m.engine.pause()
@Slot()
def PlayPause(self):
self.m.player.toggle()
@Slot()
def Stop(self):
self.m.engine.pause()
@Slot()
def Play(self):
if not self.m.engine.playing:
self.m.player.toggle()
@Slot("qlonglong")
def Seek(self, offset_us):
self.m.player.seek(self.m.engine.position() + offset_us / 1e6)
@Slot(QDBusObjectPath, "qlonglong")
def SetPosition(self, track_id, position_us):
self.m.player.seek(position_us / 1e6)
@Slot(str)
def OpenUri(self, uri):
pass
def _status(self):
s = self.m.player._get_status()
return "Playing" if s in ("playing", "buffering") or (s == "loading" and self.m.engine.playing) else \
"Paused" if self.m.player.current() else "Stopped"
def _volume(self):
return float(self.m.engine.volume)
def _set_volume(self, v):
self.m.engine.setVolume(max(0.0, min(1.0, v)))
PlaybackStatus = Property(str, _status)
LoopStatus = Property(str, lambda self: {"off": "None", "all": "Playlist", "one": "Track"}[self.m.player.repeat])
Rate = Property(float, lambda self: 1.0)
Shuffle = Property(bool, lambda self: self.m.player.shuffle)
Metadata = Property("QVariantMap", lambda self: self.m.metadata())
Volume = Property(float, _volume, _set_volume)
Position = Property("qlonglong", lambda self: int(self.m.engine.position() * 1e6))
MinimumRate = Property(float, lambda self: 1.0)
MaximumRate = Property(float, lambda self: 1.0)
CanGoNext = Property(bool, lambda self: bool(self.m.player.queue))
CanGoPrevious = Property(bool, lambda self: bool(self.m.player.queue))
CanPlay = Property(bool, lambda self: bool(self.m.player.queue))
CanPause = Property(bool, lambda self: True)
CanSeek = Property(bool, lambda self: True)
CanControl = Property(bool, lambda self: True)
class Mpris(QObject):
def __init__(self, player, engine, window_raise, quit_, parent=None):
super().__init__(parent)
self.player, self.engine = player, engine
self._root = _Root(self, window_raise, quit_)
self._adaptor = _Player(self, self)
self.bus = QDBusConnection.sessionBus()
self.ok = self.bus.isConnected() and self.bus.registerObject(PATH, self) and \
self.bus.registerService("org.mpris.MediaPlayer2.stemroller_player")
self._last = {}
player.changed.connect(self._changed)
player.mixChanged.connect(self._changed)
engine.volumeChanged.connect(self._changed)
engine.playingChanged.connect(self._changed)
def metadata(self):
t = self.player.current()
if not t:
return {"mpris:trackid": QDBusObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")}
artist = t["artist"]
label = self.player._get_preset_label()
if label and label != "Full Mix":
artist = f"{artist} · {label}" # the mix rides on the artist line, as on the phone
md = {
"mpris:trackid": QDBusObjectPath("/stemroller/track/t" + hashlib.sha1(t["key"].encode()).hexdigest()[:16]),
"mpris:length": int((self.engine.duration or t.get("durationMs", 0) / 1000) * 1e6),
"xesam:title": t["title"],
"xesam:artist": [artist],
"xesam:album": t["album"],
"xesam:albumArtist": [t.get("albumArtist") or t["artist"]],
}
art = self.player.plex.art_url(t.get("thumb"), 600) or t.get("_cover", "")
if art:
md["mpris:artUrl"] = art
if t.get("index"):
md["xesam:trackNumber"] = t["index"]
return md
def _changed(self):
if not self.ok:
return
now = {
"PlaybackStatus": self._adaptor._status(),
"LoopStatus": self._adaptor.LoopStatus,
"Shuffle": self.player.shuffle,
"Volume": float(self.engine.volume),
"Metadata": self.metadata(),
"CanGoNext": bool(self.player.queue),
"CanGoPrevious": bool(self.player.queue),
"CanPlay": bool(self.player.queue),
}
diff = {k: v for k, v in now.items() if k == "Metadata" and _md_key(v) != _md_key(self._last.get(k)) or
k != "Metadata" and self._last.get(k) != v}
self._last = now
if not diff:
return
msg = QDBusMessage.createSignal(PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
msg.setArguments([PLAYER_IFACE, diff, []])
self.bus.send(msg)
def _md_key(md):
if not md:
return None
return tuple((k, str(v.path()) if isinstance(v, QDBusObjectPath) else repr(v)) for k, v in sorted(md.items()))