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

449 lines
16 KiB
Python

"""Streaming playback: a decoder thread reads every stem ahead of the playhead in chunks,
and the PortAudio callback mixes them with per-stem gains and the master volume.
Nothing is decoded whole, so a song starts as soon as its first chunk is read (stems sit on
the network share) and memory stays flat. The callback never takes a lock: chunks travel
through a deque tagged with a generation number, and a seek or a new song bumps the
generation so stale chunks are dropped on the floor.
"""
import collections
import json
import subprocess
import threading
import numpy as np
import sounddevice as sd
import soundfile as sf
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
CHUNK = 8192 # frames per decoder read
AHEAD = 1.5 # seconds buffered ahead of the playhead
BLOCK = 1024
LATENCY = 0.15 # roomy: Python shares the GIL with library loading
RING = 64
class SfReader:
"""libsndfile: FLAC, WAV, AIFF, Ogg, MP3."""
def __init__(self, path):
self.f = sf.SoundFile(path)
self.sr = self.f.samplerate
self.frames = self.f.frames
def read(self, n):
data = self.f.read(n, dtype="float32", always_2d=True)
if data.shape[1] == 1:
return np.repeat(data, 2, axis=1)
return data[:, :2]
def seek(self, frame):
self.f.seek(min(frame, self.frames))
def close(self):
self.f.close()
class FfmpegReader:
"""Anything else (AAC/ALAC in .m4a and friends), decoded by ffmpeg to float stereo."""
def __init__(self, path, sr=None):
self.path = path
info = json.loads(subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate:format=duration",
"-of", "json", path], capture_output=True, check=True, timeout=20).stdout)
self.sr = sr or int(info["streams"][0]["sample_rate"])
self.frames = int(float(info["format"]["duration"]) * self.sr)
self.proc = None
self.seek(0)
def seek(self, frame):
self.close()
self.proc = subprocess.Popen(
["ffmpeg", "-v", "error", "-ss", f"{frame / self.sr:.6f}", "-i", self.path, "-vn",
"-f", "f32le", "-ac", "2", "-ar", str(self.sr), "-"],
stdout=subprocess.PIPE, stdin=subprocess.DEVNULL)
def read(self, n):
raw = self.proc.stdout.read(n * 8)
usable = len(raw) - len(raw) % 8
return np.frombuffer(raw[:usable], np.float32).reshape(-1, 2)
def close(self):
if self.proc:
self.proc.kill()
self.proc.stdout.close()
self.proc.wait()
self.proc = None
def open_reader(path, sr=None):
if sr is None:
try:
return SfReader(path)
except (sf.LibsndfileError, RuntimeError):
pass
return FfmpegReader(path, sr)
class Source:
"""One song: one reader per stem (or a single reader for the original file)."""
def __init__(self, names, paths):
self.names = list(names)
self.readers = []
try:
for p in paths:
self.readers.append(open_reader(p))
sr = self.readers[0].sr
# Stems always share a rate; if one didn't, resample it through ffmpeg.
for i, (r, p) in enumerate(zip(self.readers, paths)):
if r.sr != sr:
r.close()
self.readers[i] = open_reader(p, sr)
except Exception:
self.close()
raise
self.sr = sr
self.frames = min(r.frames for r in self.readers)
def read(self, n):
"""(stems, frames, 2) float32; fewer frames near the end, none at EOF."""
blocks = [r.read(n) for r in self.readers]
m = min(b.shape[0] for b in blocks)
return np.stack([b[:m] for b in blocks])
def seek(self, frame):
for r in self.readers:
r.seek(frame)
def close(self):
for r in self.readers:
try:
r.close()
except Exception:
pass
self.readers = []
class PlayerEngine(QObject):
playingChanged = Signal()
bufferingChanged = Signal()
volumeChanged = Signal()
durationChanged = Signal()
ended = Signal()
failed = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._src: Source | None = None
self._sr = 44100
self._frames = 0
self._gen = 0
self._gen_start = 0 # frame the current generation starts at
self._queue = collections.deque()
self._produced = 0
self._consumed = 0
self._eof = False
self._seek_to = None
self._to_close = []
self._cond = threading.Condition()
self._alive = True
self._playing = False
self._buffering = False
self._ended_flag = False
self._stream: sd.OutputStream | None = None
self._gains = np.ones(0, np.float32)
self._gains_cur = np.ones(0, np.float32)
self._volume = 0.8
self._master = self._volume ** 3
self._master_cur = self._master
# callback-owned
self._cb_gen = -1
self._cur = None
self._cur_off = 0
self._pos = 0
self._underrun = False
self._mix = np.zeros((BLOCK * 8, 2), np.float32)
self._ramp = np.zeros(0, np.float32)
self._ring = np.zeros((RING, 4), np.float64)
self._ring_i = 0
self._thread = threading.Thread(target=self._decode_loop, daemon=True, name="decoder")
self._thread.start()
# The callback only sets flags; this turns them into signals on the GUI thread.
self._tick = QTimer(self)
self._tick.setInterval(100)
self._tick.timeout.connect(self._poll)
self._tick.start()
# ---- decoder thread --------------------------------------------------------------
def _decode_loop(self):
while True:
with self._cond:
while self._alive and not self._to_close and self._seek_to is None and (
self._src is None or self._eof
or self._produced - self._consumed >= AHEAD * self._sr):
self._cond.wait(0.25)
if not self._alive:
return
closing, self._to_close = self._to_close, []
src, gen, seek = self._src, self._gen, self._seek_to
self._seek_to = None
for s in closing:
s.close()
if src is None:
continue
try:
if seek is not None:
src.seek(seek)
block = src.read(CHUNK)
except Exception as e: # unreadable file mid-song: treat as its end
print("decode error:", e, flush=True)
block = np.zeros((len(src.names), 0, 2), np.float32)
with self._cond:
if gen != self._gen:
continue
if block.shape[1] == 0:
self._eof = True
self._queue.append((gen, None))
else:
self._queue.append((gen, block))
self._produced += block.shape[1]
# ---- GUI thread API -----------------------------------------------------------------
def set_source(self, src: Source, start_seconds=0.0, play=True):
"""Replaces the playing song. Takes ownership of [src]."""
if self._stream is not None and self._stream.samplerate != src.sr:
self._close_stream()
with self._cond:
if self._src is not None:
self._to_close.append(self._src)
self._src = src
self._sr = src.sr
self._frames = src.frames
k = len(src.names)
if len(self._gains) != k:
self._gains = np.ones(k, np.float32)
self._gains_cur = self._gains.copy()
self._restart_locked(int(start_seconds * src.sr))
self._ended_flag = False
self.durationChanged.emit()
if play:
self.play()
def clear(self):
self.pause()
with self._cond:
if self._src is not None:
self._to_close.append(self._src)
self._src = None
self._frames = 0
self._restart_locked(0)
self._cond.notify()
self.durationChanged.emit()
def _restart_locked(self, frame):
frame = max(0, min(frame, max(0, self._frames - 1)))
self._gen += 1
self._gen_start = frame
self._queue.clear()
self._produced = self._consumed = 0
self._eof = False
self._seek_to = frame
self._ring[:] = 0
self._cond.notify()
@Slot()
def play(self):
if self._src is None or self._playing:
return
if self._stream is None:
self._stream = sd.OutputStream(samplerate=self._sr, channels=2, dtype="float32", blocksize=BLOCK,
latency=LATENCY, callback=self._callback)
self._playing = True
self._ring[:] = 0
self._stream.start()
self.playingChanged.emit()
@Slot()
def pause(self):
if not self._playing:
return
heard = self._heard_frame()
self._playing = False
if self._stream is not None:
self._stream.abort() # instant; what was queued but unheard is decoded again below
with self._cond:
self._restart_locked(int(heard))
self.playingChanged.emit()
@Slot()
def toggle(self):
self.pause() if self._playing else self.play()
@Slot(float)
def seek(self, seconds):
with self._cond:
self._restart_locked(int(seconds * self._sr))
self._ended_flag = False
def set_gains(self, gains):
g = np.asarray(gains, np.float32)
if len(g) == len(self._gains):
self._gains = g
@Slot(float)
def setVolume(self, v):
v = max(0.0, min(1.0, float(v)))
if v != self._volume:
self._volume = v
self._master = v ** 3 # cubic taper: even loudness steps along the slider
self.volumeChanged.emit()
def shutdown(self):
self._close_stream()
with self._cond:
self._alive = False
if self._src is not None:
self._src.close()
self._cond.notify()
def _close_stream(self):
if self._stream is not None:
try:
self._stream.abort()
self._stream.close()
except Exception:
pass
self._stream = None
if self._playing:
self._playing = False
self.playingChanged.emit()
def _poll(self):
# Wait for the device to play out what's queued, or the song's last moment is cut.
if self._ended_flag and self._playing and self._heard_frame() >= self._pos - BLOCK:
self._ended_flag = False
self._playing = False
if self._stream is not None:
self._stream.abort()
self.playingChanged.emit()
self.ended.emit()
return
b = self._playing and self._underrun and not self._eof
if b != self._buffering:
self._buffering = b
self.bufferingChanged.emit()
# ---- audio thread -----------------------------------------------------------------
def _callback(self, outdata, frames, time_info, status):
gen = self._gen
if gen != self._cb_gen:
self._cb_gen = gen
self._cur = None
self._pos = self._gen_start
mix = self._mix[:frames]
mix.fill(0)
start_pos = self._pos
filled = 0
g1 = self._gains
g0 = self._gains_cur if len(self._gains_cur) == len(g1) else g1
ramping = not np.array_equal(g0, g1)
if ramping and len(self._ramp) != frames:
self._ramp = np.linspace(0.0, 1.0, frames, dtype=np.float32)
q = self._queue
while self._playing and filled < frames:
if self._cur is None:
if not q:
break
item_gen, block = q.popleft()
if item_gen != gen:
continue
if block is None:
self._ended_flag = True
break
self._cur, self._cur_off = block, 0
cur, off = self._cur, self._cur_off
take = min(frames - filled, cur.shape[1] - off)
seg = cur[:, off:off + take]
if ramping:
r = self._ramp[filled:filled + take]
w = g0[:, None] + (g1 - g0)[:, None] * r[None, :]
mix[filled:filled + take] += np.einsum("kt,ktc->tc", w, seg)
else:
mix[filled:filled + take] += np.tensordot(g1, seg, axes=(0, 0))
filled += take
self._cur_off = off + take
if self._cur_off >= cur.shape[1]:
self._cur = None
self._gains_cur = g1
self._underrun = filled < frames and not self._ended_flag
self._pos = start_pos + filled
self._consumed += filled
m0, m1 = self._master_cur, self._master
if m0 != m1:
mix *= np.linspace(m0, m1, frames, dtype=np.float32)[:, None]
self._master_cur = m1
elif m1 != 1.0:
mix *= m1
outdata[:] = mix
if filled and len(q) < 3:
with self._cond:
self._cond.notify()
peak = np.abs(mix).max(axis=0)
dac = time_info.outputBufferDacTime
if dac <= 0:
dac = self._stream.time + self._stream.latency
r = self._ring[self._ring_i % RING]
r[0], r[1], r[2], r[3] = dac, start_pos, peak[0], peak[1]
self._ring_i += 1
def _heard_frame(self):
"""Interpolates the frame at the DAC from the per-block timestamps."""
if self._stream is None or not self._playing:
return float(self._gen_start if self._cb_gen != self._gen else self._pos)
if self._cb_gen != self._gen:
return float(self._gen_start)
now = self._stream.time
ring = self._ring
played = ring[(ring[:, 0] > 0) & (ring[:, 0] <= now)]
if not len(played):
return float(self._gen_start)
last = played[np.argmax(played[:, 0])]
return float(min(self._frames, last[1] + (now - last[0]) * self._sr))
# ---- UI reads ----------------------------------------------------------------------
@Slot(result="QVariantList")
def snapshot(self):
"""[position seconds, peakL, peakR] for this frame."""
pos = self._heard_frame() / self._sr if self._src is not None else 0.0
if not self._playing or self._stream is None:
return [pos, 0.0, 0.0]
ring = self._ring
played = ring[(ring[:, 0] > 0) & (ring[:, 0] <= self._stream.time)]
if not len(played):
return [pos, 0.0, 0.0]
last = played[np.argmax(played[:, 0])]
return [pos, float(last[2]), float(last[3])]
def position(self):
return self._heard_frame() / self._sr if self._src is not None else 0.0
def _get_playing(self):
return self._playing
def _get_buffering(self):
return self._buffering
def _get_duration(self):
return self._frames / self._sr if self._src is not None else 0.0
def _get_volume(self):
return self._volume
playing = Property(bool, _get_playing, notify=playingChanged)
buffering = Property(bool, _get_buffering, notify=bufferingChanged)
duration = Property(float, _get_duration, notify=durationChanged)
volume = Property(float, _get_volume, setVolume, notify=volumeChanged)