Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -0,0 +1,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"]
|
||||
Executable
+77
@@ -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"
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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.
@@ -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 |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`).
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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.
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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
Reference in new issue
Block a user