- Python package stemroller/ -> stemify/, Android package com.stemroller.mobile -> com.stemify.mobile, app names, QML module, icons, desktop entries, MPRIS and Plex product names - Done-markers are now .stemify.json; stemd renames the old ones once at startup so songs separated before the rename aren't separated again - A top-level README covering the server, the Android and Android Auto app, the EQ, Stem Now and the desktop apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
16 KiB
Python
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)
|