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

+112
View File
@@ -0,0 +1,112 @@
"""UI-side owner of the separation worker process and its socket."""
import json
import os
import sys
from PySide6.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
class WorkerClient(QObject):
stateChanged = Signal(str) # starting | ready | crashed
hello = Signal(dict)
vram = Signal(str, int, int) # device, free_mb, total_mb
progress = Signal(str, float, str) # id, pct, stage
done = Signal(str, dict)
failed = Signal(str, str) # id, message
cancelled = Signal(str)
crashed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
self._sock_path = os.path.join(runtime, f"stemroller-{os.getpid()}.sock")
QLocalServer.removeServer(self._sock_path)
self._server = QLocalServer(self)
self._server.newConnection.connect(self._on_connection)
if not self._server.listen(self._sock_path):
raise RuntimeError(f"cannot listen on {self._sock_path}: {self._server.errorString()}")
self._sock: QLocalSocket | None = None
self._buf = b""
self._proc: QProcess | None = None
self._quitting = False
self.state = "starting"
self._restart_delay = 500
def start(self):
self._set_state("starting")
proc = QProcess(self)
env = QProcessEnvironment.systemEnvironment()
pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env.insert("PYTHONPATH", pkg_root + os.pathsep + env.value("PYTHONPATH", ""))
env.insert("PYTHONUNBUFFERED", "1")
proc.setProcessEnvironment(env)
proc.setProcessChannelMode(QProcess.ProcessChannelMode.ForwardedChannels)
proc.finished.connect(self._on_finished)
proc.start(sys.executable, ["-m", "stemroller.worker", "--socket", self._sock_path])
self._proc = proc
def send(self, type_, **payload):
if self._sock and self._sock.state() == QLocalSocket.LocalSocketState.ConnectedState:
self._sock.write((json.dumps({"type": type_, **payload}) + "\n").encode())
self._sock.flush()
return True
return False
def pid(self):
return int(self._proc.processId()) if self._proc else 0
def shutdown(self):
self._quitting = True
self.send("quit")
if self._proc and self._proc.state() != QProcess.ProcessState.NotRunning:
if not self._proc.waitForFinished(1500):
self._proc.kill()
self._proc.waitForFinished(1000)
self._server.close()
def _set_state(self, s):
self.state = s
self.stateChanged.emit(s)
def _on_connection(self):
sock = self._server.nextPendingConnection()
if self._sock:
self._sock.deleteLater()
self._sock = sock
self._buf = b""
sock.readyRead.connect(self._on_ready_read)
def _on_ready_read(self):
self._buf += bytes(self._sock.readAll())
while b"\n" in self._buf:
line, self._buf = self._buf.split(b"\n", 1)
if line.strip():
self._dispatch(json.loads(line))
def _dispatch(self, msg):
t = msg.pop("type")
if t == "hello":
self._restart_delay = 500
self._set_state("ready")
self.hello.emit(msg)
elif t == "vram":
self.vram.emit(msg["device"], msg["free_mb"], msg["total_mb"])
elif t == "progress":
self.progress.emit(msg["id"], msg["pct"], msg["stage"])
elif t == "done":
self.done.emit(msg.pop("id"), msg)
elif t == "error":
self.failed.emit(msg.get("id") or "", msg.get("msg", "unknown error"))
elif t == "cancelled":
self.cancelled.emit(msg["id"])
def _on_finished(self, code, status):
self._sock = None
if self._quitting:
return
self._set_state("crashed")
self.crashed.emit()
QTimer.singleShot(self._restart_delay, self.start)
self._restart_delay = min(self._restart_delay * 2, 10000)