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,268 @@
|
||||
"""Entry point: Qt Quick window, providers, controller, dev frame-time instrumentation."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _env_defaults():
|
||||
# Basic style so every control can take the Material x Aero skin.
|
||||
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic")
|
||||
# Native Wayland on GNOME; X11 still works if forced.
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "wayland;xcb")
|
||||
# Threaded render loop: rendering paces itself to vsync off the GUI thread.
|
||||
os.environ.setdefault("QSG_RENDER_LOOP", "threaded")
|
||||
|
||||
|
||||
class FrameStats:
|
||||
"""Frame stats for the dev overlay and --bench.
|
||||
|
||||
- frame time: sync + render work per frame, timed on the render thread.
|
||||
Hooks are only connected while the overlay is visible (a GIL hop per frame).
|
||||
- cadence: intervals from a QML FrameAnimation (one tick per presented
|
||||
frame), pushed in batches; a gap over 1.5x the vsync period is a miss.
|
||||
"""
|
||||
|
||||
def __init__(self, window, refresh_hz, render_hooks=False):
|
||||
self.refresh = refresh_hz or 240.0
|
||||
self.intervals = deque(maxlen=20000)
|
||||
self.misses = []
|
||||
self.work = deque(maxlen=20000)
|
||||
self._window = window
|
||||
self._sync_start = None
|
||||
self._pc = time.perf_counter_ns
|
||||
self._hooks = False
|
||||
if render_hooks:
|
||||
self.connect_hooks()
|
||||
|
||||
def connect_hooks(self):
|
||||
from PySide6.QtCore import Qt
|
||||
direct = Qt.ConnectionType.DirectConnection
|
||||
self._window.beforeSynchronizing.connect(self._before_sync, direct)
|
||||
self._window.afterRendering.connect(self._after_render, direct)
|
||||
self._hooks = True
|
||||
|
||||
def disconnect_hooks(self):
|
||||
if self._hooks:
|
||||
self._window.beforeSynchronizing.disconnect(self._before_sync)
|
||||
self._window.afterRendering.disconnect(self._after_render)
|
||||
self._hooks = False
|
||||
|
||||
def _before_sync(self):
|
||||
self._sync_start = self._pc()
|
||||
|
||||
def _after_render(self):
|
||||
if self._sync_start is not None:
|
||||
self.work.append((self._pc() - self._sync_start) / 1e6)
|
||||
|
||||
def push(self, intervals_ms):
|
||||
self.intervals.extend(d for d in intervals_ms if 0 < d < 250)
|
||||
|
||||
def miss(self, dt_ms):
|
||||
self.misses.append((time.monotonic(), round(dt_ms, 2)))
|
||||
|
||||
def reset(self):
|
||||
self.intervals.clear()
|
||||
self.work.clear()
|
||||
self.misses.clear()
|
||||
self.t0 = time.monotonic()
|
||||
|
||||
def summary(self, last=None):
|
||||
def pct(seq, q):
|
||||
a = sorted(seq)
|
||||
return a[min(len(a) - 1, int(q * len(a)))] if a else 0.0
|
||||
iv = list(self.intervals)[-last:] if last else list(self.intervals)
|
||||
wk = list(self.work)[-last:] if last else list(self.work)
|
||||
budget = 1000.0 / self.refresh
|
||||
missed = sum(1 for d in iv if d > budget * 1.5)
|
||||
return {
|
||||
"refresh_hz": round(self.refresh, 2), "frames": len(iv),
|
||||
"fps": round(1000.0 / (sum(iv) / len(iv)), 1) if iv else 0.0,
|
||||
"interval_p50": round(pct(iv, 0.5), 3), "interval_p99": round(pct(iv, 0.99), 3),
|
||||
"interval_max": round(max(iv), 3) if iv else 0.0,
|
||||
"work_p50": round(pct(wk, 0.5), 3) if wk else None,
|
||||
"work_p99": round(pct(wk, 0.99), 3) if wk else None,
|
||||
"missed": missed, "budget_ms": round(budget, 3),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
_env_defaults()
|
||||
ap = argparse.ArgumentParser(prog="stemroller")
|
||||
ap.add_argument("files", nargs="*", help="audio files to queue on launch")
|
||||
ap.add_argument("--frame-overlay", action="store_true", help="show p50/p99 frame-time overlay (F12 toggles)")
|
||||
ap.add_argument("--bench", type=float, default=0, metavar="SECONDS",
|
||||
help="record frame stats for N seconds, print JSON, and quit")
|
||||
ap.add_argument("--bench-delay", type=float, default=2.0)
|
||||
ap.add_argument("--bench-out", default="")
|
||||
ap.add_argument("--screenshot", action="append", default=[], metavar="PNG@SECONDS",
|
||||
help="grab the window to PNG after N seconds (repeatable)")
|
||||
ap.add_argument("--size", default="", metavar="WxH")
|
||||
ap.add_argument("--autoplay", action="store_true", help="start playback as soon as stems load (dev)")
|
||||
args = ap.parse_args()
|
||||
|
||||
from PySide6.QtCore import QTimer, QUrl
|
||||
from PySide6.QtGui import QFontDatabase, QGuiApplication, QIcon, QSurfaceFormat
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuick import QQuickWindow
|
||||
|
||||
fmt = QSurfaceFormat.defaultFormat()
|
||||
fmt.setSwapInterval(1) # vsync: pace to the panel (240Hz), never a fixed timer
|
||||
fmt.setAlphaBufferSize(8) # rounded, translucent window corners
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
QQuickWindow.setDefaultAlphaBuffer(True)
|
||||
|
||||
app = QGuiApplication(sys.argv)
|
||||
app.setApplicationName("StemRoller")
|
||||
app.setOrganizationName("stemroller")
|
||||
app.setDesktopFileName("stemroller")
|
||||
app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "stemroller.svg")))
|
||||
|
||||
font_dir = os.path.join(HERE, "assets", "fonts")
|
||||
if os.path.isdir(font_dir):
|
||||
for f in sorted(os.listdir(font_dir)):
|
||||
if f.endswith((".ttf", ".otf")):
|
||||
QFontDatabase.addApplicationFont(os.path.join(font_dir, f))
|
||||
families = set(QFontDatabase.families())
|
||||
ui_font = "Segoe UI Variable Text" if "Segoe UI Variable Text" in families else \
|
||||
"Segoe UI Variable" if "Segoe UI Variable" in families else "Inter"
|
||||
|
||||
from .audio_engine import AudioEngine
|
||||
from .controller import Controller
|
||||
from .providers import FxProvider, IconProvider, PeakStore, WaveformProvider
|
||||
|
||||
audio = AudioEngine()
|
||||
peaks = PeakStore()
|
||||
controller = Controller(audio, peaks)
|
||||
|
||||
engine = QQmlApplicationEngine()
|
||||
engine.addImageProvider("icon", IconProvider())
|
||||
engine.addImageProvider("fx", FxProvider())
|
||||
engine.addImageProvider("wave", WaveformProvider(peaks, controller.peaks_path_for))
|
||||
engine.addImportPath(os.path.join(HERE, "qml"))
|
||||
ctx = engine.rootContext()
|
||||
ctx.setContextProperty("app", controller)
|
||||
ctx.setContextProperty("appSettings", controller.settings)
|
||||
ctx.setContextProperty("jobsModel", controller.jobs)
|
||||
ctx.setContextProperty("audio", audio)
|
||||
ctx.setContextProperty("uiFont", ui_font)
|
||||
ctx.setContextProperty("devOverlay", bool(args.frame_overlay or args.bench))
|
||||
from PySide6.QtCore import QObject, Slot
|
||||
|
||||
class StatsBridge(QObject):
|
||||
stats = None
|
||||
|
||||
@Slot("QVariantList")
|
||||
def push(self, intervals):
|
||||
if self.stats is not None:
|
||||
self.stats.push(intervals)
|
||||
|
||||
@Slot(result="QVariantMap")
|
||||
def summary(self):
|
||||
return self.stats.summary(last=int(self.stats.refresh)) if self.stats else {} # last ~1s
|
||||
|
||||
@Slot()
|
||||
def reset(self):
|
||||
if self.stats is not None:
|
||||
self.stats.reset()
|
||||
|
||||
@Slot(float)
|
||||
def miss(self, dt_ms):
|
||||
if self.stats is not None:
|
||||
self.stats.miss(dt_ms)
|
||||
|
||||
@Slot(bool)
|
||||
def setHooks(self, on):
|
||||
if self.stats is None:
|
||||
return
|
||||
if on and not self.stats._hooks:
|
||||
self.stats.connect_hooks()
|
||||
elif not on:
|
||||
self.stats.disconnect_hooks()
|
||||
|
||||
bridge = StatsBridge()
|
||||
ctx.setContextProperty("frameStats", bridge)
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "qml", "StemRoller", "Main.qml")))
|
||||
if not engine.rootObjects():
|
||||
print("failed to load QML", file=sys.stderr)
|
||||
controller.shutdown()
|
||||
return 1
|
||||
window = engine.rootObjects()[0]
|
||||
if args.size:
|
||||
w, h = (int(v) for v in args.size.split("x"))
|
||||
window.resize(w, h)
|
||||
|
||||
screen = window.screen()
|
||||
stats = FrameStats(window, screen.refreshRate() if screen else 240.0)
|
||||
bridge.stats = stats
|
||||
# Render-thread hooks need the GIL; only attach once the event loop owns the
|
||||
# GUI thread (Python calls that wait on the render thread would deadlock).
|
||||
if args.frame_overlay or args.bench or controller.settings.get("frameOverlay"):
|
||||
QTimer.singleShot(0, lambda: stats._hooks or stats.connect_hooks())
|
||||
window.screenChanged.connect(lambda sc: setattr(stats, "refresh", sc.refreshRate() if sc else stats.refresh))
|
||||
|
||||
|
||||
if args.autoplay:
|
||||
audio.loadedChanged.connect(lambda: audio.loaded and QTimer.singleShot(300, audio.play))
|
||||
|
||||
if args.files:
|
||||
QTimer.singleShot(0, lambda: controller.addUrls([os.path.abspath(f) for f in args.files]))
|
||||
|
||||
if args.bench:
|
||||
def start_bench():
|
||||
stats.reset()
|
||||
QTimer.singleShot(int(args.bench * 1000), finish_bench)
|
||||
|
||||
def finish_bench():
|
||||
s = stats.summary()
|
||||
s["worker_state"] = controller._get_worker_state()
|
||||
s["misses_at"] = [(round(t - stats.t0, 3), d) for t, d in stats.misses if t >= stats.t0]
|
||||
out = json.dumps(s, indent=2)
|
||||
print(out, flush=True)
|
||||
if args.bench_out:
|
||||
with open(args.bench_out, "w") as f:
|
||||
f.write(out)
|
||||
stats.disconnect_hooks()
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(int(args.bench_delay * 1000), start_bench)
|
||||
|
||||
for shot in args.screenshot:
|
||||
path, _, after = shot.rpartition("@")
|
||||
def grab(path=path):
|
||||
# grabWindow blocks on the render thread while holding the GIL, so the
|
||||
# render-thread hooks must be gone first. A hook call already in flight
|
||||
# can still be waiting for the GIL after disconnect; yield to the event
|
||||
# loop for a few frames so it drains before blocking.
|
||||
hooks = stats._hooks
|
||||
stats.disconnect_hooks()
|
||||
|
||||
def do_grab():
|
||||
window.grabWindow().save(path)
|
||||
if hooks:
|
||||
stats.connect_hooks()
|
||||
print("screenshot saved", path, flush=True)
|
||||
QTimer.singleShot(100, do_grab)
|
||||
QTimer.singleShot(int(float(after) * 1000), grab)
|
||||
|
||||
signal.signal(signal.SIGINT, lambda *_: app.quit())
|
||||
# Let Python see SIGINT while Qt's loop runs, without a fixed-rate redraw timer.
|
||||
keepalive = QTimer()
|
||||
keepalive.start(500)
|
||||
keepalive.timeout.connect(lambda: None)
|
||||
|
||||
app.aboutToQuit.connect(stats.disconnect_hooks)
|
||||
app.aboutToQuit.connect(controller.shutdown)
|
||||
rc = app.exec()
|
||||
del engine
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user