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:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
[project]
name = "stemify"
version = "0.1.0"
description = "Drag-drop stem separation (Demucs + RoFormer) with a 240Hz Material x Aero UI"
requires-python = ">=3.12,<3.13"
dependencies = [
"PySide6>=6.10",
"numpy>=2.0",
"soundfile>=0.13",
"sounddevice>=0.5",
"torch==2.14.1",
"triton-rocm",
"torchvision",
"demucs @ git+https://github.com/adefossez/demucs",
"audio-separator[cpu]>=0.30",
"audioread",
]
[project.scripts]
stemify = "stemify.app:main"
stemify-worker-cli = "stemify.worker.cli:main"
stemify-player = "stemify.player.app:main"
[tool.uv]
environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"]
# torchaudio has no build matching torch 2.14; nothing here needs it.
override-dependencies = ["torchaudio ; sys_platform == 'never'"]
[tool.uv.sources]
torch = { index = "pytorch-rocm" }
triton-rocm = { index = "pytorch-rocm" }
torchvision = { index = "pytorch-rocm" }
[[tool.uv.index]]
name = "pytorch-rocm"
url = "https://download.pytorch.org/whl/rocm7.2"
explicit = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["stemify"]
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Add Stemify and Stemify Player to the app grid (and "Open with" for audio files).
# Re-run after moving the project; `--uninstall` removes all of it.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
DESKTOP="$APPS/stemify.desktop"
ICON="$ICONS/stemify.svg"
PLAYER_DESKTOP="$APPS/stemify-player.desktop"
PLAYER_ICON="$ICONS/stemify-player.svg"
# stem-on / stem-off from older installs; library separation now runs on the server (server/stemd.py).
OLD_BIN="$HOME/.local/bin"
if [[ "${1:-}" == "--uninstall" ]]; then
rm -f "$DESKTOP" "$ICON" "$PLAYER_DESKTOP" "$PLAYER_ICON" "$OLD_BIN/stem-on" "$OLD_BIN/stem-off"
echo "removed $DESKTOP, $PLAYER_DESKTOP and their icons"
exit 0
fi
for bin in stemify stemify-player; do
if [[ ! -x "$ROOT/.venv/bin/$bin" ]]; then
echo "missing $ROOT/.venv/bin/$bin; run 'uv sync' in $ROOT first" >&2
exit 1
fi
done
mkdir -p "$APPS" "$ICONS"
install -m 644 "$ROOT/stemify/assets/stemify.svg" "$ICON"
install -m 644 "$ROOT/stemify/player/assets/stemify-player.svg" "$PLAYER_ICON"
# StartupWMClass / file name match QGuiApplication.setDesktopFileName("stemify"),
# so Wayland groups the window under this entry and shows its icon.
cat > "$DESKTOP" <<EOF
[Desktop Entry]
Type=Application
Name=Stemify
GenericName=Stem Separator
Comment=Split songs into vocals, drums, bass and more on the GPU
Exec="$ROOT/.venv/bin/stemify" %F
Path=$ROOT
Icon=stemify
Terminal=false
Categories=AudioVideo;Audio;
MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/wav;audio/x-wav;audio/ogg;audio/opus;audio/mp4;audio/aac;audio/x-aiff;audio/x-m4a;
Keywords=stems;demucs;roformer;vocals;karaoke;separate;
StartupWMClass=stemify
StartupNotify=true
EOF
chmod 644 "$DESKTOP"
# StartupWMClass matches QGuiApplication.setDesktopFileName("stemify-player"); the
# MPRIS DesktopEntry points here too, so media controls show this icon.
cat > "$PLAYER_DESKTOP" <<EOF
[Desktop Entry]
Type=Application
Name=Stemify Player
GenericName=Music Player
Comment=Your Plex library and its stems, in a spreadsheet
Exec="$ROOT/.venv/bin/stemify-player"
Path=$ROOT
Icon=stemify-player
Terminal=false
Categories=AudioVideo;Audio;Player;
Keywords=music;plex;stems;karaoke;player;excel;
StartupWMClass=stemify-player
StartupNotify=true
EOF
chmod 644 "$PLAYER_DESKTOP"
command -v update-desktop-database >/dev/null && update-desktop-database -q "$APPS" || true
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q -t "${ICONS%/scalable/apps}" || true
command -v desktop-file-validate >/dev/null && desktop-file-validate "$DESKTOP" "$PLAYER_DESKTOP" || true
rm -f "$OLD_BIN/stem-on" "$OLD_BIN/stem-off"
echo "installed $DESKTOP and $PLAYER_DESKTOP"
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import sys
from .app import main
sys.exit(main())
+268
View File
@@ -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="stemify")
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("Stemify")
app.setOrganizationName("stemify")
app.setDesktopFileName("stemify")
app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "stemify.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", "Stemify", "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())
Binary file not shown.
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5FC8FF"/>
<stop offset="1" stop-color="#1D5FB8"/>
</linearGradient>
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.55"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.05"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
<rect x="16.5" y="16.5" width="223" height="223" rx="55.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="3"/>
<rect x="62" y="70" width="22" height="116" rx="11" fill="#FF7AB6"/>
<rect x="98" y="52" width="22" height="152" rx="11" fill="#FFB547"/>
<rect x="134" y="88" width="22" height="80" rx="11" fill="#7C8CFF"/>
<rect x="170" y="66" width="22" height="124" rx="11" fill="#4CD07D"/>
<path d="M72 18 h112 a54 54 0 0 1 54 54 v18 c-60 22 -150 22 -220 0 v-18 a54 54 0 0 1 54 -54z" fill="url(#gloss)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+307
View File
@@ -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)
+51
View File
@@ -0,0 +1,51 @@
"""Model catalog and stem metadata shared by the UI and the worker.
Kept free of torch imports so the UI process stays light.
"""
DEMUCS_4 = ["drums", "bass", "other", "vocals"]
DEMUCS_6 = ["drums", "bass", "other", "vocals", "guitar", "piano"]
MODELS = [
{"id": "htdemucs", "label": "HT Demucs", "family": "demucs",
"stems": DEMUCS_4, "note": "Fast 4-stem"},
{"id": "htdemucs_ft", "label": "HT Demucs FT", "family": "demucs",
"stems": DEMUCS_4, "note": "Fine-tuned 4-stem, ~4x slower"},
{"id": "htdemucs_6s", "label": "HT Demucs 6-stem", "family": "demucs",
"stems": DEMUCS_6, "note": "Adds guitar and piano"},
{"id": "roformer_bs_vocals", "label": "BS-RoFormer Vocals", "family": "roformer",
"file": "model_bs_roformer_ep_317_sdr_12.9755.ckpt",
"stems": ["vocals", "instrumental"], "note": "Viperx 1297, vocals / instrumental"},
{"id": "roformer_mel_kim_ft", "label": "Mel-RoFormer Vocals FT", "family": "roformer",
"file": "mel_band_roformer_kim_ft_unwa.ckpt",
"stems": ["vocals", "instrumental"], "note": "Kim FT by unwa, cleanest vocals"},
{"id": "roformer_bs_sw", "label": "BS-RoFormer SW 6-stem", "family": "roformer",
"file": "BS-Roformer-SW.ckpt",
"stems": DEMUCS_6, "note": "jarredou, 6-stem RoFormer"},
]
MODELS_BY_ID = {m["id"]: m for m in MODELS}
STEM_COLORS = {
"vocals": "#FF7AB6",
"drums": "#FFB547",
"bass": "#7C8CFF",
"other": "#4CD07D",
"guitar": "#FF8A5C",
"piano": "#B48CFF",
"instrumental": "#3DD6C6",
}
# Lane order in the UI, regardless of the order a model emits stems in.
STEM_ORDER = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental"]
def stems_for(model_id: str, two_stems: bool) -> list[str]:
stems = MODELS_BY_ID[model_id]["stems"]
if two_stems and "vocals" in stems:
return ["vocals", "instrumental"]
return stems
def sort_stems(names):
return sorted(names, key=lambda n: STEM_ORDER.index(n) if n in STEM_ORDER else 99)
+542
View File
@@ -0,0 +1,542 @@
"""App controller: queue, dispatch to the worker, cache, export. Exposed to QML as `app`."""
import hashlib
import json
import os
import shutil
import threading
import time
import uuid
import numpy as np
import soundfile as sf
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QDesktopServices
from .catalog import MODELS, MODELS_BY_ID, STEM_COLORS, sort_stems
from .jobs_model import Job, JobsModel
from .settings import Settings
from .worker_client import WorkerClient
CACHE_ROOT = os.path.join(os.path.expanduser("~/.cache/stemify"), "stems")
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".aif", ".aiff",
".wma", ".mp4", ".mkv", ".webm", ".mka", ".alac", ".wv", ".ape"}
def _cpu_name():
try:
with open("/proc/cpuinfo") as f:
for line in f:
if line.startswith("model name"):
return line.split(":", 1)[1].strip().replace(" 8-Core Processor", "")
except OSError:
pass
return "CPU"
def _fmt_mb(mb):
return f"{mb / 1024:.1f} GB"
def _dir_size(path):
total = 0
for root, _, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
class Controller(QObject):
selectedChanged = Signal()
devicesChanged = Signal()
vramChanged = Signal()
workerStateChanged = Signal()
cacheChanged = Signal()
toast = Signal(str, str, str) # message, kind (info|success|error), folder to open ("" = none)
_exportDone = Signal(str, str, str)
_cacheSized = Signal(int)
def __init__(self, audio, peak_store, parent=None):
super().__init__(parent)
self.settings = Settings(self)
self.jobs = JobsModel(self)
self.audio = audio
self.peak_store = peak_store
self._selected_id = ""
self._set_running("")
self._devices = [{"id": "auto", "label": "Auto", "sub": "Starting engine…"},
{"id": "cpu", "label": "CPU", "sub": _cpu_name()}]
self._gpus = []
self._vram = {} # device -> (free_mb, total_mb)
self._cache_bytes = 0
self._exporting = False
self.worker = WorkerClient(self)
self.worker.stateChanged.connect(lambda _: (self.workerStateChanged.emit(), self._dispatch()))
self.worker.hello.connect(self._on_hello)
self.worker.vram.connect(self._on_vram)
self.worker.progress.connect(self._on_progress)
self.worker.done.connect(self._on_done)
self.worker.failed.connect(self._on_failed)
self.worker.cancelled.connect(self._on_cancelled)
self.worker.crashed.connect(self._on_crashed)
self._exportDone.connect(self._on_export_done)
self._cacheSized.connect(self._on_cache_sized)
self.settings.deviceIdChanged.connect(self.vramChanged)
self.settings.modelIdChanged.connect(self.selectedChanged)
self.settings.twoStemsChanged.connect(self.selectedChanged)
self.settings.outputModeChanged.connect(self.selectedChanged)
self.settings.stemsDirChanged.connect(self.selectedChanged)
self.audio.loadedChanged.connect(self.selectedChanged)
self.audio.loadFailed.connect(lambda m: self.toast.emit(f"Could not load stems: {m}", "error", ""))
self.audio.setVolume(self.settings.get("masterVolume"))
self.worker.start()
self.refreshCacheSize()
# ---- worker events -------------------------------------------------
def _on_hello(self, msg):
self._gpus = msg["devices"]
devs = []
if self._gpus:
best = max(self._gpus, key=lambda d: d["total_mb"])
devs.append({"id": "auto", "label": "Auto", "sub": best["name"]})
for d in self._gpus:
devs.append({"id": d["id"], "label": d["name"].replace("AMD ", ""),
"sub": f"{d['arch']} · {_fmt_mb(d['total_mb'])}"})
else:
devs.append({"id": "auto", "label": "Auto", "sub": "No GPU found, using CPU"})
devs.append({"id": "cpu", "label": "CPU", "sub": _cpu_name()})
self._devices = devs
self.devicesChanged.emit()
self.selectedChanged.emit()
def _on_vram(self, device, free_mb, total_mb):
self._vram[device] = (free_mb, total_mb)
if device == self._effective_gpu():
self.vramChanged.emit()
def _on_progress(self, job_id, pct, stage):
job = self.jobs.get(job_id)
if job and job.status == "running":
self.jobs.update(job_id, progress=max(job.progress, pct), stage=stage)
if job_id == self._selected_id:
self.selectedChanged.emit()
def _on_done(self, job_id, result):
job = self.jobs.get(job_id)
self._set_running("")
if job:
self.jobs.update(job_id, status="done", progress=1.0, stage="Done", result=result,
elapsed=time.monotonic() - job.started)
if job_id == self._selected_id:
self._load_selected()
self.selectedChanged.emit()
if self.settings.get("autoExport"):
self._export(job, [s["name"] for s in result["stems"]], mix=False, auto=True)
self._prune_cache()
self._dispatch()
def _on_failed(self, job_id, msg):
if job_id == self._running_id:
self._set_running("")
job = self.jobs.get(job_id)
if job:
self.jobs.update(job_id, status="error", stage="Failed", message=msg)
if job_id == self._selected_id:
self.selectedChanged.emit()
self.toast.emit(f"{job.file_name}: {msg}", "error", "")
self._dispatch()
def _on_cancelled(self, job_id):
if job_id == self._running_id:
self._set_running("")
if self.jobs.get(job_id):
self.jobs.update(job_id, status="cancelled", stage="Cancelled", progress=0.0)
if job_id == self._selected_id:
self.selectedChanged.emit()
self._dispatch()
def _on_crashed(self):
if self._running_id:
self._on_failed(self._running_id, "The separation engine crashed; restarting it.")
def _set_running(self, job_id):
self._running_id = job_id
self.workerStateChanged.emit()
# ---- queue ---------------------------------------------------------
def _cache_dir(self, path, model_id, two_stems):
st = os.stat(path)
key = hashlib.sha1(f"{os.path.realpath(path)}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
return os.path.join(CACHE_ROOT, f"{key}_{model_id}{'_2s' if two_stems else ''}")
def _enqueue(self, path):
model_id = self.settings.get("modelId")
if model_id not in MODELS_BY_ID:
model_id = "htdemucs"
two = self.settings.get("twoStems") and "vocals" in MODELS_BY_ID[model_id]["stems"] \
and len(MODELS_BY_ID[model_id]["stems"]) > 2
cache_dir = self._cache_dir(path, model_id, two)
job = Job(id=uuid.uuid4().hex[:12], file=path, file_name=os.path.basename(path),
model_id=model_id, model_label=MODELS_BY_ID[model_id]["label"] + (" · 2-stem" if two else ""),
two_stems=two, device_id=self.settings.get("deviceId"), cache_dir=cache_dir)
meta_path = os.path.join(cache_dir, "meta.json")
if os.path.exists(meta_path):
try:
with open(meta_path) as f:
meta = json.load(f)
if all(os.path.exists(s["path"]) for s in meta["stems"]):
os.utime(cache_dir) # LRU touch
job.status, job.progress, job.stage = "done", 1.0, "Done (cached)"
job.result = {k: meta[k] for k in ("stems", "peaks_path", "samplerate", "duration")}
job.result["device"] = meta.get("device", "")
except (OSError, ValueError, KeyError):
pass
self.jobs.add(job)
return job
@Slot("QVariantList")
def addUrls(self, urls):
paths = []
for u in urls:
if isinstance(u, QUrl):
p = u.toLocalFile()
else:
p = QUrl(u).toLocalFile() if str(u).startswith("file:") else str(u)
if os.path.isdir(p):
for root, _, files in os.walk(p):
paths += [os.path.join(root, f) for f in sorted(files)]
else:
paths.append(p)
paths = [p for p in paths if os.path.splitext(p)[1].lower() in AUDIO_EXTS and os.path.isfile(p)]
if not paths:
self.toast.emit("No audio files in that drop.", "info", "")
return
first = None
for p in paths:
job = self._enqueue(p)
first = first or job
if not self._selected_id or not self.jobs.get(self._selected_id) or len(paths) == 1:
self.select(first.id)
self._dispatch()
def _dispatch(self):
if self._running_id or self.worker.state != "ready":
return
job = next((j for j in self.jobs.jobs if j.status == "queued"), None)
if not job:
return
s = self.settings
ok = self.worker.send("start", id=job.id, file=job.file, model=job.model_id, device=job.device_id,
two_stems=job.two_stems, out_dir=job.cache_dir, segment=s.get("segment"),
shifts=s.get("shifts"), overlap=s.get("overlap"), fp16=s.get("fp16"))
if ok:
self._set_running(job.id)
self.jobs.update(job.id, status="running", stage="Starting", progress=0.0, message="",
started=time.monotonic())
if job.id == self._selected_id:
self.selectedChanged.emit()
@Slot(str)
def cancelJob(self, job_id):
job = self.jobs.get(job_id)
if not job:
return
if job.status == "running":
self.jobs.update(job_id, stage="Cancelling")
self.worker.send("cancel", id=job_id)
elif job.status == "queued":
self.jobs.update(job_id, status="cancelled", stage="Cancelled")
if job_id == self._selected_id:
self.selectedChanged.emit()
@Slot(str)
def removeJob(self, job_id):
job = self.jobs.get(job_id)
if not job:
return
if job.status == "running":
self.cancelJob(job_id)
row = self.jobs.row_of(job_id)
self.jobs.remove(job_id)
self.peak_store.drop(job_id)
if job_id == self._selected_id:
nxt = self.jobs.jobs[min(row, len(self.jobs.jobs) - 1)].id if self.jobs.jobs else ""
self.select(nxt)
@Slot(str)
def retryJob(self, job_id):
job = self.jobs.get(job_id)
if job and job.status in ("error", "cancelled"):
self.jobs.update(job_id, status="queued", stage="Waiting", progress=0.0, message="")
if job_id == self._selected_id:
self.selectedChanged.emit()
self._dispatch()
@Slot()
def rerunSelected(self):
job = self.jobs.get(self._selected_id)
if job:
new = self._enqueue(job.file)
self.select(new.id)
self._dispatch()
@Slot(str)
def select(self, job_id):
if job_id == self._selected_id:
return
self._selected_id = job_id
self._load_selected()
self.selectedChanged.emit()
def _load_selected(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
stems = sort_stems([s["name"] for s in job.result["stems"]])
by_name = {s["name"]: s["path"] for s in job.result["stems"]}
self.audio.load(job.id, [(n, by_name[n]) for n in stems])
else:
self.audio.unload()
# ---- export --------------------------------------------------------
def _target_dir(self, job):
base = os.path.splitext(job.file_name)[0]
if self.settings.get("outputMode") == "folder":
return os.path.join(self.settings.get("stemsDir"), base)
return os.path.join(os.path.dirname(job.file), f"{base} - stems")
def _export(self, job, names, mix, auto=False):
if self._exporting:
self.toast.emit("An export is already running.", "info", "")
return
self._exporting = True
fmt = self.settings.get("exportFormat")
target = self._target_dir(job)
base = os.path.splitext(job.file_name)[0]
loaded = self.audio.loaded_key() == job.id and self.audio.loaded
if loaded:
arrays, sr = self.audio.arrays()
gains = {n: self.audio.stem_gain(n) for n in arrays}
mix_gains = dict(zip(arrays.keys(), self.audio.mix_gains()))
else:
arrays, sr, gains, mix_gains = None, job.result["samplerate"], {}, {}
paths = {s["name"]: s["path"] for s in job.result["stems"]}
def read(name):
if arrays is not None and name in arrays:
return arrays[name]
data, _ = sf.read(paths[name], dtype="float32", always_2d=True)
return data
def write(path, data):
if fmt == "mp3":
sf.write(path, np.clip(data, -1, 1), sr, format="MP3", subtype="MPEG_LAYER_III",
bitrate_mode="CONSTANT", compression_level=0.0)
elif fmt == "flac":
sf.write(path, np.clip(data, -1, 1), sr, format="FLAC", subtype="PCM_24")
else:
subtype = "FLOAT" if np.abs(data).max() > 0.999 else "PCM_24"
sf.write(path, data, sr, format="WAV", subtype=subtype)
def work():
try:
os.makedirs(target, exist_ok=True)
if mix:
out = None
for n in paths:
g = mix_gains.get(n, 1.0)
if g:
d = read(n) * g
out = d if out is None else out + d
write(os.path.join(target, f"{base} - mix.{fmt}"), out if out is not None else read(next(iter(paths))) * 0)
msg = "Exported the current mix"
else:
for n in names:
g = gains.get(n, 1.0)
d = read(n)
write(os.path.join(target, f"{base} - {n}.{fmt}"), d * g if g != 1.0 else d)
msg = f"Exported {len(names)} stem{'s' if len(names) != 1 else ''}" if not auto else \
f"Auto-exported {len(names)} stems"
self._exportDone.emit(job.id, f"{msg} to {self._pretty(target)}", target)
except Exception as e:
self._exportDone.emit(job.id, f"Export failed: {e}", "")
threading.Thread(target=work, daemon=True).start()
def _on_export_done(self, job_id, msg, target):
self._exporting = False
if target and self.jobs.get(job_id):
self.jobs.update(job_id, exported_to=target)
if job_id == self._selected_id:
self.selectedChanged.emit()
self.toast.emit(msg, "success" if target else "error", target)
@staticmethod
def _pretty(path):
home = os.path.expanduser("~")
return "~" + path[len(home):] if path.startswith(home) else path
@Slot()
def exportAll(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, sort_stems([s["name"] for s in job.result["stems"]]), mix=False)
@Slot(str)
def exportStem(self, name):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, [name], mix=False)
@Slot()
def exportMix(self):
job = self.jobs.get(self._selected_id)
if job and job.status == "done":
self._export(job, [], mix=True)
@Slot(str)
def openFolder(self, path):
if path and os.path.isdir(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
@Slot()
def openTargetFolder(self):
job = self.jobs.get(self._selected_id)
if job:
t = self._target_dir(job)
self.openFolder(t if os.path.isdir(t) else os.path.dirname(t))
# ---- cache ---------------------------------------------------------
def _referenced(self):
return {os.path.realpath(j.cache_dir) for j in self.jobs.jobs}
def _prune_cache(self):
limit = self.settings.get("cacheLimitGb") * 2**30
refs = self._referenced()
def work():
if not os.path.isdir(CACHE_ROOT):
self._cacheSized.emit(0)
return
entries = []
for d in os.listdir(CACHE_ROOT):
p = os.path.join(CACHE_ROOT, d)
entries.append((os.path.getmtime(p), _dir_size(p), p))
total = sum(e[1] for e in entries)
for _, size, p in sorted(entries):
if total <= limit:
break
if os.path.realpath(p) not in refs:
shutil.rmtree(p, ignore_errors=True)
total -= size
self._cacheSized.emit(total)
threading.Thread(target=work, daemon=True).start()
@Slot()
def refreshCacheSize(self):
threading.Thread(target=lambda: self._cacheSized.emit(_dir_size(CACHE_ROOT) if os.path.isdir(CACHE_ROOT) else 0),
daemon=True).start()
@Slot()
def clearCache(self):
refs = self._referenced()
if os.path.isdir(CACHE_ROOT):
for d in os.listdir(CACHE_ROOT):
p = os.path.join(CACHE_ROOT, d)
if os.path.realpath(p) not in refs:
shutil.rmtree(p, ignore_errors=True)
self.refreshCacheSize()
self.toast.emit("Cleared cached stems not in the queue.", "info", "")
def _on_cache_sized(self, n):
self._cache_bytes = n
self.cacheChanged.emit()
# ---- QML-facing properties -----------------------------------------
def _effective_gpu(self):
dev = self.settings.get("deviceId")
if dev == "cpu" or not self._gpus:
return ""
if dev == "auto" or not any(g["id"] == dev for g in self._gpus):
return max(self._gpus, key=lambda d: d["total_mb"])["id"]
return dev
def peaks_path_for(self, job_id):
job = self.jobs.get(job_id)
return job.result.get("peaks_path") if job and job.status == "done" else None
def _get_selected(self):
job = self.jobs.get(self._selected_id)
if not job:
return {"valid": False}
stems = []
if job.status == "done":
stems = [{"name": n, "color": STEM_COLORS.get(n, "#9B9DA3")}
for n in sort_stems([s["name"] for s in job.result["stems"]])]
dev = job.result.get("device", "") if job.result else ""
dev_label = "CPU" if dev == "cpu" else next((g["name"].replace("AMD ", "") for g in self._gpus if g["id"] == dev), dev)
return {
"valid": True, "id": job.id, "fileName": job.file_name, "filePath": job.file,
"folder": self._pretty(os.path.dirname(job.file)),
"modelId": job.model_id, "modelLabel": job.model_label, "status": job.status,
"progress": job.progress, "stage": job.stage, "message": job.message,
"elapsed": job.elapsed, "duration": job.result.get("duration", 0.0) if job.result else 0.0,
"deviceLabel": dev_label, "stems": stems,
"audioReady": self.audio.loaded and self.audio.loaded_key() == job.id,
"target": self._pretty(self._target_dir(job)), "exportedTo": job.exported_to,
"rerunLabel": self._rerun_label(job),
}
def _rerun_label(self, job):
m = self.settings.get("modelId")
two = self.settings.get("twoStems") and len(MODELS_BY_ID.get(m, {"stems": []})["stems"]) > 2
if m == job.model_id and two == job.two_stems:
return ""
return MODELS_BY_ID[m]["label"] + (" · 2-stem" if two else "")
def _get_models(self):
return [{"id": m["id"], "label": m["label"], "note": m["note"], "family": m["family"],
"stems": len(m["stems"])} for m in MODELS]
def _get_devices(self):
return self._devices
def _get_vram(self):
gpu = self._effective_gpu()
if not gpu or gpu not in self._vram:
return {"valid": False}
free, total = self._vram[gpu]
return {"valid": True, "free": free, "total": total,
"text": f"{_fmt_mb(free)} free of {_fmt_mb(total)}"}
def _get_worker_state(self):
if self.worker.state == "ready" and self._running_id:
return "busy"
return self.worker.state
def _get_cache_text(self):
return f"{self._cache_bytes / 2**30:.2f} GB"
def _get_settings(self):
return self.settings
def _get_jobs(self):
return self.jobs
selected = Property("QVariantMap", _get_selected, notify=selectedChanged)
models = Property("QVariantList", _get_models, constant=True)
devices = Property("QVariantList", _get_devices, notify=devicesChanged)
vram = Property("QVariantMap", _get_vram, notify=vramChanged)
workerState = Property(str, _get_worker_state, notify=workerStateChanged)
cacheText = Property(str, _get_cache_text, notify=cacheChanged)
settingsObj = Property(QObject, _get_settings, constant=True)
jobsModel = Property(QObject, _get_jobs, constant=True)
def shutdown(self):
self.settings.masterVolume = self.audio.volume
self.audio.unload()
self.worker.shutdown()
+77
View File
@@ -0,0 +1,77 @@
"""How busy the GPU is with work that isn't ours.
Two signals, both read from the kernel (no GPU calls):
- DRM fdinfo: per-client gfx/compute engine time. Games and other apps show
up here; our ROCm worker runs through KFD and our own UI is excluded by pid.
- amdgpu gpu_busy_percent: catches other ROCm/compute users that fdinfo
misses, but also counts our worker, so it only means something while our
worker is idle.
"""
import os
import re
_KV = re.compile(r"^(drm-[\w-]+):\s+(\S+)", re.M)
_ENGINES = ("drm-engine-gfx", "drm-engine-compute")
class GpuMonitor:
def __init__(self):
self.pdev = None # "0000:03:00.0"; None counts every GPU
self.exclude: set[int] = {os.getpid()}
self._last: dict[str, int] = {} # drm-client-id -> engine ns
self._last_t = None
def set_device(self, pci):
if pci != self.pdev:
self.pdev = pci
self._last, self._last_t = {}, None
def _busy_path(self):
if self.pdev:
p = f"/sys/bus/pci/devices/{self.pdev}/gpu_busy_percent"
return p if os.path.exists(p) else None
return None
def _clients(self):
out = {}
for pid in os.listdir("/proc"):
if not pid.isdigit() or int(pid) in self.exclude:
continue
try:
fds = os.listdir(f"/proc/{pid}/fd")
except OSError:
continue
for fd in fds:
try:
if not os.readlink(f"/proc/{pid}/fd/{fd}").startswith("/dev/dri/"):
continue
with open(f"/proc/{pid}/fdinfo/{fd}") as f:
kv = dict(_KV.findall(f.read()))
except OSError:
continue
cid = kv.get("drm-client-id")
if not cid or (self.pdev and kv.get("drm-pdev") != self.pdev):
continue
# one client can sit behind several dup'd fds; count it once
out[cid] = sum(int(kv[k]) for k in _ENGINES if kv.get(k, "").isdigit())
return out
def sample(self, now):
"""(other_fraction, busy_percent). other_fraction is engine time used by
other processes over the last interval, as a fraction of wall time."""
clients = self._clients()
other = 0.0
if self._last_t is not None and now > self._last_t:
delta = sum(ns - self._last[c] for c, ns in clients.items() if c in self._last and ns >= self._last[c])
other = delta / ((now - self._last_t) * 1e9)
self._last, self._last_t = clients, now
pct = -1
path = self._busy_path()
if path:
try:
with open(path) as f:
pct = int(f.read().strip())
except (OSError, ValueError):
pass
return other, pct
+82
View File
@@ -0,0 +1,82 @@
"""Queue of separation jobs as a QML list model."""
from dataclasses import dataclass, field
from PySide6.QtCore import QAbstractListModel, QByteArray, QModelIndex, Qt, Signal
@dataclass
class Job:
id: str
file: str
file_name: str
model_id: str
model_label: str
two_stems: bool
device_id: str
cache_dir: str
status: str = "queued" # queued | running | done | error | cancelled
progress: float = 0.0
stage: str = "Waiting"
message: str = ""
started: float = 0.0
elapsed: float = 0.0
result: dict = field(default_factory=dict)
exported_to: str = ""
ROLES = ["jobId", "fileName", "filePath", "modelLabel", "status", "progress", "stage", "message", "elapsed"]
_ATTR = {"jobId": "id", "fileName": "file_name", "filePath": "file", "modelLabel": "model_label"}
class JobsModel(QAbstractListModel):
countChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.jobs: list[Job] = []
self._roles = {Qt.ItemDataRole.UserRole + i: name for i, name in enumerate(ROLES)}
def roleNames(self):
return {k: QByteArray(v.encode()) for k, v in self._roles.items()}
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.jobs)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid() or role not in self._roles:
return None
name = self._roles[role]
return getattr(self.jobs[index.row()], _ATTR.get(name, name))
def get(self, job_id) -> Job | None:
return next((j for j in self.jobs if j.id == job_id), None)
def row_of(self, job_id) -> int:
return next((i for i, j in enumerate(self.jobs) if j.id == job_id), -1)
def add(self, job: Job):
n = len(self.jobs)
self.beginInsertRows(QModelIndex(), n, n)
self.jobs.append(job)
self.endInsertRows()
self.countChanged.emit()
def remove(self, job_id):
row = self.row_of(job_id)
if row >= 0:
self.beginRemoveRows(QModelIndex(), row, row)
del self.jobs[row]
self.endRemoveRows()
self.countChanged.emit()
def update(self, job_id, **changes):
row = self.row_of(job_id)
if row < 0:
return
job = self.jobs[row]
for k, v in changes.items():
setattr(job, k, v)
idx = self.index(row)
roles = [r for r, name in self._roles.items() if _ATTR.get(name, name) in changes or name in changes]
self.dataChanged.emit(idx, idx, roles)
+107
View File
@@ -0,0 +1,107 @@
"""Library rules for the server daemon (server/stemd.py): which file is a track's source,
where its stems go, and what marks it done. stemserve reads the same layout and marker.
No Qt here, so the server container doesn't need PySide6.
<src>/Artist/Album/01 Song.flac -> <dst>/Artist/Album/01 Song/01 Song - vocals.flac, ...
"""
import errno
import json
import os
import shutil
import subprocess
import time
import numpy as np
import soundfile as sf
from .catalog import sort_stems
MARKER = ".stemify.json"
BEST_MODEL = "roformer_bs_sw"
MAX_SECONDS = 40 * 60 # longer files are mixes and radio rips
# Lower wins when one folder holds the same track in several formats.
SOURCE_RANK = {".flac": 0, ".wav": 0, ".aif": 0, ".aiff": 0, ".wv": 0, ".ape": 0, ".alac": 0,
".m4a": 1, ".opus": 2, ".ogg": 2, ".oga": 2, ".mp3": 3, ".aac": 3, ".wma": 4}
# Errors that mean the destination as a whole is unusable, not just this track.
SHARE_ERRNOS = {errno.ENOSPC, errno.EROFS, errno.EDQUOT, errno.ENOTCONN, errno.EHOSTDOWN,
errno.EHOSTUNREACH, errno.ETIMEDOUT, errno.EIO, errno.EACCES}
def duration(path):
if os.path.splitext(path)[1].lower() in (".flac", ".wav", ".aif", ".aiff"):
try:
return sf.info(path).duration
except Exception:
pass
try:
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path],
capture_output=True, text=True, timeout=30).stdout
return float(out.strip())
except (OSError, ValueError, subprocess.SubprocessError):
return None
def is_share_error(e):
return isinstance(e, OSError) and e.errno in SHARE_ERRNOS
def iter_tracks(src, dst):
"""Yield (source path, stems folder) for every track under src, best format per name."""
dst_real = os.path.realpath(dst)
for root, dirs, files in os.walk(src):
dirs[:] = sorted((d for d in dirs if not d.startswith(".")
and os.path.realpath(os.path.join(root, d)) != dst_real), key=str.casefold)
best = {}
for f in files:
stem, ext = os.path.splitext(f)
rank = SOURCE_RANK.get(ext.lower())
if rank is None or f.startswith("."):
continue
key = stem.casefold()
if key not in best or rank < best[key][0]:
best[key] = (rank, f)
for _, f in sorted(best.values(), key=lambda v: v[1].casefold()):
path = os.path.join(root, f)
yield path, os.path.join(dst, os.path.splitext(os.path.relpath(path, src))[0])
def is_done(dest):
return os.path.exists(os.path.join(dest, MARKER))
def signature(path):
"""[size, mtime_ns]: a failed track is retried once its source changes."""
st = os.stat(path)
return [st.st_size, st.st_mtime_ns]
def write_track(dest, stems, meta):
"""Write stems into dest as 24-bit FLAC, then the marker, so a half-written track is redone.
stems maps name -> a path to read, or float32 (samples, channels) audio; meta needs
"samplerate" and is stored in the marker along with the stem list.
"""
base = os.path.basename(dest)
parent = os.path.dirname(dest)
part = os.path.join(parent, f".{base}.partial")
shutil.rmtree(part, ignore_errors=True)
os.makedirs(part)
written = []
for name in sort_stems(list(stems)):
data, sr = stems[name], meta["samplerate"]
if isinstance(data, str):
data, sr = sf.read(data, dtype="float32", always_2d=True)
fname = f"{base} - {name}.flac"
sf.write(os.path.join(part, fname), np.clip(data, -1, 1), sr, format="FLAC", subtype="PCM_24")
written.append({"name": name, "file": fname})
meta = dict(meta, stems=written, finished=time.time())
with open(os.path.join(part, MARKER), "w") as f:
json.dump(meta, f, indent=2)
if not os.path.exists(dest):
os.rename(part, dest)
else: # keep whatever else is in there; the marker lands last
for name in [s["file"] for s in written] + [MARKER]:
os.replace(os.path.join(part, name), os.path.join(dest, name))
os.rmdir(part)
+59
View File
@@ -0,0 +1,59 @@
# Stemify Player
The desktop counterpart to the phone app: your Plex library and its stems, with per-stem
faders, mute/solo, mix presets and a master volume. It is dressed as Microsoft Excel 2016.
```sh
stemify-player # or "Stemify Player" in the app grid (scripts/install-desktop.sh)
```
## First run
**Data → Connect to Plex** (or click **Sign in** on the title bar), then enter the 4-character
code at plex.tv/link. It picks the music library at `…/Flacs`. Until then the workbook is built
from the stem server's index alone (artist / album / song from the stems folders), so every
separated song is already playable.
## Where things are
| Excel | Here |
|---|---|
| Sheets: Songs, Artists, Albums, Playlists, Stems, Queue | The library. Double-click (or Enter) plays a song from that point, or opens an artist, album or playlist as a new sheet. Backspace / middle-click closes it |
| Row 1 filter buttons, Sort & Filter | Sort by any column |
| "Tell me what you want to do" | Search (Ctrl+F). Results open as a sheet |
| Status bar Average / Count / Sum | Select a range of times and it adds them up |
| Zoom slider | Master volume (scroll on it, too). Ctrl+scroll on the grid is the real zoom |
| Home → Cell Styles | Mix presets: Full Mix, Karaoke ("Bad"), A Cappella ("Good"), No Drums, Rhythm, No Guitar, No Bass |
| Home → Cells | Play Next, Add to Queue, Delete Rows (queue or playlist) |
| Insert → Playlists | New Plex playlist from the selection, or add the selection to one |
| AutoSum | AutoShuffle: shuffle the whole sheet |
| Quick Access Toolbar | Play/Pause, Undo = previous song, Redo = next song |
| Format Mix task pane | Now playing, a sparkline of the song to seek on, and the stems as a column chart: drag a column to set its level, double-click for 0 dB, M / S below |
| Green triangle in a cell | The song that's playing |
| `#GETTING_DATA` | A playlist loading from Plex |
| File → Info / Account / Options | Library and server status, Plex sign-in, server addresses and folders |
Keys: Space play/pause, Ctrl+←/→ previous/next, V vocals on/off, arrows/Shift+arrows select,
Ctrl+C copies cells as text, Ctrl+Q quits. The media keys, the desktop's media controls and
KDE Connect on the phone work through MPRIS.
## How it plays
- **Stems** come straight off the `Stems` share (`/var/mnt/unraid/Music/Stems`) when it's
mounted, else they're downloaded from stemserve once into `~/.cache/stemify/player/files`
(4 GB, oldest first out). Songs without stems play the original from the music share, or from
Plex if the share isn't there.
- A decoder thread reads every stem about 1.5 s ahead of the playhead, so songs start at once
and nothing is decoded whole; the audio callback mixes with click-free gain ramps. The fader
taper matches the phone (0.8 = 0 dB, top = +3.9 dB).
- Plays are reported to Plex (now playing, and the play count at 90%).
- Album art: Plex's, or the album folder's `cover.*`, or the picture embedded in the file.
- The queue, position, mix and volume come back on the next launch (`~/.config/stemify/`).
## Checking it without a window
```sh
QT_QPA_PLATFORM=offscreen stemify-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6
```
`--eval 'JS@seconds'` runs JavaScript against the window (e.g. `library.activate(0, 0)@2`).
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import sys
from .app import main
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
"""Stemify Player: the Plex library and its stems, in a workbook."""
import argparse
import os
import signal
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
def main():
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic")
os.environ.setdefault("QT_QPA_PLATFORM", "wayland;xcb")
os.environ.setdefault("QSG_RENDER_LOOP", "threaded")
ap = argparse.ArgumentParser(prog="stemify-player")
ap.add_argument("--screenshot", action="append", default=[], metavar="PNG@SECONDS",
help="grab the window to PNG after N seconds (repeatable; for headless checks)")
ap.add_argument("--quit-after", type=float, default=0, metavar="SECONDS")
ap.add_argument("--size", default="", metavar="WxH")
ap.add_argument("--no-mpris", action="store_true")
ap.add_argument("--eval", action="append", default=[], metavar="JS@SECONDS",
help="run a JavaScript snippet against the window after N seconds (dev)")
args = ap.parse_args()
from PySide6.QtCore import QTimer, QUrl
from PySide6.QtGui import QFontDatabase, QGuiApplication, QIcon, QSurfaceFormat
from PySide6.QtQml import QQmlApplicationEngine
fmt = QSurfaceFormat.defaultFormat()
fmt.setSwapInterval(1)
QSurfaceFormat.setDefaultFormat(fmt)
app = QGuiApplication(sys.argv)
app.setApplicationName("Stemify Player")
app.setOrganizationName("stemify")
app.setDesktopFileName("stemify-player")
app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "stemify-player.svg")))
for d in (os.path.join(HERE, "assets", "fonts"), os.path.join(HERE, "..", "assets", "fonts")):
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
if f.endswith((".ttf", ".otf")):
QFontDatabase.addApplicationFont(os.path.join(d, f))
families = set(QFontDatabase.families())
# Excel: Segoe UI for the chrome, Calibri (or its metric twin Carlito) in the cells.
ui_font = next((f for f in ("Segoe UI", "Selawik", "Inter") if f in families), "sans-serif")
cell_font = next((f for f in ("Aptos", "Calibri", "Carlito") if f in families), ui_font)
from . import config
from .engine import PlayerEngine
from .icons import IconProvider
from .library import Library
from .player import Player
from .plex import Plex
from .stems import StemServer
cfg = config.load()
plex = Plex(cfg)
stems = StemServer(cfg, config.CACHE_DIR)
engine = PlayerEngine()
library = Library(cfg, plex, stems)
player = Player(cfg, plex, stems, engine)
library.player = player
player.library = library
library.update_queue()
qml = QQmlApplicationEngine()
qml.addImageProvider("icon", IconProvider())
qml.addImportPath(os.path.join(HERE, "qml"))
ctx = qml.rootContext()
ctx.setContextProperty("library", library)
ctx.setContextProperty("sheetModel", library.model)
ctx.setContextProperty("player", player)
ctx.setContextProperty("engine", engine)
settings = _Settings(cfg)
ctx.setContextProperty("settings", settings)
ctx.setContextProperty("uiFont", ui_font)
ctx.setContextProperty("cellFont", cell_font)
qml.load(QUrl.fromLocalFile(os.path.join(HERE, "qml", "StemPlayer", "Main.qml")))
if not qml.rootObjects():
print("failed to load QML", file=sys.stderr)
return 1
window = qml.rootObjects()[0]
if args.size:
w, h = (int(v) for v in args.size.split("x"))
window.resize(w, h)
if not args.no_mpris:
from .mpris import Mpris
mpris = Mpris(player, engine, lambda: (window.show(), window.raise_(), window.requestActivate()), app.quit)
window.setProperty("mprisOk", mpris.ok)
for e in args.eval:
code, _, after = e.rpartition("@")
QTimer.singleShot(int(float(after) * 1000), lambda code=code: window.setProperty("devEval", code))
for shot in args.screenshot:
path, _, after = shot.rpartition("@")
QTimer.singleShot(int(float(after) * 1000),
lambda path=path: (window.grabWindow().save(path), print("screenshot saved", path, flush=True)))
if args.quit_after:
QTimer.singleShot(int(args.quit_after * 1000), app.quit)
signal.signal(signal.SIGINT, lambda *_: app.quit())
keepalive = QTimer()
keepalive.start(500)
keepalive.timeout.connect(lambda: None)
def shutdown():
player.shutdown()
engine.shutdown()
config.save(cfg)
app.aboutToQuit.connect(shutdown)
rc = app.exec()
del qml
return rc
def _Settings(cfg):
"""Editable settings for File → Options."""
from PySide6.QtCore import QObject, Slot
from . import config
class Settings(QObject):
@Slot(str, result="QVariant")
def get(self, key):
return cfg.get(key)
@Slot(str, "QVariant")
def set(self, key, value):
cfg[key] = value
config.save(cfg)
return Settings()
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
@@ -0,0 +1,94 @@
Copyright 2013 The Carlito Project Authors (https://github.com/googlefonts/carlito), with Reserved Font Name "Carlito"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,91 @@
Copyright 2015, Microsoft Corporation (www.microsoft.com), with Reserved Font Name Selawik. All Rights Reserved. Selawik is a trademark of Microsoft Corporation in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="6" width="40" height="52" rx="4" fill="#217346"/>
<rect x="24" y="14" width="36" height="36" rx="3" fill="#ffffff"/>
<path d="M24 26h36M24 38h36M42 14v36" stroke="#217346" stroke-width="1.5"/>
<rect x="28" y="30" width="4" height="16" fill="#FF7AB6"/>
<rect x="34" y="22" width="4" height="24" fill="#FFB547"/>
<rect x="46" y="28" width="4" height="18" fill="#7C8CFF"/>
<rect x="52" y="19" width="4" height="27" fill="#4CD07D"/>
<path d="M9 20h7l4 7 4-7h7l-7.5 12L31 44h-7l-4-7-4 7H9l7.5-12z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

+47
View File
@@ -0,0 +1,47 @@
"""Player settings and saved state: ~/.config/stemify/player.json."""
import json
import os
import uuid
CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "stemify")
CACHE_DIR = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "stemify", "player")
PATH = os.path.join(CONFIG_DIR, "player.json")
DEFAULTS = {
"plexUrl": "http://192.168.86.2:32400",
"plexToken": "",
"plexUser": "",
"musicSection": "",
"sectionLocations": [],
"stemUrl": "http://192.168.86.2:8765",
"stemToken": "",
# The unraid shares, mounted here; files play straight from them when present.
"stemsDir": "/var/mnt/unraid/Music/Stems",
"musicDir": "/var/mnt/unraid/Music/Actual music/Flacs",
"stemRoots": [],
"volume": 0.8,
"keepMix": False,
"showMixer": True,
"gridlines": True,
"zoom": 100,
}
def load() -> dict:
cfg = dict(DEFAULTS)
try:
with open(PATH) as f:
cfg.update(json.load(f))
except (OSError, ValueError):
pass
cfg.setdefault("clientId", str(uuid.uuid4()))
return cfg
def save(cfg: dict):
os.makedirs(CONFIG_DIR, exist_ok=True)
tmp = PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=1)
os.replace(tmp, PATH)
+448
View File
@@ -0,0 +1,448 @@
"""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)
+104
View File
@@ -0,0 +1,104 @@
"""Office-style ribbon icons: flat, two-tone, drawn from SVG at whatever size QML asks for.
image://icon/<name>[/<hex colour>] — the colour overrides the dark-gray ink (for white-on-green chrome).
"""
from PySide6.QtCore import QByteArray, QRectF, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtQuick import QQuickImageProvider
from PySide6.QtSvg import QSvgRenderer
INK = "#444444"
GREEN = "#217346"
BLUE = "#2B7CD3"
RED = "#D13438"
ORANGE = "#E8912D"
LIGHT = "#FFFFFF"
# Each icon is SVG body markup on a 32×32 grid; {ink} is the main colour.
ICONS = {
"play": f'<path fill="{GREEN}" d="M9 5v22l18-11z"/>',
"pause": '<path fill="{ink}" d="M8 5h6v22H8zM18 5h6v22h-6z"/>',
"stop": '<rect fill="{ink}" x="7" y="7" width="18" height="18" rx="1"/>',
"previous": '<path fill="{ink}" d="M6 6h3v20H6z"/><path fill="{ink}" d="M27 6v20L11 16z"/>',
"next": '<path fill="{ink}" d="M23 6h3v20h-3z"/><path fill="{ink}" d="M5 6v20l16-10z"/>',
"shuffle": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M3 9h5c6 0 10 14 16 14h3M3 23h5c2.6 0 4.6-2.6 6.4-5.6M17.6 13.6C19.4 10.6 21.4 9 24 9h3"/>'
'<path fill="{ink}" d="M25 4l5 5-5 5zM25 18l5 5-5 5z"/>',
"repeat": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M6 15v-3c0-2.2 1.8-4 4-4h16M26 17v3c0 2.2-1.8 4-4 4H6"/>'
'<path fill="{ink}" d="M24 3l6 5-6 5zM8 19l-6 5 6 5z"/>',
"repeat_one": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M6 15v-3c0-2.2 1.8-4 4-4h16M26 17v3c0 2.2-1.8 4-4 4H6"/>'
f'<path fill="{{ink}}" d="M24 3l6 5-6 5zM8 19l-6 5 6 5z"/><path fill="{GREEN}" d="M15 11h3v10h-3v-7h-2z"/>',
"volume": '<path fill="{ink}" d="M4 12v8h6l7 6V6l-7 6z"/><path fill="none" stroke="{ink}" stroke-width="2.2" d="M21 11c1.6 1.4 1.6 8.6 0 10M24.5 7.5c4 3.5 4 13.5 0 17"/>',
"mute": f'<path fill="{{ink}}" d="M4 12v8h6l7 6V6l-7 6z"/><path stroke="{RED}" stroke-width="2.6" d="M21 12l8 8M29 12l-8 8"/>',
"vocals": '<rect fill="{ink}" x="12" y="3" width="8" height="15" rx="4"/><path fill="none" stroke="{ink}" stroke-width="2.2" d="M8 14c0 4.4 3.6 8 8 8s8-3.6 8-8M16 22v6M11 28h10"/>',
"vocals_off": f'<rect fill="{{ink}}" x="12" y="3" width="8" height="15" rx="4"/><path fill="none" stroke="{{ink}}" stroke-width="2.2" d="M8 14c0 4.4 3.6 8 8 8s8-3.6 8-8M16 22v6M11 28h10"/><path stroke="{RED}" stroke-width="2.8" d="M5 4l22 24"/>',
"mixer": f'<path stroke="{{ink}}" stroke-width="2" d="M8 4v24M16 4v24M24 4v24"/><rect fill="{GREEN}" x="4.5" y="17" width="7" height="5" rx="1"/><rect fill="{BLUE}" x="12.5" y="8" width="7" height="5" rx="1"/><rect fill="{ORANGE}" x="20.5" y="13" width="7" height="5" rx="1"/>',
"queue_add": f'<path fill="{{ink}}" d="M3 6h18v2.5H3zM3 12h18v2.5H3zM3 18h11v2.5H3z"/><path fill="{GREEN}" d="M22 16h3v5h5v3h-5v5h-3v-5h-5v-3h5z"/>',
"play_next": f'<path fill="{{ink}}" d="M3 14h16v2.5H3zM3 20h16v2.5H3zM3 26h11v2.5H3z"/><path fill="{GREEN}" d="M18 3v10l9-5z"/>',
"playlist_new": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M5 3h15l6 6v20H5z"/><path fill="{{ink}}" d="M9 13h12v2H9zM9 18h12v2H9zM9 23h7v2H9z"/><path fill="{GREEN}" d="M23 1h3v4h4v3h-4v4h-3V8h-4V5h4z"/>',
"playlist_add": f'<path fill="{{ink}}" d="M3 7h16v2.5H3zM3 13h16v2.5H3zM3 19h9v2.5H3z"/><circle fill="{BLUE}" cx="23" cy="22" r="7"/><path fill="#FFFFFF" d="M22 18h2v3h3v2h-3v3h-2v-3h-3v-2h3z"/>',
"delete_rows": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 6h26v20H3z"/><path fill="{{ink}}" d="M3 12.5h26v1.5H3zM3 18.5h26v1.5H3z"/><path fill="#FFC7CE" d="M3.8 14h24.4v4.5H3.8z"/><path stroke="{RED}" stroke-width="2.4" d="M12 11.5l8 9M20 11.5l-8 9"/>',
"refresh": f'<path fill="none" stroke="{GREEN}" stroke-width="3" d="M26 16a10 10 0 1 1-3-7.1"/><path fill="{GREEN}" d="M27 3v9h-9z"/>',
"plex": f'<path fill="{ORANGE}" d="M8 3h8l9 13-9 13H8l9-13z"/>',
"database": f'<ellipse fill="{{ink}}" cx="14" cy="7" rx="10" ry="4"/><path fill="{{ink}}" d="M4 9.5c0 2.2 4.5 4 10 4s10-1.8 10-4V14c0 2.2-4.5 4-10 4S4 16.2 4 14zM4 16.5c0 2.2 4.5 4 10 4s10-1.8 10-4V21c0 2.2-4.5 4-10 4S4 23.2 4 21z"/><path fill="{GREEN}" d="M22 18l8 6-8 6z"/>',
"server": f'<rect fill="{{ink}}" x="5" y="4" width="22" height="10" rx="1.5"/><rect fill="{{ink}}" x="5" y="17" width="22" height="10" rx="1.5"/><circle fill="{GREEN}" cx="22" cy="9" r="2"/><circle fill="{GREEN}" cx="22" cy="22" r="2"/>',
"sort_az": f'<text x="2" y="14" font-family="Arial" font-weight="bold" font-size="12" fill="{BLUE}">A</text><text x="2" y="29" font-family="Arial" font-weight="bold" font-size="12" fill="{{ink}}">Z</text><path fill="{{ink}}" d="M20 4h3v18h4l-5.5 7-5.5-7h4z"/>',
"sort_za": f'<text x="2" y="14" font-family="Arial" font-weight="bold" font-size="12" fill="{{ink}}">Z</text><text x="2" y="29" font-family="Arial" font-weight="bold" font-size="12" fill="{BLUE}">A</text><path fill="{{ink}}" d="M20 4h3v18h4l-5.5 7-5.5-7h4z"/>',
"filter": '<path fill="{ink}" d="M3 4h26l-10 12v10l-6 3V16z"/>',
"sigma": '<path fill="{ink}" d="M6 4h20v6h-3V7H11.5l7.5 9-7.5 9H23v-3h3v6H6v-2.4L14.8 16 6 6.4z"/>',
"search": '<circle fill="none" stroke="{ink}" stroke-width="2.6" cx="13" cy="13" r="8"/><path stroke="{ink}" stroke-width="3.4" stroke-linecap="round" d="M19 19l8 8"/>',
"lightbulb": '<path fill="none" stroke="{ink}" stroke-width="2" d="M11 22c0-4-5-6-5-12a10 10 0 0 1 20 0c0 6-5 8-5 12z"/><path fill="{ink}" d="M11 24h10v2H11zM12 27.5h8V30h-8z"/>',
"grid": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path stroke="{{ink}}" stroke-width="1" d="M3 10h26M3 16h26M3 22h26M11 4v24M20 4v24"/><path fill="{GREEN}" d="M3 4h8v6H3z"/>',
"freeze": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{BLUE}" d="M3 4h26v6H3z" opacity="0.35"/><path stroke="{BLUE}" stroke-width="2" d="M3 10h26"/><path stroke="{{ink}}" stroke-width="1" d="M3 16h26M3 22h26M11 4v24M20 4v24"/>',
"headings": '<path fill="#FFFFFF" stroke="{ink}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="#E6E6E6" d="M3.8 4.8h24.4V9H3.8zM3.8 9h5v18.2h-5z"/><text x="13" y="8.6" font-family="Arial" font-size="5" fill="{ink}">A B</text><text x="5" y="16" font-family="Arial" font-size="5" fill="{ink}">1</text><text x="5" y="23" font-family="Arial" font-size="5" fill="{ink}">2</text>',
"pane": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{GREEN}" d="M20 4.8h8.2v22.4H20z" opacity="0.85"/><path stroke="#FFFFFF" stroke-width="1.6" d="M22 10h4M22 14h4M22 18h4"/>',
"zoom": '<circle fill="none" stroke="{ink}" stroke-width="2.6" cx="13" cy="13" r="8"/><path stroke="{ink}" stroke-width="3.4" stroke-linecap="round" d="M19 19l8 8"/><path stroke="{ink}" stroke-width="2.2" d="M9 13h8M13 9v8"/>',
"chart": f'<path fill="#FFFFFF" stroke="{{ink}}" stroke-width="1.6" d="M3 4h26v24H3z"/><path fill="{BLUE}" d="M7 16h4v9H7z"/><path fill="{ORANGE}" d="M14 9h4v16h-4z"/><path fill="#A5A5A5" d="M21 13h4v12h-4z"/>',
"styles": '<rect fill="#C6EFCE" x="3" y="4" width="12" height="10"/><rect fill="#FFC7CE" x="17" y="4" width="12" height="10"/><rect fill="#FFEB9C" x="3" y="17" width="12" height="10"/><rect fill="#FFFFFF" stroke="{ink}" x="17" y="17" width="12" height="10"/>',
"info": f'<circle fill="{BLUE}" cx="16" cy="16" r="13"/><path fill="#FFFFFF" d="M14.5 13h3v11h-3zM14.5 7.5h3v3h-3z"/>',
"account": '<circle fill="{ink}" cx="16" cy="10" r="6"/><path fill="{ink}" d="M4 29c0-6.6 5.4-11 12-11s12 4.4 12 11z"/>',
"options": '<path fill="{ink}" d="M14 2h4l.7 3.6 2.5 1 3-2 2.8 2.8-2 3 1 2.5 3.6.7v4l-3.6.7-1 2.5 2 3-2.8 2.8-3-2-2.5 1L18 30h-4l-.7-3.6-2.5-1-3 2-2.8-2.8 2-3-1-2.5L2 18v-4l3.6-.7 1-2.5-2-3 2.8-2.8 3 2 2.5-1zM16 11a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/>',
"back": '<circle fill="none" stroke="{ink}" stroke-width="2" cx="16" cy="16" r="13"/><path fill="none" stroke="{ink}" stroke-width="2.6" d="M18 9l-7 7 7 7"/>',
"close": '<path stroke="{ink}" stroke-width="1.4" d="M10 10l12 12M22 10L10 22"/>',
"minimize": '<path stroke="{ink}" stroke-width="1.4" d="M10 16h12"/>',
"maximize": '<path fill="none" stroke="{ink}" stroke-width="1.4" d="M10.5 10.5h11v11h-11z"/>',
"restore": '<path fill="none" stroke="{ink}" stroke-width="1.4" d="M10.5 13.5h8v8h-8zM13.5 13.5v-3h8v8h-3"/>',
"save": '<path fill="{ink}" d="M5 4h18l4 4v19H5z"/><path fill="#FFFFFF" d="M9 4h12v8H9zM9 17h14v10H9z"/><path fill="{ink}" d="M17 5.5h3v5h-3z"/>',
"undo": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M8 12h12a6 6 0 0 1 0 12h-6"/><path fill="{ink}" d="M3 12l7-6v12z"/>',
"redo": '<path fill="none" stroke="{ink}" stroke-width="2.4" d="M24 12H12a6 6 0 0 0 0 12h6"/><path fill="{ink}" d="M29 12l-7-6v12z"/>',
"excel": f'<rect fill="{GREEN}" x="2" y="4" width="18" height="24" rx="1.5"/><rect fill="#FFFFFF" x="14" y="8" width="16" height="16" rx="1"/><path stroke="{GREEN}" stroke-width="1" d="M14 13.3h16M14 18.6h16M22 8v16"/><path fill="#FFFFFF" d="M5 10h3.6l2.4 4 2.4-4H17l-4.2 6 4.2 6h-3.6L11 18l-2.4 4H5l4.2-6z"/>',
"dropdown": '<path fill="{ink}" d="M9 12h14l-7 8z"/>',
"sort_up": '<path fill="{ink}" d="M16 9l7 8H9z"/><path fill="{ink}" d="M14.5 17h3v7h-3z"/>',
"sort_down": '<path fill="{ink}" d="M16 24l7-8H9z"/><path fill="{ink}" d="M14.5 8h3v8h-3z"/>',
"left": '<path fill="{ink}" d="M20 8v16l-10-8z"/>',
"right": '<path fill="{ink}" d="M12 8v16l10-8z"/>',
"plus": '<circle fill="none" stroke="{ink}" stroke-width="1.6" cx="16" cy="16" r="12"/><path stroke="{ink}" stroke-width="2" d="M16 10v12M10 16h12"/>',
"normal_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 7h22v18H5z"/><path stroke="{ink}" stroke-width="1.4" d="M5 13h22M5 19h22M12 7v18M19 7v18"/>',
"page_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 4h22v24H5z"/><path fill="{ink}" d="M9 9h14v2H9zM9 14h14v2H9zM9 19h9v2H9z"/>',
"break_view": '<path fill="none" stroke="{ink}" stroke-width="2" d="M5 4h22v24H5z"/><path stroke="{ink}" stroke-width="2" stroke-dasharray="3 2" d="M16 4v24M5 16h22"/>',
"function": '<text x="3" y="24" font-family="Times New Roman" font-style="italic" font-size="22" fill="{ink}">fx</text>',
"check": f'<path fill="none" stroke="{GREEN}" stroke-width="3" d="M6 16l7 7 13-14"/>',
"cancel": f'<path stroke="{RED}" stroke-width="3" d="M8 8l16 16M24 8L8 24"/>',
}
class IconProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
def requestImage(self, id_, size, requested):
name, _, ink = id_.partition("/")
w = requested.width() if requested.width() > 0 else 32
h = requested.height() if requested.height() > 0 else w
body = ICONS.get(name, ICONS["grid"]).replace("{ink}", "#" + ink if ink else INK)
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">{body}</svg>'
img = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
QSvgRenderer(QByteArray(svg.encode())).render(p, QRectF(0, 0, w, h))
p.end()
if size is not None:
size.setWidth(w)
size.setHeight(h)
return img
+604
View File
@@ -0,0 +1,604 @@
"""The library as a workbook: Plex data cached on disk, refreshed in the background, shown as sheets."""
import json
import os
import random
import re
import threading
import time
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
from PySide6.QtGui import QGuiApplication
from . import config
from .plex import PlexError
from .sheets import ALBUM, ARTIST, PLAYLIST, SONG, Sheet, SheetModel, fmt, fmt_time, fold
BASE_TABS = ["Songs", "Artists", "Albums", "Playlists", "Stems", "Queue"]
_SONG_NAME = re.compile(r"^(?:.* - )?(?:\d{1,2}-)?(\d{1,3})\s*[-.]?\s+(.+)$")
class Library(QObject):
tabsChanged = Signal()
sheetChanged = Signal() # anything about the current sheet: data, sort, columns
sheetSwitched = Signal() # a different sheet is showing: selection and scroll start over
statusChanged = Signal()
toast = Signal(str)
_done = Signal(str, object) # worker results, delivered on the GUI thread
def __init__(self, cfg, plex, stems, parent=None):
super().__init__(parent)
self.cfg, self.plex, self.stems = cfg, plex, stems
self.player = None # set by the app; the queue sheet reads it
self.tracks, self.artists, self.albums, self.playlists = [], [], [], []
self._by_album, self._by_artist = {}, {}
self._tabs = [Sheet(t, "songs", []) for t in BASE_TABS]
self._tabs[1] = Sheet("Artists", "artists", [], ARTIST)
self._tabs[2] = Sheet("Albums", "albums", [], ALBUM)
self._tabs[3] = Sheet("Playlists", "playlists", [], PLAYLIST)
self._tabs[5] = Sheet("Queue", "queue", [])
self._current = 0
self.model = SheetModel(self)
self._status = ""
self._progress = -1.0
self._busy = False
self._done.connect(self._on_done)
self._cache = os.path.join(config.CACHE_DIR, "library.json")
self._load_cache()
self._rebuild()
self.model.set_sheet(self._tabs[0])
QTimer.singleShot(0, self.refresh)
# stemserve rescans every 30 s; pick up newly separated songs while open
self._stem_timer = QTimer(self, interval=120_000)
self._stem_timer.timeout.connect(self._refresh_stems)
self._stem_timer.start()
# ---- data ------------------------------------------------------------------------
def _load_cache(self):
try:
with open(self._cache) as f:
d = json.load(f)
if d.get("section") == self.cfg.get("musicSection"):
self.tracks, self.artists = d["tracks"], d["artists"]
self.albums, self.playlists = d["albums"], d["playlists"]
except (OSError, ValueError, KeyError):
pass
def _save_cache(self):
os.makedirs(config.CACHE_DIR, exist_ok=True)
tmp = self._cache + ".tmp"
with open(tmp, "w") as f:
json.dump({"section": self.cfg.get("musicSection"), "tracks": self.tracks, "artists": self.artists,
"albums": self.albums, "playlists": self.playlists}, f)
os.replace(tmp, self._cache)
def _work(self, tag, fn):
def run():
try:
self._done.emit(tag, fn())
except Exception as e: # noqa: BLE001 - shown in the status bar
self._done.emit(tag + ":error", e)
threading.Thread(target=run, daemon=True).start()
@Slot()
def refresh(self):
"""Data → Refresh All."""
self._refresh_stems()
if not self.plex.signed_in or not self.cfg.get("musicSection") or self._busy:
return
self._busy = True
self._set_status("Calculating: Plex library", 0.0)
def fetch():
def progress(n, total):
self._done.emit("progress", (n, total))
return {"tracks": self.plex.tracks(progress), "artists": self.plex.artists(),
"albums": self.plex.albums(), "playlists": self.plex.playlists()}
self._work("library", fetch)
def _refresh_stems(self):
self._work("stems", self.stems.refresh)
def _on_done(self, tag, result):
if tag == "pin":
self._poll_pin(result)
elif tag == "token":
self._signed_in(result)
elif tag == "sections":
self._sections(result)
elif tag == "progress":
n, total = result
self._set_status("Calculating: Plex library", n / total if total else 0.0)
elif tag == "library":
self._busy = False
self.tracks, self.artists = result["tracks"], result["artists"]
self.albums, self.playlists = result["albums"], result["playlists"]
self._rebuild()
self._set_status("", -1)
self._work("save", self._save_cache)
elif tag == "stems":
if result:
self._rebuild()
self.statusChanged.emit()
elif tag == "library:error":
self._busy = False
self._set_status("", -1)
self.toast.emit(str(result))
elif tag.endswith(":error"):
self.toast.emit(str(result))
for s in self._tabs:
if s.loading:
s.loading = False
self.sheetChanged.emit()
elif tag.startswith("playlist:"):
key = tag.split(":", 1)[1]
for s in self._tabs:
if s.sid == f"playlist:{key}":
s.rows, s.loading = self._song_rows(result), False
if s is self._tabs[self._current]:
self.model.set_sheet(s)
self.sheetChanged.emit()
elif tag == "playlists":
self.playlists = result
self._tabs[3].rows = self._playlist_rows()
if self._current == 3:
self.model.refresh()
def _stems_only(self):
"""Without Plex, the library is the stem server's index: artist/album/song from the folders."""
tracks, albums, artists = [], {}, {}
for e in self.stems.index.entries:
parts = e["key"].split("/")
artist = parts[0] if len(parts) > 2 else ""
album = parts[-2] if len(parts) > 1 else ""
m = _SONG_NAME.match(parts[-1])
title, index = (m.group(2), int(m.group(1))) if m else (parts[-1], 0)
ak, rk = "stem-album:" + "/".join(parts[:-1]), "stem-artist:" + artist
tracks.append({"key": "stem:" + e["key"], "title": title, "artist": artist, "albumArtist": artist,
"album": album, "albumKey": ak, "artistKey": rk, "year": 0, "index": index, "disc": 0,
"durationMs": int((e.get("duration") or 0) * 1000), "thumb": "", "partFile": "",
"partKey": "", "codec": "flac", "plays": 0, "addedAt": int(e.get("finished") or 0),
"stemKey": e["key"]})
albums.setdefault(ak, {"key": ak, "title": album, "artist": artist, "artistKey": rk, "year": 0,
"tracks": 0, "thumb": "", "addedAt": 0, "genre": ""})["tracks"] += 1
artists.setdefault(rk, {"key": rk, "title": artist, "sortTitle": artist, "albums": 0, "thumb": "", "genre": ""})
for a in albums.values():
artists[a["artistKey"]]["albums"] += 1
tracks.sort(key=lambda t: fold(t["title"]))
return tracks, sorted(artists.values(), key=lambda a: fold(a["title"])), \
sorted(albums.values(), key=lambda a: fold(a["title"]))
def _rebuild(self):
"""Derives stems flags, groupings and the base sheets from the raw lists."""
if not self.plex.signed_in or not self.cfg.get("musicSection"):
self.tracks, self.artists, self.albums = self._stems_only()
self.playlists = []
for t in self.tracks:
e = self._entry(t)
t["stemKey"] = e["key"] if e else ""
t["stems"] = len(e["stems"]) if e else 0
self._by_album, self._by_artist = {}, {}
for t in self.tracks:
self._by_album.setdefault(t["albumKey"], []).append(t)
self._by_artist.setdefault(t["artistKey"], []).append(t)
for lst in self._by_album.values():
lst.sort(key=lambda t: (t.get("disc") or 0, t.get("index") or 0))
for a in self.artists:
songs = self._by_artist.get(a["key"], [])
a["songs"] = len(songs)
a["stemSongs"] = sum(1 for t in songs if t["stems"])
for a in self.albums:
a["stemSongs"] = sum(1 for t in self._by_album.get(a["key"], []) if t["stems"])
self._tabs[0].rows = self._song_rows(self.tracks)
self._tabs[1].rows = [dict(a) for a in self.artists]
self._tabs[2].rows = [dict(a) for a in self.albums]
self._tabs[3].rows = self._playlist_rows()
self._tabs[4].rows = self._song_rows([t for t in self.tracks if t["stems"]])
for s in self._tabs[:5]:
if s.sort:
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
self.statusChanged.emit()
def _entry(self, t):
if t["key"].startswith("stem:"):
return self.stems.index.by_key.get(t.get("stemKey"))
return self.stems.find(t)
def _playlist_rows(self):
return [dict(p, kind="Smart" if p["smart"] else "Manual") for p in self.playlists]
def _song_rows(self, tracks):
rows = []
for t in tracks:
e = self._entry(t)
rows.append(dict(t, stemKey=e["key"] if e else "", stems=len(e["stems"]) if e else 0))
return rows
# ---- tabs -----------------------------------------------------------------------
def _get_tabs(self):
return [{"title": s.title, "base": i < len(BASE_TABS)} for i, s in enumerate(self._tabs)]
def _get_current(self):
return self._current
@Slot(int)
def setCurrent(self, i):
if 0 <= i < len(self._tabs) and i != self._current:
self._current = i
if self._tabs[i].layout == "queue":
self.update_queue()
self.sheetSwitched.emit()
self.model.set_sheet(self._tabs[i])
self.tabsChanged.emit()
self.sheetChanged.emit()
@Slot(int)
def closeTab(self, i):
if i >= len(BASE_TABS) and i < len(self._tabs):
del self._tabs[i]
cur = min(self._current, len(self._tabs) - 1) if self._current >= i else self._current
self._current = -1
self.tabsChanged.emit()
self.setCurrent(cur if cur != i or cur < len(self._tabs) else i - 1)
@Slot()
def back(self):
"""Backspace: closes the drilled-in sheet and returns to the one before it."""
if self._current >= len(BASE_TABS):
self.closeTab(self._current)
def _open(self, sheet):
"""Opens a drilled-in sheet after the current one, replacing what was to its right."""
for i, s in enumerate(self._tabs):
if s.sid == sheet.sid:
self._tabs[i] = sheet
self._current = -1
self.setCurrent(i)
return
cut = max(len(BASE_TABS), self._current + 1)
self._tabs = self._tabs[:cut] + [sheet]
self.tabsChanged.emit()
self.setCurrent(len(self._tabs) - 1)
@property
def sheet(self) -> Sheet:
return self._tabs[self._current]
# ---- rows -----------------------------------------------------------------------
@Slot(int, int)
def activate(self, row, mods=0):
"""Enter / double-click: play a song from here, or open an artist, album or playlist."""
s = self.sheet
if not (0 <= row < len(s.rows)):
return
kind, r = s.row_kind(row), s.rows[row]
if kind == SONG:
if s.layout == "queue":
self.player.skipTo(row)
return
songs = [x for i, x in enumerate(s.rows) if s.row_kind(i) == SONG]
self.player.play_tracks(songs, songs.index(r))
elif kind == ARTIST:
albums = sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0)
self._open(Sheet(r["title"], "albums", [dict(a) for a in albums], ALBUM, sid=f"artist:{r['key']}"))
elif kind == ALBUM:
self._open(Sheet(r["title"], "albumSongs", self._song_rows(self._by_album.get(r["key"], [])),
sid=f"album:{r['key']}"))
elif kind == PLAYLIST:
self._open(Sheet(r["title"], "songs", [], sid=f"playlist:{r['key']}", loading=True))
self._work(f"playlist:{r['key']}", lambda: self.plex.playlist_tracks(r["key"]))
def rows_for_play(self, r0, r1):
s = self.sheet
out = []
for i in range(max(0, r0), min(len(s.rows), r1 + 1)):
k, r = s.row_kind(i), s.rows[i]
if k == SONG:
out.append(r)
elif k == ALBUM:
out += self._song_rows(self._by_album.get(r["key"], []))
elif k == ARTIST:
for a in sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0):
out += self._song_rows(self._by_album.get(a["key"], []))
return out
@Slot(int, int)
def queueRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.add_to_queue(tracks)
self.toast.emit(f"Added {len(tracks)} song{'s' if len(tracks) != 1 else ''} to the queue")
@Slot(int, int)
def playNextRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_next(tracks)
self.toast.emit(f"{len(tracks)} song{'s' if len(tracks) != 1 else ''} will play next")
@Slot(int, int)
def playRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_tracks(tracks, 0)
@Slot()
def shuffleSheet(self):
"""AutoSum's spot on the ribbon: shuffle everything on this sheet."""
tracks = self.rows_for_play(0, len(self.sheet.rows) - 1)
if tracks:
self.player.play_tracks(tracks, random.randrange(len(tracks)), shuffle=True)
@Slot(int, int)
def removeRows(self, r0, r1):
s = self.sheet
if s.layout == "queue":
for i in range(min(r1, len(s.rows) - 1), r0 - 1, -1):
self.player.removeAt(i)
elif s.sid.startswith("playlist:"):
key = s.sid.split(":", 1)[1]
items = [s.rows[i] for i in range(r0, min(r1 + 1, len(s.rows)))]
def remove():
for it in items:
if it.get("playlistItemID"):
self.plex.remove_from_playlist(key, it["playlistItemID"])
return self.plex.playlist_tracks(key)
self._work(f"playlist:{key}", remove)
@Slot(str, int, int)
def newPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
if not title.strip() or not keys:
return
def make():
self.plex.create_playlist(title.strip(), keys)
return self.plex.playlists()
self._work("playlists", make)
self.toast.emit(f"Created playlist “{title.strip()}”")
@Slot(str, int, int)
def addToPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
p = next((p for p in self.playlists if p["title"] == title and not p["smart"]), None)
if not keys or p is None:
return
def add():
self.plex.add_to_playlist(p["key"], keys)
return self.plex.playlists()
self._work("playlists", add)
self.toast.emit(f"Added {len(keys)} song{'s' if len(keys) != 1 else ''} to “{p['title']}”")
def _get_playlist_names(self):
return [p["title"] for p in self.playlists if not p["smart"]]
# ---- queue sheet ------------------------------------------------------------------
def update_queue(self):
q = self._tabs[5]
q.rows = [dict(self._song_rows([t])[0], pos=i + 1) for i, t in enumerate(self.player.queue)]
if self._current == 5:
self.model.refresh()
self.model.set_playing(self.player.current_key(), self.player.index)
# ---- search, sort ------------------------------------------------------------------
@Slot(str)
def search(self, text):
q = fold(text.strip())
if not q:
return
rows = [dict(a, _kind=ARTIST, type="Artist") for a in self.artists if q in fold(a["title"])][:50]
rows += [dict(a, _kind=ALBUM, type="Album", album="") for a in self.albums if q in fold(a["title"])][:100]
hits = [t for t in self.tracks if q in fold(t["title"])]
hits += [t for t in self.tracks if q not in fold(t["title"]) and (q in fold(t["artist"]) or q in fold(t["album"]))]
rows += [dict(r, type="Song") for r in self._song_rows(hits[:2000])]
self._open(Sheet(f"Search “{text.strip()}”", "search", rows, SONG, sid="search"))
@Slot(int)
def sortBy(self, col):
s = self.sheet
desc = bool(s.sort and s.sort[0] == col and not s.sort[1])
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@Slot(int, bool)
def sortTo(self, col, desc):
s = self.sheet
if 0 <= col < len(s.columns):
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@staticmethod
def _apply_sort(s):
col, desc = s.sort
key, _, _, kind = s.columns[col]
if kind == "text":
s.rows.sort(key=lambda r: fold(str(r.get(key) or "")), reverse=desc)
else:
s.rows.sort(key=lambda r: r.get(key) or 0, reverse=desc)
def _get_sort_col(self):
return self.sheet.sort[0] if self.sheet.sort else -1
def _get_sort_desc(self):
return bool(self.sheet.sort and self.sheet.sort[1])
# ---- formula bar, status bar --------------------------------------------------------
@Slot(int, int, result=str)
def formula(self, row, col):
"""What the formula bar shows for a cell: the value, or a formula for the row's action."""
s = self.sheet
if not (0 <= row < len(s.rows) and 0 <= col < len(s.columns)):
return ""
r = s.rows[row]
key, _, _, kind = s.columns[col]
if kind == "stems":
return f"=STEMS(\"{r.get('title', '')}\")" if r.get(key) else "=FALSE"
if kind == "time" and r.get(key):
return f"=TIME(0,{int(r[key] / 60000)},{int(r[key] / 1000) % 60})"
if key == "title":
k = s.row_kind(row)
fn = {SONG: "PLAY", ARTIST: "ARTIST", ALBUM: "ALBUM", PLAYLIST: "PLAYLIST"}[k]
q = r["title"].replace('"', '""')
return f'={fn}("{q}")'
v = r.get(key)
return "" if v in (None, "", 0) and kind != "int" else str(v)
@Slot(int, int, int, int)
def copyCells(self, r0, c0, r1, c1):
"""Ctrl+C: the selection as tab-separated text, like copying from Excel."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
lines = ["\t".join(fmt(s.rows[r].get(s.columns[c][0]), s.columns[c][3]) for c in range(c0, c1 + 1))
for r in range(r0, r1 + 1)]
QGuiApplication.clipboard().setText("\n".join(lines))
@Slot(int, int, int, int, result="QVariantMap")
def stats(self, r0, c0, r1, c1):
"""Average / Count / Sum for a selection, like Excel's status bar."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
cells, nums, kinds = 0, [], set()
for r in range(r0, r1 + 1):
row = s.rows[r]
for c in range(c0, c1 + 1):
key, _, _, kind = s.columns[c]
v = row.get(key)
if v in (None, "") or (kind != "int" and not v):
continue
cells += 1
if kind in ("int", "time", "year") and isinstance(v, (int, float)):
nums.append(v)
kinds.add(kind)
if cells < 2:
return {}
out = {"count": cells}
if nums:
is_time = kinds == {"time"}
total = sum(nums)
out["sum"] = fmt_time(total) if is_time else f"{total:,}"
avg = total / len(nums)
out["average"] = fmt_time(avg) if is_time else f"{avg:,.1f}".rstrip("0").rstrip(".")
return out
def _set_status(self, text, progress):
self._status, self._progress = text, progress
self.statusChanged.emit()
def _get_status(self):
return self._status
def _get_progress(self):
return self._progress
def _get_sheet_title(self):
return self.sheet.title
def _get_loading(self):
return self.sheet.loading
def _get_sheet_id(self):
return self.sheet.sid
def _get_layout(self):
return self.sheet.layout
def _get_columns(self):
return [{"title": c[1], "width": c[2], "kind": c[3]} for c in self.sheet.columns]
def _get_counts(self):
return {"songs": len(self.tracks), "stems": sum(1 for t in self.tracks if t.get("stems")),
"stemIndex": len(self.stems.index), "stemOnline": self.stems.online}
tabs = Property("QVariantList", _get_tabs, notify=tabsChanged)
current = Property(int, _get_current, setCurrent, notify=tabsChanged)
columns = Property("QVariantList", _get_columns, notify=sheetChanged)
sheetTitle = Property(str, _get_sheet_title, notify=sheetChanged)
layout = Property(str, _get_layout, notify=sheetChanged)
sheetId = Property(str, _get_sheet_id, notify=sheetChanged)
loading = Property(bool, _get_loading, notify=sheetChanged)
sortColumn = Property(int, _get_sort_col, notify=sheetChanged)
sortDescending = Property(bool, _get_sort_desc, notify=sheetChanged)
status = Property(str, _get_status, notify=statusChanged)
progress = Property(float, _get_progress, notify=statusChanged)
counts = Property("QVariantMap", _get_counts, notify=statusChanged)
playlistNames = Property("QVariantList", _get_playlist_names, notify=sheetChanged)
# ---- Plex sign-in (File → Account) ---------------------------------------------------
pinChanged = Signal()
def _get_pin(self):
return getattr(self, "_pin_code", "")
pin = Property(str, _get_pin, notify=pinChanged)
@Slot()
def signIn(self):
def start():
return self.plex.create_pin()
self._work("pin", start)
def _poll_pin(self, pin):
self._pin_code = pin.get("code", "")
self.pinChanged.emit()
pin_id = pin["id"]
def wait():
for _ in range(300):
time.sleep(2)
p = self.plex.check_pin(pin_id)
if p.get("authToken"):
return p["authToken"]
if getattr(self, "_pin_code", "") != pin.get("code"):
return None
return None
self._work("token", wait)
def _signed_in(self, token):
self._pin_code = ""
self.pinChanged.emit()
if not token:
return
url = self.cfg["plexUrl"]
def finish():
if not self.plex.probe(url, token):
raise PlexError(f"Signed in, but the server at {url} didn't answer")
self.cfg["plexToken"] = token
self.cfg["plexUser"] = self.plex.username(token)
secs = self.plex.music_sections()
return secs
self._work("sections", finish)
def _sections(self, secs):
if not secs:
self.toast.emit("No music library on that Plex server")
return
# The phone uses the "Music" library; prefer the one at Flacs if there are several.
pick = next((s for s in secs if any("Flacs" in l for l in s["locations"])), secs[0])
self.cfg["musicSection"] = pick["key"]
self.cfg["sectionLocations"] = pick["locations"]
config.save(self.cfg)
self.toast.emit(f"Connected to Plex — {pick['title']}")
self.statusChanged.emit()
self.refresh()
@Slot()
def cancelSignIn(self):
self._pin_code = ""
self.pinChanged.emit()
def _get_signed_in(self):
return self.plex.signed_in and bool(self.cfg.get("musicSection"))
signedIn = Property(bool, _get_signed_in, notify=statusChanged)
+171
View File
@@ -0,0 +1,171 @@
"""MPRIS on the session bus: media keys, the desktop's media controls, and KDE Connect on the phone."""
import hashlib
from PySide6.QtCore import ClassInfo, Property, QObject, Slot
from PySide6.QtDBus import QDBusAbstractAdaptor, QDBusConnection, QDBusMessage, QDBusObjectPath
PATH = "/org/mpris/MediaPlayer2"
ROOT_IFACE = "org.mpris.MediaPlayer2"
PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
@ClassInfo({"D-Bus Interface": ROOT_IFACE})
class _Root(QDBusAbstractAdaptor):
def __init__(self, parent, window_raise, quit_):
super().__init__(parent)
self._raise, self._quit = window_raise, quit_
@Slot()
def Raise(self):
self._raise()
@Slot()
def Quit(self):
self._quit()
CanQuit = Property(bool, lambda self: True)
CanRaise = Property(bool, lambda self: True)
HasTrackList = Property(bool, lambda self: False)
Identity = Property(str, lambda self: "Stemify Player")
DesktopEntry = Property(str, lambda self: "stemify-player")
SupportedUriSchemes = Property("QStringList", lambda self: [])
SupportedMimeTypes = Property("QStringList", lambda self: [])
@ClassInfo({"D-Bus Interface": PLAYER_IFACE})
class _Player(QDBusAbstractAdaptor):
def __init__(self, parent, mpris):
super().__init__(parent)
self.m = mpris
@Slot()
def Next(self):
self.m.player.next()
@Slot()
def Previous(self):
self.m.player.previous()
@Slot()
def Pause(self):
self.m.engine.pause()
@Slot()
def PlayPause(self):
self.m.player.toggle()
@Slot()
def Stop(self):
self.m.engine.pause()
@Slot()
def Play(self):
if not self.m.engine.playing:
self.m.player.toggle()
@Slot("qlonglong")
def Seek(self, offset_us):
self.m.player.seek(self.m.engine.position() + offset_us / 1e6)
@Slot(QDBusObjectPath, "qlonglong")
def SetPosition(self, track_id, position_us):
self.m.player.seek(position_us / 1e6)
@Slot(str)
def OpenUri(self, uri):
pass
def _status(self):
s = self.m.player._get_status()
return "Playing" if s in ("playing", "buffering") or (s == "loading" and self.m.engine.playing) else \
"Paused" if self.m.player.current() else "Stopped"
def _volume(self):
return float(self.m.engine.volume)
def _set_volume(self, v):
self.m.engine.setVolume(max(0.0, min(1.0, v)))
PlaybackStatus = Property(str, _status)
LoopStatus = Property(str, lambda self: {"off": "None", "all": "Playlist", "one": "Track"}[self.m.player.repeat])
Rate = Property(float, lambda self: 1.0)
Shuffle = Property(bool, lambda self: self.m.player.shuffle)
Metadata = Property("QVariantMap", lambda self: self.m.metadata())
Volume = Property(float, _volume, _set_volume)
Position = Property("qlonglong", lambda self: int(self.m.engine.position() * 1e6))
MinimumRate = Property(float, lambda self: 1.0)
MaximumRate = Property(float, lambda self: 1.0)
CanGoNext = Property(bool, lambda self: bool(self.m.player.queue))
CanGoPrevious = Property(bool, lambda self: bool(self.m.player.queue))
CanPlay = Property(bool, lambda self: bool(self.m.player.queue))
CanPause = Property(bool, lambda self: True)
CanSeek = Property(bool, lambda self: True)
CanControl = Property(bool, lambda self: True)
class Mpris(QObject):
def __init__(self, player, engine, window_raise, quit_, parent=None):
super().__init__(parent)
self.player, self.engine = player, engine
self._root = _Root(self, window_raise, quit_)
self._adaptor = _Player(self, self)
self.bus = QDBusConnection.sessionBus()
self.ok = self.bus.isConnected() and self.bus.registerObject(PATH, self) and \
self.bus.registerService("org.mpris.MediaPlayer2.stemify_player")
self._last = {}
player.changed.connect(self._changed)
player.mixChanged.connect(self._changed)
engine.volumeChanged.connect(self._changed)
engine.playingChanged.connect(self._changed)
def metadata(self):
t = self.player.current()
if not t:
return {"mpris:trackid": QDBusObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")}
artist = t["artist"]
label = self.player._get_preset_label()
if label and label != "Full Mix":
artist = f"{artist} · {label}" # the mix rides on the artist line, as on the phone
md = {
"mpris:trackid": QDBusObjectPath("/stemify/track/t" + hashlib.sha1(t["key"].encode()).hexdigest()[:16]),
"mpris:length": int((self.engine.duration or t.get("durationMs", 0) / 1000) * 1e6),
"xesam:title": t["title"],
"xesam:artist": [artist],
"xesam:album": t["album"],
"xesam:albumArtist": [t.get("albumArtist") or t["artist"]],
}
art = self.player.plex.art_url(t.get("thumb"), 600) or t.get("_cover", "")
if art:
md["mpris:artUrl"] = art
if t.get("index"):
md["xesam:trackNumber"] = t["index"]
return md
def _changed(self):
if not self.ok:
return
now = {
"PlaybackStatus": self._adaptor._status(),
"LoopStatus": self._adaptor.LoopStatus,
"Shuffle": self.player.shuffle,
"Volume": float(self.engine.volume),
"Metadata": self.metadata(),
"CanGoNext": bool(self.player.queue),
"CanGoPrevious": bool(self.player.queue),
"CanPlay": bool(self.player.queue),
}
diff = {k: v for k, v in now.items() if k == "Metadata" and _md_key(v) != _md_key(self._last.get(k)) or
k != "Metadata" and self._last.get(k) != v}
self._last = now
if not diff:
return
msg = QDBusMessage.createSignal(PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
msg.setArguments([PLAYER_IFACE, diff, []])
self.bus.send(msg)
def _md_key(md):
if not md:
return None
return tuple((k, str(v.path()) if isinstance(v, QDBusObjectPath) else repr(v)) for k, v in sorted(md.items()))
+601
View File
@@ -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))
+275
View File
@@ -0,0 +1,275 @@
"""Plex Media Server and plex.tv calls (standard library only; call from worker threads)."""
import json
import platform
import urllib.error
import urllib.parse
import urllib.request
PRODUCT = "Stemify Player"
PAGE = 1000
class PlexError(Exception):
pass
class NotSignedIn(PlexError):
def __init__(self):
super().__init__("Not signed in to Plex")
def _track(m: dict) -> dict:
"""A Plex track as the player's flat dict."""
media = (m.get("Media") or [{}])[0]
part = (media.get("Part") or [{}])[0]
artist = m.get("originalTitle") or m.get("grandparentTitle") or ""
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"artist": artist,
"albumArtist": m.get("grandparentTitle", ""),
"album": m.get("parentTitle", ""),
"albumKey": str(m.get("parentRatingKey", "")),
"artistKey": str(m.get("grandparentRatingKey", "")),
"year": m.get("parentYear") or m.get("year") or 0,
"index": m.get("index") or 0,
"disc": m.get("parentIndex") or 0,
"durationMs": m.get("duration") or media.get("duration") or part.get("duration") or 0,
"thumb": m.get("thumb") or m.get("parentThumb") or m.get("grandparentThumb") or "",
"partFile": part.get("file", ""),
"partKey": part.get("key", ""),
"codec": media.get("audioCodec", ""),
"plays": m.get("viewCount") or 0,
"addedAt": m.get("addedAt") or 0,
"playlistItemID": m.get("playlistItemID"),
}
def _album(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"artist": m.get("parentTitle", ""),
"artistKey": str(m.get("parentRatingKey", "")),
"year": m.get("year") or 0,
"tracks": m.get("leafCount") or 0,
"thumb": m.get("thumb", ""),
"addedAt": m.get("addedAt") or 0,
"genre": ", ".join(g.get("tag", "") for g in (m.get("Genre") or [])[:2]),
}
def _artist(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"sortTitle": m.get("titleSort") or m.get("title", ""),
"albums": m.get("childCount") or 0,
"thumb": m.get("thumb", ""),
"genre": ", ".join(g.get("tag", "") for g in (m.get("Genre") or [])[:2]),
}
def _playlist(m: dict) -> dict:
return {
"key": str(m.get("ratingKey", "")),
"title": m.get("title", ""),
"tracks": m.get("leafCount") or 0,
"durationMs": m.get("duration") or 0,
"thumb": m.get("composite") or m.get("thumb", ""),
"smart": bool(m.get("smart")),
"updatedAt": m.get("updatedAt") or 0,
}
class Plex:
def __init__(self, config):
self.cfg = config
@property
def base(self):
return self.cfg["plexUrl"].rstrip("/")
@property
def token(self):
return self.cfg.get("plexToken", "")
@property
def signed_in(self):
return bool(self.token)
@property
def section(self):
return self.cfg.get("musicSection", "")
def _headers(self, token=None):
h = {
"Accept": "application/json",
"X-Plex-Product": PRODUCT,
"X-Plex-Version": "1.0",
"X-Plex-Client-Identifier": self.cfg["clientId"],
"X-Plex-Platform": "Linux",
"X-Plex-Platform-Version": platform.release(),
"X-Plex-Device": "Desktop",
"X-Plex-Device-Name": f"{PRODUCT} ({platform.node()})",
}
if token:
h["X-Plex-Token"] = token
return h
def _call(self, url, method="GET", token=None, headers=None, timeout=20):
req = urllib.request.Request(url, method=method, headers={**self._headers(token), **(headers or {})},
data=b"" if method in ("POST", "PUT") else None)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
body = r.read()
except urllib.error.HTTPError as e:
raise PlexError(f"Plex answered {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise PlexError(f"Can't reach Plex: {getattr(e, 'reason', e)}") from e
if not body.strip() or not body.lstrip().startswith(b"{"):
return {}
return json.loads(body)
def _url(self, path, **params):
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
return f"{self.base}{path}" + (f"?{q}" if q else "")
def get(self, path, start=None, size=None, **params):
if not self.signed_in:
raise NotSignedIn()
extra = {}
if start is not None:
extra["X-Plex-Container-Start"] = str(start)
if size is not None:
extra["X-Plex-Container-Size"] = str(size)
return self._call(self._url(path, **params), token=self.token, headers=extra).get("MediaContainer", {})
def send(self, method, path, **params):
if not self.signed_in:
raise NotSignedIn()
return self._call(self._url(path, **params), method=method, token=self.token).get("MediaContainer", {})
def all(self, path, progress=None, **params):
"""Pages through a listing; Plex caps one response on big libraries."""
out, start = [], 0
while True:
page = self.get(path, start=start, size=PAGE, **params)
items = page.get("Metadata") or []
out += items
start += len(items)
total = page.get("totalSize") or page.get("size") or 0
if progress:
progress(start, total)
if not items or start >= total:
return out
# ---- server ------------------------------------------------------------------
def music_sections(self):
dirs = self.get("/library/sections").get("Directory") or []
return [{"key": d["key"], "title": d.get("title", ""), "locations": [l.get("path", "") for l in d.get("Location") or []]}
for d in dirs if d.get("type") == "artist"]
def machine_id(self):
mid = self._call(self._url("/identity")).get("MediaContainer", {}).get("machineIdentifier")
if not mid:
raise PlexError("Not a Plex server")
return mid
# ---- library -----------------------------------------------------------------
def tracks(self, progress=None):
return [_track(m) for m in self.all(f"/library/sections/{self.section}/all", progress, type=10, sort="titleSort")]
def albums(self):
return [_album(m) for m in self.all(f"/library/sections/{self.section}/all", type=9, sort="titleSort")]
def artists(self):
return [_artist(m) for m in self.all(f"/library/sections/{self.section}/all", type=8, sort="titleSort")]
def playlists(self):
return [_playlist(m) for m in self.get("/playlists", playlistType="audio").get("Metadata") or []]
def playlist_tracks(self, key):
return [_track(m) for m in self.all(f"/playlists/{key}/items")]
def album_tracks(self, key):
return [_track(m) for m in self.get(f"/library/metadata/{key}/children").get("Metadata") or []]
def artist_tracks(self, key):
return [_track(m) for m in self.get(f"/library/metadata/{key}/allLeaves").get("Metadata") or []]
def loudness(self, track_key, samples):
"""Plex's loudness analysis in dB (what Plexamp draws), or None if not analysed."""
meta = (self.get(f"/library/metadata/{track_key}").get("Metadata") or [{}])[0]
part = (((meta.get("Media") or [{}])[0]).get("Part") or [{}])[0]
stream = next((s for s in part.get("Stream") or [] if s.get("streamType") == 2), None)
if not stream:
return None
try:
levels = self.get(f"/library/streams/{stream['id']}/levels", subsample=samples).get("Level") or []
except PlexError:
return None
return [float(l.get("v", -60)) for l in levels] or None
# ---- playlists -----------------------------------------------------------------
def _library_uri(self, keys):
mid = self.cfg.get("plexMachineId") or self.machine_id()
self.cfg["plexMachineId"] = mid
return f"server://{mid}/com.plexapp.plugins.library/library/metadata/{','.join(keys)}"
def create_playlist(self, title, keys):
c = self.send("POST", "/playlists", type="audio", title=title, smart=0, uri=self._library_uri(keys))
items = c.get("Metadata") or []
return _playlist(items[0]) if items else None
def add_to_playlist(self, key, keys):
self.send("PUT", f"/playlists/{key}/items", uri=self._library_uri(keys))
def remove_from_playlist(self, key, item_id):
self.send("DELETE", f"/playlists/{key}/items/{item_id}")
# ---- reporting ---------------------------------------------------------------
def scrobble(self, key):
try:
self.get("/:/scrobble", key=key, identifier="com.plexapp.plugins.library")
except PlexError:
pass
def timeline(self, track, state, position_ms):
try:
self.get("/:/timeline", ratingKey=track["key"], key=f"/library/metadata/{track['key']}", state=state,
time=int(position_ms), duration=track.get("durationMs", 0), hasMDE=1)
except PlexError:
pass
# ---- URLs ----------------------------------------------------------------------
def art_url(self, thumb, px):
if not thumb or not self.signed_in:
return ""
return self._url("/photo/:/transcode", width=px, height=px, minSize=1, upscale=1, url=thumb,
**{"X-Plex-Token": self.token})
def part_url(self, part_key):
return f"{self.base}{part_key}?X-Plex-Token={urllib.parse.quote(self.token)}"
# ---- plex.tv sign-in -----------------------------------------------------------
def create_pin(self):
return self._call("https://plex.tv/api/v2/pins?strong=false", method="POST")
def check_pin(self, pin_id):
return self._call(f"https://plex.tv/api/v2/pins/{pin_id}")
def username(self, token):
try:
u = self._call("https://plex.tv/api/v2/user", token=token)
return u.get("username") or u.get("title") or ""
except PlexError:
return ""
def probe(self, url, token):
try:
self._call(url.rstrip("/") + "/library/sections", token=token, timeout=4)
return True
except PlexError:
return False
@@ -0,0 +1,192 @@
import QtQuick
// File: Excel's backstage view. Info, Account (Plex sign-in) and Options.
Rectangle {
id: bs
property var win: Window.window
property string page: "info"
color: "#FFFFFF"
Rectangle {
id: side
width: 190
height: parent.height
color: X.green
Column {
width: parent.width
Item {
width: parent.width
height: 64
Rectangle { anchors.fill: parent; color: bm.containsMouse ? X.greenHover : "transparent" }
Ico { x: 22; anchors.verticalCenter: parent.verticalCenter; name: "back"; ink: "#FFFFFF"; size: 30 }
MouseArea { id: bm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.closeBackstage() }
}
Repeater {
model: [["info", "Info"], ["account", "Account"], ["options", "Options"]]
delegate: Item {
required property var modelData
width: side.width
height: 40
Rectangle {
anchors.fill: parent
color: bs.page === parent.modelData[0] ? X.greenDark : im.containsMouse ? X.greenHover : "transparent"
}
T { x: 26; height: parent.height; text: parent.modelData[1]; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: im; anchors.fill: parent; hoverEnabled: true; onClicked: bs.page = parent.modelData[0] }
}
}
Item { width: 1; height: 20 }
Item {
width: side.width
height: 40
Rectangle { anchors.fill: parent; color: qm.containsMouse ? X.greenHover : "transparent" }
T { x: 26; height: parent.height; text: "Close"; color: "#FFFFFF"; font.pixelSize: 15 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: bs.win.close() }
}
}
}
Item {
x: side.width + 40
y: 24
width: parent.width - x - 40
height: parent.height - y
T {
id: heading
text: bs.page === "info" ? "Info" : bs.page === "account" ? "Account" : "Options"
font.pixelSize: 38
font.weight: Font.Light
color: X.ink
height: 56
}
// ---- Info ----
Column {
visible: bs.page === "info"
y: heading.height + 20
width: parent.width
spacing: 18
Row {
spacing: 40
Column {
width: 360
spacing: 8
T { text: "Now Playing"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Title"; v: player.track.title || "—" }
Prop { k: "Artist"; v: player.track.artist || "—" }
Prop { k: "Album"; v: player.track.album || "—" }
Prop { k: "Plays from"; v: player.sourceKind === "stems" ? "Stems (" + player.stems.length + ")" : player.sourceKind === "original" ? "Original file" + (player.track.codec ? " (" + player.track.codec.toUpperCase() + ")" : "") : "—" }
Prop { k: "Mix"; v: player.presetLabel || "—" }
Prop { k: "Queue"; v: player.queueInfo.count ? (player.queueInfo.index + 1) + " of " + player.queueInfo.count : "Empty" }
}
Column {
width: 360
spacing: 8
T { text: "Properties"; font.pixelSize: 20; color: X.greenText; height: 30 }
Prop { k: "Songs"; v: library.counts.songs.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "With stems"; v: library.counts.stems.toLocaleString(Qt.locale(), "f", 0) }
Prop { k: "Stems index"; v: library.counts.stemIndex.toLocaleString(Qt.locale(), "f", 0) + " songs" }
Prop { k: "Stem server"; v: (library.counts.stemOnline === true ? "Online · " : library.counts.stemOnline === false ? "Offline · " : "Checking · ") + settings.get("stemUrl") }
Prop { k: "Plex"; v: (library.signedIn ? "Connected · " : "Not connected · ") + settings.get("plexUrl") }
Prop { k: "Media keys"; v: bs.win.mprisOk ? "On (MPRIS)" : "Off" }
}
}
}
// ---- Account ----
Column {
visible: bs.page === "account"
y: heading.height + 20
width: parent.width
spacing: 14
T { text: "Connected Services"; font.pixelSize: 20; color: X.greenText; height: 30 }
Row {
spacing: 14
Ico { name: "plex"; size: 40 }
Column {
spacing: 4
T { text: "Plex Media Server"; font.pixelSize: 15; font.bold: true }
T { text: library.signedIn ? "Connected to " + settings.get("plexUrl") : "Not connected"; color: X.inkSoft }
}
}
Rectangle {
visible: library.pin !== ""
width: 420
height: 150
color: "#F3F3F3"
border.color: X.ribbonLine
Column {
anchors.centerIn: parent
spacing: 6
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Go to plex.tv/link and enter"; font.pixelSize: 14 }
T {
anchors.horizontalCenter: parent.horizontalCenter
text: library.pin
font.family: cellFont
font.pixelSize: 54
font.bold: true
font.letterSpacing: 10
color: X.greenText
}
T { anchors.horizontalCenter: parent.horizontalCenter; text: "Waiting for Plex…"; color: X.inkSoft }
}
}
Row {
spacing: 10
OfficeButton {
text: library.pin !== "" ? "Cancel" : library.signedIn ? "Sign in again" : "Sign in to Plex"
primary: library.pin === ""
onClicked: library.pin !== "" ? library.cancelSignIn() : library.signIn()
}
}
}
// ---- Options ----
Column {
visible: bs.page === "options"
y: heading.height + 20
width: Math.min(parent.width, 640)
spacing: 10
T { text: "Connections"; font.pixelSize: 20; color: X.greenText; height: 30 }
Opt { id: oPlex; label: "Plex server"; key: "plexUrl" }
Opt { id: oStem; label: "Stem server (stemserve)"; key: "stemUrl" }
T { text: "Folders"; font.pixelSize: 20; color: X.greenText; height: 40; verticalAlignment: Text.AlignBottom }
Opt { id: oStems; label: "Stems share (played directly when mounted)"; key: "stemsDir" }
Opt { id: oMusic; label: "Music share"; key: "musicDir" }
Item { width: 1; height: 8 }
Row {
spacing: 10
OfficeButton {
text: "Save"
primary: true
onClicked: {
oPlex.save(); oStem.save(); oStems.save(); oMusic.save()
library.refresh()
bs.win.closeBackstage()
}
}
OfficeButton { text: "Cancel"; onClicked: bs.win.closeBackstage() }
}
}
}
component Prop: Row {
property string k
property string v
spacing: 10
T { width: 110; text: parent.k; color: X.inkSoft }
T { width: 240; text: parent.v }
}
component Opt: Column {
id: o
property string label
property string key
function save() { settings.set(key, f.text.trim()) }
spacing: 3
width: parent.width
T { text: o.label; color: X.inkSoft }
XField { id: f; width: o.width; text: settings.get(o.key) || "" }
Connections { target: bs; function onVisibleChanged() { if (bs.visible) f.text = settings.get(o.key) || "" } }
}
}
@@ -0,0 +1,18 @@
import QtQuick
Item {
id: c
property string icon
property bool danger: false
property string tip: ""
signal clicked()
width: icon === "pane" ? 40 : 46
height: parent.height
Rectangle {
anchors.fill: parent
color: cm.pressed ? (c.danger ? "#F1707A" : X.greenDark) : cm.containsMouse ? (c.danger ? "#E81123" : X.greenHover) : "transparent"
}
Ico { anchors.centerIn: parent; name: c.icon; ink: "#FFFFFF"; size: c.icon === "pane" ? 16 : 24 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: c.clicked() }
Tip { target: cm; text: c.tip }
}
@@ -0,0 +1,51 @@
import QtQuick
// Name box, the ✕ ✓ fx buttons, and the formula of the active cell.
Rectangle {
id: fb
property var win: Window.window
height: 30
color: "#FFFFFF"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
Rectangle {
id: nameBox
x: 4
y: 4
width: 110
height: 22
border.color: "#C6C6C6"
T {
x: 5
height: parent.height
width: parent.width - 20
text: fb.win.multiSel ? (fb.win.selBottom - fb.win.selTop + 1) + "R x " + (Math.abs(fb.win.curCol - fb.win.anchorCol) + 1) + "C"
: X.colName(fb.win.curCol) + (fb.win.curRow + 2)
}
Ico { anchors.right: parent.right; anchors.rightMargin: 4; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
}
Row {
x: nameBox.x + nameBox.width + 10
height: parent.height
spacing: 12
Ico { anchors.verticalCenter: parent.verticalCenter; name: "cancel"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "check"; size: 14; opacity: 0.35 }
Ico { anchors.verticalCenter: parent.verticalCenter; name: "function"; size: 18 }
}
Rectangle {
x: nameBox.x + nameBox.width + 92
y: 4
width: parent.width - x - 6
height: 22
border.color: "#C6C6C6"
T {
x: 6
width: parent.width - 12
height: parent.height
font.family: cellFont
font.pixelSize: 14
color: "#000000"
text: fb.win.formulaText
}
}
}
@@ -0,0 +1,15 @@
import QtQuick
// image://icon/<name>[/<hex ink>], rendered at device pixels.
Image {
property string name: "grid"
property string ink: ""
property int size: 16
width: size
height: size
source: "image://icon/" + name + (ink ? "/" + ink.replace("#", "") : "")
sourceSize.width: size * Screen.devicePixelRatio
sourceSize.height: size * Screen.devicePixelRatio
smooth: true
asynchronous: false
}
@@ -0,0 +1,205 @@
import QtQuick
import QtQuick.Controls.Basic
Window {
id: win
width: 1440
height: 900
minimumWidth: 960
minimumHeight: 600
visible: true
title: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
color: "#FFFFFF"
flags: Qt.Window | Qt.FramelessWindowHint
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
// ---- selection: the active cell and the anchor of the range ----
property int curRow: 0
property int curCol: 0
property int anchorRow: 0
property int anchorCol: 0
readonly property int selTop: Math.min(curRow, anchorRow)
readonly property int selBottom: Math.max(curRow, anchorRow)
readonly property bool multiSel: curRow !== anchorRow || curCol !== anchorCol
property string formulaText: ""
property var selStats: ({})
function refreshCell() {
formulaText = library.formula(curRow, curCol)
selStats = multiSel ? library.stats(anchorRow, anchorCol, curRow, curCol) : ({})
}
onCurRowChanged: cellTimer.restart()
onCurColChanged: cellTimer.restart()
onAnchorRowChanged: cellTimer.restart()
Timer { id: cellTimer; interval: 0; onTriggered: win.refreshCell() }
Connections {
target: library
function onSheetSwitched() { win.curRow = win.anchorRow = 0; win.curCol = win.anchorCol = 0; win.refreshCell() }
function onSheetChanged() { cellTimer.restart() }
function onToast(msg) { messages.show(msg) }
}
Connections { target: player; function onToast(msg) { messages.show(msg) } }
Connections {
target: sheetModel
function onModelReset() {
// new data under the same sheet: keep the selection, inside the rows that exist
var last = Math.max(0, grid.rows - 1)
if (win.curRow > last) win.curRow = last
if (win.anchorRow > last) win.anchorRow = last
cellTimer.restart()
}
}
Component.onCompleted: refreshCell()
// ---- view options ----
property bool showMixer: settings.get("showMixer") !== false
property bool gridlines: settings.get("gridlines") !== false
property bool headings: settings.get("headings") !== false
property int zoom: settings.get("zoom") || 100
property bool mprisOk: false
readonly property bool inPlaylist: library.sheetId.indexOf("playlist:") === 0
function toggleMixer() { showMixer = !showMixer; settings.set("showMixer", showMixer) }
function setOption(k, v) { win[k] = v; settings.set(k, v) }
function setZoom(z) { zoom = Math.max(50, Math.min(200, z)); settings.set("zoom", zoom) }
function openBackstage(page) { backstage.page = page; backstage.visible = true }
function closeBackstage() { backstage.visible = false; focusGrid() }
function focusGrid() { grid.forceActiveFocus() }
function focusSearch() { ribbon.focusSearch() }
function selectAll() { anchorRow = 0; anchorCol = 0; curRow = Math.max(0, grid.rows - 1); curCol = Math.max(0, library.columns.length - 1) }
function copySelection() { library.copyCells(anchorRow, anchorCol, curRow, curCol); messages.show("Copied " + (selBottom - selTop + 1) + " rows") }
function sortCurrent(desc) { library.sortTo(curCol, desc) }
function sortColumn(c, desc) { library.sortTo(c, desc) }
function newPlaylist() { plDialog.open() }
function addToPlaylist(name) { library.addToPlaylist(name, selTop, selBottom) }
// ---- playhead, read once per frame while playing ----
property real playPos: 0
property real levelL: 0
property real levelR: 0
function seekTo(s) { player.seek(s); playPos = s }
FrameAnimation {
running: engine.playing
onTriggered: {
var s = engine.snapshot()
win.playPos = s[0]
// meters fall back smoothly between peaks
var k = Math.exp(-frameTime / 0.12)
win.levelL = Math.max(s[1], win.levelL * k)
win.levelR = Math.max(s[2], win.levelR * k)
}
}
Connections {
target: engine
function onPlayingChanged() { var s = engine.snapshot(); win.playPos = s[0]; if (!engine.playing) { win.levelL = 0; win.levelR = 0 } }
function onDurationChanged() { win.playPos = engine.snapshot()[0] }
}
Timer { interval: 500; running: !engine.playing; repeat: true; onTriggered: win.playPos = engine.snapshot()[0] }
// dev hook: --eval runs JavaScript against the window
property string devEval: ""
onDevEvalChanged: if (devEval) { try { console.log("eval:", eval(devEval)) } catch (e) { console.log("eval error:", e) } }
Shortcut { sequence: "Ctrl+F"; onActivated: win.focusSearch() }
Shortcut { sequence: "Ctrl+Q"; onActivated: win.close() }
Shortcut { sequence: "Media Play"; onActivated: player.toggle() }
Shortcut { sequence: "Ctrl+Right"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.next() }
Shortcut { sequence: "Ctrl+Left"; context: Qt.ApplicationShortcut; enabled: !grid.activeFocus; onActivated: player.previous() }
Shortcut { sequence: "Alt+Left"; onActivated: library.back() }
Shortcut { sequence: "F1"; onActivated: win.openBackstage("info") }
Column {
anchors.fill: parent
TitleBar { width: parent.width }
Ribbon { id: ribbon; width: parent.width }
MessageBar { id: messages; width: parent.width }
FormulaBar { width: parent.width }
Item {
width: parent.width
height: parent.height - y - tabs.height - status.height
Sheet {
id: grid
width: parent.width - (pane.visible ? pane.width : 0)
height: parent.height
focus: true
}
TaskPane {
id: pane
visible: win.showMixer
anchors.right: parent.right
width: 330
height: parent.height
}
}
SheetTabs { id: tabs; width: parent.width }
StatusBar { id: status; width: parent.width }
}
Backstage {
id: backstage
visible: false
y: 32
width: parent.width
height: parent.height - y
}
// New Playlist: an Office dialog
Popup {
id: plDialog
modal: true
anchors.centerIn: parent
width: 380
padding: 0
onOpened: { plName.text = ""; plName.forceActiveFocus() }
onClosed: win.focusGrid()
background: Rectangle { color: "#FFFFFF"; border.color: "#7A7A7A" }
Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.12) }
contentItem: Column {
Rectangle {
width: parent.width
height: 30
color: "#FFFFFF"
T { x: 10; height: parent.height; text: "New Playlist" }
Ico { anchors.right: parent.right; anchors.rightMargin: 8; anchors.verticalCenter: parent.verticalCenter; name: "close"; size: 18
MouseArea { anchors.fill: parent; onClicked: plDialog.close() } }
}
Column {
x: 14
width: parent.width - 28
spacing: 8
T { text: (win.selBottom - win.selTop + 1) + " selected row(s) go into a new Plex playlist."; color: X.inkSoft }
T { text: "Name:" }
XField {
id: plName
width: parent.width
onAccepted: okBtn.clicked()
}
Row {
anchors.right: parent.right
spacing: 8
bottomPadding: 14
topPadding: 6
OfficeButton {
id: okBtn
text: "OK"
primary: true
onClicked: { library.newPlaylist(plName.text, win.selTop, win.selBottom); plDialog.close() }
}
OfficeButton { text: "Cancel"; onClicked: plDialog.close() }
}
}
}
}
// frameless: resize from the edges
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge; x: 0; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge; x: parent.width - 5; y: 6; width: 5; height: parent.height - 12 }
ResizeEdge { visible: !win.maximized; edges: Qt.TopEdge; x: 6; y: 0; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.BottomEdge; x: 6; y: parent.height - 4; width: parent.width - 12; height: 4 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.TopEdge; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.LeftEdge | Qt.BottomEdge; y: parent.height - 8; width: 8; height: 8 }
ResizeEdge { visible: !win.maximized; edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 8; y: parent.height - 8; width: 8; height: 8 }
Rectangle { anchors.fill: parent; color: "transparent"; border.color: X.green; border.width: win.maximized ? 0 : 1 }
}
@@ -0,0 +1,30 @@
import QtQuick
// Excel's yellow message bar, for notices.
Rectangle {
id: mb
property string text: ""
height: text ? 32 : 0
visible: text !== ""
color: "#FFF4CE"
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: "#E6D9A8" }
Behavior on height { NumberAnimation { duration: X.easeMs } }
function show(t) { text = t; hide.restart() }
Timer { id: hide; interval: 6000; onTriggered: mb.text = "" }
Row {
x: 12
height: parent.height
spacing: 10
Ico { anchors.verticalCenter: parent.verticalCenter; name: "info"; size: 16 }
T { height: parent.height; text: "NOTICE"; font.bold: true }
T { height: parent.height; text: mb.text }
}
Item {
anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
width: 22
height: 22
Rectangle { anchors.fill: parent; color: xm.containsMouse ? "#F2E2A6" : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 18 }
MouseArea { id: xm; anchors.fill: parent; hoverEnabled: true; onClicked: mb.text = "" }
}
}
@@ -0,0 +1,16 @@
import QtQuick
// Dialog button: gray with a border; the default one outlined in green.
Rectangle {
id: ob
property string text
property bool primary: false
signal clicked()
implicitWidth: Math.max(80, t.implicitWidth + 24)
implicitHeight: 26
color: m.pressed ? "#CCE4F7" : m.containsMouse ? "#E5F1FB" : "#FDFDFD"
border.color: m.containsMouse ? "#0078D7" : primary ? X.green : "#ADADAD"
border.width: primary ? 2 : 1
T { id: t; anchors.centerIn: parent; text: ob.text }
MouseArea { id: m; anchors.fill: parent; hoverEnabled: true; onClicked: ob.clicked() }
}
@@ -0,0 +1,59 @@
import QtQuick
// A ribbon command: large (icon over a label) or small (icon beside a label).
Item {
id: b
property string icon: ""
property string text: ""
property bool big: false
property bool checkable: false
property bool checked: false
property bool dropdown: false
property string tip: ""
signal clicked()
implicitWidth: big ? Math.max(44, lbl.implicitWidth + 12) : (icon ? 22 : 6) + (text ? lbl.implicitWidth + 8 : 0) + (dropdown ? 12 : 0)
implicitHeight: big ? 66 : 22
opacity: enabled ? 1 : 0.4
Rectangle {
anchors.fill: parent
color: b.checked ? X.checked : ma.pressed ? X.pressed : ma.containsMouse ? X.hover : "transparent"
border.color: b.checked ? X.checkedBorder : "transparent"
}
Ico {
id: ic
visible: b.icon !== ""
name: b.icon
size: b.big ? 32 : 16
x: b.big ? (parent.width - width) / 2 : 3
y: b.big ? 4 : 3
}
T {
id: lbl
text: b.text
font.pixelSize: X.fsUi
x: b.big ? (parent.width - width) / 2 : (b.icon ? 23 : 4)
y: b.big ? 38 : 0
height: b.big ? implicitHeight : parent.height
width: b.big ? Math.min(implicitWidth, 90) : implicitWidth
horizontalAlignment: Text.AlignHCenter
wrapMode: b.big ? Text.WordWrap : Text.NoWrap
maximumLineCount: 2
elide: Text.ElideNone
lineHeight: 0.95
}
Ico {
visible: b.dropdown
name: "dropdown"
size: 10
x: b.big ? (parent.width - width) / 2 : parent.width - 12
y: b.big ? parent.height - 13 : 6
}
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: b.clicked()
}
Tip { target: ma; text: b.tip || b.text }
}
@@ -0,0 +1,30 @@
import QtQuick
// A ribbon group: its commands, a caption along the bottom, a rule on the right.
Item {
id: g
property string title: ""
default property alias content: row.data
implicitWidth: Math.max(row.implicitWidth, cap.implicitWidth) + 12
height: parent ? parent.height : 92
Row {
id: row
x: 6
y: 3
height: 68
spacing: 2
}
T {
id: cap
text: g.title
font.pixelSize: X.fsSmall
color: X.inkSoft
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height - 18
}
Rectangle {
anchors { right: parent.right; top: parent.top; bottom: parent.bottom; topMargin: 4; bottomMargin: 4 }
width: 1
color: X.ribbonLine
}
}
@@ -0,0 +1,13 @@
import QtQuick
// Invisible edge/corner grip that hands resizing to the compositor.
MouseArea {
property int edges: 0
property var win: Window.window
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: edges === (Qt.LeftEdge | Qt.TopEdge) || edges === (Qt.RightEdge | Qt.BottomEdge) ? Qt.SizeFDiagCursor
: edges === (Qt.RightEdge | Qt.TopEdge) || edges === (Qt.LeftEdge | Qt.BottomEdge) ? Qt.SizeBDiagCursor
: (edges & (Qt.LeftEdge | Qt.RightEdge)) ? Qt.SizeHorCursor : Qt.SizeVerCursor
onPressed: win.startSystemResize(edges)
}
@@ -0,0 +1,290 @@
import QtQuick
import QtQuick.Controls.Basic
// Tabs on the green strip, commands on the gray ribbon below.
Column {
id: rib
property var win: Window.window
property int tab: 1 // 0 = File (backstage), 1 Home, 2 Insert, 3 Data, 4 View
readonly property var tabs: ["File", "Home", "Insert", "Data", "View"]
function focusSearch() { tell.startEditing() }
Rectangle {
width: parent.width
height: 28
color: X.green
Row {
x: 0
height: parent.height
Repeater {
model: rib.tabs
delegate: Item {
id: tabItem
required property int index
required property string modelData
readonly property bool file: index === 0
readonly property bool active: rib.tab === index && !file
width: tl.implicitWidth + (file ? 30 : 22)
height: 28
Rectangle {
anchors.fill: parent
anchors.topMargin: tabItem.active ? 2 : 0
color: tabItem.active ? X.ribbon : tabItem.file ? (tm.containsMouse ? "#0E4B2A" : X.greenDark)
: tm.containsMouse ? X.greenHover : "transparent"
}
T {
id: tl
anchors.centerIn: parent
text: tabItem.modelData
color: tabItem.active ? X.greenText : "#FFFFFF"
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
onClicked: tabItem.file ? rib.win.openBackstage("info") : rib.tab = tabItem.index
}
}
}
// "Tell me what you want to do": here it searches the library.
Item {
id: tell
width: 260
height: 28
function startEditing() { field.visible = true; field.forceActiveFocus(); field.selectAll() }
Ico { x: 12; anchors.verticalCenter: parent.verticalCenter; name: "lightbulb"; ink: "#FFFFFF"; size: 16 }
T {
x: 34
anchors.verticalCenter: parent.verticalCenter
visible: !field.visible
color: "#FFFFFF"
text: "Tell me what you want to listen to..."
}
MouseArea { anchors.fill: parent; cursorShape: Qt.IBeamCursor; onClicked: tell.startEditing() }
XField {
id: field
visible: false
x: 32
width: 220
anchors.verticalCenter: parent.verticalCenter
placeholderText: "Song, artist or album"
onAccepted: { library.search(text); visible = false; rib.win.focusGrid() }
onActiveFocusChanged: if (!activeFocus && text === "") visible = false
Keys.onEscapePressed: { text = ""; visible = false; rib.win.focusGrid() }
}
}
}
}
Rectangle {
width: parent.width
height: 94
color: X.ribbon
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.ribbonLine }
// ---- Home ----
Row {
visible: rib.tab === 1
height: parent.height - 2
RGroup {
title: "Playback"
RButton {
big: true
icon: player.status === "playing" ? "pause" : "play"
text: player.status === "playing" ? "Pause" : "Play"
tip: "Play or pause (Space)"
onClicked: player.toggle()
}
Column {
RButton { icon: "previous"; text: "Previous"; tip: "Previous song (Ctrl+Left)"; onClicked: player.previous() }
RButton { icon: "next"; text: "Next"; tip: "Next song (Ctrl+Right)"; onClicked: player.next() }
RButton { icon: "stop"; text: "Stop"; onClicked: { engine.pause(); player.seek(0) } }
}
Column {
RButton { icon: "shuffle"; text: "Shuffle"; checked: player.shuffleOn; onClicked: player.toggleShuffle() }
RButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
text: player.repeatMode === "one" ? "Repeat One" : "Repeat"
checked: player.repeatMode !== "off"
onClicked: player.cycleRepeat()
}
}
}
RGroup {
title: "Karaoke"
RButton {
big: true
enabled: player.sourceKind === "stems"
icon: player.presetLabel === "Karaoke" ? "vocals_off" : "vocals"
text: player.presetLabel === "Karaoke" ? "Vocals\nOff" : "Vocals\nOn"
checked: player.presetLabel === "Karaoke"
tip: "Mute the vocals (V)"
onClicked: player.toggleVocals()
}
}
RGroup {
title: "Mix Styles"
Grid {
columns: 4
rowSpacing: 3
columnSpacing: 3
y: 4
Repeater {
model: player.presets
delegate: Rectangle {
id: sty
required property int index
required property var modelData
readonly property var s: X.styles[index]
width: 82
height: 28
opacity: modelData.usable ? 1 : 0.35
color: s.fill
border.width: modelData.active ? 2 : 1
border.color: modelData.active ? X.green : (sm.containsMouse ? "#8A8A8A" : s.border)
T {
anchors.fill: parent
anchors.leftMargin: 5
font.family: cellFont
font.pixelSize: 14
font.bold: sty.s.bold
color: sty.s.text
text: sty.modelData.label
}
MouseArea {
id: sm
anchors.fill: parent
hoverEnabled: true
enabled: sty.modelData.usable
onClicked: player.applyPreset(sty.index)
}
Tip { target: sm; text: "Style: " + sty.s.name + " — " + sty.modelData.label }
}
}
}
}
RGroup {
title: "Cells"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; tip: "Play the selection after this song"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; tip: "Add the selection to the end of the queue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton {
big: true
icon: "delete_rows"
text: "Delete\nRows"
enabled: library.layout === "queue" || library.sheetTitle !== "" && rib.win.inPlaylist
tip: "Remove the selected rows from the queue or playlist (Delete)"
onClicked: library.removeRows(rib.win.selTop, rib.win.selBottom)
}
}
RGroup {
title: "Editing"
Column {
RButton { icon: "sigma"; text: "AutoShuffle"; tip: "Shuffle everything on this sheet"; onClicked: library.shuffleSheet() }
RButton { icon: "sort_az"; text: "Sort & Filter"; dropdown: true; onClicked: sortMenu.popup() }
RButton { icon: "search"; text: "Find & Select"; tip: "Search the library (Ctrl+F)"; onClicked: rib.focusSearch() }
}
}
}
// ---- Insert ----
Row {
visible: rib.tab === 2
height: parent.height - 2
RGroup {
title: "Playlists"
RButton { big: true; icon: "playlist_new"; text: "New\nPlaylist"; tip: "A Plex playlist from the selected rows"; onClicked: rib.win.newPlaylist() }
RButton { big: true; icon: "playlist_add"; text: "Add to\nPlaylist"; dropdown: true; onClicked: plMenu.popup() }
}
RGroup {
title: "Queue"
RButton { big: true; icon: "play_next"; text: "Play\nNext"; onClicked: library.playNextRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "queue_add"; text: "Add to\nQueue"; onClicked: library.queueRows(rib.win.selTop, rib.win.selBottom) }
RButton { big: true; icon: "play"; text: "Play\nSelection"; onClicked: library.playRows(rib.win.selTop, rib.win.selBottom) }
}
RGroup {
title: "Charts"
RButton { big: true; icon: "chart"; text: "Stem\nChart"; checked: rib.win.showMixer; tip: "Show the stem mixer pane"; onClicked: rib.win.toggleMixer() }
}
}
// ---- Data ----
Row {
visible: rib.tab === 3
height: parent.height - 2
RGroup {
title: "Get External Data"
RButton { big: true; icon: "plex"; text: library.signedIn ? "Plex\nAccount" : "Connect\nto Plex"; onClicked: rib.win.openBackstage("account") }
RButton { big: true; icon: "server"; text: "Connections"; tip: "Server addresses and folders"; onClicked: rib.win.openBackstage("options") }
}
RGroup {
title: "Connections"
RButton { big: true; icon: "refresh"; text: "Refresh\nAll"; tip: "Reload the Plex library and the stems index"; onClicked: library.refresh() }
Column {
y: 4
spacing: 2
T { text: "Songs: " + library.counts.songs.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T { text: "With stems: " + library.counts.stems.toLocaleString(Qt.locale(), "f", 0); height: 20 }
T {
text: "Stem server: " + (library.counts.stemOnline === true ? "Online" : library.counts.stemOnline === false ? "Offline" : "Checking…")
color: library.counts.stemOnline === true ? X.goodText : library.counts.stemOnline === false ? X.badText : X.inkSoft
height: 20
}
}
}
RGroup {
title: "Sort & Filter"
Column {
RButton { icon: "sort_az"; text: "Sort A to Z"; onClicked: rib.win.sortCurrent(false) }
RButton { icon: "sort_za"; text: "Sort Z to A"; onClicked: rib.win.sortCurrent(true) }
RButton { icon: "filter"; text: "Stems Only"; tip: "Songs that have stems"; onClicked: library.current = 4 }
}
}
}
// ---- View ----
Row {
visible: rib.tab === 4
height: parent.height - 2
RGroup {
title: "Show"
Column {
y: 2
XCheck { text: "Gridlines"; checked: rib.win.gridlines; onToggled: rib.win.setOption("gridlines", !rib.win.gridlines) }
XCheck { text: "Headings"; checked: rib.win.headings; onToggled: rib.win.setOption("headings", !rib.win.headings) }
XCheck { text: "Mixer Pane"; checked: rib.win.showMixer; onToggled: rib.win.toggleMixer() }
}
}
RGroup {
title: "Zoom"
RButton { big: true; icon: "zoom"; text: "Zoom"; tip: "Ctrl+scroll on the grid"; onClicked: rib.win.setZoom(rib.win.zoom >= 150 ? 100 : rib.win.zoom + 25) }
RButton { big: true; icon: "grid"; text: "100%"; onClicked: rib.win.setZoom(100) }
}
RGroup {
title: "Window"
RButton { big: true; icon: "freeze"; text: "Freeze\nPanes"; checked: true; tip: "The header row is always frozen" }
}
}
}
XMenu {
id: sortMenu
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: rib.win.sortCurrent(false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: rib.win.sortCurrent(true) }
MenuSeparator {}
Action { text: "Filter: Stems Only"; icon.name: "filter"; onTriggered: library.current = 4 }
}
XMenu {
id: plMenu
Instantiator {
model: library.playlistNames
delegate: MenuItem {
required property int index
required property string modelData
text: modelData
onTriggered: rib.win.addToPlaylist(modelData)
}
onObjectAdded: (i, o) => plMenu.insertItem(i, o)
onObjectRemoved: (i, o) => plMenu.removeItem(o)
}
}
}
@@ -0,0 +1,501 @@
import QtQuick
import QtQuick.Controls.Basic
// The worksheet. Row 1 is the frozen table header; data starts on row 2. Past the data the
// sheet carries on as empty gridlines, as Excel's does.
FocusScope {
id: g
property var win: Window.window
readonly property real z100: win.zoom / 100
readonly property int rowH: Math.round(20 * z100)
readonly property int headW: Math.max(30, Math.round(rowNumW.advanceWidth + 12))
readonly property int colHeadH: 20
readonly property var cols: library.columns
readonly property int rows: table.rows
readonly property int padCols: 26
property real extraColW: 64 * z100
function colX(c) {
var x = 0
for (var i = 0; i < c && i < cols.length; i++) x += cols[i].width * z100
if (c > cols.length) x += (c - cols.length) * extraColW
return x
}
function colW(c) { return c < cols.length ? cols[c].width * z100 : extraColW }
function colAt(x) {
var acc = 0
for (var i = 0; i < cols.length; i++) {
acc += cols[i].width * z100
if (x < acc) return i
}
return cols.length + Math.floor((x - acc) / extraColW)
}
function ensureVisible(r, c) {
var y = r * rowH
if (y < table.contentY) table.contentY = y
else if (y + rowH > table.contentY + table.height) table.contentY = y + rowH - table.height
var x = colX(c), w = colW(c)
if (x < table.contentX) table.contentX = x
else if (x + w > table.contentX + table.width) table.contentX = Math.min(x + w - table.width, Math.max(0, table.contentWidth - table.width))
}
function moveTo(r, c, extend) {
r = Math.max(0, Math.min(r, Math.max(0, rows - 1)))
c = Math.max(0, Math.min(c, cols.length - 1))
win.curRow = r
win.curCol = c
if (!extend) { win.anchorRow = r; win.anchorCol = c }
ensureVisible(r, c)
}
TextMetrics { id: rowNumW; font.family: uiFont; font.pixelSize: X.fsSmall; text: String(g.rows + 40) }
// ---- column letters ----
Rectangle {
id: colHead
visible: win.headings
x: g.headW
width: parent.width - x
height: visible ? g.colHeadH : 0
color: X.header
clip: true
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
readonly property bool sel: index >= Math.min(win.curCol, win.anchorCol) && index <= Math.max(win.curCol, win.anchorCol)
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.colHeadH
visible: x + width > 0 && x < colHead.width
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: X.colName(index)
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { visible: parent.sel; anchors.bottom: parent.bottom; width: parent.width; height: 2; color: X.green }
MouseArea {
anchors.fill: parent
onClicked: if (index < g.cols.length) { win.anchorRow = 0; win.curRow = Math.max(0, g.rows - 1); win.curCol = win.anchorCol = index }
}
}
}
}
// the select-all corner
Rectangle {
visible: win.headings
width: g.headW
height: g.colHeadH
color: X.header
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Canvas {
anchors { right: parent.right; bottom: parent.bottom; margins: 3 }
width: 10
height: 10
onPaint: {
var c = getContext("2d")
c.fillStyle = "#B4B4B4"
c.beginPath(); c.moveTo(10, 0); c.lineTo(10, 10); c.lineTo(0, 10); c.closePath(); c.fill()
}
}
MouseArea { anchors.fill: parent; onClicked: win.selectAll() }
}
// ---- row numbers ----
Rectangle {
id: rowHead
visible: win.headings
y: colHead.height
width: visible ? g.headW : 0
height: parent.height - y
color: X.header
clip: true
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
// row 1: the frozen table header
Rectangle {
width: parent.width
height: g.rowH
color: X.header
T { anchors.centerIn: parent; text: "1"; font.pixelSize: X.fsSmall }
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: X.headerLine }
}
Item {
y: g.rowH
width: parent.width
height: parent.height - y
clip: true
Repeater {
model: Math.max(0, Math.ceil(parent.height / g.rowH) + 1)
delegate: Rectangle {
required property int index
readonly property int r: Math.floor(table.contentY / g.rowH) + index
readonly property bool sel: r >= win.selTop && r <= win.selBottom
y: r * g.rowH - table.contentY
width: rowHead.width
height: g.rowH
color: sel ? X.headerSel : X.header
T {
anchors.centerIn: parent
text: parent.r + 2
font.pixelSize: X.fsSmall
color: parent.sel ? X.greenText : X.ink
}
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.headerLine }
Rectangle { anchors.right: parent.right; width: parent.sel ? 2 : 1; height: parent.height; color: parent.sel ? X.green : X.headerLine }
MouseArea {
anchors.fill: parent
onClicked: (m) => { if (parent.r < g.rows) { g.moveTo(parent.r, 0, m.modifiers & Qt.ShiftModifier); if (!(m.modifiers & Qt.ShiftModifier)) win.anchorCol = 0; win.curCol = g.cols.length - 1 } }
onDoubleClicked: if (parent.r < g.rows) library.activate(parent.r, 0)
}
}
}
}
}
// ---- sheet ----
Item {
id: sheet
x: rowHead.width
y: colHead.height
width: parent.width - x
height: parent.height - y
clip: true
Rectangle { anchors.fill: parent; color: "#FFFFFF" }
// the empty sheet: gridlines everywhere, under the cells
Item {
anchors.fill: parent
visible: win.gridlines
Repeater {
model: Math.max(0, Math.ceil(sheet.height / g.rowH) + 2)
delegate: Rectangle {
required property int index
y: g.rowH + (index + 1) * g.rowH - (table.contentY % g.rowH) - 1
width: sheet.width
height: 1
color: X.grid
}
}
Repeater {
model: g.cols.length + g.padCols
delegate: Rectangle {
required property int index
x: g.colX(index + 1) - table.contentX - 1
width: 1
height: sheet.height
color: X.grid
}
}
}
// row 1, frozen: the table header with its filter buttons
Item {
id: headRow
width: parent.width
height: g.rowH
z: 2
Repeater {
model: g.cols
delegate: Rectangle {
id: hc
required property int index
required property var modelData
x: g.colX(index) - table.contentX
width: g.colW(index)
height: g.rowH
color: X.tableHead
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: "#FFFFFF"; opacity: 0.5 }
T {
x: 4
width: parent.width - 26
height: parent.height
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: true
color: "#FFFFFF"
text: hc.modelData.title
horizontalAlignment: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? Text.AlignLeft : Text.AlignRight
rightPadding: hc.modelData.kind === "text" || hc.modelData.kind === "stems" ? 0 : 2
}
// the AutoFilter button: sorts this column
Rectangle {
anchors { right: parent.right; rightMargin: 3; verticalCenter: parent.verticalCenter }
width: Math.round(15 * g.z100)
height: width
color: fm.containsMouse ? "#F0F0F0" : "#FFFFFF"
border.color: "#A0A0A0"
Ico {
anchors.centerIn: parent
size: Math.round(12 * g.z100)
name: library.sortColumn === hc.index ? (library.sortDescending ? "sort_down" : "sort_up") : "dropdown"
}
MouseArea {
id: fm
anchors.fill: parent
hoverEnabled: true
onClicked: { g.win.curCol = g.win.anchorCol = hc.index; filterMenu.col = hc.index; filterMenu.popup() }
}
}
MouseArea {
anchors { fill: parent; rightMargin: 20 }
onClicked: library.sortBy(hc.index)
}
}
}
}
TableView {
id: table
y: g.rowH
width: parent.width
height: parent.height - y
model: sheetModel
clip: true
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
columnWidthProvider: (c) => g.colW(c)
rowHeightProvider: (r) => g.rowH
interactive: false // the wheel and the scrollbars move it; dragging selects
ScrollBar.vertical: XScrollBar { id: vbar }
ScrollBar.horizontal: XScrollBar { id: hbar }
Connections {
target: g.win
function onZoomChanged() { Qt.callLater(table.forceLayout) }
}
delegate: Rectangle {
id: cell
required property int row
required property int column
required property string display
required property string align
required property string cellStyle
required property bool playing
readonly property bool bar: cellStyle.indexOf("bar:") === 0
color: cellStyle === "good" ? X.good : (row % 2 === 1 ? X.tableBand : "#FFFFFF")
Rectangle { // data bar
visible: cell.bar
x: 2
y: 2
height: parent.height - 4
width: cell.bar ? (parent.width - 4) * parseFloat(cell.cellStyle.slice(4)) : 0
color: X.dataBar
opacity: 0.85
}
Rectangle { visible: g.win.gridlines; anchors.right: parent.right; width: 1; height: parent.height; color: X.grid }
Rectangle { visible: g.win.gridlines; anchors.bottom: parent.bottom; width: parent.width; height: 1; color: X.grid }
Text {
anchors.fill: parent
leftPadding: 3
rightPadding: 4
verticalAlignment: Text.AlignBottom
bottomPadding: Math.round(2 * g.z100)
horizontalAlignment: cell.align === "right" ? Text.AlignRight : Text.AlignLeft
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
font.bold: cell.playing
color: cell.cellStyle === "good" ? X.goodText : X.cellText
text: cell.cellStyle === "good" ? cell.display + " stems" : cell.display
elide: cell.align === "right" ? Text.ElideLeft : Text.ElideRight
renderType: Text.NativeRendering
}
// Excel's green error triangle, here marking the song that's playing
Canvas {
visible: cell.playing && cell.column === 0
width: 7
height: 7
onPaint: {
var c = getContext("2d")
c.fillStyle = "#217346"
c.beginPath(); c.moveTo(0, 0); c.lineTo(7, 0); c.lineTo(0, 7); c.closePath(); c.fill()
}
}
}
// selection: range fill, green border, fill handle
Rectangle {
id: selBox
parent: table.contentItem
z: 50
visible: g.rows > 0
readonly property bool range: g.win.selTop !== g.win.selBottom || g.win.curCol !== g.win.anchorCol
x: g.colX(Math.min(g.win.curCol, g.win.anchorCol)) - 1
y: g.win.selTop * g.rowH - 1
width: g.colX(Math.max(g.win.curCol, g.win.anchorCol) + 1) - x + 1
height: (g.win.selBottom - g.win.selTop + 1) * g.rowH + 1
color: range ? X.selFill : "transparent"
border.color: X.green
border.width: 2
Rectangle {
// the active cell stays clear inside a range
visible: selBox.range
x: g.colX(g.win.curCol) - parent.x
y: g.win.curRow * g.rowH - parent.y
width: g.colW(g.win.curCol)
height: g.rowH
color: "#FFFFFF"
opacity: 0.6
}
Rectangle {
x: parent.width - 4
y: parent.height - 4
width: 7
height: 7
color: X.green
border.color: "#FFFFFF"
border.width: 1
}
}
MouseArea {
parent: table.contentItem
z: 60
width: Math.max(table.contentWidth, table.width)
height: Math.max(table.contentHeight, table.height)
acceptedButtons: Qt.LeftButton | Qt.RightButton
property bool dragging: false
function cellAt(m) { return [Math.floor(m.y / g.rowH), g.colAt(m.x)] }
onPressed: (m) => {
g.forceActiveFocus()
var rc = cellAt(m)
if (rc[0] >= g.rows) return
if (m.button === Qt.RightButton) {
if (rc[0] < g.win.selTop || rc[0] > g.win.selBottom) g.moveTo(rc[0], rc[1], false)
ctxMenu.popup()
return
}
g.moveTo(rc[0], rc[1], m.modifiers & Qt.ShiftModifier)
dragging = true
}
onPositionChanged: (m) => {
if (!dragging) return
var rc = cellAt(m)
g.moveTo(rc[0], rc[1], true)
}
onReleased: dragging = false
onDoubleClicked: (m) => { var rc = cellAt(m); if (rc[0] < g.rows) library.activate(rc[0], 0) }
onWheel: (w) => {
if (w.modifiers & Qt.ControlModifier) {
g.win.setZoom(g.win.zoom + (w.angleDelta.y > 0 ? 10 : -10))
return
}
var dy = w.pixelDelta.y !== 0 ? w.pixelDelta.y : w.angleDelta.y / 120 * 3 * g.rowH
var dx = w.pixelDelta.x !== 0 ? w.pixelDelta.x : w.angleDelta.x / 120 * 3 * g.rowH
if (w.modifiers & Qt.ShiftModifier) { dx = dy; dy = 0 }
table.contentY = Math.max(0, Math.min(table.contentY - dy, table.contentHeight - table.height))
table.contentX = Math.max(0, Math.min(table.contentX - dx, Math.max(0, table.contentWidth - table.width)))
}
}
}
// #GETTING_DATA: what Excel shows while a query runs
T {
visible: library.loading
x: 4
y: g.rowH
height: g.rowH
font.family: cellFont
font.pixelSize: Math.round(X.fsCell * g.z100)
color: X.cellText
text: "#GETTING_DATA"
}
// a cell comment when there's nothing to show
Rectangle {
visible: g.rows === 0 && !library.loading
x: g.colX(1) + 30
y: g.rowH * 2
width: 300
height: note.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Rectangle { x: -30; y: 6; width: 30; height: 1; color: "#000000" }
Text {
id: note
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: !library.signedIn ? "<b>Stemify:</b><br>This workbook isn't connected yet. Go to <b>Data → Connect to Plex</b> and sign in with the code."
: library.layout === "queue" ? "<b>Stemify:</b><br>The queue is empty. Double-click a song on any sheet to play it."
: library.status !== "" ? "<b>Stemify:</b><br>Loading the library from Plex…"
: "<b>Stemify:</b><br>Nothing on this sheet."
}
}
}
Keys.onPressed: (e) => {
var ext = e.modifiers & Qt.ShiftModifier
var ctrl = e.modifiers & Qt.ControlModifier
var page = Math.max(1, Math.floor(table.height / g.rowH) - 1)
switch (e.key) {
case Qt.Key_Down: g.moveTo(ctrl ? g.rows - 1 : win.curRow + 1, win.curCol, ext); break
case Qt.Key_Up: g.moveTo(ctrl ? 0 : win.curRow - 1, win.curCol, ext); break
case Qt.Key_Right: if (ctrl) { player.next(); break } g.moveTo(win.curRow, win.curCol + 1, ext); break
case Qt.Key_Left: if (ctrl) { player.previous(); break } g.moveTo(win.curRow, win.curCol - 1, ext); break
case Qt.Key_Tab: g.moveTo(win.curRow, win.curCol + 1, false); break
case Qt.Key_PageDown: g.moveTo(win.curRow + page, win.curCol, ext); break
case Qt.Key_PageUp: g.moveTo(win.curRow - page, win.curCol, ext); break
case Qt.Key_Home: g.moveTo(ctrl ? 0 : win.curRow, 0, ext); break
case Qt.Key_End: g.moveTo(ctrl ? g.rows - 1 : win.curRow, g.cols.length - 1, ext); break
case Qt.Key_Return:
case Qt.Key_Enter: library.activate(win.curRow, 0); break
case Qt.Key_Space: player.toggle(); break
case Qt.Key_Delete: library.removeRows(win.selTop, win.selBottom); break
case Qt.Key_Backspace: library.back(); break
case Qt.Key_A: if (ctrl) win.selectAll(); else return; break
case Qt.Key_C: if (ctrl) win.copySelection(); else return; break
case Qt.Key_V: if (!ctrl) player.toggleVocals(); else return; break
default: return
}
e.accepted = true
}
// A new sheet starts at the top; fresh data for the same sheet keeps its scroll position.
property real keptY: 0
property bool switching: false
Connections {
target: library
function onSheetSwitched() { g.switching = true; table.contentY = 0; table.contentX = 0 }
}
Connections {
target: sheetModel
function onModelAboutToBeReset() { g.keptY = table.contentY }
function onModelReset() {
if (g.switching) { g.switching = false; return }
Qt.callLater(() => { table.contentY = Math.min(g.keptY, Math.max(0, table.contentHeight - table.height)) })
}
}
XMenu {
id: ctxMenu
Action { text: "Play"; icon.name: "play"; onTriggered: library.playRows(g.win.selTop, g.win.selBottom) }
Action { text: "Play Next"; icon.name: "play_next"; onTriggered: library.playNextRows(g.win.selTop, g.win.selBottom) }
Action { text: "Add to Queue"; icon.name: "queue_add"; onTriggered: library.queueRows(g.win.selTop, g.win.selBottom) }
MenuSeparator {}
Action { text: "Open"; icon.name: "right"; onTriggered: library.activate(g.win.curRow, 0) }
Action { text: "New Playlist from Selection…"; icon.name: "playlist_new"; onTriggered: g.win.newPlaylist() }
Action { text: "Copy"; onTriggered: g.win.copySelection() }
MenuSeparator {}
Action {
text: "Delete Rows"
icon.name: "delete_rows"
enabled: library.layout === "queue" || g.win.inPlaylist
onTriggered: library.removeRows(g.win.selTop, g.win.selBottom)
}
}
XMenu {
id: filterMenu
property int col: 0
Action { text: "Sort A to Z"; icon.name: "sort_az"; onTriggered: g.win.sortColumn(filterMenu.col, false) }
Action { text: "Sort Z to A"; icon.name: "sort_za"; onTriggered: g.win.sortColumn(filterMenu.col, true) }
}
}
@@ -0,0 +1,89 @@
import QtQuick
// Worksheet tabs. The first six are fixed; opened artists, albums, playlists and searches follow.
Rectangle {
id: st
property var win: Window.window
height: 26
color: X.ribbon
Rectangle { width: parent.width; height: 1; color: X.ribbonLine }
Row {
id: nav
x: 6
height: parent.height
spacing: 2
Repeater {
model: ["left", "right"]
delegate: Item {
required property int index
required property string modelData
width: 18
height: parent.height
Ico { anchors.centerIn: parent; name: parent.modelData; size: 12; opacity: na.containsMouse ? 1 : 0.6 }
MouseArea {
id: na
anchors.fill: parent
hoverEnabled: true
onClicked: library.current = Math.max(0, Math.min(library.tabs.length - 1, library.current + (parent.index ? 1 : -1)))
}
}
}
}
Flickable {
id: flick
x: nav.x + nav.width + 8
width: parent.width - x - 8
height: parent.height
contentWidth: tabRow.width + 40
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: tabRow
height: parent.height
Repeater {
model: library.tabs
delegate: Item {
id: tb
required property int index
required property var modelData
readonly property bool active: library.current === index
width: lbl.implicitWidth + 28
height: st.height
Rectangle {
anchors.fill: parent
anchors.topMargin: 0
color: tb.active ? "#FFFFFF" : tm.containsMouse ? "#E5E5E5" : "transparent"
}
Rectangle { visible: !tb.active; anchors.right: parent.right; y: 6; width: 1; height: parent.height - 12; color: "#C8C8C8" }
Rectangle { visible: tb.active; anchors.bottom: parent.bottom; width: parent.width; height: 3; color: X.green }
T {
id: lbl
anchors.centerIn: parent
text: tb.modelData.title
color: tb.active ? X.greenText : X.ink
font.bold: tb.active
}
MouseArea {
id: tm
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (m) => m.button === Qt.MiddleButton ? library.closeTab(tb.index) : (library.current = tb.index)
}
Tip { target: tm; text: tb.modelData.base ? "" : "Middle-click or Backspace to close" }
}
}
// ⊕ new sheet: here, the search box
Item {
width: 30
height: st.height
Ico { anchors.centerIn: parent; name: "plus"; size: 16; opacity: pm.containsMouse ? 1 : 0.6 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: st.win.focusSearch() }
Tip { target: pm; text: "New sheet from a search" }
}
}
}
}
@@ -0,0 +1,96 @@
import QtQuick
// Ready / Calculating on the left; Average, Count, Sum; view buttons; the zoom slider is the volume.
Rectangle {
id: sb
property var win: Window.window
height: 24
color: X.green
T {
x: 10
height: parent.height
color: "#FFFFFF"
text: library.status !== "" ? library.status + (library.progress >= 0 ? ": " + Math.round(library.progress * 100) + "%" : "")
: player.status === "loading" ? "Calculating..."
: player.status === "buffering" ? "Buffering..."
: player.status === "playing" ? "Playing" + (player.presetLabel && player.presetLabel !== "Full Mix" ? " · " + player.presetLabel : "")
: "Ready"
}
Rectangle { // progress for Calculating, like Excel's
visible: library.progress >= 0
x: 190
anchors.verticalCenter: parent.verticalCenter
width: 120
height: 8
color: "#FFFFFF"
opacity: 0.9
Rectangle { width: parent.width * Math.max(0, library.progress); height: parent.height; color: "#9FD2B4" }
}
Row {
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
spacing: 18
Row {
height: parent.height
spacing: 18
visible: !!sb.win.selStats.count
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.average; text: "Average: " + (sb.win.selStats.average || "") }
T { color: "#FFFFFF"; height: parent.height; text: "Count: " + (sb.win.selStats.count || "") }
T { color: "#FFFFFF"; height: parent.height; visible: !!sb.win.selStats.sum; text: "Sum: " + (sb.win.selStats.sum || "") }
}
Row {
height: parent.height
spacing: 2
Repeater {
model: ["normal_view", "page_view", "break_view"]
delegate: Item {
required property int index
required property string modelData
width: 24
height: sb.height
Rectangle { anchors.fill: parent; anchors.margins: 2; color: index === 0 ? X.greenDark : "transparent" }
Ico { anchors.centerIn: parent; name: parent.modelData; ink: "#FFFFFF"; size: 16 }
}
}
}
// the zoom slider, wired to the master volume
Row {
height: parent.height
spacing: 6
T { color: "#FFFFFF"; height: parent.height; text: "−"; font.pixelSize: 16
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.max(0, engine.volume - 0.05) } }
Item {
id: track
width: 110
height: parent.height
Rectangle { anchors.verticalCenter: parent.verticalCenter; width: parent.width; height: 2; color: "#FFFFFF"; opacity: 0.8 }
Rectangle { x: parent.width / 2 - 1; anchors.verticalCenter: parent.verticalCenter; width: 2; height: 8; color: "#FFFFFF"; opacity: 0.8 }
Rectangle {
x: engine.volume * (track.width - width)
anchors.verticalCenter: parent.verticalCenter
width: 6
height: 13
color: vm.pressed ? "#E0E0E0" : "#FFFFFF"
}
MouseArea {
id: vm
anchors.fill: parent
anchors.margins: -3
hoverEnabled: true
function set(m) { engine.volume = Math.max(0, Math.min(1, (m.x - 3) / (track.width))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onWheel: (w) => engine.volume = Math.max(0, Math.min(1, engine.volume + (w.angleDelta.y > 0 ? 0.03 : -0.03)))
}
Tip { target: vm; text: "Volume" }
}
T { color: "#FFFFFF"; height: parent.height; text: "+"; font.pixelSize: 15
MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: engine.volume = Math.min(1, engine.volume + 0.05) } }
T { color: "#FFFFFF"; height: parent.height; width: 40; horizontalAlignment: Text.AlignRight; text: Math.round(engine.volume * 100) + "%" }
}
}
}
@@ -0,0 +1,136 @@
import QtQuick
// The stem faders as an Excel column chart. Drag a column to set its level; double-click
// for 0 dB. Below each category: M(ute) and S(olo).
Rectangle {
id: ch
color: "#FFFFFF"
border.color: "#D9D9D9"
readonly property var stems: player.stems
readonly property int n: stems.length
readonly property real unity: 0.8
T {
id: title
anchors.horizontalCenter: parent.horizontalCenter
y: 6
text: "Stem Mix"
font.family: cellFont
font.pixelSize: 17
color: X.chartText
}
Item {
id: plot
x: 34
y: title.y + title.height + 18
width: parent.width - x - 8
height: parent.height - y - 52
// value axis: fader position, 0 dB marked
Repeater {
model: [0, 0.25, 0.5, 0.8, 1.0]
delegate: Item {
required property var modelData
y: plot.height * (1 - modelData)
width: plot.width
Rectangle { width: parent.width; height: 1; color: modelData === 0.8 ? "#A6A6A6" : X.chartLine }
T {
x: -32
width: 28
y: -7
height: 14
horizontalAlignment: Text.AlignRight
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: modelData === 0 ? "-∞" : modelData === 0.8 ? "0 dB" : modelData === 1 ? "+4" : (40 * Math.log(modelData / 0.8) / Math.LN10).toFixed(0)
}
}
}
Repeater {
model: ch.stems
delegate: Item {
id: cat
required property int index
required property var modelData
readonly property real cw: plot.width / Math.max(1, ch.n)
x: index * cw
width: cw
height: plot.height
Rectangle {
id: bar
anchors.horizontalCenter: parent.horizontalCenter
width: cat.cw * 0.62
height: Math.max(1, plot.height * cat.modelData.level)
y: plot.height - height
color: cat.modelData.color
opacity: cat.modelData.audible ? 1 : 0.22
border.color: dm.containsMouse || dm.pressed ? "#404040" : "transparent"
Behavior on height { enabled: !dm.pressed; NumberAnimation { duration: X.easeMs; easing.type: Easing.OutCubic } }
}
T { // data label
anchors.horizontalCenter: parent.horizontalCenter
y: bar.y - 16
font.family: cellFont
font.pixelSize: 11
color: X.chartText
text: cat.modelData.muted ? "Muted" : !cat.modelData.audible ? "—" : cat.modelData.level <= 0.001 ? "-∞" : (cat.modelData.db >= 0 ? "+" : "") + cat.modelData.db.toFixed(1)
}
MouseArea {
id: dm
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeVerCursor
function set(m) { player.setLevel(cat.index, Math.max(0, Math.min(1, 1 - m.y / plot.height))) }
onPressed: (m) => set(m)
onPositionChanged: (m) => { if (pressed) set(m) }
onDoubleClicked: player.setLevel(cat.index, ch.unity)
onWheel: (w) => player.setLevel(cat.index, Math.max(0, Math.min(1, cat.modelData.level + (w.angleDelta.y > 0 ? 0.02 : -0.02))))
}
Tip { target: dm; text: cat.modelData.label + ": drag to set, double-click for 0 dB" }
// category axis label and the M / S toggles
Column {
y: plot.height + 4
anchors.horizontalCenter: parent.horizontalCenter
spacing: 3
T {
anchors.horizontalCenter: parent.horizontalCenter
font.family: cellFont
font.pixelSize: 12
color: X.chartText
text: cat.modelData.label
width: Math.min(implicitWidth, cat.cw)
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 2
Toggle { label: "M"; on: cat.modelData.muted; onFill: X.bad; onText: X.badText; tip: "Mute " + cat.modelData.label; onClicked: player.toggleMute(cat.index) }
Toggle { label: "S"; on: cat.modelData.solo; onFill: X.neutral; onText: X.neutralText; tip: "Solo " + cat.modelData.label; onClicked: player.toggleSolo(cat.index) }
}
}
}
}
Rectangle { y: plot.height; width: plot.width; height: 1; color: "#BFBFBF" }
}
component Toggle: Rectangle {
id: tg
property string label
property bool on: false
property color onFill
property color onText
property string tip
signal clicked()
width: 18
height: 18
color: on ? onFill : tgm.containsMouse ? "#F2F2F2" : "#FFFFFF"
border.color: on ? onText : "#BFBFBF"
T { anchors.centerIn: parent; text: tg.label; font.pixelSize: 11; font.bold: tg.on; color: tg.on ? tg.onText : X.inkSoft }
MouseArea { id: tgm; anchors.fill: parent; hoverEnabled: true; onClicked: tg.clicked() }
Tip { target: tgm; text: tg.tip }
}
}
@@ -0,0 +1,10 @@
import QtQuick
Text {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
@@ -0,0 +1,302 @@
import QtQuick
import QtQuick.Controls.Basic
// The "Format Mix" task pane: what's playing, a sparkline to seek with, and the stems as a chart.
Rectangle {
id: pane
property var win: Window.window
color: "#FFFFFF"
Rectangle { width: 1; height: parent.height; color: X.ribbonLine }
Item {
id: head
x: 1
width: parent.width - 1
height: 40
T { x: 14; height: parent.height; text: "Format Mix"; font.pixelSize: 17; color: X.ink }
Ico { anchors.right: parent.right; anchors.rightMargin: 34; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 12 }
Item {
anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter }
width: 24
height: 24
Rectangle { anchors.fill: parent; color: cm.containsMouse ? X.hover : "transparent" }
Ico { anchors.centerIn: parent; name: "close"; size: 20 }
MouseArea { id: cm; anchors.fill: parent; hoverEnabled: true; onClicked: pane.win.toggleMixer() }
}
}
Flickable {
id: flick
x: 1
y: head.height
width: parent.width - 1
height: parent.height - y
contentHeight: col.height + 20
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: XScrollBar { policy: flick.contentHeight > flick.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff }
Column {
id: col
x: 14
width: flick.width - 34
spacing: 10
// ---- now playing ----
Row {
width: parent.width
spacing: 12
Rectangle {
width: 128
height: 128
color: "#F2F2F2"
border.color: "#D9D9D9"
Image {
id: art
anchors.fill: parent
anchors.margins: 1
source: player.track.art || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
sourceSize.width: 256
sourceSize.height: 256
}
Ico { visible: art.status !== Image.Ready; anchors.centerIn: parent; name: "excel"; size: 48; opacity: 0.25 }
}
Column {
width: parent.width - 140
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: player.track.title || "Nothing playing"
font.family: uiFont
font.pixelSize: 16
font.bold: true
color: "#222222"
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
}
T { width: parent.width; text: player.track.artist || "Double-click a song to play it"; font.pixelSize: 13; color: X.inkSoft }
Text {
width: parent.width
visible: !!player.track.album
text: (player.track.album || "") + (player.track.year ? " · " + player.track.year : "")
font.family: uiFont
font.pixelSize: 12
color: X.inkFaint
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
}
// ---- the sparkline seek bar ----
Item {
id: spark
width: parent.width
height: 46
readonly property real dur: engine.duration > 0 ? engine.duration : (player.track.durationMs || 0) / 1000
readonly property real frac: dur > 0 ? Math.min(1, pane.win.playPos / dur) : 0
readonly property var bars: player.wave.length ? player.wave : []
readonly property int n: bars.length ? bars.length : 80
property real hoverFrac: -1
Rectangle { anchors.fill: parent; color: "#FFFFFF"; border.color: "#D9D9D9" }
Canvas {
id: sparkCanvas
anchors.fill: parent
anchors.margins: 3
property real frac: spark.frac
property var bars: spark.bars
onFracChanged: requestPaint()
onBarsChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
var c = getContext("2d")
c.reset()
var n = spark.n, w = width / n, h = height
for (var i = 0; i < n; i++) {
var v = bars.length ? bars[i] : 0.12
var bh = Math.max(2, v * h)
c.fillStyle = (i + 0.5) / n <= frac ? "#217346" : "#A9D08E"
c.fillRect(i * w + w * 0.15, h - bh, Math.max(1, w * 0.7), bh)
}
}
}
Rectangle { // playhead
visible: spark.dur > 0
x: 3 + spark.frac * (parent.width - 6) - 1
y: 2
width: 2
height: parent.height - 4
color: "#C00000"
}
Rectangle {
visible: spark.hoverFrac >= 0
x: Math.max(0, Math.min(parent.width - width, spark.hoverFrac * parent.width - width / 2))
y: -22
width: hv.implicitWidth + 10
height: 18
color: "#FFFFFF"
border.color: "#BEBEBE"
T { id: hv; anchors.centerIn: parent; font.pixelSize: X.fsSmall; text: X.time(spark.hoverFrac * spark.dur) }
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
function f(m) { return Math.max(0, Math.min(1, (m.x - 3) / (width - 6))) }
onPositionChanged: (m) => { spark.hoverFrac = f(m); if (pressed) pane.win.seekTo(f(m) * spark.dur) }
onExited: spark.hoverFrac = -1
onPressed: (m) => pane.win.seekTo(f(m) * spark.dur)
}
}
Item {
width: parent.width
height: 14
y: -6
T { font.pixelSize: X.fsSmall; color: X.inkSoft; text: X.time(pane.win.playPos) }
T {
anchors.right: parent.right
font.pixelSize: X.fsSmall
color: X.inkSoft
text: spark.dur > 0 ? "-" + X.time(spark.dur - pane.win.playPos) : ""
}
}
// ---- transport ----
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
PaneButton { icon: "shuffle"; checked: player.shuffleOn; tip: "Shuffle"; onClicked: player.toggleShuffle() }
PaneButton { icon: "previous"; tip: "Previous"; onClicked: player.previous() }
PaneButton {
icon: player.status === "playing" ? "pause" : "play"
size: 40
tip: "Play / Pause (Space)"
onClicked: player.toggle()
}
PaneButton { icon: "next"; tip: "Next"; onClicked: player.next() }
PaneButton {
icon: player.repeatMode === "one" ? "repeat_one" : "repeat"
checked: player.repeatMode !== "off"
tip: player.repeatMode === "off" ? "Repeat" : player.repeatMode === "all" ? "Repeat all" : "Repeat one"
onClicked: player.cycleRepeat()
}
}
// output meter, as a pair of data bars
Column {
width: parent.width
spacing: 2
Repeater {
model: 2
delegate: Rectangle {
required property int index
width: parent.width
height: 5
color: "#EDEDED"
Rectangle {
readonly property real lv: index === 0 ? pane.win.levelL : pane.win.levelR
readonly property real db: 20 * Math.log(Math.max(lv, 1e-5)) / Math.LN10
width: parent.width * Math.max(0, Math.min(1, (db + 48) / 48))
height: parent.height
color: db > -1 ? "#C00000" : db > -9 ? "#FFC000" : X.dataBar
}
}
}
}
// ---- section: Stem Mix ----
Row {
spacing: 6
height: 24
Ico { anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
T { anchors.verticalCenter: parent.verticalCenter; text: "Stem Mix"; font.bold: true; font.pixelSize: 13 }
}
Rectangle { // "comment" for songs without stems
visible: player.sourceKind === "original"
width: parent.width
height: noteTxt.implicitHeight + 16
color: X.note
border.color: "#B2B2B2"
Text {
id: noteTxt
x: 8
y: 8
width: parent.width - 16
wrapMode: Text.WordWrap
font.family: uiFont
font.pixelSize: X.fsUi
textFormat: Text.StyledText
text: "<b>Stemify:</b><br>This song hasn't been separated yet, so it plays the original file. The server's stemd will get to it."
}
}
StemChart {
visible: player.sourceKind === "stems"
width: parent.width
height: 250
}
Row {
visible: player.sourceKind === "stems"
spacing: 8
T { height: 22; text: "Mix style:" }
Rectangle {
width: 150
height: 22
border.color: pm.containsMouse ? X.green : "#ABABAB"
T { x: 6; height: parent.height; width: parent.width - 24; text: player.presetLabel }
Ico { anchors.right: parent.right; anchors.rightMargin: 5; anchors.verticalCenter: parent.verticalCenter; name: "dropdown"; size: 10 }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; onClicked: presetMenu.popup() }
}
}
XCheck {
text: "Keep the mix for the next song"
checked: player.keepMix
onToggled: player.setKeepMix(!player.keepMix)
}
}
}
XMenu {
id: presetMenu
Instantiator {
model: player.presets
delegate: MenuItem {
required property int index
required property var modelData
text: modelData.label
enabled: modelData.usable
checkable: true
checked: modelData.active
onTriggered: player.applyPreset(index)
}
onObjectAdded: (i, o) => presetMenu.insertItem(i, o)
onObjectRemoved: (i, o) => presetMenu.removeItem(o)
}
}
component PaneButton: Item {
id: pb
property string icon
property bool checked: false
property int size: 32
property string tip: ""
signal clicked()
width: size
height: size
Rectangle {
anchors.fill: parent
radius: pb.size / 2
color: pb.checked ? X.checked : pbm.pressed ? X.pressed : pbm.containsMouse ? X.hover : "transparent"
}
Ico { anchors.centerIn: parent; name: pb.icon; size: Math.round(pb.size * 0.55) }
MouseArea { id: pbm; anchors.fill: parent; hoverEnabled: true; onClicked: pb.clicked() }
Tip { target: pbm; text: pb.tip }
}
}
@@ -0,0 +1,22 @@
import QtQuick
import QtQuick.Controls.Basic
// Office-style tooltip: white box, gray border, after a short hover.
ToolTip {
id: tt
property var target
visible: target && target.containsMouse && text !== "" && hoverTimer.done
delay: 0
timeout: 6000
padding: 6
Timer {
id: hoverTimer
property bool done: false
interval: 600
running: tt.target && tt.target.containsMouse
onRunningChanged: if (!running) done = false
onTriggered: done = true
}
contentItem: T { text: tt.text; font.pixelSize: X.fsSmall; elide: Text.ElideNone }
background: Rectangle { color: "#FFFFFF"; border.color: "#BEBEBE" }
}
@@ -0,0 +1,65 @@
import QtQuick
// Excel's green title bar: Quick Access Toolbar, workbook name, account, caption buttons.
Rectangle {
id: bar
property var win: Window.window
height: 32
color: X.green
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) bar.win.startSystemMove()
}
TapHandler { onDoubleTapped: bar.win.toggleMaximized() }
Row {
x: 6
height: parent.height
spacing: 0
Ico { name: "excel"; size: 20; anchors.verticalCenter: parent.verticalCenter }
Item { width: 10; height: 1 }
QatButton { icon: player.status === "playing" ? "pause" : "play"; tip: player.status === "playing" ? "Pause (Space)" : "Play (Space)"; onClicked: player.toggle() }
QatButton { icon: "undo"; tip: "Undo — previous song (Ctrl+Left)"; onClicked: player.previous() }
QatButton { icon: "redo"; tip: "Redo — next song (Ctrl+Right)"; onClicked: player.next() }
QatButton { icon: "dropdown"; tip: "Customize Quick Access Toolbar"; width: 16 }
}
T {
anchors.centerIn: parent
width: Math.min(implicitWidth, parent.width - 560)
color: "#FFFFFF"
font.pixelSize: X.fsUi
text: (player.track.title ? player.track.title + " - " + player.track.artist : "Book1") + ".xlsx - Excel"
}
Row {
anchors.right: parent.right
height: parent.height
T {
anchors.verticalCenter: parent.verticalCenter
color: "#FFFFFF"
text: library.signedIn ? (settings.get("plexUser") || "Plex") : "Sign in"
rightPadding: 14
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: bar.win.openBackstage("account") }
}
CaptionButton { icon: "pane"; tip: "Show or hide the mixer pane"; onClicked: bar.win.toggleMixer() }
CaptionButton { icon: "minimize"; onClicked: bar.win.showMinimized() }
CaptionButton { icon: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
CaptionButton { icon: "close"; danger: true; onClicked: bar.win.close() }
}
component QatButton: Item {
id: q
property string icon
property string tip
signal clicked()
width: 26
height: parent.height
Rectangle { anchors.fill: parent; color: qm.pressed ? X.greenDark : qm.containsMouse ? X.greenHover : "transparent" }
Ico { anchors.centerIn: parent; name: q.icon; ink: "#FFFFFF"; size: q.icon === "dropdown" ? 10 : 16 }
MouseArea { id: qm; anchors.fill: parent; hoverEnabled: true; onClicked: q.clicked() }
Tip { target: qm; text: q.tip }
}
}
@@ -0,0 +1,71 @@
pragma Singleton
import QtQuick
// Excel 2016, "Colorful" Office theme.
QtObject {
readonly property color green: "#217346"
readonly property color greenDark: "#185C37"
readonly property color greenHover: "#2A8754"
readonly property color greenText: "#217346"
readonly property color ribbon: "#F3F3F3"
readonly property color ribbonLine: "#D5D5D5"
readonly property color hover: "#DCDCDC"
readonly property color pressed: "#C6C6C6"
readonly property color checked: "#C6C6C6"
readonly property color checkedBorder: "#A5A5A5"
readonly property color ink: "#444444"
readonly property color inkSoft: "#666666"
readonly property color inkFaint: "#9A9A9A"
readonly property color white: "#FFFFFF"
readonly property color grid: "#D4D4D4"
readonly property color header: "#F3F3F3"
readonly property color headerSel: "#D2D2D2"
readonly property color headerLine: "#BDBDBD"
readonly property color selFill: Qt.rgba(0, 0, 0, 0.09)
readonly property color cellText: "#000000"
readonly property color tableHead: "#70AD47"
readonly property color tableBand: "#E2EFDA"
readonly property color good: "#C6EFCE"
readonly property color goodText: "#006100"
readonly property color bad: "#FFC7CE"
readonly property color badText: "#9C0006"
readonly property color neutral: "#FFEB9C"
readonly property color neutralText: "#9C5700"
readonly property color dataBar: "#63BE7B"
readonly property color note: "#FFFFE1"
readonly property color menuBorder: "#C6C6C6"
readonly property color chartLine: "#D9D9D9"
readonly property color chartText: "#595959"
// Excel's cell styles, used for the mix presets.
readonly property var styles: [
{ name: "Normal", fill: "#FFFFFF", text: "#000000", border: "#D4D4D4", bold: false },
{ name: "Bad", fill: "#FFC7CE", text: "#9C0006", border: "#FFC7CE", bold: false },
{ name: "Good", fill: "#C6EFCE", text: "#006100", border: "#C6EFCE", bold: false },
{ name: "Neutral", fill: "#FFEB9C", text: "#9C5700", border: "#FFEB9C", bold: false },
{ name: "Calculation", fill: "#F2F2F2", text: "#FA7D00", border: "#7F7F7F", bold: true },
{ name: "Check Cell", fill: "#A5A5A5", text: "#FFFFFF", border: "#3F3F3F", bold: true },
{ name: "Note", fill: "#FFFFCC", text: "#000000", border: "#B2B2B2", bold: false }
]
readonly property int fsSmall: 11
readonly property int fsUi: 12
readonly property int fsCell: 15 // Calibri 11 pt
readonly property int easeMs: 120
function colName(i) {
var s = ""
i += 1
while (i > 0) {
var m = (i - 1) % 26
s = String.fromCharCode(65 + m) + s
i = Math.floor((i - 1) / 26)
}
return s
}
function time(sec) {
if (!(sec > 0)) sec = 0
var s = Math.floor(sec), m = Math.floor(s / 60)
return m + ":" + ("0" + (s % 60)).slice(-2)
}
}
@@ -0,0 +1,24 @@
import QtQuick
// Office checkbox: square box, green tick.
Item {
id: c
property bool checked: false
property string text: ""
signal toggled()
implicitWidth: 18 + lbl.implicitWidth + 4
implicitHeight: 20
opacity: enabled ? 1 : 0.4
Rectangle {
id: box
width: 13
height: 13
y: (parent.height - height) / 2
x: 2
color: "#FFFFFF"
border.color: ma.containsMouse ? X.green : "#7A7A7A"
Ico { visible: c.checked; anchors.centerIn: parent; name: "check"; size: 12 }
}
T { id: lbl; x: 20; height: parent.height; text: c.text }
MouseArea { id: ma; anchors.fill: parent; hoverEnabled: true; onClicked: c.toggled() }
}
@@ -0,0 +1,17 @@
import QtQuick
import QtQuick.Controls.Basic
TextField {
font.family: uiFont
font.pixelSize: X.fsUi
color: X.ink
selectByMouse: true
selectionColor: "#B7D7C4"
selectedTextColor: "#000000"
leftPadding: 5
rightPadding: 5
topPadding: 2
bottomPadding: 2
implicitHeight: 22
background: Rectangle { color: "#FFFFFF"; border.color: parent.activeFocus ? X.green : "#ABABAB" }
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls.Basic
// Office context menu: white, gray border, icon gutter on the left.
Menu {
id: m
padding: 2
delegate: MenuItem {
id: mi
implicitWidth: Math.max(200, lab.implicitWidth + 56)
implicitHeight: mi.text === "" ? 7 : 26
enabled: mi.action ? mi.action.enabled : true
contentItem: Item {
T {
id: lab
x: 28
anchors.verticalCenter: parent.verticalCenter
text: mi.text
color: mi.enabled ? X.ink : X.inkFaint
}
Ico {
visible: mi.action && mi.action.icon.name !== ""
name: mi.action ? mi.action.icon.name : ""
size: 16
x: 4
anchors.verticalCenter: parent.verticalCenter
}
Ico {
visible: mi.checkable && mi.checked
name: "check"
size: 14
x: 5
anchors.verticalCenter: parent.verticalCenter
}
}
background: Rectangle {
color: mi.highlighted && mi.enabled ? "#E0E0E0" : "transparent"
}
arrow: null
indicator: null
}
background: Rectangle {
implicitWidth: 200
color: "#FFFFFF"
border.color: X.menuBorder
Rectangle { x: 25; y: 1; width: 1; height: parent.height - 2; color: "#E7E7E7" }
}
}
@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls.Basic
// Office scrollbar: flat gray thumb on a light track.
ScrollBar {
id: sb
policy: ScrollBar.AlwaysOn
minimumSize: 0.04
padding: 2
implicitWidth: 15
implicitHeight: 15
contentItem: Rectangle {
implicitWidth: 9
implicitHeight: 9
color: sb.pressed ? "#7A7A7A" : sb.hovered ? "#A6A6A6" : "#C2C2C2"
}
background: Rectangle { color: "#F0F0F0"; border.color: "#E0E0E0" }
}
@@ -0,0 +1,25 @@
module StemPlayer
singleton X 1.0 X.qml
Backstage 1.0 Backstage.qml
CaptionButton 1.0 CaptionButton.qml
FormulaBar 1.0 FormulaBar.qml
Ico 1.0 Ico.qml
Main 1.0 Main.qml
MessageBar 1.0 MessageBar.qml
OfficeButton 1.0 OfficeButton.qml
RButton 1.0 RButton.qml
ResizeEdge 1.0 ResizeEdge.qml
RGroup 1.0 RGroup.qml
Ribbon 1.0 Ribbon.qml
Sheet 1.0 Sheet.qml
SheetTabs 1.0 SheetTabs.qml
StatusBar 1.0 StatusBar.qml
StemChart 1.0 StemChart.qml
TaskPane 1.0 TaskPane.qml
Tip 1.0 Tip.qml
TitleBar 1.0 TitleBar.qml
T 1.0 T.qml
XCheck 1.0 XCheck.qml
XField 1.0 XField.qml
XMenu 1.0 XMenu.qml
XScrollBar 1.0 XScrollBar.qml
+149
View File
@@ -0,0 +1,149 @@
"""The workbook: every view is a sheet (columns + rows) shown in the Excel grid."""
import time
import unicodedata
from PySide6.QtCore import QAbstractTableModel, QByteArray, QModelIndex, Qt
# Column formats: how a value is shown, aligned and summed in the status bar.
# text, int, year, time (ms -> m:ss), stems (count of stems, shown TRUE/FALSE-ish), date (unix s)
COLUMNS = {
"songs": [
("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"), ("album", "Album", 230, "text"),
("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
("plays", "Plays", 64, "int"),
],
"albumSongs": [
("index", "#", 44, "int"), ("title", "Title", 320, "text"), ("artist", "Artist", 200, "text"),
("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"), ("plays", "Plays", 64, "int"),
],
"artists": [
("title", "Artist", 280, "text"), ("albums", "Albums", 70, "int"), ("songs", "Songs", 70, "int"),
("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 200, "text"),
],
"albums": [
("title", "Album", 300, "text"), ("artist", "Artist", 200, "text"), ("year", "Year", 64, "year"),
("tracks", "Songs", 64, "int"), ("stemSongs", "With stems", 90, "int"), ("genre", "Genre", 160, "text"),
("addedAt", "Added", 96, "date"),
],
"playlists": [
("title", "Playlist", 300, "text"), ("tracks", "Songs", 70, "int"), ("durationMs", "Length", 80, "time"),
("kind", "Type", 80, "text"), ("updatedAt", "Updated", 96, "date"),
],
"queue": [
("pos", "#", 44, "int"), ("title", "Title", 300, "text"), ("artist", "Artist", 190, "text"),
("album", "Album", 230, "text"), ("durationMs", "Time", 64, "time"), ("stems", "Stems", 70, "stems"),
],
"search": [
("type", "Type", 70, "text"), ("title", "Name", 300, "text"), ("artist", "Artist", 190, "text"),
("album", "Album", 230, "text"), ("year", "Year", 64, "year"), ("durationMs", "Time", 64, "time"),
("stems", "Stems", 70, "stems"),
],
}
# Plex row kind -> what a double-click does
SONG, ARTIST, ALBUM, PLAYLIST = "song", "artist", "album", "playlist"
def fold(s: str) -> str:
"""Case- and accent-insensitive form for search and sorting."""
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if not unicodedata.combining(c)).casefold()
def fmt_time(ms) -> str:
s = int(round((ms or 0) / 1000))
h, m = divmod(s // 60, 60)
return f"{h}:{m:02d}:{s % 60:02d}" if h else f"{m}:{s % 60:02d}"
def fmt(value, kind) -> str:
if kind == "time":
return fmt_time(value) if value else ""
if kind == "year":
return str(value) if value else ""
if kind == "int":
return "" if value in (None, "") else str(value)
if kind == "stems":
return f"{value}" if value else ""
if kind == "date":
return time.strftime("%Y-%m-%d", time.localtime(value)) if value else ""
return "" if value is None else str(value)
class Sheet:
"""A tab: its rows, columns and what the rows are."""
def __init__(self, title, columns, rows, kind=SONG, sid=None, loading=False):
self.title = title
self.columns = COLUMNS[columns]
self.layout = columns
self.rows = rows
self.kind = kind
self.sid = sid or title
self.loading = loading
self.sort = None # (column, descending)
def row_kind(self, r):
return self.rows[r].get("_kind", self.kind)
class SheetModel(QAbstractTableModel):
"""One sheet for the QML TableView. Roles: display, align, style, playing."""
ROLES = {Qt.ItemDataRole.DisplayRole: b"display", Qt.ItemDataRole.UserRole + 1: b"align",
Qt.ItemDataRole.UserRole + 2: b"cellStyle", Qt.ItemDataRole.UserRole + 3: b"playing",
Qt.ItemDataRole.UserRole + 4: b"bold"}
def __init__(self, parent=None):
super().__init__(parent)
self.sheet: Sheet | None = None
self.playing_key = ""
self.playing_pos = -1 # for the queue sheet, where the same song can appear twice
def set_sheet(self, sheet):
self.beginResetModel()
self.sheet = sheet
self.endResetModel()
def refresh(self):
self.beginResetModel()
self.endResetModel()
def set_playing(self, key, pos):
self.playing_key, self.playing_pos = key, pos
if self.sheet and self.sheet.rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sheet.rows) - 1, len(self.sheet.columns) - 1),
[Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4])
def roleNames(self):
return {k: QByteArray(v) for k, v in self.ROLES.items()}
def rowCount(self, parent=QModelIndex()):
return len(self.sheet.rows) if self.sheet else 0
def columnCount(self, parent=QModelIndex()):
return len(self.sheet.columns) if self.sheet else 0
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
s = self.sheet
if s is None or not index.isValid():
return None
row = s.rows[index.row()]
key, _, _, kind = s.columns[index.column()]
if role == Qt.ItemDataRole.DisplayRole:
return fmt(row.get(key), kind)
if role == Qt.ItemDataRole.UserRole + 1:
return "left" if kind in ("text", "stems") else "right"
if role == Qt.ItemDataRole.UserRole + 2:
# Conditional formatting: "Good" for songs with stems, a data bar for stem counts.
if kind == "stems" and row.get(key):
return "good"
if key == "stemSongs" and row.get(key):
return "bar:%.3f" % (row[key] / max(1, row.get("tracks") or row.get("songs") or row[key]))
return ""
if role in (Qt.ItemDataRole.UserRole + 3, Qt.ItemDataRole.UserRole + 4):
if s.layout == "queue":
return row.get("pos") == self.playing_pos + 1
return s.row_kind(index.row()) == SONG and row.get("key") == self.playing_key and bool(self.playing_key)
return None
+153
View File
@@ -0,0 +1,153 @@
"""stemserve's index, matched to Plex tracks by path (a port of the phone app's StemIndex)."""
import json
import os
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
# Display order and colours, matching the desktop separator and the phone.
ORDER = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
COLORS = {
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6", "mix": "#8E8E93",
}
def sort_names(names):
return sorted(names, key=lambda n: ORDER.index(n) if n in ORDER else 99)
def norm(path: str) -> str:
"""NFC, forward slashes, no extension, lower case."""
p = unicodedata.normalize("NFC", path).replace("\\", "/")
slash, dot = p.rfind("/"), p.rfind(".")
if dot > slash + 1:
p = p[:dot]
return p.lower()
class StemIndex:
"""Maps Plex file paths to stem folders.
The stems folder mirrors the music folder, so the source path relative to the music root
identifies a track. Plex reports paths as its container sees them, so the root is unknown
up front: every suffix of the Plex path is tried, longest first. A bare file name, or a
suffix whose duration disagrees, only counts under a root learned from a longer match.
"""
def __init__(self, entries=(), roots=()):
self.entries = list(entries)
self.by_source = {}
self.by_key = {}
for e in self.entries:
self.by_source.setdefault(norm(e["source"]), e)
self.by_key[e["key"]] = e
self.roots = list(dict.fromkeys(roots))
def __len__(self):
return len(self.entries)
def find(self, plex_path, duration_ms=0):
if not plex_path or not self.entries:
return None
path = norm(plex_path)
for root in self.roots:
if path.startswith(root):
hit = self.by_source.get(path[len(root):])
if hit:
return hit
parts = path.split("/")
for i in range(1, len(parts)):
hit = self.by_source.get("/".join(parts[i:]))
if hit is None:
continue
if len(parts) - i >= 2 and self._duration_ok(hit, duration_ms):
self.roots.append("/".join(parts[:i]) + "/")
return hit
return None
@staticmethod
def _duration_ok(e, duration_ms):
d = e.get("duration")
return d is None or duration_ms <= 0 or abs(d * 1000 - duration_ms) < 3000
class StemServer:
"""Client for server/stemserve.py. Files come from the local mount when it's there."""
def __init__(self, config, cache_dir):
self.cfg = config
self.cache_dir = cache_dir
self.index = StemIndex(roots=config.get("stemRoots", []))
self.online = None # not checked yet
self._index_file = os.path.join(cache_dir, "stem_index.json")
self._etag_file = os.path.join(cache_dir, "stem_index.etag")
try:
with open(self._index_file) as f:
self.index = StemIndex(json.load(f).get("tracks", []), self.index.roots)
except (OSError, ValueError):
pass
@property
def base(self):
return self.cfg["stemUrl"].rstrip("/")
def _open(self, url, headers=None, timeout=15):
h = dict(headers or {})
if self.cfg.get("stemToken"):
h["X-Stem-Token"] = self.cfg["stemToken"]
return urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=timeout)
def refresh(self):
"""Fetches the index (ETag-cached). Returns True if it changed."""
headers = {}
if os.path.exists(self._etag_file) and os.path.exists(self._index_file):
with open(self._etag_file) as f:
headers["If-None-Match"] = f.read().strip()
try:
with self._open(self.base + "/api/index", headers) as r:
body = r.read()
etag = r.headers.get("ETag")
except urllib.error.HTTPError as e:
self.online = e.code == 304
return False
except (urllib.error.URLError, TimeoutError, OSError):
self.online = False
return False
self.online = True
data = json.loads(body)
self.index = StemIndex(data.get("tracks", []), self.index.roots)
os.makedirs(self.cache_dir, exist_ok=True)
with open(self._index_file, "wb") as f:
f.write(body)
if etag:
with open(self._etag_file, "w") as f:
f.write(etag)
return True
def find(self, track):
hit = self.index.find(track.get("partFile"), track.get("durationMs", 0))
if self.index.roots != self.cfg.get("stemRoots"):
self.cfg["stemRoots"] = list(self.index.roots)
return hit
def local_path(self, entry, stem):
root = self.cfg.get("stemsDir", "")
p = os.path.join(root, entry["key"], stem["file"]) if root else ""
return p if p and os.path.exists(p) else None
def file_url(self, entry, stem):
path = "/".join(urllib.parse.quote(p) for p in entry["key"].split("/"))
return f"{self.base}/file/{path}/{urllib.parse.quote(stem['file'])}"
def peaks(self, entry, buckets):
"""Per-stem RMS envelopes (0..1), or None."""
url = f"{self.base}/api/peaks?" + urllib.parse.urlencode({"key": entry["key"], "n": buckets})
try:
with self._open(url, timeout=30) as r:
stems = json.loads(r.read()).get("stems", {})
return {name: [v / 1000 for v in arr] for name, arr in stems.items()}
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None
+317
View File
@@ -0,0 +1,317 @@
"""QML image providers. Everything here renders off the UI thread and is
uploaded once as a texture: no per-frame CPU work, no live blur.
image://icon/<name>/<RRGGBB> Material icon tinted, sized by sourceSize
image://fx/backdrop/<W>x<H>/<radius> window background (aurora)
image://fx/glass/<W>x<H>/<radius>/<blur> blurred + tinted snapshot of the backdrop
image://fx/glasscrop/<W>x<H>/<r>/<blur>/<x>,<y>,<w>,<h>/<sheet r> rounded glass sheet
image://fx/shadow/<sigma>/<alpha>/<r> 9-patch elevation shadow
image://fx/glow/<RRGGBB>/<sigma>/<r> 9-patch hover glow
image://fx/stripes/<h> progress stripe tile
image://fx/noise dither tile
image://wave/<job>/<stem>/<W>x<H>/<t0>/<t1>/<RRGGBB>[/<rev>]
"""
import threading
import numpy as np
from PySide6.QtCore import QByteArray, QRectF, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtQuick import QQuickImageProvider
from PySide6.QtSvg import QSvgRenderer
from scipy.ndimage import gaussian_filter
# Material Icons (Apache 2.0), 24x24 viewBox.
ICONS = {
"play": "M8 5v14l11-7z",
"pause": "M6 19h4V5H6v14zm8-14v14h4V5h-4z",
"stop": "M6 6h12v12H6z",
"skip_start": "M6 6h2v12H6zm3.5 6l8.5 6V6z",
"close": "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
"minimize": "M5 11h14v2H5z",
"maximize": "M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H6V6h12v12z",
"restore": "M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z",
"settings": "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z",
"folder": "M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z",
"download": "M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z",
"check": "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
"error": "M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z",
"refresh": "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z",
"add": "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
"music": "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z",
"volume": "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
"volume_off": "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z",
"headphones": "M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z",
"chip": "M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z",
"delete": "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z",
"upload": "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z",
"equalizer": "M7 18h2V6H7v12zm4 4h2V2h-2v20zm-8-8h2v-4H3v4zm12 4h2V6h-2v12zm4-8v4h2v-4h-2z",
"expand": "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z",
"layers": "M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z",
"zoom_out": "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zM7 9h5v1H7z",
"speed": "M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z",
}
def _hex(c):
c = c.lstrip("#")
return np.array([int(c[i:i + 2], 16) for i in (0, 2, 4)], np.float32) / 255
def _to_qimage(rgba: np.ndarray) -> QImage:
"""float RGBA [0,1] (H, W, 4), straight alpha -> premultiplied QImage (copies)."""
a = np.clip(rgba[..., 3:4], 0, 1)
out = np.empty(rgba.shape, np.uint8)
out[..., :3] = np.clip(rgba[..., :3] * a * 255 + 0.5, 0, 255)
out[..., 3:] = np.clip(a * 255 + 0.5, 0, 255)
h, w = out.shape[:2]
return QImage(out.data, w, h, w * 4, QImage.Format.Format_RGBA8888_Premultiplied).copy()
class IconProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
def requestImage(self, id_, size, requested):
parts = id_.split("/")
name, color = parts[0], (parts[1] if len(parts) > 1 else "ECEDEF")
w = requested.width() if requested.width() > 0 else 24
h = requested.height() if requested.height() > 0 else w
svg = (f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>"
f"<path fill='#{color}' d='{ICONS.get(name, ICONS['music'])}'/></svg>")
img = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
QSvgRenderer(QByteArray(svg.encode())).render(p, QRectF(0, 0, w, h))
p.end()
return img
# ---- backdrop / glass ---------------------------------------------------
BG = _hex("1D1E21")
GLASS_TINT = _hex("242529")
# (color, cx, cy, sigma as fraction of the larger side, strength)
# Kept faint so the window reads as neutral dark gray with a hint of Aero colour.
BLOBS = [
(_hex("3BA8FF"), 0.10, 0.05, 0.30, 0.14),
(_hex("7C8CFF"), 0.62, 0.18, 0.22, 0.06),
(_hex("5FE3FF"), 0.92, 0.95, 0.26, 0.07),
(_hex("FF7AB6"), 0.02, 0.98, 0.18, 0.025),
]
# Aero-style diagonal light beams: (offset fraction, width fraction, strength)
BEAMS = [(0.18, 0.025, 0.035), (0.30, 0.06, 0.025), (0.74, 0.04, 0.02)]
def _backdrop(w, h, blur_px=0.0, crop=None):
"""Analytic aurora; blurring a gaussian blob is a wider gaussian, so the
'blurred snapshot' is exact and costs the same as the sharp one.
crop=(x, y, cw, ch) renders only that window-space rectangle."""
big = max(w, h)
cx0, cy0, cw, ch = crop or (0, 0, w, h)
x = np.arange(cx0, cx0 + cw, dtype=np.float32)
y = np.arange(cy0, cy0 + ch, dtype=np.float32)
img = np.empty((ch, cw, 3), np.float32)
img[:] = BG
for color, cx, cy, sig, strength in BLOBS:
s0 = sig * big
s = np.sqrt(s0 * s0 + blur_px * blur_px)
amp = strength * (s0 / s) ** 2
gx = np.exp(-((x - cx * w) ** 2) / (2 * s * s))
gy = np.exp(-((y - cy * h) ** 2) / (2 * s * s))
img += np.outer(gy * amp, gx)[..., None] * color
# beams run along x + 0.55 y = const
d = (x[None, :] + 0.55 * y[:, None]) / (big * 1.55)
for off, width, strength in BEAMS:
s0 = width
s = np.sqrt(s0 * s0 + (blur_px / (big * 1.55)) ** 2)
img += (strength * (s0 / s) * np.exp(-((d - off) ** 2) / (2 * s * s)))[..., None]
return img
def _round_alpha(h, w, r):
a = np.ones((h, w), np.float32)
if r <= 0:
return a
yy, xx = np.mgrid[0:r, 0:r].astype(np.float32) + 0.5
dist = np.sqrt((r - xx) ** 2 + (r - yy) ** 2)
corner = np.clip(r - dist + 0.5, 0, 1)
a[:r, :r] = corner
a[:r, -r:] = corner[:, ::-1]
a[-r:, :r] = corner[::-1, :]
a[-r:, -r:] = corner[::-1, ::-1]
return a
class FxProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Image)
self._cache: dict[str, QImage] = {}
self._lock = threading.Lock()
def requestImage(self, id_, size, requested):
with self._lock:
hit = self._cache.get(id_)
if hit is not None:
return hit
img = self._make(id_.split("/"))
with self._lock:
if len(self._cache) > 48:
self._cache.clear()
self._cache[id_] = img
return img
def _make(self, p):
kind = p[0]
if kind in ("backdrop", "glass", "glasscrop"):
w, h = (max(1, int(v)) for v in p[1].split("x"))
r = int(p[2])
crop = None
if kind == "glasscrop": # <W>x<H>/<r>/<blur>/<x>,<y>,<cw>,<ch>: a rounded glass sheet (dialogs)
crop = tuple(max(0, int(float(v))) for v in p[4].split(","))
crop = (crop[0], crop[1], max(1, crop[2]), max(1, crop[3]))
if kind == "backdrop":
rgb = _backdrop(w, h)
noise = np.random.default_rng(1).random((h, w, 1), dtype=np.float32)
rgb += (noise - 0.5) / 255 # dither the dark gradients
else:
rgb = _backdrop(w, h, blur_px=float(p[3]), crop=crop)
rgb = rgb * 0.30 + GLASS_TINT * 0.70 # glass-tint rgba(36,37,41,0.70)
# glass sheen: one broad soft highlight along the top-left
ox, oy = (crop[0], crop[1]) if crop else (0, 0)
hh, ww = rgb.shape[:2]
d = (np.arange(ox, ox + ww)[None, :] + 1.4 * np.arange(oy, oy + hh)[:, None]) / max(w, h)
rgb += (0.035 * np.exp(-((d - 0.08) ** 2) / 0.004))[..., None]
if crop:
r = int(p[3 + 2]) if len(p) > 5 else 12
hh, ww = rgb.shape[:2]
rgba = np.concatenate([rgb, _round_alpha(hh, ww, r)[..., None]], axis=2)
return _to_qimage(rgba)
if kind in ("shadow", "glow"):
if kind == "shadow":
color, sigma, alpha, r = np.zeros(3, np.float32), float(p[1]), float(p[2]), int(p[3])
else:
color, sigma, alpha, r = _hex(p[1]), float(p[2]), 0.9, int(p[3])
m = int(np.ceil(sigma * 3)) + 1
n = 2 * m + 2 * r + 2
mask = np.zeros((n, n), np.float32)
core = _round_alpha(2 * r + 2, 2 * r + 2, r)
mask[m:m + 2 * r + 2, m:m + 2 * r + 2] = core
blurred = gaussian_filter(mask, sigma) * alpha
rgba = np.zeros((n, n, 4), np.float32)
rgba[..., :3] = color
rgba[..., 3] = blurred
return _to_qimage(rgba)
if kind == "stripes":
h = int(p[1])
period = 2 * h
xx, yy = np.meshgrid(np.arange(period) + 0.5, np.arange(h) + 0.5)
phase = ((xx + yy) % period) / period
band = np.clip(np.minimum(phase, 1 - phase) * period - h / 2 + 0.5, 0, 1)
rgba = np.zeros((h, period, 4), np.float32)
rgba[..., :3] = 1
rgba[..., 3] = band * 0.22
return _to_qimage(rgba)
if kind == "noise":
rng = np.random.default_rng(7)
v = rng.random((256, 256), dtype=np.float32)
rgba = np.zeros((256, 256, 4), np.float32)
rgba[..., :3] = v[..., None]
rgba[..., 3] = 0.025
return _to_qimage(rgba)
img = QImage(1, 1, QImage.Format.Format_RGBA8888_Premultiplied)
img.fill(Qt.GlobalColor.transparent)
return img
# ---- waveforms ------------------------------------------------------------
class PeakStore:
"""Loaded peak pyramids keyed by job id. Shared with the controller."""
def __init__(self):
self._data: dict[str, dict] = {}
self._lock = threading.Lock()
def get(self, job_id, path):
with self._lock:
entry = self._data.get(job_id)
if entry is None or entry["path"] != path:
with np.load(path) as z:
levels: dict[str, list] = {}
for key in z.files:
if "/" in key:
stem, k = key.split("/")
levels.setdefault(stem, []).append((int(k), z[key].astype(np.float32)))
entry = {"path": path, "sr": int(z["samplerate"]), "base": int(z["base"]),
"levels": {s: [a for _, a in sorted(v)] for s, v in levels.items()}}
with self._lock:
self._data[job_id] = entry
return entry
def drop(self, job_id):
with self._lock:
self._data.pop(job_id, None)
class WaveformProvider(QQuickImageProvider):
def __init__(self, store: PeakStore, paths_for):
super().__init__(QQuickImageProvider.ImageType.Image)
self.store = store
self.paths_for = paths_for # job_id -> peaks_path (or None)
def requestImage(self, id_, size, requested):
p = id_.split("/")
job_id, stem = p[0], p[1]
w, h = (max(1, int(v)) for v in p[2].split("x"))
t0, t1, color = float(p[3]), float(p[4]), _hex(p[5])
path = self.paths_for(job_id)
rgba = np.zeros((h, w, 4), np.float32)
if path:
try:
entry = self.store.get(job_id, path)
levels = entry["levels"].get(stem)
if levels:
rgba = render_wave(levels, entry["base"], entry["sr"], t0, t1, w, h, color)
except Exception:
pass
return _to_qimage(rgba)
def render_wave(levels, base, sr, t0, t1, w, h, color):
s0, s1 = t0 * sr, max(t0 * sr + 1, t1 * sr)
spp = (s1 - s0) / w # samples per pixel
k = 0
while k + 1 < len(levels) and base * 2 ** (k + 1) <= spp:
k += 1
lvl = levels[k]
bucket = base * 2 ** k
edges = np.linspace(s0 / bucket, s1 / bucket, w + 1)
starts = np.clip(edges[:-1].astype(np.int64), 0, len(lvl) - 1)
ends = np.clip(np.ceil(edges[1:]).astype(np.int64), 1, len(lvl))
ends = np.maximum(ends, starts + 1)
# reduceat spans starts[i]..starts[i+1]; trim so the last pixel stops at its own edge
view = lvl[:ends[-1]]
lo = np.minimum.reduceat(view[:, 0], starts)
hi = np.maximum.reduceat(view[:, 1], starts)
valid = (edges[:-1] >= 0) & (edges[:-1] < len(lvl))
lo = np.where(valid, np.clip(lo, -1, 1), 0)
hi = np.where(valid, np.clip(hi, -1, 1), 0)
mid = h / 2
amp = mid - 1
top = mid - hi * amp
bot = mid - lo * amp
# at least 1px tall so silence reads as a line
short = bot - top < 1
top = np.where(short, mid - 0.5, top)
bot = np.where(short, mid + 0.5, bot)
rows = np.arange(h, dtype=np.float32)[:, None] + 0.5
cov = np.clip(np.minimum(rows - top[None, :] + 0.5, bot[None, :] - rows + 0.5), 0, 1)
# brighter toward the centre line, like a lit glass bar
shade = 0.55 + 0.45 * (1 - np.abs(rows - mid) / mid)
rgba = np.empty((h, w, 4), np.float32)
rgba[..., :3] = color
rgba[..., 3] = cov * shade
return rgba
@@ -0,0 +1,84 @@
import QtQuick
import QtQuick.Controls.Basic
// Glossy pill, 40px. variant: "filled" | "tonal" | "text". Cyan glow on hover.
Button {
id: b
property string iconName: ""
property string variant: "filled"
property color tone: Theme.primary
property int pillHeight: 40
readonly property bool filled: variant === "filled"
readonly property bool tonal: variant === "tonal"
readonly property color fg: filled ? "#FFFFFF" : (variant === "text" ? tone : Theme.textHi)
implicitHeight: pillHeight
implicitWidth: Math.max(pillHeight, row.implicitWidth + leftPadding + rightPadding)
leftPadding: text.length ? (iconName.length ? 14 : 18) : 0
rightPadding: text.length ? 18 : 0
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
font.weight: Font.DemiBold
hoverEnabled: true
focusPolicy: Qt.TabFocus
opacity: enabled ? 1 : 0.4
contentItem: Item {
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
Row {
id: row
anchors.centerIn: parent
spacing: 8
Icon {
visible: b.iconName.length > 0
name: b.iconName
color: b.fg
size: 18
anchors.verticalCenter: parent.verticalCenter
}
Txt {
visible: b.text.length > 0
text: b.text
font: b.font
color: b.fg
anchors.verticalCenter: parent.verticalCenter
}
}
}
background: Item {
implicitHeight: b.pillHeight
Glow {
radius: Math.round(b.pillHeight / 2)
opacity: (b.hovered || b.visualFocus) && b.enabled ? (b.filled ? 0.55 : 0.35) : 0
}
Rectangle {
id: base
anchors.fill: parent
radius: height / 2
color: b.filled ? b.tone : (b.tonal ? Theme.alpha(b.tone, 0.16) : "transparent")
border.width: b.variant === "text" ? 0 : 1
border.color: b.filled ? Qt.lighter(b.tone, 1.3) : Theme.alpha(b.tone, 0.35)
// Aero gloss on the top half
Rectangle {
visible: b.variant !== "text"
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) / 2
topLeftRadius: height
topRightRadius: height
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, b.filled ? 0.32 : 0.10) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, b.filled ? 0.06 : 0.0) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: b.down ? Qt.rgba(0, 0, 0, 0.18) : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent")
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
}
}
}
@@ -0,0 +1,133 @@
import QtQuick
import QtQuick.Controls.Basic
// Tonal glossy dropdown; menu on surface-2 at elevation 3.
ComboBox {
id: control
property string iconName: ""
property string subRole: "sub"
property string caption: ""
property int popupWidth: 280
textRole: "label"
valueRole: "id"
implicitHeight: 36
hoverEnabled: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
background: Item {
Glow { radius: 18; sigma: 4; opacity: (control.hovered || control.visualFocus) ? 0.3 : 0 }
Rectangle {
anchors.fill: parent
radius: height / 2
color: control.down || control.popup.visible ? Qt.rgba(1, 1, 1, 0.11) : (control.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.05))
border.color: control.popup.visible ? Theme.alpha(Theme.accentGlow, 0.6) : Qt.rgba(1, 1, 1, 0.10)
Behavior on color { ColorAnimation { duration: Theme.fast } }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) / 2
topLeftRadius: height
topRightRadius: height
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
}
}
contentItem: Row {
leftPadding: control.iconName.length ? 12 : 16
spacing: 8
Icon {
visible: control.iconName.length > 0
name: control.iconName
size: 18
color: Theme.accentGlow
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
Txt {
visible: control.caption.length > 0
text: control.caption
font.pixelSize: 10
font.weight: Font.DemiBold
font.letterSpacing: 0.6
color: Theme.textLo
}
Txt {
text: control.displayText
font: control.font
width: control.width - 64
}
}
}
indicator: Icon {
x: control.width - width - 10
anchors.verticalCenter: control.verticalCenter
name: "expand"
size: 18
color: Theme.textLo
rotation: control.popup.visible ? 180 : 0
Behavior on rotation { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
}
delegate: ItemDelegate {
id: d
required property var modelData
required property int index
width: ListView.view ? ListView.view.width : control.popupWidth
height: 52
highlighted: control.highlightedIndex === index
hoverEnabled: true
contentItem: Column {
spacing: 2
leftPadding: 4
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
Txt {
text: d.modelData[control.textRole]
font.weight: control.currentIndex === d.index ? Font.DemiBold : Font.Normal
color: control.currentIndex === d.index ? Theme.accentGlow : Theme.textHi
}
Txt {
text: d.modelData[control.subRole] || ""
visible: text.length > 0
font.pixelSize: Theme.fs12
color: Theme.textLo
}
}
background: Rectangle {
radius: 6
color: d.highlighted ? Theme.alpha(Theme.primary, 0.18) : (d.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
}
}
popup: Popup {
y: control.height + 6
width: Math.max(control.width, control.popupWidth)
padding: 6
transformOrigin: Item.Top
implicitHeight: Math.min(contentItem.implicitHeight + 12, 420)
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: control.popup.visible ? control.delegateModel : null
currentIndex: control.highlightedIndex
spacing: 2
boundsBehavior: Flickable.StopAtBounds
}
background: Rectangle {
color: Theme.surface2
radius: 8
border.color: Theme.outline
Elevation { level: 3; radius: 8 }
}
enter: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast }
NumberAnimation { property: "scale"; from: 0.96; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
}
}
@@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Controls.Basic
Slider {
id: s
property color tone: Theme.primary
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
x: s.leftPadding
y: s.topPadding + s.availableHeight / 2 - height / 2
width: s.availableWidth
height: 4
radius: 2
color: Qt.rgba(1, 1, 1, 0.10)
Rectangle {
width: s.visualPosition * parent.width
height: parent.height
radius: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(s.tone, 0.7) }
GradientStop { position: 1; color: s.tone }
}
}
}
handle: Item {
x: s.leftPadding + s.visualPosition * (s.availableWidth - width)
y: s.topPadding + s.availableHeight / 2 - height / 2
width: 14
height: 14
Glow { radius: 7; sigma: 4; color: s.tone; opacity: s.hovered || s.pressed ? 0.6 : 0 }
Rectangle {
anchors.fill: parent
radius: 7
color: s.pressed ? Qt.lighter(s.tone, 1.2) : s.tone
border.color: Qt.lighter(s.tone, 1.4)
Rectangle { // gloss
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 5
radius: 3
color: Qt.rgba(1, 1, 1, 0.45)
}
}
}
}
@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls.Basic
Switch {
id: sw
hoverEnabled: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
spacing: 12
indicator: Rectangle {
implicitWidth: 40
implicitHeight: 22
x: sw.leftPadding
y: parent.height / 2 - height / 2
radius: 11
color: sw.checked ? Theme.primary : Qt.rgba(1, 1, 1, 0.10)
border.color: sw.checked ? Qt.lighter(Theme.primary, 1.3) : Qt.rgba(1, 1, 1, 0.16)
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 11; sigma: 4; opacity: sw.hovered ? 0.35 : 0 }
Rectangle {
x: sw.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
radius: 8
color: "#FFFFFF"
Behavior on x { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 6
radius: 3
color: Qt.rgba(1, 1, 1, 0.6)
}
}
}
contentItem: Txt {
text: sw.text
font: sw.font
leftPadding: sw.indicator.width + sw.spacing
wrapMode: Text.WordWrap
elide: Text.ElideNone
}
}
@@ -0,0 +1,31 @@
import QtQuick
import QtQuick.Controls.Basic
// Win7-flavoured caption button; close turns glossy red on hover.
AbstractButton {
id: b
property string iconName: "minimize"
property bool danger: false
implicitWidth: 46
implicitHeight: 30
hoverEnabled: true
focusPolicy: Qt.NoFocus
background: Rectangle {
radius: 6
gradient: Gradient {
GradientStop { position: 0; color: b.danger && b.hovered ? "#F0645A" : (b.hovered ? Qt.rgba(1, 1, 1, 0.14) : "transparent") }
GradientStop { position: 0.5; color: b.danger && b.hovered ? "#D8392B" : (b.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent") }
GradientStop { position: 1; color: b.danger && b.hovered ? "#C42B1C" : (b.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent") }
}
border.color: b.hovered ? (b.danger ? "#FF8A80" : Qt.rgba(1, 1, 1, 0.14)) : "transparent"
Rectangle {
anchors.fill: parent
radius: parent.radius
color: b.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent"
}
Glow { radius: 6; sigma: 5; color: "#FF5C4C"; opacity: b.danger && b.hovered ? 0.5 : 0 }
}
contentItem: Item {
Icon { anchors.centerIn: parent; name: b.iconName; size: 16; color: b.hovered ? "#FFFFFF" : Theme.textHi }
}
}
+26
View File
@@ -0,0 +1,26 @@
import QtQuick
// Material card (elevation 2, radius 8) with Aero top gloss: white 6% -> 0 over the top 40%.
Rectangle {
id: card
property int elevation: 2
property bool glossy: true
radius: 8
color: Theme.surface1
border.color: Theme.hairline
border.width: 1
Elevation { level: card.elevation; radius: card.radius }
Rectangle {
visible: card.glossy
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: (parent.height - 2) * 0.4
topLeftRadius: card.radius - 1
topRightRadius: card.radius - 1
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.06) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
}
@@ -0,0 +1,25 @@
import QtQuick
import QtQuick.Shapes
// Static dashed rounded border (geometry built once; only opacity animates).
Shape {
id: s
property color color: Theme.accentGlow
property real radius: 12
property real lineWidth: 2
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: s.color
strokeWidth: s.lineWidth
strokeStyle: ShapePath.DashLine
dashPattern: [5, 4]
fillColor: "transparent"
PathRectangle {
x: s.lineWidth / 2
y: s.lineWidth / 2
width: s.width - s.lineWidth
height: s.height - s.lineWidth
radius: s.radius
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import QtQuick
// Empty state: card filling the main pane; dashed border glows on drag-over.
Item {
id: dz
property bool dragging: false
signal browse()
Card {
id: card
anchors.fill: parent
anchors.margins: 24
radius: 16
color: dz.dragging ? Theme.alpha(Theme.primary, 0.08) : Theme.alpha(Theme.surface1, 0.72)
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 16; sigma: 8; opacity: dz.dragging ? 0.55 : 0 }
DashedFrame {
anchors.fill: parent
anchors.margins: 10
radius: 12
color: dz.dragging ? Theme.accentGlow : Theme.alpha(Theme.textLo, 0.35)
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: dz.browse()
}
Column {
anchors.centerIn: parent
spacing: 16
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 88
height: 88
radius: 44
color: Theme.alpha(Theme.primary, dz.dragging ? 0.28 : 0.14)
border.color: Theme.alpha(Theme.accentGlow, 0.5)
scale: dz.dragging ? 1.08 : 1
Behavior on scale { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Behavior on color { ColorAnimation { duration: Theme.med } }
Glow { radius: 44; sigma: 8; opacity: 0.35 }
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 3 }
height: 40
topLeftRadius: 41
topRightRadius: 41
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.18) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
Icon { anchors.centerIn: parent; name: "upload"; size: 40; color: Theme.accentGlow }
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
text: dz.dragging ? "Release to separate" : "Drop audio files here"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
text: "or click to browse · MP3, WAV, FLAC, M4A, OGG, OPUS and more"
color: Theme.textLo
font.pixelSize: Theme.fs14
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
topPadding: 8
Repeater {
model: ["vocals", "drums", "bass", "other"]
Rectangle {
required property string modelData
height: 26
width: chipText.implicitWidth + 28
radius: 13
color: Theme.alpha(Theme.stemColor(modelData), 0.14)
border.color: Theme.alpha(Theme.stemColor(modelData), 0.4)
Rectangle { x: 10; anchors.verticalCenter: parent.verticalCenter; width: 6; height: 6; radius: 3; color: Theme.stemColor(parent.modelData) }
Txt { id: chipText; x: 21; anchors.verticalCenter: parent.verticalCenter; text: parent.modelData; font.pixelSize: Theme.fs12; color: Theme.textHi }
}
}
}
Txt {
anchors.horizontalCenter: parent.horizontalCenter
topPadding: 4
text: "Separates with " + (app.models.find(m => m.id === appSettings.modelId) || {label: ""}).label
+ " on " + ((app.devices.find(d => d.id === appSettings.deviceId) || {label: "Auto"}).label)
color: Theme.alpha(Theme.textLo, 0.8)
font.pixelSize: Theme.fs12
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import QtQuick
// Static 9-patch shadow: no runtime blur, one textured quad.
BorderImage {
id: shadow
property int level: 2
property int radius: 8
readonly property real sigma: Theme.shadowSigma[level]
readonly property int m: Math.ceil(sigma * 3) + 1
readonly property int dy: Theme.shadowDy[level]
visible: level > 0
z: -1
anchors.fill: parent
anchors.leftMargin: -m
anchors.rightMargin: -m
anchors.topMargin: -m + dy
anchors.bottomMargin: -m - dy
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
source: "image://fx/shadow/" + sigma + "/" + Theme.shadowAlpha[level] + "/" + radius
cache: true
}
@@ -0,0 +1,74 @@
import QtQuick
// Dev overlay: p50/p99 frame interval (and render-thread work with --render-hooks).
// While visible, the heartbeat bar changes every frame so the window renders at
// full refresh and the numbers describe real 240Hz cadence, not idle frames.
Rectangle {
id: o
property var s: ({})
property real acc: 0
property var batch: []
property real beat: 0
width: 236
height: col.implicitHeight + 24
radius: 8
color: Theme.alpha(Theme.surface1, 0.92)
border.color: Theme.outline
readonly property color good: Theme.success
readonly property color bad: Theme.error
onVisibleChanged: frameStats.setHooks(visible)
FrameAnimation {
running: o.visible
onTriggered: {
o.batch.push(frameTime * 1000)
if (frameTime * 1000 > 1.5 * (o.s.budget_ms || 4.17)) frameStats.miss(frameTime * 1000)
o.beat = (o.beat + frameTime) % 1
o.acc += frameTime
if (o.acc > 0.25) {
o.acc = 0
frameStats.push(o.batch)
o.batch = []
o.s = frameStats.summary()
}
}
}
Rectangle { // heartbeat: moves every frame
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
x: 8 + (parent.width - 16 - width) * o.beat
width: 24
height: 2
radius: 1
color: Theme.accentGlow
opacity: 0.6
}
Column {
id: col
anchors { left: parent.left; top: parent.top; margins: 10 }
spacing: 3
Txt {
text: (o.s.refresh_hz || 0).toFixed(0) + " Hz panel · " + (o.s.fps || 0).toFixed(1) + " fps"
font.pixelSize: Theme.fs12
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
color: (o.s.fps || 0) >= (o.s.refresh_hz || 240) * 0.97 ? o.good : Theme.warning
}
Txt {
text: o.s.work_p50 === null || o.s.work_p50 === undefined ? "frame time measuring…"
: "frame time p50 " + o.s.work_p50.toFixed(2) + " p99 " + o.s.work_p99.toFixed(2) + " ms"
font.pixelSize: 11; font.features: { "tnum": 1 }
color: (o.s.work_p99 || 0) < (o.s.budget_ms || 4.17) ? Theme.textHi : o.bad
}
Txt {
text: "interval p50 " + (o.s.interval_p50 || 0).toFixed(2) + " p99 " + (o.s.interval_p99 || 0).toFixed(2) + " ms"
font.pixelSize: 11; font.features: { "tnum": 1 }
color: Theme.textLo
}
Txt {
text: "budget " + (o.s.budget_ms || 0).toFixed(2) + " ms · missed " + (o.s.missed || 0) + "/" + (o.s.frames || 0)
font.pixelSize: 11; font.features: { "tnum": 1 }
color: (o.s.missed || 0) === 0 ? Theme.textLo : o.bad
}
}
}
@@ -0,0 +1,57 @@
import QtQuick
// Aero glass for chrome. Shows the window's cached, pre-blurred backdrop
// (refreshed only on resize), so nothing is blurred per frame.
Item {
id: root
property real offsetX: 0
property real offsetY: 0
property int blur: 16
property real topLeftRadius: 0
property real topRightRadius: 0
property real bottomLeftRadius: 0
property real bottomRightRadius: 0
property bool gloss: true
property bool innerBorder: true
clip: true
readonly property var win: Window.window
Image {
x: -root.offsetX
y: -root.offsetY
width: root.win ? root.win.width : 0
height: root.win ? root.win.height : 0
source: root.win ? "image://fx/glass/" + root.win.fxW + "x" + root.win.fxH + "/" + root.win.radius + "/" + root.blur : ""
asynchronous: true
retainWhileLoading: true
cache: true
smooth: true
}
// Aero gloss: top highlight band
Rectangle {
visible: root.gloss
anchors { left: parent.left; right: parent.right; top: parent.top }
height: Math.min(parent.height * 0.5, 30)
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.07) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
// 1px inner white border at 8%
Rectangle {
visible: root.innerBorder
anchors.fill: parent
color: "transparent"
border.color: Theme.outline
border.width: 1
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
bottomLeftRadius: root.bottomLeftRadius
bottomRightRadius: root.bottomRightRadius
}
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
// Static 9-patch Aero hover glow; animate `opacity` only.
BorderImage {
property color color: Theme.accentGlow
property int radius: 20
property int sigma: 6
readonly property int m: Math.ceil(sigma * 3) + 1
anchors.fill: parent
anchors.margins: -m
border { left: m + radius; right: m + radius; top: m + radius; bottom: m + radius }
source: "image://fx/glow/" + Theme.hex(color) + "/" + sigma + "/" + radius
opacity: 0
visible: opacity > 0.001
z: -1
cache: true
Behavior on opacity { NumberAnimation { duration: Theme.fast; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
}
+14
View File
@@ -0,0 +1,14 @@
import QtQuick
// Material icon rendered once per (name, color, size) by the icon provider.
Image {
property string name: "music"
property color color: Theme.textHi
property int size: 20
width: size
height: size
sourceSize: Qt.size(size * Screen.devicePixelRatio, size * Screen.devicePixelRatio)
source: Theme.icon(name, color)
smooth: true
asynchronous: false
}
@@ -0,0 +1,47 @@
import QtQuick
import QtQuick.Controls.Basic
// Round icon button; checkable variant used for solo / mute.
AbstractButton {
id: b
property string iconName: "music"
property color iconColor: Theme.textLo
property color activeColor: Theme.primary
property color hoverColor: Theme.alpha(Theme.textHi, 0.08)
property int size: 32
property int iconSize: 18
property string tip: ""
property bool glow: true
implicitWidth: size
implicitHeight: size
hoverEnabled: true
focusPolicy: Qt.TabFocus
opacity: enabled ? 1 : 0.35
background: Item {
Glow {
radius: Math.round(b.size / 2)
sigma: 4
color: b.checked ? b.activeColor : Theme.accentGlow
opacity: b.glow && (b.hovered || b.visualFocus) && b.enabled ? 0.35 : 0
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: b.checked ? Theme.alpha(b.activeColor, b.down ? 0.34 : 0.22)
: (b.down ? Theme.alpha(Theme.textHi, 0.14) : (b.hovered ? b.hoverColor : "transparent"))
border.width: b.checked ? 1 : 0
border.color: Theme.alpha(b.activeColor, 0.6)
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
}
contentItem: Item {
Icon {
anchors.centerIn: parent
name: b.iconName
size: b.iconSize
color: b.checked ? b.activeColor : (b.hovered ? Theme.textHi : b.iconColor)
}
}
Tip { visible: b.hovered && b.tip.length > 0; text: b.tip }
}
@@ -0,0 +1,73 @@
import QtQuick
// Stereo peak meter fed per frame from the audio ring buffer (via Main).
Item {
id: m
property real levelLeft: 0
property real levelRight: 0
property real holdL: 0
property real holdR: 0
implicitWidth: 150
implicitHeight: 22
function frac(v) {
if (v <= 0.000001) return 0
var db = 20 * Math.log(v) / Math.LN10
return Math.max(0, Math.min(1, (db + 54) / 54))
}
onLevelLeftChanged: holdL = Math.max(holdL, frac(levelLeft))
onLevelRightChanged: holdR = Math.max(holdR, frac(levelRight))
FrameAnimation {
running: m.holdL > 0.001 || m.holdR > 0.001
onTriggered: {
var d = frameTime * 0.35
m.holdL = Math.max(m.frac(m.levelLeft), m.holdL - d)
m.holdR = Math.max(m.frac(m.levelRight), m.holdR - d)
}
}
Column {
anchors.fill: parent
spacing: 4
Repeater {
model: 2
Item {
required property int index
readonly property real level: m.frac(index === 0 ? m.levelLeft : m.levelRight)
readonly property real hold: index === 0 ? m.holdL : m.holdR
width: m.width
height: (m.height - 4) / 2
Rectangle {
anchors.fill: parent
radius: 2
color: Qt.rgba(1, 1, 1, 0.07)
}
Item {
width: parent.width * parent.level
height: parent.height
clip: true
Rectangle {
width: m.width
height: parent.height
radius: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.success }
GradientStop { position: 0.72; color: Theme.accentGlow }
GradientStop { position: 0.88; color: Theme.warning }
GradientStop { position: 1.0; color: Theme.error }
}
}
}
Rectangle {
visible: parent.hold > 0.01
x: Math.min(parent.width - 2, parent.width * parent.hold)
width: 2
height: parent.height
color: parent.hold > 0.95 ? Theme.error : Theme.textHi
opacity: 0.85
}
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import Stemify
Window {
id: win
width: 1440
height: 900
minimumWidth: 960
minimumHeight: 600
visible: true
title: "Stemify"
color: "transparent"
flags: Qt.Window | Qt.FramelessWindowHint
readonly property bool maximized: visibility === Window.Maximized || visibility === Window.FullScreen
readonly property int radius: maximized ? 0 : 10
function toggleMaximized() { maximized ? showNormal() : showMaximized() }
// Glass snapshot size: refreshed only after a resize settles.
property int fxW: width
property int fxH: height
Timer { id: fxTimer; interval: 150; onTriggered: { win.fxW = win.width; win.fxH = win.height } }
onWidthChanged: fxTimer.restart()
onHeightChanged: fxTimer.restart()
// Per-frame playback state, read from the audio ring buffer.
property real playPos: 0
property real levelL: 0
property real levelR: 0
FrameAnimation {
running: audio.playing || win.levelL > 0.0005 || win.levelR > 0.0005
onTriggered: {
var s = audio.snapshot()
if (s[3]) win.playPos = s[0]
var k = Math.exp(-frameTime * 10)
win.levelL = Math.max(s[1], win.levelL * k)
win.levelR = Math.max(s[2], win.levelR * k)
if (win.levelL < 0.0005) win.levelL = 0
if (win.levelR < 0.0005) win.levelR = 0
}
}
Connections {
target: audio
function onLoadedChanged() { win.playPos = 0 }
}
Connections {
target: app
function onToast(msg, kind, folder) { toast.show(msg, kind, folder) }
}
Component.onCompleted: Theme.fontFamily = uiFont
FileDialog {
id: fileDialog
title: "Add audio files"
fileMode: FileDialog.OpenFiles
nameFilters: ["Audio (*.mp3 *.wav *.flac *.ogg *.oga *.opus *.m4a *.aac *.aif *.aiff *.wma *.mp4 *.mkv *.webm *.mka *.wv *.ape)", "All files (*)"]
onAccepted: app.addUrls(selectedFiles)
}
Shortcut { sequences: ["Space"]; enabled: audio.loaded && !settings.opened; onActivated: audio.toggle() }
Shortcut { sequences: ["Home"]; enabled: audio.loaded; onActivated: { audio.seek(0); win.playPos = 0 } }
Shortcut { sequences: [StandardKey.Open]; onActivated: fileDialog.open() }
Shortcut { sequences: ["Ctrl+,"]; onActivated: settings.open() }
Shortcut { sequences: ["F12"]; onActivated: appSettings.frameOverlay = !appSettings.frameOverlay }
Shortcut { sequences: ["F11"]; onActivated: win.toggleMaximized() }
Shortcut { sequences: [StandardKey.Quit]; onActivated: win.close() }
Item {
id: frame
anchors.fill: parent
// window background: placeholder until the aurora texture arrives
Rectangle {
anchors.fill: parent
radius: win.radius
color: Theme.bg
}
Image {
id: backdrop
anchors.fill: parent
source: "image://fx/backdrop/" + win.fxW + "x" + win.fxH + "/" + win.radius
asynchronous: true
retainWhileLoading: true
cache: true
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.slow } }
}
TitleBar {
id: titleBar
anchors { left: parent.left; right: parent.right; top: parent.top }
offsetX: 0
offsetY: 0
onOpenSettings: settings.open()
}
Sidebar {
id: sidebar
anchors { left: parent.left; top: titleBar.bottom; bottom: transport.top }
width: 220
offsetX: 0
offsetY: titleBar.height
onBrowse: fileDialog.open()
Rectangle { anchors { right: parent.right; top: parent.top; bottom: parent.bottom } width: 1; color: Theme.hairline }
}
Item {
id: mainPane
anchors { left: sidebar.right; right: parent.right; top: titleBar.bottom; bottom: transport.top }
clip: true
Image { // dither the dark gradients behind content
anchors.fill: parent
source: "image://fx/noise"
fillMode: Image.Tile
cache: true
}
DropZone {
anchors.fill: parent
visible: !app.selected.valid
dragging: drop.containsDrag
onBrowse: fileDialog.open()
}
TrackView {
anchors.fill: parent
visible: app.selected.valid
}
// drag-over affordance when a track is already showing
Item {
anchors.fill: parent
visible: opacity > 0
opacity: drop.containsDrag && app.selected.valid ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.med } }
Rectangle { anchors.fill: parent; color: Theme.alpha(Theme.bg, 0.82) }
DashedFrame { anchors.fill: parent; anchors.margins: 24; radius: 16 }
Glow { anchors.margins: 24 - m; radius: 16; sigma: 8; opacity: 0.4 }
Txt {
anchors.centerIn: parent
text: "Drop to add to the queue"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
}
}
FrameOverlay {
visible: appSettings.frameOverlay || devOverlay
anchors { right: parent.right; bottom: parent.bottom; margins: 12 }
z: 100
}
}
Transport {
id: transport
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
height: 64
offsetX: 0
offsetY: win.height - 64
}
Item {
anchors { horizontalCenter: mainPane.horizontalCenter; bottom: transport.top; bottomMargin: 16 }
width: toast.width
height: 48
Toast { id: toast; maxWidth: mainPane.width - 48 }
}
// window edge highlight
Rectangle {
anchors.fill: parent
radius: win.radius
color: "transparent"
border.width: win.maximized ? 0 : 1
border.color: Qt.rgba(1, 1, 1, 0.12)
}
DropArea {
id: drop
anchors.fill: parent
keys: ["text/uri-list"]
onDropped: (e) => {
if (e.hasUrls) {
app.addUrls(e.urls)
e.acceptProposedAction()
}
}
}
// resize grips (compositor-driven)
Item {
anchors.fill: parent
visible: !win.maximized
readonly property int g: 6
ResizeEdge { edges: Qt.LeftEdge; x: 0; y: 12; width: parent.g; height: parent.height - 24 }
ResizeEdge { edges: Qt.RightEdge; x: parent.width - parent.g; y: 12; width: parent.g; height: parent.height - 24 }
ResizeEdge { edges: Qt.TopEdge; x: 12; y: 0; width: parent.width - 24; height: parent.g - 2 }
ResizeEdge { edges: Qt.BottomEdge; x: 12; y: parent.height - parent.g; width: parent.width - 24; height: parent.g }
ResizeEdge { edges: Qt.LeftEdge | Qt.TopEdge; x: 0; y: 0; width: 12; height: 12 }
ResizeEdge { edges: Qt.RightEdge | Qt.TopEdge; x: parent.width - 12; y: 0; width: 12; height: 12 }
ResizeEdge { edges: Qt.LeftEdge | Qt.BottomEdge; x: 0; y: parent.height - 12; width: 12; height: 12 }
ResizeEdge { edges: Qt.RightEdge | Qt.BottomEdge; x: parent.width - 12; y: parent.height - 12; width: 12; height: 12 }
}
}
SettingsDialog { id: settings; parent: Overlay.overlay; win: win }
}
+89
View File
@@ -0,0 +1,89 @@
import QtQuick
import QtQuick.Controls.Basic
ItemDelegate {
id: item
required property string jobId
required property string fileName
required property string modelLabel
required property string status
required property real progress
required property string stage
required property int index
readonly property bool selected: app.selected.valid && app.selected.id === jobId
readonly property bool active: status === "running" || status === "queued"
width: ListView.view ? ListView.view.width : 200
height: active ? 84 : 66
hoverEnabled: true
padding: 0
onClicked: app.select(jobId)
Behavior on height { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
background: Rectangle {
radius: 8
color: item.selected ? Theme.alpha(Theme.primary, 0.16) : (item.hovered ? Qt.rgba(1, 1, 1, 0.05) : "transparent")
border.color: item.selected ? Theme.alpha(Theme.primary, 0.45) : "transparent"
Behavior on color { ColorAnimation { duration: Theme.fast } }
Rectangle {
visible: item.selected
x: 0
anchors.verticalCenter: parent.verticalCenter
width: 3
height: parent.height - 20
radius: 2
color: Theme.accentGlow
}
}
contentItem: Item {
Column {
anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 12; rightMargin: 6; topMargin: 10 }
spacing: 6
Row {
width: parent.width
spacing: 8
Icon {
anchors.verticalCenter: parent.verticalCenter
size: 16
name: item.status === "done" ? "check" : item.status === "error" ? "error" : "music"
color: item.status === "done" ? Theme.success : item.status === "error" ? Theme.error : Theme.textLo
}
Txt {
width: parent.width - 16 - 28 - 16
anchors.verticalCenter: parent.verticalCenter
text: item.fileName
font.weight: item.selected ? Font.DemiBold : Font.Medium
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 14
iconName: "close"
glow: false
tip: item.active ? "Cancel" : "Remove"
onClicked: item.active ? app.cancelJob(item.jobId) : app.removeJob(item.jobId)
}
}
Row {
width: parent.width
spacing: 8
Txt {
width: parent.width - chip.width - 14
anchors.verticalCenter: parent.verticalCenter
text: item.status === "running" ? item.stage : item.modelLabel
font.pixelSize: Theme.fs12
color: Theme.textLo
}
StatusChip { id: chip; status: item.status; progress: item.progress }
}
StripedProgress {
visible: item.active
width: parent.width - 6
value: item.progress
active: item.status === "running"
opacity: item.status === "running" ? 1 : 0.35
}
}
}
}
@@ -0,0 +1,13 @@
import QtQuick
// Invisible edge/corner grip that hands resizing to the compositor.
MouseArea {
property int edges: 0
property var win: Window.window
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: edges === (Qt.LeftEdge | Qt.TopEdge) || edges === (Qt.RightEdge | Qt.BottomEdge) ? Qt.SizeFDiagCursor
: edges === (Qt.RightEdge | Qt.TopEdge) || edges === (Qt.LeftEdge | Qt.BottomEdge) ? Qt.SizeBDiagCursor
: (edges & (Qt.LeftEdge | Qt.RightEdge)) ? Qt.SizeHorCursor : Qt.SizeVerCursor
onPressed: win.startSystemResize(edges)
}
@@ -0,0 +1,274 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
// Glass dialog (elevation 3, stronger 24px blur, 50% scrim).
Popup {
id: dlg
property var win: null
modal: true
focus: true
width: Math.min(parent.width - 64, 640)
height: Math.min(parent.height - 64, content.implicitHeight + 120)
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
padding: 0
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: app.refreshCacheSize()
Overlay.modal: Rectangle {
color: Qt.rgba(0, 0, 0, 0.5)
Behavior on opacity { NumberAnimation { duration: Theme.med } }
}
enter: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
NumberAnimation { property: "scale"; from: 0.95; to: 1; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
background: Item {
Elevation { level: 3; radius: 12 }
Image { // stronger-blur glass sheet, pre-rounded, rendered once per open/resize
anchors.fill: parent
asynchronous: true
retainWhileLoading: true
source: dlg.win && dlg.width > 0
? "image://fx/glasscrop/" + dlg.win.fxW + "x" + dlg.win.fxH + "/0/24/"
+ Math.round(dlg.x) + "," + Math.round(dlg.y) + "," + Math.round(dlg.width) + "," + Math.round(dlg.height) + "/12"
: ""
}
Rectangle { // Aero gloss
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: 48
topLeftRadius: 11
topRightRadius: 11
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.08) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.0) }
}
}
Rectangle {
anchors.fill: parent
radius: 12
color: "transparent"
border.color: Qt.rgba(1, 1, 1, 0.12)
}
}
FolderDialog {
id: folderDialog
title: "Choose the stems folder"
currentFolder: "file://" + appSettings.stemsDir
onAccepted: appSettings.stemsDir = decodeURIComponent(selectedFolder.toString().replace("file://", ""))
}
component SectionTitle: Txt {
font.pixelSize: 11
font.weight: Font.DemiBold
font.letterSpacing: 0.8
color: Theme.accentGlow
topPadding: 8
}
component Hint: Txt {
font.pixelSize: Theme.fs12
color: Theme.textLo
wrapMode: Text.WordWrap
elide: Text.ElideNone
}
component Segmented: Row {
id: seg
property var options: []
property string value: ""
signal picked(string v)
spacing: 0
Repeater {
model: seg.options
AbstractButton {
id: sb
required property var modelData
required property int index
readonly property bool on: seg.value === modelData.v
implicitWidth: 84
implicitHeight: 34
hoverEnabled: true
onClicked: seg.picked(modelData.v)
background: Rectangle {
topLeftRadius: sb.index === 0 ? 17 : 0
bottomLeftRadius: sb.index === 0 ? 17 : 0
topRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
bottomRightRadius: sb.index === seg.options.length - 1 ? 17 : 0
color: sb.on ? Theme.alpha(Theme.primary, 0.28) : (sb.hovered ? Qt.rgba(1, 1, 1, 0.07) : Qt.rgba(1, 1, 1, 0.03))
border.color: sb.on ? Theme.alpha(Theme.primary, 0.7) : Qt.rgba(1, 1, 1, 0.12)
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
contentItem: Txt {
text: sb.modelData.t
horizontalAlignment: Text.AlignHCenter
font.weight: sb.on ? Font.DemiBold : Font.Normal
color: sb.on ? Theme.textHi : Theme.textLo
}
}
}
}
contentItem: Item {
Item {
id: head
anchors { left: parent.left; right: parent.right; top: parent.top }
height: 60
Txt {
anchors { left: parent.left; leftMargin: 24; verticalCenter: parent.verticalCenter }
text: "Settings"
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
}
IconButton {
anchors { right: parent.right; rightMargin: 14; verticalCenter: parent.verticalCenter }
iconName: "close"
onClicked: dlg.close()
}
Rectangle { anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: 1; color: Theme.hairline }
}
Flickable {
anchors { left: parent.left; right: parent.right; top: head.bottom; bottom: parent.bottom }
contentHeight: content.implicitHeight + 32
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
background: Item {} }
Column {
id: content
x: 24
y: 12
width: parent.width - 48
spacing: 12
SectionTitle { text: "OUTPUT" }
Segmented {
options: [{ v: "beside", t: "Beside" }, { v: "folder", t: "Folder" }]
value: appSettings.outputMode
onPicked: (v) => appSettings.outputMode = v
}
Hint {
width: parent.width
text: appSettings.outputMode === "beside"
? "Stems go next to the original, in “<name> - stems”."
: "Stems go to a folder per track inside the stems folder."
}
Row {
visible: appSettings.outputMode === "folder"
width: parent.width
spacing: 8
Rectangle {
width: parent.width - browseBtn.width - 8
height: 36
radius: 18
color: Qt.rgba(1, 1, 1, 0.05)
border.color: Qt.rgba(1, 1, 1, 0.12)
Txt {
anchors { fill: parent; leftMargin: 16; rightMargin: 16 }
text: appSettings.stemsDir
elide: Text.ElideMiddle
}
}
AeroButton { id: browseBtn; variant: "tonal"; pillHeight: 36; iconName: "folder"; text: "Browse"; onClicked: folderDialog.open() }
}
Row {
spacing: 16
Txt { text: "Format"; width: 80; height: 34 }
Segmented {
options: [{ v: "wav", t: "WAV" }, { v: "flac", t: "FLAC" }, { v: "mp3", t: "MP3" }]
value: appSettings.exportFormat
onPicked: (v) => appSettings.exportFormat = v
}
}
Hint { width: parent.width; text: "WAV and FLAC are 24-bit (WAV switches to 32-bit float if a stem would clip). MP3 is 320 kbps." }
AeroSwitch {
width: parent.width
text: "Export all stems automatically when separation finishes"
checked: appSettings.autoExport
onToggled: appSettings.autoExport = checked
}
SectionTitle { text: "SEPARATION" }
AeroSwitch {
width: parent.width
text: "Half precision on GPU (about 2x faster on the RX 9070 XT, same quality)"
checked: appSettings.fp16
onToggled: appSettings.fp16 = checked
}
Row {
width: parent.width
spacing: 12
Txt { text: "Segment"; width: 80; height: 24 }
AeroSlider {
id: segSlider
width: parent.width - 200
from: 0; to: 7.8; stepSize: 0.1
value: appSettings.segment
onMoved: appSettings.segment = value
}
Txt { text: appSettings.segment <= 0.05 ? "Model default" : appSettings.segment.toFixed(1) + " s"; width: 96; height: 24; color: Theme.textLo }
}
Hint { width: parent.width; text: "Demucs chunk length. Lower it only if you run out of VRAM; 16 GB fits every model at the default." }
Row {
width: parent.width
spacing: 12
Txt { text: "Shifts"; width: 80; height: 24 }
AeroSlider {
width: parent.width - 200
from: 0; to: 5; stepSize: 1; snapMode: Slider.SnapAlways
value: appSettings.shifts
onMoved: appSettings.shifts = Math.round(value)
}
Txt { text: appSettings.shifts === 0 ? "Off" : appSettings.shifts + " passes"; width: 96; height: 24; color: Theme.textLo }
}
Hint { width: parent.width; text: "Demucs random-shift averaging: small quality gain, each pass adds a full run." }
Row {
width: parent.width
spacing: 12
Txt { text: "Overlap"; width: 80; height: 24 }
AeroSlider {
width: parent.width - 200
from: 0.1; to: 0.9; stepSize: 0.05
value: appSettings.overlap
onMoved: appSettings.overlap = value
}
Txt { text: Math.round(appSettings.overlap * 100) + "%"; width: 96; height: 24; color: Theme.textLo }
}
Hint { width: parent.width; text: "Segment overlap. RoFormer models always use at least 75%." }
SectionTitle { text: "STORAGE" }
Row {
width: parent.width
spacing: 12
Txt { text: "Stem cache uses " + app.cacheText; height: 36 }
AeroButton { variant: "tonal"; pillHeight: 36; iconName: "delete"; text: "Clear cache"; onClicked: app.clearCache() }
}
Row {
width: parent.width
spacing: 12
Txt { text: "Keep up to"; width: 80; height: 24 }
AeroSlider {
width: parent.width - 200
from: 1; to: 40; stepSize: 1; snapMode: Slider.SnapAlways
value: appSettings.cacheLimitGb
onMoved: appSettings.cacheLimitGb = Math.round(value)
}
Txt { text: appSettings.cacheLimitGb.toFixed(0) + " GB"; width: 96; height: 24; color: Theme.textLo }
}
Hint { width: parent.width; text: "Cached stems make re-opening a track instant. Oldest entries are pruned past the limit." }
SectionTitle { text: "DEVELOPER" }
AeroSwitch {
width: parent.width
text: "Frame-time overlay (p50 / p99) · F12"
checked: appSettings.frameOverlay
onToggled: appSettings.frameOverlay = checked
}
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: side
signal browse()
Item {
id: header
anchors { left: parent.left; right: parent.right; top: parent.top }
height: 52
Txt {
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
text: "Queue"
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
}
Rectangle {
visible: list.count > 0
anchors { left: parent.left; leftMargin: 76; verticalCenter: parent.verticalCenter }
width: cnt.implicitWidth + 12
height: 18
radius: 9
color: Qt.rgba(1, 1, 1, 0.08)
Txt { id: cnt; anchors.centerIn: parent; text: list.count; font.pixelSize: 11; color: Theme.textLo }
}
IconButton {
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
iconName: "add"
tip: "Add files (Ctrl+O)"
onClicked: side.browse()
}
}
ListView {
id: list
anchors { left: parent.left; right: parent.right; top: header.bottom; bottom: parent.bottom; margins: 8; topMargin: 0 }
model: jobsModel
spacing: 4
clip: true
boundsBehavior: Flickable.StopAtBounds
delegate: QueueItem {}
add: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.med }
NumberAnimation { property: "x"; from: -16; duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
displaced: Transition {
NumberAnimation { properties: "y"; duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease }
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
background: Item {}
}
Column {
visible: list.count === 0
anchors.centerIn: parent
spacing: 8
width: parent.width - 32
Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "upload"; size: 28; color: Theme.alpha(Theme.textLo, 0.7) }
Txt {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "Drop audio anywhere to queue it"
color: Theme.textLo
font.pixelSize: Theme.fs12
}
}
}
}
@@ -0,0 +1,28 @@
import QtQuick
Rectangle {
id: chip
property string status: "queued"
property real progress: 0
readonly property color tone: status === "done" ? Theme.success
: status === "error" ? Theme.error
: status === "running" ? Theme.primary : Theme.textLo
readonly property string label: status === "running" ? Math.floor(progress * 100) + "%"
: status === "done" ? "Done"
: status === "error" ? "Failed"
: status === "cancelled" ? "Cancelled" : "Queued"
implicitWidth: t.implicitWidth + 16
implicitHeight: 20
radius: 10
color: Theme.alpha(tone, 0.16)
border.color: Theme.alpha(tone, 0.35)
Txt {
id: t
anchors.centerIn: parent
text: chip.label
color: chip.tone
font.pixelSize: 11
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
}
}
+147
View File
@@ -0,0 +1,147 @@
import QtQuick
import QtQuick.Controls.Basic
// One lane per stem: color strip, solo / mute / gain / export, waveform texture.
Item {
id: lane
required property int index
required property var modelData
property string jobId: ""
property real viewStart: 0
property real viewEnd: 1
property int waveW: 100
property int waveH: 60
property real waveX: 0
property bool anySolo: false
property bool solo: false
property bool mute: false
property real gainDb: 0
signal soloToggled(bool on)
readonly property string stem: modelData.name
readonly property color tone: modelData.color
readonly property bool audible: anySolo ? solo : !mute
height: 92
Card {
anchors.fill: parent
clip: false
// color strip with soft glow
Rectangle {
x: 0
width: 4
height: parent.height
topLeftRadius: 8
bottomLeftRadius: 8
color: lane.tone
}
Rectangle {
x: 4
width: 24
height: parent.height
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(lane.tone, 0.14) }
GradientStop { position: 1; color: Theme.alpha(lane.tone, 0.0) }
}
}
Column {
id: controls
x: 18
width: lane.waveX - 30
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Row {
spacing: 6
width: parent.width
Txt {
width: parent.width - 3 * 30 - 12
text: lane.stem.charAt(0).toUpperCase() + lane.stem.slice(1)
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
IconButton {
size: 30; iconSize: 16
iconName: "headphones"
checkable: true
checked: lane.solo
activeColor: Theme.warning
tip: "Solo"
onClicked: lane.soloToggled(checked)
}
IconButton {
size: 30; iconSize: 16
iconName: lane.mute ? "volume_off" : "volume"
checkable: true
checked: lane.mute
activeColor: Theme.error
tip: "Mute"
onClicked: { lane.mute = checked; audio.setMute(lane.index, checked) }
}
IconButton {
size: 30; iconSize: 16
iconName: "download"
tip: "Export " + lane.stem
onClicked: app.exportStem(lane.stem)
}
}
Row {
spacing: 8
width: parent.width
AeroSlider {
id: gain
width: parent.width - 52
from: -24
to: 12
stepSize: 0.5
value: lane.gainDb
tone: lane.tone
onMoved: { lane.gainDb = value; audio.setGain(lane.index, value) }
// double-click resets to unity
TapHandler { onDoubleTapped: { lane.gainDb = 0; audio.setGain(lane.index, 0) } }
}
Txt {
width: 44
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: (lane.gainDb > 0 ? "+" : "") + lane.gainDb.toFixed(1) + " dB"
font.pixelSize: 11
font.features: { "tnum": 1 }
color: lane.gainDb === 0 ? Theme.textLo : Theme.textHi
}
}
}
Rectangle { // waveform well
x: lane.waveX
y: 8
width: lane.waveW
height: parent.height - 16
radius: 6
color: Qt.rgba(0, 0, 0, 0.22)
border.color: Qt.rgba(1, 1, 1, 0.04)
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: Theme.alpha(lane.tone, 0.18)
}
Image {
anchors.fill: parent
asynchronous: true
retainWhileLoading: true
cache: false
smooth: true
opacity: lane.audible ? 1 : 0.28
Behavior on opacity { NumberAnimation { duration: Theme.med } }
source: lane.jobId && lane.waveW > 4
? "image://wave/" + lane.jobId + "/" + lane.stem + "/"
+ Math.round(lane.waveW * Screen.devicePixelRatio) + "x" + Math.round((lane.height - 16) * Screen.devicePixelRatio)
+ "/" + lane.viewStart.toFixed(3) + "/" + lane.viewEnd.toFixed(3) + "/" + Theme.hex(lane.tone)
: ""
}
}
}
}
@@ -0,0 +1,63 @@
import QtQuick
// Win7-style green-to-cyan striped bar. Backend sends <= 30 updates/s;
// `shown` eases toward `value` and the stripes advance per frame by delta-time.
Item {
id: root
property real value: 0
property bool active: true
property real shown: 0
property real phase: 0
property int barHeight: 6
implicitHeight: barHeight
readonly property int tile: barHeight * 2
onValueChanged: if (value < shown) shown = value
Rectangle {
anchors.fill: parent
radius: height / 2
color: Qt.rgba(1, 1, 1, 0.07)
border.color: Qt.rgba(1, 1, 1, 0.05)
}
Item {
id: fill
height: parent.height
width: Math.max(root.active ? root.barHeight * 2 : 0, parent.width * Math.min(1, root.shown))
clip: true
Rectangle {
anchors.fill: parent
radius: height / 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.success }
GradientStop { position: 1; color: Theme.accentGlow }
}
}
Image {
visible: root.active
x: -root.phase
width: parent.width + root.tile
height: parent.height
fillMode: Image.Tile
source: "image://fx/stripes/" + root.barHeight
cache: true
}
Rectangle { // gloss
anchors { left: parent.left; right: parent.right; top: parent.top }
height: parent.height / 2
topLeftRadius: height
topRightRadius: height
color: Qt.rgba(1, 1, 1, 0.28)
}
}
FrameAnimation {
running: root.visible && (root.active || Math.abs(root.value - root.shown) > 0.0005)
onTriggered: {
root.shown += (root.value - root.shown) * (1 - Math.exp(-frameTime * 10))
if (root.active)
root.phase = (root.phase + frameTime * root.tile * 2.5) % root.tile
}
}
}
+65
View File
@@ -0,0 +1,65 @@
pragma Singleton
import QtQuick
// Design tokens: Material structure (8dp grid, type ramp, motion), Aero surface.
QtObject {
// dark gray tokens
readonly property color bg: "#1D1E21"
readonly property color surface1: "#26272B"
readonly property color surface2: "#2F3034"
readonly property color glassTint: Qt.rgba(36 / 255, 37 / 255, 41 / 255, 0.70)
readonly property color primary: "#3BA8FF"
readonly property color accentGlow: "#5FE3FF"
readonly property color success: "#4CD07D"
readonly property color error: "#FF5C6C"
readonly property color warning: "#FFB547"
readonly property color textHi: "#ECEDEF"
readonly property color textLo: "#9B9DA3"
readonly property color outline: Qt.rgba(1, 1, 1, 0.08)
readonly property color hairline: Qt.rgba(1, 1, 1, 0.06)
// 8dp grid
readonly property int u: 8
// type ramp 12 / 14 / 16 / 20 / 28
property string fontFamily: "Inter"
readonly property int fs12: 12
readonly property int fs14: 14
readonly property int fs16: 16
readonly property int fs20: 20
readonly property int fs28: 28
// motion: Material standard easing, nothing over 300 ms
readonly property int fast: 100
readonly property int med: 200
readonly property int slow: 300
readonly property var ease: [0.2, 0, 0, 1, 1, 1]
// elevation: blur sigma, alpha, y offset per level
readonly property var shadowSigma: [0, 2, 4, 8]
readonly property var shadowAlpha: [0, 0.35, 0.45, 0.55]
readonly property var shadowDy: [0, 1, 2, 4]
readonly property var stemColors: ({
"vocals": "#FF7AB6", "drums": "#FFB547", "bass": "#7C8CFF", "other": "#4CD07D",
"guitar": "#FF8A5C", "piano": "#B48CFF", "instrumental": "#3DD6C6"
})
readonly property var stemIcons: ({
"vocals": "music", "drums": "equalizer", "bass": "speed", "other": "layers",
"guitar": "music", "piano": "music", "instrumental": "layers"
})
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
function hex(c) { return c.toString().substring(1, 7).toUpperCase() }
function icon(name, color) { return "image://icon/" + name + "/" + hex(color) }
function stemColor(name) { return stemColors[name] || "#9B9DA3" }
function time(sec, tenths) {
if (!isFinite(sec) || sec < 0) sec = 0
var m = Math.floor(sec / 60)
var s = sec - m * 60
var ss = tenths ? s.toFixed(1) : Math.floor(s).toString()
if ((tenths && s < 10) || (!tenths && s < 10)) ss = "0" + ss
return m + ":" + ss
}
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls.Basic
ToolTip {
id: tip
delay: 600
timeout: 4000
padding: 8
contentItem: Txt { text: tip.text; font.pixelSize: Theme.fs12 }
background: Rectangle {
color: Theme.surface2
radius: 6
border.color: Theme.outline
Elevation { level: 2; radius: 6 }
}
enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.fast } }
exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.fast } }
}
+182
View File
@@ -0,0 +1,182 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: bar
property var win: Window.window
signal openSettings()
height: 52
topLeftRadius: win.radius
topRightRadius: win.radius
// Drag to move / double-click to maximize via the compositor (Wayland-native).
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) bar.win.startSystemMove()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: bar.win.toggleMaximized()
}
Row {
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 10
Rectangle {
width: 28
height: 28
radius: 8
anchors.verticalCenter: parent.verticalCenter
gradient: Gradient {
GradientStop { position: 0; color: "#5FC8FF" }
GradientStop { position: 1; color: "#2A7BD8" }
}
border.color: Qt.rgba(1, 1, 1, 0.3)
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 1 }
height: 12
topLeftRadius: 7
topRightRadius: 7
color: Qt.rgba(1, 1, 1, 0.25)
}
Icon { anchors.centerIn: parent; name: "equalizer"; size: 18; color: "#FFFFFF" }
}
Txt {
text: "Stemify"
font.pixelSize: Theme.fs16
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
Rectangle { // engine status
anchors.verticalCenter: parent.verticalCenter
height: 22
width: st.implicitWidth + 26
radius: 11
readonly property color tone: app.workerState === "ready" ? Theme.success
: app.workerState === "busy" ? Theme.primary
: app.workerState === "crashed" ? Theme.error : Theme.warning
color: Theme.alpha(tone, 0.12)
border.color: Theme.alpha(tone, 0.3)
Rectangle {
x: 9
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
radius: 4
color: parent.tone
}
Txt {
id: st
x: 20
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: 11
font.weight: Font.Medium
color: parent.tone
text: app.workerState === "ready" ? "Engine ready"
: app.workerState === "busy" ? "Separating"
: app.workerState === "crashed" ? "Engine restarting" : "Starting engine"
}
}
}
Row {
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
spacing: 8
AeroComboBox {
id: modelPicker
width: 240
caption: "MODEL"
iconName: "layers"
implicitHeight: 40
subRole: "note"
popupWidth: 300
model: app.models
currentIndex: app.models.findIndex(m => m.id === appSettings.modelId)
onActivated: appSettings.modelId = currentValue
anchors.verticalCenter: parent.verticalCenter
}
AbstractButton { // 2-stem vocals toggle chip
id: twoStem
readonly property var model: app.models.find(m => m.id === appSettings.modelId)
readonly property bool applicable: !!model && model.stems > 2
anchors.verticalCenter: parent.verticalCenter
checkable: true
checked: appSettings.twoStems && applicable
enabled: applicable
onToggled: appSettings.twoStems = checked
hoverEnabled: true
implicitHeight: 32
implicitWidth: twoLbl.implicitWidth + 40
opacity: enabled ? 1 : 0.4
background: Rectangle {
radius: 16
color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.2) : (twoStem.hovered ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.04))
border.color: twoStem.checked ? Theme.alpha(Theme.stemColor("vocals"), 0.6) : Qt.rgba(1, 1, 1, 0.10)
Behavior on color { ColorAnimation { duration: Theme.fast } }
}
contentItem: Row {
leftPadding: 12
spacing: 6
Icon {
name: twoStem.checked ? "check" : "music"
size: 16
color: twoStem.checked ? Theme.stemColor("vocals") : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
Txt {
id: twoLbl
text: "Vocals only"
font.pixelSize: Theme.fs12
font.weight: Font.Medium
color: twoStem.checked ? Theme.textHi : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
}
Tip { visible: twoStem.hovered; text: twoStem.applicable ? "2-stem mode: vocals + instrumental" : "This model is already 2-stem" }
}
AeroComboBox {
id: devicePicker
width: 250
caption: app.vram.valid === true && appSettings.deviceId !== "cpu"
? "DEVICE · " + (app.vram.free / 1024).toFixed(1) + " GB FREE" : "DEVICE"
iconName: "chip"
implicitHeight: 40
popupWidth: 300
model: app.devices
currentIndex: Math.max(0, app.devices.findIndex(d => d.id === appSettings.deviceId))
onActivated: appSettings.deviceId = currentValue
anchors.verticalCenter: parent.verticalCenter
HoverHandler { id: devHover }
Tip { visible: devHover.hovered && app.vram.valid === true; text: app.vram.text || "" }
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "settings"
size: 36
tip: "Settings (Ctrl+,)"
onClicked: bar.openSettings()
}
Rectangle { width: 1; height: 24; color: Theme.hairline; anchors.verticalCenter: parent.verticalCenter }
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
CaptionButton { iconName: "minimize"; onClicked: bar.win.showMinimized() }
CaptionButton { iconName: bar.win.maximized ? "restore" : "maximize"; onClicked: bar.win.toggleMaximized() }
CaptionButton { iconName: "close"; danger: true; onClicked: bar.win.close() }
}
}
Rectangle { // separator to content (elevation 1 edge)
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
height: 1
color: Theme.hairline
}
}
+65
View File
@@ -0,0 +1,65 @@
import QtQuick
// Material snackbar on surface-2.
Item {
id: t
property string message: ""
property string kind: "info"
property string folder: ""
property real maxWidth: 800
width: Math.min(maxWidth, row.implicitWidth + 32)
height: 48
opacity: 0
visible: opacity > 0
y: shown ? 0 : 12
property bool shown: false
function show(m, k, f) {
message = m; kind = k; folder = f
shown = true
hideTimer.restart()
}
Timer { id: hideTimer; interval: 5000; onTriggered: t.shown = false }
states: State { when: t.shown; PropertyChanges { t.opacity: 1 } }
Behavior on opacity { NumberAnimation { duration: Theme.med; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Behavior on y { NumberAnimation { duration: Theme.slow; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.ease } }
Rectangle {
anchors.fill: parent
radius: 8
color: Theme.surface2
border.color: t.kind === "error" ? Theme.alpha(Theme.error, 0.5) : t.kind === "success" ? Theme.alpha(Theme.success, 0.45) : Theme.outline
Elevation { level: 3; radius: 8 }
}
Row {
id: row
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 12
Icon {
anchors.verticalCenter: parent.verticalCenter
name: t.kind === "error" ? "error" : t.kind === "success" ? "check" : "music"
size: 18
color: t.kind === "error" ? Theme.error : t.kind === "success" ? Theme.success : Theme.accentGlow
}
Txt {
anchors.verticalCenter: parent.verticalCenter
text: t.message
width: Math.min(implicitWidth, 640)
}
AeroButton {
visible: t.folder.length > 0
anchors.verticalCenter: parent.verticalCenter
variant: "text"
pillHeight: 32
text: "Open folder"
onClicked: { app.openFolder(t.folder); t.shown = false }
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 28; iconSize: 14
iconName: "close"
glow: false
onClicked: t.shown = false
}
}
}
+344
View File
@@ -0,0 +1,344 @@
import QtQuick
import QtQuick.Controls.Basic
// Main pane for the selected track: progress, error, or stem lanes.
Item {
id: tv
property var win: Window.window
readonly property var sel: app.selected
readonly property string status: sel.valid ? sel.status : ""
// Lane model only changes when the track or its stems change, so per-lane
// UI state survives the frequent selectedChanged notifications.
property string stemsKey: ""
property var stems: []
property var soloState: []
property var muteState: []
property var gainState: []
property real viewStart: 0
property real viewEnd: 1
property int waveW: 100
function syncStems() {
var key = sel.valid && sel.status === "done" ? sel.id + ":" + sel.stems.map(s => s.name).join(",") : ""
if (key === stemsKey) return
stemsKey = key
stems = key ? sel.stems : []
soloState = stems.map(() => false)
muteState = stems.map(() => false)
gainState = stems.map(() => 0)
viewStart = 0
viewEnd = Math.max(0.01, sel.duration || 1)
}
onSelChanged: syncStems()
Component.onCompleted: syncStems()
readonly property bool anySolo: soloState.indexOf(true) >= 0
readonly property real span: Math.max(0.01, viewEnd - viewStart)
readonly property real duration: Math.max(0.01, sel.duration || 1)
function zoomAt(fx, factor) {
var t = viewStart + fx * span
var newSpan = Math.max(0.25, Math.min(duration, span * factor))
var s = Math.max(0, Math.min(duration - newSpan, t - fx * newSpan))
viewStart = s
viewEnd = s + newSpan
}
function pan(dt) {
var s = Math.max(0, Math.min(duration - span, viewStart + dt))
viewEnd = s + span
viewStart = s
}
// ------------------------------------------------------------- done
Item {
id: doneView
anchors.fill: parent
visible: tv.status === "done"
Card {
id: header
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 24; bottomMargin: 0 }
height: 104
Column {
anchors { left: parent.left; leftMargin: 20; right: actions.left; rightMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 6
Txt {
width: parent.width
text: tv.sel.fileName || ""
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
}
Txt {
width: parent.width
color: Theme.textLo
font.pixelSize: Theme.fs12
text: [tv.sel.modelLabel, tv.sel.deviceLabel, Theme.time(tv.sel.duration || 0, false),
tv.sel.elapsed > 0 ? "separated in " + tv.sel.elapsed.toFixed(1) + " s" : "from cache"]
.filter(s => s && s.length).join(" · ")
}
Row {
spacing: 6
Icon { name: "folder"; size: 14; color: Theme.textLo; anchors.verticalCenter: parent.verticalCenter }
Txt {
text: (tv.sel.exportedTo ? "Exported to " : "Exports to ") + (tv.sel.target || "")
+ " · " + appSettings.exportFormat.toUpperCase()
font.pixelSize: Theme.fs12
color: tv.sel.exportedTo ? Theme.success : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
}
}
Row {
id: actions
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
spacing: 8
AeroButton {
visible: (tv.sel.rerunLabel || "").length > 0
variant: "text"
iconName: "refresh"
text: "Re-run with " + (tv.sel.rerunLabel || "")
onClicked: app.rerunSelected()
}
IconButton {
anchors.verticalCenter: parent.verticalCenter
size: 40
iconName: "folder"
tip: "Open output folder"
onClicked: app.openTargetFolder()
}
AeroButton {
variant: "tonal"
iconName: "layers"
text: "Export mix"
onClicked: app.exportMix()
}
AeroButton {
variant: "filled"
iconName: "download"
text: "Export all"
onClicked: app.exportAll()
}
}
}
// timeline ruler + zoom hint
Item {
id: ruler
anchors { left: parent.left; right: parent.right; top: header.bottom; leftMargin: 24; rightMargin: 24; topMargin: 12 }
height: 22
readonly property real waveX: 248
Txt {
anchors.verticalCenter: parent.verticalCenter
text: tv.span < tv.duration - 0.01 ? "Zoomed · Ctrl+wheel to zoom, Shift+wheel to pan, double-click to reset"
: "Ctrl+wheel to zoom"
font.pixelSize: 11
color: Theme.alpha(Theme.textLo, 0.8)
}
Repeater {
model: 6
Txt {
required property int index
x: ruler.waveX + (ruler.width - ruler.waveX - 8) * index / 5 - (index === 5 ? width : index === 0 ? 0 : width / 2)
anchors.verticalCenter: parent.verticalCenter
text: Theme.time(tv.viewStart + tv.span * index / 5, tv.span < 20)
font.pixelSize: 11
font.features: { "tnum": 1 }
color: Theme.textLo
}
}
}
Flickable {
id: lanes
anchors { left: parent.left; right: parent.right; top: ruler.bottom; bottom: parent.bottom; leftMargin: 24; rightMargin: 24; topMargin: 6 }
contentHeight: col.height + 24
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: Qt.rgba(1, 1, 1, 0.18); visible: parent.size < 1 }
background: Item {}
}
readonly property real waveX: ruler.waveX
onWidthChanged: waveTimer.restart()
Timer { // debounce texture re-renders during window resize
id: waveTimer
interval: 90
onTriggered: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
}
Component.onCompleted: tv.waveW = Math.max(10, lanes.width - lanes.waveX - 8)
Column {
id: col
width: lanes.width
spacing: 8
topPadding: 4
Repeater {
model: tv.stems
StemLane {
width: col.width
jobId: tv.sel.id || ""
waveX: lanes.waveX
waveW: tv.waveW
viewStart: tv.viewStart
viewEnd: tv.viewEnd
anySolo: tv.anySolo
solo: tv.soloState[index] || false
mute: tv.muteState[index] || false
gainDb: tv.gainState[index] || 0
onSoloToggled: (on) => {
var s = tv.soloState.slice(); s[index] = on; tv.soloState = s
audio.setSolo(index, on)
}
onMuteChanged: { var m = tv.muteState.slice(); m[index] = mute; tv.muteState = m }
onGainDbChanged: { var g = tv.gainState.slice(); g[index] = gainDb; tv.gainState = g }
}
}
}
// click / drag on any waveform to seek
MouseArea {
x: lanes.waveX
y: 0
width: tv.waveW
height: col.height
cursorShape: Qt.IBeamCursor
enabled: audio.loaded
function seekTo(mx) {
var t = tv.viewStart + Math.max(0, Math.min(1, mx / width)) * tv.span
audio.seek(t)
tv.win.playPos = t
}
onPressed: (e) => seekTo(e.x)
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
onDoubleClicked: { tv.viewStart = 0; tv.viewEnd = tv.duration }
}
// playhead (position interpolated per frame from the audio ring buffer)
Rectangle {
readonly property real f: (tv.win.playPos - tv.viewStart) / tv.span
visible: audio.loaded && f >= 0 && f <= 1
x: lanes.waveX + f * tv.waveW - 1
y: 2
width: 2
height: col.height
color: "#FFFFFF"
Glow { radius: 1; sigma: 3; opacity: 0.8 }
}
WheelHandler {
acceptedModifiers: Qt.ControlModifier
onWheel: (e) => {
var fx = Math.max(0, Math.min(1, (point.position.x - lanes.waveX) / tv.waveW))
tv.zoomAt(fx, e.angleDelta.y > 0 ? 0.8 : 1.25)
}
}
WheelHandler {
acceptedModifiers: Qt.ShiftModifier
onWheel: (e) => tv.pan(-(e.angleDelta.y || e.angleDelta.x) / 120 * tv.span * 0.1)
}
}
}
// ------------------------------------------------- running / queued
Card {
visible: tv.status === "running" || tv.status === "queued"
anchors.centerIn: parent
width: Math.min(parent.width - 48, 620)
height: 236
elevation: 2
Column {
anchors { fill: parent; margins: 28 }
spacing: 12
Txt {
width: parent.width
text: tv.sel.fileName || ""
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
}
Txt {
text: tv.status === "queued" ? "Waiting for the GPU · " + (tv.sel.modelLabel || "")
: (tv.sel.stage || "") + " · " + (tv.sel.modelLabel || "")
color: Theme.textLo
}
Row {
spacing: 12
Txt {
text: Math.floor((progressBar.shown) * 100) + "%"
font.pixelSize: Theme.fs28
font.weight: Font.DemiBold
font.features: { "tnum": 1 }
color: tv.status === "running" ? Theme.textHi : Theme.textLo
}
}
StripedProgress {
id: progressBar
width: parent.width
barHeight: 10
value: tv.sel.progress || 0
active: tv.status === "running"
opacity: tv.status === "running" ? 1 : 0.4
}
Item { width: 1; height: 4 }
AeroButton {
variant: "tonal"
tone: Theme.error
iconName: "close"
text: tv.status === "queued" ? "Remove from queue" : "Cancel"
onClicked: app.cancelJob(tv.sel.id)
}
}
}
// ------------------------------------------------ error / cancelled
Card {
visible: tv.status === "error" || tv.status === "cancelled"
anchors.centerIn: parent
width: Math.min(parent.width - 48, 620)
height: errCol.implicitHeight + 56
Column {
id: errCol
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 28 }
spacing: 12
Row {
spacing: 10
Icon {
name: tv.status === "error" ? "error" : "close"
size: 24
color: tv.status === "error" ? Theme.error : Theme.textLo
anchors.verticalCenter: parent.verticalCenter
}
Txt {
text: tv.status === "error" ? "Separation failed" : "Cancelled"
font.pixelSize: Theme.fs20
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
}
Txt { width: parent.width; text: tv.sel.fileName || ""; color: Theme.textLo }
Rectangle {
visible: tv.status === "error"
width: parent.width
height: msg.implicitHeight + 20
radius: 6
color: Theme.alpha(Theme.error, 0.08)
border.color: Theme.alpha(Theme.error, 0.25)
Txt {
id: msg
anchors { fill: parent; margins: 10 }
text: tv.sel.message || ""
wrapMode: Text.WordWrap
elide: Text.ElideNone
font.pixelSize: Theme.fs12
}
}
Row {
spacing: 8
AeroButton { variant: "filled"; iconName: "refresh"; text: "Retry"; onClicked: app.retryJob(tv.sel.id) }
AeroButton { variant: "text"; tone: Theme.textLo; iconName: "delete"; text: "Remove"; onClicked: app.removeJob(tv.sel.id) }
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import QtQuick
import QtQuick.Controls.Basic
GlassPanel {
id: tr
property var win: Window.window
readonly property bool ready: audio.loaded
readonly property real dur: audio.duration
bottomLeftRadius: win.radius
bottomRightRadius: win.radius
gloss: true
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top }
height: 1
color: Theme.hairline
}
Row {
id: left
anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter }
spacing: 10
IconButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "skip_start"
enabled: tr.ready
tip: "Back to start (Home)"
onClicked: { audio.seek(0); tr.win.playPos = 0 }
}
AbstractButton { // glossy round play/pause
id: play
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
enabled: tr.ready
hoverEnabled: true
opacity: enabled ? 1 : 0.4
onClicked: audio.toggle()
background: Item {
Glow { radius: 22; opacity: play.hovered && play.enabled ? 0.6 : 0 }
Rectangle {
anchors.fill: parent
radius: 22
gradient: Gradient {
GradientStop { position: 0; color: "#6CC4FF" }
GradientStop { position: 1; color: "#1F7FE0" }
}
border.color: Qt.rgba(1, 1, 1, 0.35)
Rectangle {
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 2 }
height: 19
topLeftRadius: 20
topRightRadius: 20
gradient: Gradient {
GradientStop { position: 0; color: Qt.rgba(1, 1, 1, 0.45) }
GradientStop { position: 1; color: Qt.rgba(1, 1, 1, 0.08) }
}
}
Rectangle { anchors.fill: parent; radius: 22; color: play.down ? Qt.rgba(0, 0, 0, 0.2) : "transparent" }
}
}
contentItem: Item {
Icon { anchors.centerIn: parent; name: audio.playing ? "pause" : "play"; size: 24; color: "#FFFFFF" }
}
}
Txt {
anchors.verticalCenter: parent.verticalCenter
width: 118
text: Theme.time(tr.win.playPos, true) + " / " + Theme.time(tr.dur, false)
font.features: { "tnum": 1 }
font.pixelSize: Theme.fs14
color: tr.ready ? Theme.textHi : Theme.textLo
}
}
// scrub bar
Item {
id: scrub
anchors { left: left.right; leftMargin: 16; right: volume.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
height: 24
enabled: tr.ready
readonly property real frac: tr.dur > 0 ? Math.min(1, tr.win.playPos / tr.dur) : 0
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: scrubArea.containsMouse || scrubArea.pressed ? 6 : 4
radius: 3
color: Qt.rgba(1, 1, 1, 0.10)
Behavior on height { NumberAnimation { duration: Theme.fast } }
Rectangle {
width: parent.width * scrub.frac
height: parent.height
radius: 3
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.primary }
GradientStop { position: 1; color: Theme.accentGlow }
}
}
}
Rectangle {
visible: tr.ready
x: scrub.width * scrub.frac - width / 2
anchors.verticalCenter: parent.verticalCenter
width: scrubArea.containsMouse || scrubArea.pressed ? 14 : 10
height: width
radius: width / 2
color: "#FFFFFF"
Behavior on width { NumberAnimation { duration: Theme.fast } }
Glow { radius: 7; sigma: 4; opacity: scrubArea.containsMouse ? 0.7 : 0.3 }
}
MouseArea {
id: scrubArea
anchors.fill: parent
hoverEnabled: true
enabled: tr.ready
cursorShape: Qt.PointingHandCursor
function seekTo(x) {
var t = Math.max(0, Math.min(1, x / width)) * tr.dur
audio.seek(t)
tr.win.playPos = t
}
onPressed: (e) => seekTo(e.x)
onPositionChanged: (e) => { if (pressed) seekTo(e.x) }
}
}
// master volume: monitoring only, never baked into exports
Row {
id: volume
anchors { right: meter.left; rightMargin: 24; verticalCenter: parent.verticalCenter }
spacing: 6
IconButton {
id: muteBtn
property real restore: 1.0
anchors.verticalCenter: parent.verticalCenter
iconName: audio.volume <= 0 ? "volume_off" : "volume"
tip: audio.volume <= 0 ? "Unmute" : "Mute"
onClicked: {
if (audio.volume > 0) {
restore = audio.volume
audio.volume = 0
} else {
audio.volume = restore > 0 ? restore : 1.0
}
appSettings.masterVolume = audio.volume
}
}
AeroSlider {
anchors.verticalCenter: parent.verticalCenter
width: 112
from: 0
to: 1
value: audio.volume
onMoved: audio.volume = value
onPressedChanged: if (!pressed) appSettings.masterVolume = audio.volume
WheelHandler {
onWheel: (e) => {
audio.volume = Math.max(0, Math.min(1, audio.volume + e.angleDelta.y / 120 * 0.05))
appSettings.masterVolume = audio.volume
}
}
}
Txt {
anchors.verticalCenter: parent.verticalCenter
width: 36
text: Math.round(audio.volume * 100) + "%"
font.features: { "tnum": 1 }
font.pixelSize: Theme.fs12
color: Theme.textLo
}
}
Row {
id: meter
anchors { right: parent.right; rightMargin: 20; verticalCenter: parent.verticalCenter }
spacing: 10
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Txt { text: "L"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
Txt { text: "R"; font.pixelSize: 9; color: Theme.textLo; height: 9 }
}
LevelMeter {
anchors.verticalCenter: parent.verticalCenter
width: 160
levelLeft: tr.win.levelL
levelRight: tr.win.levelR
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
Text {
color: Theme.textHi
font.family: Theme.fontFamily
font.pixelSize: Theme.fs14
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
+30
View File
@@ -0,0 +1,30 @@
module Stemify
singleton Theme 1.0 Theme.qml
AeroButton 1.0 AeroButton.qml
AeroComboBox 1.0 AeroComboBox.qml
AeroSlider 1.0 AeroSlider.qml
AeroSwitch 1.0 AeroSwitch.qml
CaptionButton 1.0 CaptionButton.qml
Card 1.0 Card.qml
DashedFrame 1.0 DashedFrame.qml
DropZone 1.0 DropZone.qml
Elevation 1.0 Elevation.qml
FrameOverlay 1.0 FrameOverlay.qml
GlassPanel 1.0 GlassPanel.qml
Glow 1.0 Glow.qml
IconButton 1.0 IconButton.qml
Icon 1.0 Icon.qml
LevelMeter 1.0 LevelMeter.qml
QueueItem 1.0 QueueItem.qml
ResizeEdge 1.0 ResizeEdge.qml
SettingsDialog 1.0 SettingsDialog.qml
Sidebar 1.0 Sidebar.qml
StatusChip 1.0 StatusChip.qml
StemLane 1.0 StemLane.qml
StripedProgress 1.0 StripedProgress.qml
Tip 1.0 Tip.qml
TitleBar 1.0 TitleBar.qml
Toast 1.0 Toast.qml
TrackView 1.0 TrackView.qml
Transport 1.0 Transport.qml
Txt 1.0 Txt.qml
+78
View File
@@ -0,0 +1,78 @@
"""Persistent settings exposed to QML as plain properties."""
import json
import os
from PySide6.QtCore import Property, QObject, Signal
CONFIG_PATH = os.path.join(os.path.expanduser("~/.config/stemify"), "settings.json")
DEFAULTS = {
"modelId": "htdemucs",
"twoStems": False,
"deviceId": "auto",
"outputMode": "beside", # "beside" the source file, or "folder" (stemsDir)
"stemsDir": os.path.expanduser("~/Music/Stems"),
"exportFormat": "wav", # wav | flac | mp3
"segment": 0.0, # seconds; 0 = model default
"shifts": 0,
"overlap": 0.25,
"fp16": True,
"autoExport": False,
"frameOverlay": False,
"cacheLimitGb": 8.0,
"masterVolume": 1.0, # slider position 0..1 (cubic taper in the audio engine)
}
class _SettingsBase(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._values = dict(DEFAULTS)
try:
with open(CONFIG_PATH) as f:
stored = json.load(f)
for k, v in stored.items():
if k not in DEFAULTS:
continue
want = type(DEFAULTS[k])
if type(v) is want or (want is float and type(v) is int):
self._values[k] = want(v)
except (OSError, ValueError):
pass
def save(self):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(self._values, f, indent=2)
os.replace(tmp, CONFIG_PATH)
def get(self, name):
return self._values[name]
def _build():
# Qt only sees properties and signals present when the class is created,
# so generate one Property + <name>Changed signal per default up front.
ns = {}
for name, default in DEFAULTS.items():
type_ = type(default)
sig = Signal()
ns[f"{name}Changed"] = sig
def getter(self, _n=name):
return self._values[_n]
def setter(self, value, _n=name, _t=type_):
value = _t(value)
if self._values[_n] != value:
self._values[_n] = value
getattr(self, f"{_n}Changed").emit()
self.save()
ns[name] = Property(type_, getter, setter, notify=sig)
return type("Settings", (_SettingsBase,), ns)
Settings = _build()
View File
Whitespace-only changes.
+289
View File
@@ -0,0 +1,289 @@
"""Separation worker process.
Connects to the UI's local socket and speaks newline-delimited JSON:
UI -> worker start {id, file, model, device, two_stems, out_dir, segment, shifts, overlap, fp16}
cancel {id}
release {} drop cached models and free VRAM
quit {}
worker -> UI hello {devices[], torch, hip}
vram {device, free_mb, total_mb}
progress {id, pct, stage}
done {id, stems[], peaks_path, samplerate, duration, out_dir}
error {id, msg}
cancelled {id}
Runs one job at a time; the UI owns the queue.
"""
import argparse
import json
import os
import queue
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
import traceback
# Fast MIOpen kernel selection: avoids multi-minute first-run tuning on RDNA4.
os.environ.setdefault("MIOPEN_FIND_MODE", "FAST")
os.environ.setdefault("TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL", "1")
os.environ.setdefault("MIOPEN_LOG_LEVEL", "2") # silence workspace warnings
PROGRESS_INTERVAL = 1 / 30 # doc: at most 30 progress updates per second
MODEL_DIR = os.path.join(os.path.expanduser("~/.cache/stemify"), "models")
class Conn:
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(path)
self.lock = threading.Lock()
self.rfile = self.sock.makefile("r", encoding="utf-8")
def send(self, type_, **payload):
line = json.dumps({"type": type_, **payload}) + "\n"
with self.lock:
self.sock.sendall(line.encode())
def __iter__(self):
for line in self.rfile:
line = line.strip()
if line:
yield json.loads(line)
class Job:
def __init__(self, msg, conn):
from stemify.catalog import MODELS_BY_ID
self.id = msg["id"]
self.file = msg["file"]
self.model = MODELS_BY_ID[msg["model"]]
self.device = msg.get("device", "auto")
self.two_stems = bool(msg.get("two_stems"))
self.out_dir = msg["out_dir"]
self.segment = float(msg.get("segment") or 0)
self.shifts = int(msg.get("shifts") or 0)
self.overlap = float(msg.get("overlap", 0.25))
self.fp16 = bool(msg.get("fp16", True))
# audio-separator expresses overlap as a divisor of the chunk (step = chunk / overlap);
# RoFormer's windowed overlap-add wants at least 4 (75%).
self.overlap_int = max(4, round(1 / max(0.05, 1 - self.overlap)))
self.cancel_event = threading.Event()
self._conn = conn
self._last_sent = 0.0
def check(self):
if self.cancel_event.is_set():
from stemify.worker.backends import Cancelled
raise Cancelled()
def report(self, stage, pct, force=False):
now = time.monotonic()
if force or now - self._last_sent >= PROGRESS_INTERVAL:
self._last_sent = now
self._conn.send("progress", id=self.id, pct=round(float(pct), 4), stage=stage)
def gpu_marketing_names():
"""gfx arch -> marketing name from rocminfo (torch reports a generic name on ROCm)."""
names = {}
if not shutil.which("rocminfo"):
return names
try:
out = subprocess.run(["rocminfo"], capture_output=True, text=True, timeout=5).stdout
except Exception:
return names
arch = None
for line in out.splitlines():
m = re.match(r"\s*Name:\s+(gfx\w+)\s*$", line)
if m:
arch = m.group(1)
m = re.match(r"\s*Marketing Name:\s+(.+?)\s*$", line)
if m and arch:
names.setdefault(arch, m.group(1))
arch = None
return names
def pci_address(props):
bus = getattr(props, "pci_bus_id", None)
if bus is None:
return None
return f"{props.pci_domain_id:04x}:{bus:02x}:{props.pci_device_id:02x}.0"
def sysfs_vram_paths(props):
pci = pci_address(props)
if pci is None:
return None
base = f"/sys/bus/pci/devices/{pci}"
total, used = base + "/mem_info_vram_total", base + "/mem_info_vram_used"
return (total, used) if os.path.exists(used) else None
def enumerate_devices():
import torch
devices = []
if torch.cuda.is_available():
names = gpu_marketing_names() if torch.version.hip else {}
for i in range(torch.cuda.device_count()):
p = torch.cuda.get_device_properties(i)
if getattr(p, "is_integrated", False):
continue # skip APU iGPUs (e.g. the 9800X3D's gfx1036)
arch = getattr(p, "gcnArchName", "").split(":")[0]
devices.append({
"id": f"cuda:{i}",
"name": names.get(arch, p.name),
"arch": arch,
"total_mb": p.total_memory // 2**20,
"pci": pci_address(p),
"_sysfs": sysfs_vram_paths(p),
})
return devices
def vram_loop(conn, devices):
import torch
while True:
for d in devices:
try:
if d["_sysfs"]:
total = int(open(d["_sysfs"][0]).read()) // 2**20
used = int(open(d["_sysfs"][1]).read()) // 2**20
free = total - used
else:
free, total = (v // 2**20 for v in torch.cuda.mem_get_info(int(d["id"].split(":")[1])))
conn.send("vram", device=d["id"], free_mb=free, total_mb=total)
except Exception:
pass
time.sleep(1.0)
def resolve_device(choice, devices):
import torch
if choice == "cpu" or not devices:
return torch.device("cpu")
if choice != "auto" and any(d["id"] == choice for d in devices):
return torch.device(choice)
best = max(devices, key=lambda d: d["total_mb"])
return torch.device(best["id"])
def run_job(job, backends, devices, conn):
import numpy as np
import torch
from stemify.worker.audio_io import save_peaks, write_stem
device = resolve_device(job.device, devices)
backend = backends[job.model["family"]]
os.makedirs(job.out_dir, exist_ok=True)
try:
stems, sr = backend.run(job, device)
except torch.OutOfMemoryError:
torch.cuda.empty_cache()
raise RuntimeError("Out of GPU memory. Lower the segment size in Settings or pick CPU.")
if job.two_stems and "vocals" in stems and len(stems) > 2:
rest = [a for k, a in stems.items() if k != "vocals"]
stems = {"vocals": stems["vocals"], "instrumental": np.sum(rest, axis=0)}
job.report("Writing stems", 0.94, force=True)
out = []
for name, audio in stems.items():
job.check()
path = os.path.join(job.out_dir, f"{name}.wav")
write_stem(path, audio, sr)
out.append({"name": name, "path": path})
job.report("Building waveforms", 0.98, force=True)
peaks_path = os.path.join(job.out_dir, "peaks.npz")
save_peaks(peaks_path, stems, sr)
duration = next(iter(stems.values())).shape[1] / sr
meta = {"source": job.file, "model": job.model["id"], "two_stems": job.two_stems,
"stems": out, "peaks_path": peaks_path, "samplerate": sr, "duration": duration,
"device": str(device), "finished": time.time()}
with open(os.path.join(job.out_dir, "meta.json"), "w") as f:
json.dump(meta, f)
if device.type == "cuda":
torch.cuda.empty_cache()
return {"stems": out, "peaks_path": peaks_path, "samplerate": sr, "duration": duration,
"out_dir": job.out_dir, "device": str(device)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--socket", required=True)
args = ap.parse_args()
try:
os.nice(5) # UI first; the GPU does the heavy lifting anyway
except OSError:
pass
conn = Conn(args.socket)
conn.send("booting")
import torch
torch.set_num_threads(max(1, (os.cpu_count() or 4) - 2))
from stemify.worker.backends import Cancelled, DemucsBackend, RoformerBackend
devices = enumerate_devices()
conn.send("hello", devices=[{k: v for k, v in d.items() if not k.startswith("_")} for d in devices],
torch=torch.__version__, hip=torch.version.hip)
threading.Thread(target=vram_loop, args=(conn, devices), daemon=True).start()
jobs: queue.Queue = queue.Queue()
current: dict = {}
def reader():
try:
for msg in conn:
t = msg.get("type")
if t in ("start", "release"):
jobs.put(msg)
elif t == "cancel":
job = current.get("job")
if job and job.id == msg.get("id"):
job.cancel_event.set()
elif t == "quit":
break
except (OSError, ValueError):
pass
# UI went away or asked us to quit: no point finishing work nobody will read.
os._exit(0)
threading.Thread(target=reader, daemon=True).start()
backends = {"demucs": DemucsBackend(), "roformer": RoformerBackend(MODEL_DIR)}
while True:
msg = jobs.get()
if msg.get("type") == "release":
for b in backends.values():
b.release()
continue
try:
job = Job(msg, conn)
except Exception as e:
conn.send("error", id=msg.get("id"), msg=f"Bad job: {e}")
continue
current["job"] = job
try:
result = run_job(job, backends, devices, conn)
conn.send("done", id=job.id, **result)
except Cancelled:
shutil.rmtree(job.out_dir, ignore_errors=True)
conn.send("cancelled", id=job.id)
except Exception as e:
traceback.print_exc()
shutil.rmtree(job.out_dir, ignore_errors=True)
conn.send("error", id=job.id, msg=str(e) or e.__class__.__name__)
finally:
current.pop("job", None)
sys.stdout.flush()
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
"""Audio decode/encode and waveform peak generation for the worker."""
import subprocess
import numpy as np
import soundfile as sf
PEAK_BASE = 256 # samples per bucket at mip level 0
def _source_channels(path: str) -> int:
try:
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
"stream=channels", "-of", "csv=p=0", path],
capture_output=True, text=True, timeout=10).stdout
return int(out.strip().splitlines()[0])
except (OSError, ValueError, IndexError, subprocess.SubprocessError):
return 0
def load_audio(path: str, samplerate: int, channels: int = 2) -> np.ndarray:
"""Decode any ffmpeg-readable file to float32 (channels, samples)."""
# ffmpeg's -ac upmix treats mono as centre and spreads it at -3 dB per side;
# decode mono as mono and duplicate it so stems keep the source level.
decode_ch = 1 if _source_channels(path) == 1 else channels
cmd = ["ffmpeg", "-nostdin", "-v", "error", "-i", path, "-map", "0:a:0",
"-f", "f32le", "-acodec", "pcm_f32le", "-ac", str(decode_ch), "-ar", str(samplerate), "-"]
proc = subprocess.run(cmd, capture_output=True)
if proc.returncode != 0:
lines = [l.strip() for l in proc.stderr.decode(errors="replace").splitlines() if l.strip()]
reason = lines[-1].split(": ", 1)[-1] if lines else f"ffmpeg exited with {proc.returncode}"
raise RuntimeError(f"Could not decode the file ({reason[:160]})")
data = np.frombuffer(proc.stdout, dtype=np.float32)
if data.size == 0:
raise RuntimeError("file contains no audio")
data = data.reshape(-1, decode_ch).T
if decode_ch != channels:
data = np.repeat(data, channels, axis=0)
return data.copy()
def write_stem(path: str, audio: np.ndarray, samplerate: int):
"""Write (channels, samples) audio. 24-bit unless it would clip, then float."""
peak = float(np.abs(audio).max()) if audio.size else 0.0
subtype = "FLOAT" if peak > 0.999 else "PCM_24"
sf.write(path, audio.T, samplerate, subtype=subtype, format="WAV")
def compute_peaks(audio: np.ndarray) -> list[np.ndarray]:
"""Min/max mip pyramid of a (channels, samples) signal, mono-folded.
Level k has PEAK_BASE * 2**k samples per bucket; each level is (n, 2) float16.
"""
lo = audio.min(axis=0)
hi = audio.max(axis=0)
n = -(-lo.size // PEAK_BASE)
pad = n * PEAK_BASE - lo.size
if pad:
lo = np.concatenate([lo, np.zeros(pad, lo.dtype)])
hi = np.concatenate([hi, np.zeros(pad, hi.dtype)])
lvl_lo = lo.reshape(n, PEAK_BASE).min(axis=1)
lvl_hi = hi.reshape(n, PEAK_BASE).max(axis=1)
levels = [np.stack([lvl_lo, lvl_hi], axis=1).astype(np.float16)]
while lvl_lo.size > 64:
if lvl_lo.size % 2:
lvl_lo = np.append(lvl_lo, 0)
lvl_hi = np.append(lvl_hi, 0)
lvl_lo = lvl_lo.reshape(-1, 2).min(axis=1)
lvl_hi = lvl_hi.reshape(-1, 2).max(axis=1)
levels.append(np.stack([lvl_lo, lvl_hi], axis=1).astype(np.float16))
return levels
def save_peaks(path: str, stems: dict[str, np.ndarray], samplerate: int):
arrays = {"samplerate": np.array(samplerate), "base": np.array(PEAK_BASE)}
for name, audio in stems.items():
for k, level in enumerate(compute_peaks(audio)):
arrays[f"{name}/{k}"] = level
np.savez(path, **arrays)
+251
View File
@@ -0,0 +1,251 @@
"""Separation backends. Each returns ({stem: float32 (channels, samples)}, samplerate)."""
import gc
import logging
import os
import random
import numpy as np
import soundfile as sf
import torch
from .audio_io import load_audio
class Cancelled(BaseException):
"""Raised from progress hooks to abort a running separation."""
class DemucsBackend:
"""Demucs with batched segments.
demucs.apply.apply_model runs one segment per forward pass, which leaves an
RX 9070 XT mostly idle waiting on MIOpen host overhead. Batching 8 segments
under fp16 autocast measured ~110x realtime vs ~31x for the stock loop.
"""
BATCH = 8
def __init__(self):
self._model = None
self._model_name = None
def _get(self, name):
if self._model_name != name:
from demucs.pretrained import get_model
self._model = None
torch.cuda.empty_cache()
self._model = get_model(name).eval()
self._model_name = name
return self._model
def release(self):
self._model = self._model_name = None
gc.collect()
torch.cuda.empty_cache()
def run(self, job, device):
from demucs.apply import BagOfModels
job.report("Loading model", 0.01)
bag = self._get(job.model["id"])
sub_models = bag.models if isinstance(bag, BagOfModels) else [bag]
weights = bag.weights if isinstance(bag, BagOfModels) else [[1.0] * len(bag.sources)]
job.check()
job.report("Decoding audio", 0.03)
wav = torch.from_numpy(load_audio(job.file, bag.samplerate, bag.audio_channels))
job.check()
ref = wav.mean(0)
mean, std = ref.mean(), ref.std() + 1e-8
mix = ((wav - mean) / std).to(device)
length = mix.shape[-1]
passes = max(1, job.shifts)
max_shift = int(0.5 * bag.samplerate) if job.shifts else 0
padded = torch.nn.functional.pad(mix, (max_shift, max_shift)) if max_shift else mix
# Work units for progress: one per segment per model per shift.
plans = []
for sub in sub_models:
seg_len = int(min(job.segment, float(sub.segment)) * bag.samplerate) if job.segment else int(float(sub.segment) * bag.samplerate)
stride = max(1, int((1 - job.overlap) * seg_len))
plans.append((sub, seg_len, stride))
total_units = sum(passes * len(range(0, length + max_shift, stride)) for _, _, stride in plans)
done_units = 0
def tick(n):
nonlocal done_units
done_units += n
job.check()
job.report("Separating", 0.05 + 0.88 * min(1.0, done_units / total_units))
self.fp16 = job.fp16
job.report("Separating", 0.05)
estimates = torch.zeros(len(bag.sources), mix.shape[0], length, device=device)
totals = [0.0] * len(bag.sources)
for (sub, seg_len, stride), w in zip(plans, weights):
sub.to(device)
out = torch.zeros_like(estimates)
for _ in range(passes):
offset = random.randint(0, max_shift) if max_shift else 0
piece = padded[..., offset: offset + length + max_shift - offset] if max_shift else padded
res = self._split(sub, piece, seg_len, stride, job.overlap, device, tick)
out += res[..., max_shift - offset: max_shift - offset + length]
out /= passes
for k, wk in enumerate(w):
estimates[k] += out[k] * wk
totals[k] += wk
sub.cpu()
for k in range(len(totals)):
estimates[k] /= totals[k]
out = (estimates * std.to(device) + mean.to(device)).cpu().numpy()
return dict(zip(bag.sources, out)), bag.samplerate
def _split(self, model, mix, seg_len, stride, overlap, device, tick):
"""Overlap-add over fixed-length segments, BATCH segments per forward pass."""
channels, length = mix.shape
n_src = len(model.sources)
weight = torch.cat([torch.arange(1, seg_len // 2 + 1, device=device),
torch.arange(seg_len - seg_len // 2, 0, -1, device=device)]).float()
weight /= weight.max()
out = torch.zeros(n_src, channels, length, device=device)
sum_weight = torch.zeros(length, device=device)
offsets = list(range(0, length, stride))
use_amp = device.type == "cuda" and self.fp16
batch = self.BATCH
i = 0
while i < len(offsets):
group = offsets[i:i + batch]
chunks = torch.zeros(len(group), channels, seg_len, device=device)
for j, off in enumerate(group):
piece = mix[:, off:off + seg_len]
chunks[j, :, :piece.shape[-1]] = piece
try:
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16, enabled=use_amp):
res = model(chunks).float()
except torch.OutOfMemoryError:
if batch == 1:
raise
torch.cuda.empty_cache()
batch = max(1, batch // 2)
continue
for j, off in enumerate(group):
n = min(seg_len, length - off)
out[..., off:off + n] += weight[:n] * res[j, ..., :n]
sum_weight[off:off + n] += weight[:n]
i += len(group)
tick(len(group))
return out / sum_weight
def _progress_iter_factory(job, lo, hi):
"""Stand-in for tqdm inside audio-separator: reports progress and checks cancel."""
class ProgressIter:
def __init__(self, iterable=None, *args, total=None, **kwargs):
self.iterable = iterable
self.total = total if total is not None else (len(iterable) if hasattr(iterable, "__len__") else None)
self.n = 0
def __iter__(self):
for item in self.iterable:
self.update(0)
yield item
self.n += 1
def update(self, n=1):
self.n += n
job.check()
if self.total:
job.report("Separating", lo + (hi - lo) * min(1.0, self.n / self.total))
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
return ProgressIter
class RoformerBackend:
def __init__(self, model_dir):
self.model_dir = model_dir
self._sep = None
self._key = None
def _get(self, job, device):
key = (job.model["file"], str(device), job.overlap_int, job.fp16)
if self._key != key:
from audio_separator.separator import Separator
self._sep = None
torch.cuda.empty_cache()
if not os.path.exists(os.path.join(self.model_dir, job.model["file"])):
job.report("Downloading model", 0.01)
sep = Separator(
log_level=logging.WARNING,
model_file_dir=self.model_dir,
output_dir=job.out_dir,
normalization_threshold=1.0,
amplification_threshold=0.0,
use_autocast=device.type == "cuda" and job.fp16,
mdxc_params={"segment_size": 256, "override_model_segment_size": False,
"batch_size": 1, "overlap": job.overlap_int, "pitch_shift": 0},
)
# Separator picks "cuda" (current device) on its own; honour an explicit choice.
if device.type == "cpu":
sep.torch_device = torch.device("cpu")
elif device.index is not None:
torch.cuda.set_device(device.index)
sep.load_model(model_filename=job.model["file"])
self._sep, self._key = sep, key
return self._sep
def release(self):
self._sep = self._key = None
gc.collect()
torch.cuda.empty_cache()
def run(self, job, device):
from audio_separator.separator.architectures import mdxc_separator
job.report("Loading model", 0.02)
sep = self._get(job, device)
job.check()
captured = {}
def capture(stem_path, source, stem_name):
captured[stem_name] = source
return {stem_name: source}
inst = sep.model_instance
inst.final_process = capture
inst.output_dir = job.out_dir
# audio-separator reads with libsndfile, which rejects many MP3s (big ID3 tags) and
# FLACs with one damaged frame; ffmpeg decodes both, so hand it a clean WAV instead.
job.report("Decoding audio", 0.03)
os.makedirs(job.out_dir, exist_ok=True)
mix_path = os.path.join(job.out_dir, "mix.wav")
sf.write(mix_path, load_audio(job.file, inst.sample_rate, 2).T, inst.sample_rate, subtype="FLOAT")
job.check()
mdxc_separator.tqdm = _progress_iter_factory(job, 0.05, 0.93)
job.report("Separating", 0.05)
try:
sep.separate(mix_path)
finally:
os.remove(mix_path)
two_stem_model = len(job.model["stems"]) == 2
stems = {}
for name, source in captured.items():
key = name.lower()
if two_stem_model and key != "vocals":
key = "instrumental"
stems[key] = np.ascontiguousarray(source.T, dtype=np.float32)
return stems, inst.sample_rate
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more