Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -0,0 +1,307 @@
|
||||
"""Stem playback: mixes loaded stems with gain/solo/mute and a master volume in a PortAudio callback.
|
||||
|
||||
The callback thread owns mixing; the UI reads position and meter levels once
|
||||
per frame via snapshot(), which interpolates from a small ring of per-block
|
||||
DAC timestamps so the playhead stays smooth at 240Hz.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import soundfile as sf
|
||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||
|
||||
RING = 64
|
||||
BLOCK = 256
|
||||
|
||||
|
||||
class AudioEngine(QObject):
|
||||
playingChanged = Signal()
|
||||
loadedChanged = Signal()
|
||||
volumeChanged = Signal()
|
||||
durationChanged = Signal()
|
||||
loadFailed = Signal(str)
|
||||
_loadedInternal = Signal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._stems: list[np.ndarray] = []
|
||||
self._names: list[str] = []
|
||||
self._sr = 44100
|
||||
self._len = 0
|
||||
self._pos = 0
|
||||
self._playing = False
|
||||
self._ended = False
|
||||
self._stream: sd.OutputStream | None = None
|
||||
self._key = None
|
||||
self._load_token = 0
|
||||
self._gain_db: list[float] = []
|
||||
self._solo: list[bool] = []
|
||||
self._mute: list[bool] = []
|
||||
self._target = np.zeros(0, np.float32)
|
||||
self._current = np.zeros(0, np.float32)
|
||||
self._volume = 1.0 # master slider position, 0..1
|
||||
self._master = 1.0 # master gain the callback ramps toward
|
||||
self._master_cur = 1.0
|
||||
self._mix = np.zeros((4096, 2), np.float32)
|
||||
self._tmp = np.zeros((4096, 2), np.float32)
|
||||
# ring of (dac_time, block_start_frame, peakL, peakR); written by the audio thread
|
||||
self._ring = np.zeros((RING, 4), np.float64)
|
||||
self._ring_i = 0
|
||||
self._loadedInternal.connect(self._on_loaded)
|
||||
|
||||
# ---- loading -------------------------------------------------------
|
||||
def load(self, key, stems: list[tuple[str, str]]):
|
||||
"""Load stems [(name, path)] in a background thread; replaces the current set."""
|
||||
if key == self._key and self._stems:
|
||||
return
|
||||
self.unload()
|
||||
self._key = key
|
||||
self._load_token += 1
|
||||
token = self._load_token
|
||||
|
||||
def work():
|
||||
try:
|
||||
arrays, sr = [], None
|
||||
for name, path in stems:
|
||||
data, file_sr = sf.read(path, dtype="float32", always_2d=True)
|
||||
if data.shape[1] == 1:
|
||||
data = np.repeat(data, 2, axis=1)
|
||||
arrays.append(np.ascontiguousarray(data[:, :2]))
|
||||
sr = sr or file_sr
|
||||
n = min(a.shape[0] for a in arrays)
|
||||
self._loadedInternal.emit((token, [s[0] for s in stems], sr), [a[:n] for a in arrays])
|
||||
except Exception as e: # surfaced in the UI as a toast
|
||||
self._loadedInternal.emit((token, None, str(e)), None)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_loaded(self, info, arrays):
|
||||
token, names, sr = info
|
||||
if token != self._load_token:
|
||||
return
|
||||
if names is None:
|
||||
self.loadFailed.emit(sr)
|
||||
return
|
||||
self._names = names
|
||||
self._sr = sr
|
||||
self._stems = arrays
|
||||
self._len = arrays[0].shape[0] if arrays else 0
|
||||
self._pos = 0
|
||||
k = len(arrays)
|
||||
self._gain_db = [0.0] * k
|
||||
self._solo = [False] * k
|
||||
self._mute = [False] * k
|
||||
self._target = np.ones(k, np.float32)
|
||||
self._current = self._target.copy()
|
||||
self._ring[:] = 0
|
||||
self.loadedChanged.emit()
|
||||
self.durationChanged.emit()
|
||||
|
||||
@Slot()
|
||||
def unload(self):
|
||||
self.pause()
|
||||
self._close_stream()
|
||||
self._stems, self._names, self._len, self._pos, self._key = [], [], 0, 0, None
|
||||
self._load_token += 1
|
||||
self.loadedChanged.emit()
|
||||
self.durationChanged.emit()
|
||||
|
||||
# ---- transport -----------------------------------------------------
|
||||
@Slot()
|
||||
def toggle(self):
|
||||
self.pause() if self._playing else self.play()
|
||||
|
||||
@Slot()
|
||||
def play(self):
|
||||
if not self._stems or self._playing:
|
||||
return
|
||||
if self._pos >= self._len - BLOCK:
|
||||
self._pos = 0
|
||||
self._ended = False
|
||||
if self._stream is None or self._stream.samplerate != self._sr:
|
||||
self._close_stream()
|
||||
self._stream = sd.OutputStream(samplerate=self._sr, channels=2, dtype="float32",
|
||||
blocksize=BLOCK, latency="low", callback=self._callback)
|
||||
self._ring[:] = 0
|
||||
self._playing = True
|
||||
self._stream.start()
|
||||
self.playingChanged.emit()
|
||||
|
||||
@Slot()
|
||||
def pause(self):
|
||||
if not self._playing:
|
||||
return
|
||||
self._playing = False
|
||||
if self._stream is not None:
|
||||
# Rewind to what was actually heard, not what was queued to the device.
|
||||
heard = self._interpolated_pos()
|
||||
self._stream.stop()
|
||||
self._pos = int(heard)
|
||||
self.playingChanged.emit()
|
||||
|
||||
@Slot(float)
|
||||
def seek(self, seconds):
|
||||
self._pos = int(max(0.0, min(seconds * self._sr, self._len - 1)))
|
||||
self._ring[:] = 0
|
||||
self._ended = False
|
||||
|
||||
def _close_stream(self):
|
||||
if self._stream is not None:
|
||||
try:
|
||||
self._stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
|
||||
# ---- mixer controls ------------------------------------------------
|
||||
@Slot(int, float)
|
||||
def setGain(self, i, db):
|
||||
if 0 <= i < len(self._gain_db):
|
||||
self._gain_db[i] = db
|
||||
self._recompute()
|
||||
|
||||
@Slot(int, bool)
|
||||
def setSolo(self, i, on):
|
||||
if 0 <= i < len(self._solo):
|
||||
self._solo[i] = on
|
||||
self._recompute()
|
||||
|
||||
@Slot(int, bool)
|
||||
def setMute(self, i, on):
|
||||
if 0 <= i < len(self._mute):
|
||||
self._mute[i] = on
|
||||
self._recompute()
|
||||
|
||||
@Slot(float)
|
||||
def setVolume(self, v):
|
||||
v = max(0.0, min(1.0, float(v)))
|
||||
if v != self._volume:
|
||||
self._volume = v
|
||||
# cubic taper: roughly even loudness steps across the slider
|
||||
self._master = v ** 3
|
||||
self.volumeChanged.emit()
|
||||
|
||||
def _recompute(self):
|
||||
any_solo = any(self._solo)
|
||||
t = np.zeros(len(self._stems), np.float32)
|
||||
for i, db in enumerate(self._gain_db):
|
||||
audible = self._solo[i] if any_solo else not self._mute[i]
|
||||
t[i] = 10 ** (db / 20) if audible else 0.0
|
||||
self._target = t
|
||||
|
||||
def mix_gains(self) -> np.ndarray:
|
||||
return self._target.copy()
|
||||
|
||||
def stem_gain(self, name) -> float:
|
||||
i = self._names.index(name)
|
||||
return 10 ** (self._gain_db[i] / 20)
|
||||
|
||||
def loaded_key(self):
|
||||
return self._key
|
||||
|
||||
def arrays(self):
|
||||
return dict(zip(self._names, self._stems)), self._sr
|
||||
|
||||
# ---- audio thread --------------------------------------------------
|
||||
def _callback(self, outdata, frames, time_info, status):
|
||||
pos = self._pos
|
||||
n = max(0, min(frames, self._len - pos))
|
||||
mix = self._mix[:frames]
|
||||
mix.fill(0)
|
||||
if self._playing and n:
|
||||
target, current = self._target, self._current
|
||||
if len(target) != len(self._stems):
|
||||
target = current = np.zeros(len(self._stems), np.float32)
|
||||
ramp = None
|
||||
for i, stem in enumerate(self._stems):
|
||||
g0, g1 = current[i], target[i]
|
||||
if g0 == 0 and g1 == 0:
|
||||
continue
|
||||
tmp = self._tmp[:n]
|
||||
if g0 == g1:
|
||||
np.multiply(stem[pos:pos + n], g1, out=tmp)
|
||||
else: # click-free gain change across one block
|
||||
if ramp is None:
|
||||
ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
|
||||
np.multiply(stem[pos:pos + n], g0 + (g1 - g0) * ramp, out=tmp)
|
||||
mix[:n] += tmp
|
||||
self._current = target.copy()
|
||||
m0, m1 = self._master_cur, self._master
|
||||
if m0 != m1:
|
||||
if ramp is None:
|
||||
ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
|
||||
mix[:n] *= m0 + (m1 - m0) * ramp
|
||||
self._master_cur = m1
|
||||
elif m1 != 1.0:
|
||||
mix[:n] *= m1
|
||||
self._pos = pos + n
|
||||
outdata[:] = mix
|
||||
peak = np.abs(mix).max(axis=0) if frames else (0.0, 0.0)
|
||||
dac = time_info.outputBufferDacTime
|
||||
if dac <= 0: # some host APIs leave it unset; fall back to the stream clock
|
||||
dac = self._stream.time + self._stream.latency
|
||||
r = self._ring[self._ring_i % RING]
|
||||
r[0] = dac
|
||||
r[1] = pos
|
||||
r[2], r[3] = peak[0], peak[1]
|
||||
self._ring_i += 1
|
||||
if self._playing and pos + n >= self._len:
|
||||
self._ended = True
|
||||
|
||||
def _played(self, now):
|
||||
ring = self._ring
|
||||
return ring[(ring[:, 0] > 0) & (ring[:, 0] <= now)]
|
||||
|
||||
def _interpolated_pos(self):
|
||||
if self._stream is None:
|
||||
return self._pos
|
||||
now = self._stream.time
|
||||
played = self._played(now)
|
||||
if not len(played):
|
||||
return self._pos if not self._playing else max(0, self._pos - BLOCK * 2)
|
||||
last = played[np.argmax(played[:, 0])]
|
||||
return min(self._len, last[1] + (now - last[0]) * self._sr)
|
||||
|
||||
# ---- per-frame UI read ---------------------------------------------
|
||||
@Slot(result="QVariantList")
|
||||
def snapshot(self):
|
||||
"""[position_seconds, peakL, peakR, playing] for the current frame."""
|
||||
if not self._stems:
|
||||
return [0.0, 0.0, 0.0, False]
|
||||
if not self._playing or self._stream is None:
|
||||
return [float(self._pos) / self._sr, 0.0, 0.0, False]
|
||||
now = self._stream.time
|
||||
played = self._played(now)
|
||||
if len(played):
|
||||
last = played[np.argmax(played[:, 0])]
|
||||
pos = min(self._len, last[1] + (now - last[0]) * self._sr)
|
||||
l, r = float(last[2]), float(last[3])
|
||||
else:
|
||||
pos, l, r = self._pos, 0.0, 0.0
|
||||
if self._ended and pos >= self._len - 1:
|
||||
self._playing = False
|
||||
self._stream.stop()
|
||||
self._pos = 0
|
||||
self.playingChanged.emit()
|
||||
return [0.0, 0.0, 0.0, False]
|
||||
return [float(pos) / self._sr, l, r, True]
|
||||
|
||||
# ---- properties ----------------------------------------------------
|
||||
def _get_playing(self):
|
||||
return self._playing
|
||||
|
||||
def _get_loaded(self):
|
||||
return bool(self._stems)
|
||||
|
||||
def _get_duration(self):
|
||||
return self._len / self._sr if self._stems else 0.0
|
||||
|
||||
def _get_volume(self):
|
||||
return self._volume
|
||||
|
||||
playing = Property(bool, _get_playing, notify=playingChanged)
|
||||
loaded = Property(bool, _get_loaded, notify=loadedChanged)
|
||||
duration = Property(float, _get_duration, notify=durationChanged)
|
||||
volume = Property(float, _get_volume, setVolume, notify=volumeChanged)
|
||||
Reference in new issue
Block a user