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,601 @@
|
||||
"""The queue and the mix: what plays, where its audio comes from, and how loud each stem is."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
|
||||
|
||||
from . import config
|
||||
from .engine import Source
|
||||
from .stems import COLORS, sort_names
|
||||
|
||||
UNITY = 0.8 # stem slider position for 0 dB (the phone uses the same taper)
|
||||
WAVE_BUCKETS = 240
|
||||
|
||||
|
||||
def stem_taper(v):
|
||||
return (max(0.0, min(1.0, v)) / UNITY) ** 2
|
||||
|
||||
|
||||
# One-click mixes, as on the phone. A preset is a function of the stems a song has, or None
|
||||
# when the song lacks what it needs.
|
||||
PRESETS = [
|
||||
("Full Mix", lambda n: {}),
|
||||
("Karaoke", lambda n: {"vocals": {"muted": True}} if "vocals" in n else None),
|
||||
("A Cappella", lambda n: {"vocals": {"solo": True}} if "vocals" in n else None),
|
||||
("No Drums", lambda n: {"drums": {"muted": True}} if "drums" in n else None),
|
||||
("Rhythm", lambda n: {"drums": {"solo": True}, "bass": {"solo": True}} if "drums" in n and "bass" in n else None),
|
||||
("No Guitar", lambda n: {"guitar": {"muted": True}} if "guitar" in n else None),
|
||||
("No Bass", lambda n: {"bass": {"muted": True}} if "bass" in n else None),
|
||||
]
|
||||
|
||||
|
||||
def _setting(mix, name):
|
||||
s = mix.get(name, {})
|
||||
return s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
|
||||
|
||||
|
||||
def _normalized(mix):
|
||||
out = {}
|
||||
for name, s in mix.items():
|
||||
level, muted, solo = s.get("level", UNITY), bool(s.get("muted")), bool(s.get("solo"))
|
||||
if abs(level - UNITY) > 1e-3 or muted or solo:
|
||||
out[name] = (round(level, 3), muted, solo)
|
||||
return out
|
||||
|
||||
|
||||
class Player(QObject):
|
||||
changed = Signal() # track, queue, play state
|
||||
mixChanged = Signal()
|
||||
waveChanged = Signal()
|
||||
toast = Signal(str)
|
||||
_loaded = Signal(int, object, object) # token, Source or error, track
|
||||
_wave = Signal(int, object)
|
||||
|
||||
def __init__(self, cfg, plex, stems, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cfg, self.plex, self.server, self.engine = cfg, plex, stems, engine
|
||||
self.library = None
|
||||
self.queue: list[dict] = []
|
||||
self.index = -1
|
||||
self._unshuffled = None
|
||||
self.shuffle = False
|
||||
self.repeat = "off" # off | all | one
|
||||
self.mix: dict[str, dict] = {}
|
||||
self.names: list[str] = []
|
||||
self.source_kind = "" # stems | original
|
||||
self._token = 0
|
||||
self._loading = False
|
||||
self._wave_raw = None # {"stems": {name: [..]}} or {"db": [..]}
|
||||
self._scrobbled = False
|
||||
self._pending_seek = 0.0
|
||||
self._queue_dirty = False
|
||||
self._loaded.connect(self._on_loaded)
|
||||
self._wave.connect(self._on_wave)
|
||||
engine.ended.connect(self._on_ended)
|
||||
engine.playingChanged.connect(self._on_play_state)
|
||||
engine.bufferingChanged.connect(self.changed)
|
||||
engine.setVolume(cfg.get("volume", 0.8))
|
||||
engine.volumeChanged.connect(self._save_soon)
|
||||
self._report = QTimer(self, interval=10_000)
|
||||
self._report.timeout.connect(self._tick)
|
||||
self._report.start()
|
||||
self._saver = QTimer(self, interval=1500, singleShot=True)
|
||||
self._saver.timeout.connect(self.save)
|
||||
self._restore()
|
||||
|
||||
# ---- state -------------------------------------------------------------------------
|
||||
# The queue can be the whole library, so it lives in its own file, written off the GUI
|
||||
# thread and only when it changes; the small state (position, mix) saves often.
|
||||
QUEUE_PATH = os.path.join(config.CONFIG_DIR, "player-queue.json")
|
||||
QUEUE_KEYS = ("key", "title", "artist", "albumArtist", "album", "albumKey", "artistKey", "year", "index", "disc",
|
||||
"durationMs", "thumb", "partFile", "partKey", "codec", "stemKey", "_cover")
|
||||
|
||||
def _restore(self):
|
||||
st = self.cfg.get("state") or {}
|
||||
try:
|
||||
with open(self.QUEUE_PATH) as f:
|
||||
q = json.load(f)
|
||||
self.queue, self._unshuffled = q.get("queue") or [], q.get("unshuffled")
|
||||
except (OSError, ValueError):
|
||||
self.queue, self._unshuffled = st.get("queue") or [], st.get("unshuffled")
|
||||
self.index = st.get("index", -1) if self.queue else -1
|
||||
self.shuffle = bool(st.get("shuffle"))
|
||||
self.repeat = st.get("repeat", "off")
|
||||
self.mix = st.get("mix") or {}
|
||||
self._pending_seek = float(st.get("position") or 0)
|
||||
if 0 <= self.index < len(self.queue):
|
||||
QTimer.singleShot(0, lambda: self._load(self.index, play=False, start=self._pending_seek))
|
||||
|
||||
def save(self, wait=False):
|
||||
self.cfg["volume"] = round(self.engine.volume, 3)
|
||||
self.cfg["state"] = {
|
||||
"index": self.index, "shuffle": self.shuffle, "repeat": self.repeat, "mix": self.mix,
|
||||
"position": round(self.engine.position() if self.engine.duration else self._pending_seek, 2),
|
||||
}
|
||||
config.save(self.cfg)
|
||||
if self._queue_dirty:
|
||||
self._queue_dirty = False
|
||||
slim = lambda ts: [{k: t[k] for k in self.QUEUE_KEYS if k in t} for t in ts] if ts is not None else None
|
||||
data = {"queue": slim(self.queue), "unshuffled": slim(self._unshuffled)}
|
||||
|
||||
def write():
|
||||
tmp = self.QUEUE_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, self.QUEUE_PATH)
|
||||
t = threading.Thread(target=write, daemon=not wait)
|
||||
t.start()
|
||||
if wait:
|
||||
t.join()
|
||||
|
||||
def _save_soon(self):
|
||||
self._saver.start()
|
||||
|
||||
def current(self):
|
||||
return self.queue[self.index] if 0 <= self.index < len(self.queue) else None
|
||||
|
||||
def current_key(self):
|
||||
t = self.current()
|
||||
return t["key"] if t else ""
|
||||
|
||||
# ---- queue ---------------------------------------------------------------------------
|
||||
def play_tracks(self, tracks, start=0, shuffle=False):
|
||||
tracks = [dict(t) for t in tracks]
|
||||
if not tracks:
|
||||
return
|
||||
self._unshuffled = list(tracks) if shuffle else None
|
||||
if shuffle:
|
||||
first = tracks.pop(start)
|
||||
random.shuffle(tracks)
|
||||
tracks.insert(0, first)
|
||||
start = 0
|
||||
self.shuffle = shuffle
|
||||
self.queue = tracks
|
||||
if not self.cfg.get("keepMix"):
|
||||
self.mix = {}
|
||||
self._load(start, play=True)
|
||||
self._queue_changed()
|
||||
|
||||
def add_to_queue(self, tracks):
|
||||
self.queue += [dict(t) for t in tracks]
|
||||
if self.index < 0:
|
||||
self._load(0, play=True)
|
||||
self._queue_changed()
|
||||
|
||||
def play_next(self, tracks):
|
||||
at = self.index + 1
|
||||
self.queue[at:at] = [dict(t) for t in tracks]
|
||||
if self.index < 0:
|
||||
self._load(0, play=True)
|
||||
self._queue_changed()
|
||||
|
||||
@Slot(int)
|
||||
def removeAt(self, i):
|
||||
if not (0 <= i < len(self.queue)):
|
||||
return
|
||||
del self.queue[i]
|
||||
if i < self.index:
|
||||
self.index -= 1
|
||||
elif i == self.index:
|
||||
if self.queue:
|
||||
self._load(min(i, len(self.queue) - 1), play=self.engine.playing)
|
||||
else:
|
||||
self.index = -1
|
||||
self.engine.clear()
|
||||
self._queue_changed()
|
||||
|
||||
@Slot()
|
||||
def clearQueue(self):
|
||||
cur = self.current()
|
||||
self.queue = [cur] if cur else []
|
||||
self.index = 0 if cur else -1
|
||||
self._unshuffled = None
|
||||
self._queue_changed()
|
||||
|
||||
@Slot(int)
|
||||
def skipTo(self, i):
|
||||
if 0 <= i < len(self.queue):
|
||||
self._load(i, play=True)
|
||||
|
||||
def _queue_changed(self):
|
||||
self._queue_dirty = True
|
||||
if self.library:
|
||||
self.library.update_queue()
|
||||
self.changed.emit()
|
||||
self._save_soon()
|
||||
|
||||
# ---- transport -------------------------------------------------------------------------
|
||||
@Slot()
|
||||
def toggle(self):
|
||||
if self.engine.duration:
|
||||
self.engine.toggle()
|
||||
elif self.current() and not self._loading:
|
||||
self._load(self.index, play=True, start=self._pending_seek)
|
||||
|
||||
@Slot()
|
||||
def next(self):
|
||||
if not self.queue:
|
||||
return
|
||||
i = self.index + 1
|
||||
if i >= len(self.queue):
|
||||
if self.repeat != "all":
|
||||
return
|
||||
i = 0
|
||||
self._load(i, play=True)
|
||||
|
||||
@Slot()
|
||||
def previous(self):
|
||||
if not self.queue:
|
||||
return
|
||||
if self.engine.position() > 3 or self.index <= 0:
|
||||
self.engine.seek(0)
|
||||
return
|
||||
self._load(self.index - 1, play=True)
|
||||
|
||||
@Slot(float)
|
||||
def seek(self, seconds):
|
||||
if self.engine.duration:
|
||||
self.engine.seek(max(0.0, min(seconds, self.engine.duration - 0.25)))
|
||||
else:
|
||||
self._pending_seek = seconds
|
||||
|
||||
@Slot()
|
||||
def toggleShuffle(self):
|
||||
cur = self.current()
|
||||
if not self.shuffle:
|
||||
self._unshuffled = list(self.queue)
|
||||
rest = [t for i, t in enumerate(self.queue) if i != self.index]
|
||||
random.shuffle(rest)
|
||||
self.queue = ([cur] if cur else []) + rest
|
||||
self.index = 0 if cur else -1
|
||||
self.shuffle = True
|
||||
else:
|
||||
if self._unshuffled:
|
||||
self.queue = self._unshuffled
|
||||
keys = [t["key"] for t in self.queue]
|
||||
self.index = keys.index(cur["key"]) if cur and cur["key"] in keys else 0
|
||||
self._unshuffled = None
|
||||
self.shuffle = False
|
||||
self._queue_changed()
|
||||
|
||||
@Slot()
|
||||
def cycleRepeat(self):
|
||||
self.repeat = {"off": "all", "all": "one", "one": "off"}[self.repeat]
|
||||
self.changed.emit()
|
||||
self._save_soon()
|
||||
|
||||
def _on_ended(self):
|
||||
cur = self.current()
|
||||
if cur and not self._scrobbled:
|
||||
self._scrobbled = True
|
||||
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
|
||||
if self.repeat == "one":
|
||||
self._load(self.index, play=True)
|
||||
elif self.index + 1 < len(self.queue) or self.repeat == "all":
|
||||
self.next()
|
||||
else:
|
||||
self.engine.seek(0)
|
||||
self.changed.emit()
|
||||
|
||||
def _on_play_state(self):
|
||||
self._report_state("playing" if self.engine.playing else "paused")
|
||||
self.changed.emit()
|
||||
self._save_soon()
|
||||
|
||||
# ---- loading ------------------------------------------------------------------------------
|
||||
def _load(self, i, play=True, start=0.0):
|
||||
"""Opens queue[i] off the GUI thread; a newer request makes older ones moot."""
|
||||
if not (0 <= i < len(self.queue)):
|
||||
return
|
||||
if self.engine.playing and self.current():
|
||||
self._report_state("stopped")
|
||||
self._token += 1
|
||||
token = self._token
|
||||
self.index = i
|
||||
self._loading = True
|
||||
self._scrobbled = False
|
||||
self._wave_raw = None
|
||||
track = self.queue[i]
|
||||
if not self.cfg.get("keepMix") and start == 0.0:
|
||||
self.mix = {}
|
||||
self.engine.pause()
|
||||
if self.library:
|
||||
self.library.model.set_playing(track["key"], i)
|
||||
self.changed.emit()
|
||||
self.waveChanged.emit()
|
||||
self._pending_seek = start
|
||||
|
||||
def work():
|
||||
try:
|
||||
src, entry = self._open(track)
|
||||
self._loaded.emit(token, (src, play, start, entry), track)
|
||||
except Exception as e: # noqa: BLE001 - reported, then skipped
|
||||
self._loaded.emit(token, e, track)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _open(self, track):
|
||||
entry = self.server.index.by_key.get(track.get("stemKey") or "") or self.server.find(track)
|
||||
if entry and "_cover" not in track:
|
||||
track["_cover"] = self._cover(entry)
|
||||
if entry:
|
||||
stems = {s["name"]: s for s in entry["stems"]}
|
||||
names = sort_names(list(stems))
|
||||
paths = [self.server.local_path(entry, stems[n]) or self._download(self.server.file_url(entry, stems[n]), stems[n]["file"])
|
||||
for n in names]
|
||||
return Source(names, paths), entry
|
||||
return Source(["mix"], [self._original_path(track)]), None
|
||||
|
||||
def _cover(self, entry):
|
||||
"""The album folder's own cover on the music share: art for songs Plex doesn't know."""
|
||||
root = self.cfg.get("musicDir", "")
|
||||
folder = os.path.join(root, os.path.dirname(entry.get("source", ""))) if root else ""
|
||||
try:
|
||||
pics = [f for f in os.listdir(folder) if f.lower().endswith((".jpg", ".jpeg", ".png"))]
|
||||
except OSError:
|
||||
return ""
|
||||
if pics:
|
||||
pics.sort(key=lambda f: (not f.lower().startswith(("cover", "folder", "front")), f.lower()))
|
||||
return "file://" + urllib.request.pathname2url(os.path.join(folder, pics[0]))
|
||||
# no picture file: the art embedded in the song itself, pulled out once by ffmpeg
|
||||
src = os.path.join(root, entry["source"])
|
||||
out = os.path.join(config.CACHE_DIR, "art", hashlib.sha1(folder.encode()).hexdigest() + ".jpg")
|
||||
if not os.path.exists(out) and os.path.exists(src) and shutil.which("ffmpeg"):
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-an", "-frames:v", "1", "-vf", "scale=600:-2", out],
|
||||
stdin=subprocess.DEVNULL, capture_output=True, timeout=20)
|
||||
return "file://" + urllib.request.pathname2url(out) if os.path.exists(out) else ""
|
||||
|
||||
def _original_path(self, track):
|
||||
"""The file on the music share when it's mounted, else a download from Plex."""
|
||||
pf = track.get("partFile") or ""
|
||||
local = self.cfg.get("musicDir", "")
|
||||
if pf and local and os.path.isdir(local):
|
||||
for loc in self.cfg.get("sectionLocations") or []:
|
||||
if pf.startswith(loc.rstrip("/") + "/"):
|
||||
p = os.path.join(local, pf[len(loc.rstrip("/")) + 1:])
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
parts = pf.replace("\\", "/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
p = os.path.join(local, *parts[i:])
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
if not track.get("partKey"):
|
||||
raise OSError(f"No file for {track.get('title')}")
|
||||
return self._download(self.plex.part_url(track["partKey"]), os.path.basename(pf) or "track")
|
||||
|
||||
def _download(self, url, name):
|
||||
d = os.path.join(config.CACHE_DIR, "files")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
ext = os.path.splitext(name)[1] or ".bin"
|
||||
path = os.path.join(d, hashlib.sha1(url.split("X-Plex-Token")[0].encode()).hexdigest() + ext)
|
||||
if os.path.exists(path):
|
||||
os.utime(path)
|
||||
return path
|
||||
tmp = path + ".part"
|
||||
req = urllib.request.Request(url, headers={"X-Stem-Token": self.cfg["stemToken"]} if self.cfg.get("stemToken") else {})
|
||||
with urllib.request.urlopen(req, timeout=30) as r, open(tmp, "wb") as f:
|
||||
while chunk := r.read(1 << 20):
|
||||
f.write(chunk)
|
||||
os.replace(tmp, path)
|
||||
self._trim_cache(d)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _trim_cache(d, limit=4 << 30):
|
||||
files = [os.path.join(d, f) for f in os.listdir(d) if not f.endswith(".part")]
|
||||
files.sort(key=lambda p: os.path.getmtime(p))
|
||||
total = sum(os.path.getsize(p) for p in files)
|
||||
while files and total > limit:
|
||||
p = files.pop(0)
|
||||
total -= os.path.getsize(p)
|
||||
os.remove(p)
|
||||
|
||||
def _on_loaded(self, token, result, track):
|
||||
if token != self._token:
|
||||
if not isinstance(result, Exception):
|
||||
result[0].close()
|
||||
return
|
||||
self._loading = False
|
||||
if isinstance(result, Exception):
|
||||
self.toast.emit(f"Couldn't play “{track.get('title')}”: {result}")
|
||||
self.changed.emit()
|
||||
if self.index + 1 < len(self.queue):
|
||||
QTimer.singleShot(800, lambda t=token: t == self._token and self.next())
|
||||
return
|
||||
src, play, start, entry = result
|
||||
self.names = src.names
|
||||
self.source_kind = "stems" if entry else "original"
|
||||
self.engine.set_source(src, start, play=play)
|
||||
self._push_mix()
|
||||
self.changed.emit()
|
||||
self._load_wave(token, track, entry)
|
||||
self._save_soon()
|
||||
|
||||
def _load_wave(self, token, track, entry):
|
||||
def work():
|
||||
w = None
|
||||
if entry:
|
||||
st = self.server.peaks(entry, WAVE_BUCKETS)
|
||||
w = {"stems": st} if st else None
|
||||
if w is None:
|
||||
try:
|
||||
db = self.plex.loudness(track["key"], WAVE_BUCKETS)
|
||||
w = {"db": db} if db else None
|
||||
except Exception: # noqa: BLE001 - no waveform is fine
|
||||
w = None
|
||||
self._wave.emit(token, w)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_wave(self, token, w):
|
||||
if token == self._token:
|
||||
self._wave_raw = w
|
||||
self.waveChanged.emit()
|
||||
|
||||
# ---- mix -----------------------------------------------------------------------------------
|
||||
def _push_mix(self):
|
||||
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
|
||||
gains = []
|
||||
for n in self.names:
|
||||
level, muted, solo = _setting(self.mix, n)
|
||||
gains.append(0.0 if (any_solo and not solo) or muted else stem_taper(level))
|
||||
self.engine.set_gains(gains)
|
||||
self.mixChanged.emit()
|
||||
self.waveChanged.emit()
|
||||
self._save_soon()
|
||||
|
||||
def _edit(self, i, **kw):
|
||||
if 0 <= i < len(self.names):
|
||||
n = self.names[i]
|
||||
self.mix[n] = {**self.mix.get(n, {}), **kw}
|
||||
self._push_mix()
|
||||
|
||||
@Slot(int, float)
|
||||
def setLevel(self, i, v):
|
||||
self._edit(i, level=max(0.0, min(1.0, v)))
|
||||
|
||||
@Slot(int)
|
||||
def toggleMute(self, i):
|
||||
if 0 <= i < len(self.names):
|
||||
self._edit(i, muted=not _setting(self.mix, self.names[i])[1])
|
||||
|
||||
@Slot(int)
|
||||
def toggleSolo(self, i):
|
||||
if 0 <= i < len(self.names):
|
||||
self._edit(i, solo=not _setting(self.mix, self.names[i])[2])
|
||||
|
||||
@Slot(int)
|
||||
def applyPreset(self, p):
|
||||
m = PRESETS[p][1](self.names)
|
||||
if m is not None:
|
||||
self.mix = {k: dict(v) for k, v in m.items()}
|
||||
self._push_mix()
|
||||
|
||||
@Slot()
|
||||
def toggleVocals(self):
|
||||
"""Karaoke switch: vocals off, or back to the full mix."""
|
||||
if "vocals" not in self.names:
|
||||
return
|
||||
self.applyPreset(0 if self._preset_index() == 1 else 1)
|
||||
|
||||
def _preset_index(self):
|
||||
m = _normalized(self.mix)
|
||||
for i, (_, build) in enumerate(PRESETS):
|
||||
b = build(self.names)
|
||||
if b is not None and _normalized(b) == m:
|
||||
return i
|
||||
return -1
|
||||
|
||||
# ---- reporting to Plex (play counts, "now playing" on the server) -------------------------------
|
||||
def _report_state(self, state):
|
||||
cur = self.current()
|
||||
if cur:
|
||||
pos = self.engine.position() * 1000
|
||||
threading.Thread(target=self.plex.timeline, args=(cur, state, pos), daemon=True).start()
|
||||
|
||||
def _tick(self):
|
||||
if self.engine.playing:
|
||||
self._report_state("playing")
|
||||
cur, d = self.current(), self.engine.duration
|
||||
if cur and d and not self._scrobbled and self.engine.position() > 0.9 * d:
|
||||
self._scrobbled = True
|
||||
threading.Thread(target=self.plex.scrobble, args=(cur["key"],), daemon=True).start()
|
||||
self.save()
|
||||
|
||||
def shutdown(self):
|
||||
self._report_state("stopped")
|
||||
self.save(wait=True)
|
||||
|
||||
# ---- QML properties -------------------------------------------------------------------------
|
||||
def _t(self, k, default=""):
|
||||
t = self.current()
|
||||
return t.get(k, default) if t else default
|
||||
|
||||
def _get_track(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return {}
|
||||
return {"title": t["title"], "artist": t["artist"], "album": t["album"], "year": t.get("year") or 0,
|
||||
"art": self.plex.art_url(t.get("thumb"), 600) or t.get("_cover", ""), "durationMs": t.get("durationMs", 0),
|
||||
"codec": t.get("codec", ""), "key": t["key"]}
|
||||
|
||||
def _get_status(self):
|
||||
if self._loading:
|
||||
return "loading"
|
||||
if self.engine.buffering:
|
||||
return "buffering"
|
||||
return "playing" if self.engine.playing else ("paused" if self.current() else "")
|
||||
|
||||
def _get_stems(self):
|
||||
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
|
||||
out = []
|
||||
for n in self.names:
|
||||
level, muted, solo = _setting(self.mix, n)
|
||||
out.append({"name": n, "label": "Original mix" if n == "mix" else n.capitalize(),
|
||||
"color": COLORS.get(n, "#8E8E93"), "level": level, "muted": muted, "solo": solo,
|
||||
"audible": not muted and (solo or not any_solo), "db": 20 * math.log10(max(1e-6, stem_taper(level)))})
|
||||
return out
|
||||
|
||||
def _get_presets(self):
|
||||
cur = self._preset_index()
|
||||
return [{"label": label, "usable": build(self.names) is not None and self.source_kind == "stems", "active": i == cur}
|
||||
for i, (label, build) in enumerate(PRESETS)]
|
||||
|
||||
def _get_preset_label(self):
|
||||
if self.source_kind != "stems":
|
||||
return ""
|
||||
i = self._preset_index()
|
||||
return PRESETS[i][0] if i >= 0 else "Custom Mix"
|
||||
|
||||
def _get_wave(self):
|
||||
"""0..1 loudness per bucket for the current mix: stems weighted by their faders."""
|
||||
w = self._wave_raw
|
||||
if not w:
|
||||
return []
|
||||
if "stems" in w:
|
||||
any_solo = any(_setting(self.mix, n)[2] for n in self.names)
|
||||
gains = {}
|
||||
for n in w["stems"]:
|
||||
level, muted, solo = _setting(self.mix, n)
|
||||
gains[n] = 0.0 if (any_solo and not solo) or muted else stem_taper(level)
|
||||
size = max(len(a) for a in w["stems"].values())
|
||||
out = []
|
||||
for i in range(size):
|
||||
e = sum((a[i] if i < len(a) else 0.0) ** 2 * gains.get(n, 1.0) ** 2 for n, a in w["stems"].items())
|
||||
out.append(_norm_db(20 * math.log10(math.sqrt(e) + 1e-6)))
|
||||
return out
|
||||
return [_norm_db(v) for v in w["db"]]
|
||||
|
||||
def _get_queue_info(self):
|
||||
return {"index": self.index, "count": len(self.queue)}
|
||||
|
||||
track = Property("QVariantMap", _get_track, notify=changed)
|
||||
status = Property(str, _get_status, notify=changed)
|
||||
stems = Property("QVariantList", _get_stems, notify=mixChanged)
|
||||
presets = Property("QVariantList", _get_presets, notify=mixChanged)
|
||||
presetLabel = Property(str, _get_preset_label, notify=mixChanged)
|
||||
sourceKind = Property(str, lambda self: self.source_kind, notify=changed)
|
||||
wave = Property("QVariantList", _get_wave, notify=waveChanged)
|
||||
shuffleOn = Property(bool, lambda self: self.shuffle, notify=changed)
|
||||
repeatMode = Property(str, lambda self: self.repeat, notify=changed)
|
||||
queueInfo = Property("QVariantMap", _get_queue_info, notify=changed)
|
||||
keepMix = Property(bool, lambda self: bool(self.cfg.get("keepMix")), notify=mixChanged)
|
||||
|
||||
@Slot(bool)
|
||||
def setKeepMix(self, on):
|
||||
self.cfg["keepMix"] = on
|
||||
self.mixChanged.emit()
|
||||
self._save_soon()
|
||||
|
||||
|
||||
def _norm_db(db):
|
||||
"""Loudness to bar height, as on the phone: -34 dB and below is a stub, -6 dB and up full."""
|
||||
return max(0.06, min(1.0, (db + 34) / 28))
|
||||
Reference in new issue
Block a user