perf(qbz-qt): interpolate the spectrum band instead of raising the FFT rate
The band moved at the producer's 30 Hz. Raising TARGET_FPS would have been the obvious knob, but it lives in qbz-audio and is shared with Slint, and the amplitudes are already EMA-smoothed in Rust to roughly 6 Hz — a faster producer would have bought CPU and no smoothness. The published frames are now replayed across the render frames that follow them, which is what Slint's dock gets from its per-bar 90ms animate. A late publish degrades to a hold instead of a jump, and the motion runs at the display's rate rather than the data's. Splits the strip out of the dock into SpectrumBand, so the dock file owns only the gating and the layout arithmetic. cc
This commit is contained in:
5 files changed
+314
-124
No files matched your search
@@ -17,22 +17,9 @@
|
||||
// `height - largeDockHeight`. Change the arithmetic in one place and the
|
||||
// cover slides off the window edge.
|
||||
//
|
||||
// PERF — this band redraws 30x/s next to a full-window ambient canvas, so
|
||||
// every one of these matters:
|
||||
// 1. The FFT streams REPLACE their QList each tick, so a Repeater bound to
|
||||
// QbzViz.bars would rebuild every delegate every frame. The Repeaters use
|
||||
// STATIC models (column counts) and bind only each bar's HEIGHT.
|
||||
// 2. Each mode caches the list in ONE `var` property per tick. Reading
|
||||
// QbzViz.bars straight from 28 delegates would marshal the QList into a
|
||||
// fresh JS array 28 times a frame (48x512 floats for Waveform — the
|
||||
// expensive one).
|
||||
// 3. ONE shared Gradient for every bar, not one object per delegate.
|
||||
// 4. No per-bar Behavior: the producer already smooths and clamps in Rust
|
||||
// (qbz-audio), so 28 concurrent QML animations would buy nothing.
|
||||
// 5. Only the ACTIVE mode is instantiated (`active:` Loaders) and viz_qt.rs
|
||||
// only publishes the stream that mode consumes.
|
||||
// 6. The capture tap is gated by vizShouldRun — a hidden band costs zero:
|
||||
// the producer parks and the drain thread sleeps.
|
||||
// The spectrum strip itself lives in SpectrumBand.qml (render modes + motion);
|
||||
// its perf contract is documented there. This file owns the gating and the
|
||||
// layout arithmetic only.
|
||||
//
|
||||
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
|
||||
// focus — a tiling desktop keeps windows visible and unfocused.
|
||||
@@ -86,118 +73,13 @@ Item {
|
||||
}
|
||||
|
||||
// ---- Spectrum band -----------------------------------------------------
|
||||
Rectangle {
|
||||
id: band
|
||||
visible: root.bandOn
|
||||
SpectrumBand {
|
||||
x: 0
|
||||
y: root.padTop
|
||||
width: root.width
|
||||
height: root.bandHeight
|
||||
radius: theme.radiusMd
|
||||
color: "#59000000"
|
||||
clip: true
|
||||
|
||||
// Album-derived gradient, reusing the ambient triad (ambient_qt.rs)
|
||||
// instead of a second album-color pipeline — the Slint dock reads
|
||||
// ImmersiveState.spectrum-primary/secondary, which is the same idea.
|
||||
readonly property color topColor: QbzShell.ambientPrimary
|
||||
readonly property color bottomColor: QbzShell.ambientSecondary
|
||||
|
||||
// ONE gradient instance shared by every bar (perf note 3).
|
||||
Gradient {
|
||||
id: barGradient
|
||||
GradientStop { position: 0.0; color: band.topColor }
|
||||
GradientStop { position: 1.0; color: band.bottomColor }
|
||||
}
|
||||
|
||||
// Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the
|
||||
// 16-bin FFT leaves {1, 15} empty; SpectrumPanel.slint parity).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: QbzShell.largeSpectrumMode === 0
|
||||
sourceComponent: Item {
|
||||
id: barsMode
|
||||
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
readonly property real slot: band.width / 28
|
||||
// Marshal the QList into JS ONCE per tick (perf note 2).
|
||||
readonly property var values: QbzViz.bars
|
||||
Repeater {
|
||||
model: 28
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
// Mirror: columns 14..27 replay 13..0.
|
||||
readonly property int bin: index < 14
|
||||
? barsMode.activeBins[index]
|
||||
: barsMode.activeBins[27 - index]
|
||||
readonly property real amp: Math.max(0, Math.min(1, barsMode.values[bin] || 0))
|
||||
x: index * barsMode.slot + 1
|
||||
width: barsMode.slot - 2
|
||||
height: Math.max(2, amp * band.height)
|
||||
y: band.height - height
|
||||
radius: 1
|
||||
gradient: barGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1 — Waveform: 48 downsampled L columns, mirrored about the
|
||||
// band's vertical centre (the L half is samples 0..255).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: QbzShell.largeSpectrumMode === 1
|
||||
sourceComponent: Item {
|
||||
id: waveMode
|
||||
readonly property real slot: band.width / 48
|
||||
// 512 floats — marshalling this per delegate would be the
|
||||
// single most expensive thing in the dock (perf note 2).
|
||||
readonly property var values: QbzViz.waveform
|
||||
Repeater {
|
||||
model: 48
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
readonly property real amp: Math.min(1, Math.abs(waveMode.values[index * 5] || 0))
|
||||
x: index * waveMode.slot + 1
|
||||
width: waveMode.slot - 2
|
||||
height: Math.max(2, amp * band.height)
|
||||
y: (band.height - height) / 2
|
||||
radius: 1
|
||||
color: band.topColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 2 — Energy: the 5 semantic bands (sub-bass .. air).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: QbzShell.largeSpectrumMode === 2
|
||||
sourceComponent: Item {
|
||||
id: energyMode
|
||||
readonly property real slot: band.width / 5
|
||||
readonly property var values: QbzViz.energy
|
||||
Repeater {
|
||||
model: 5
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
readonly property real amp: Math.max(0, Math.min(1, energyMode.values[index] || 0))
|
||||
x: index * energyMode.slot + 3
|
||||
width: energyMode.slot - 6
|
||||
height: Math.max(2, amp * band.height)
|
||||
y: band.height - height
|
||||
radius: 1
|
||||
gradient: barGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click the band → cycle Bars -> Waveform -> Energy (persisted).
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.largeCycleSpectrum()
|
||||
}
|
||||
shown: root.bandOn
|
||||
capturing: root.vizShouldRun
|
||||
}
|
||||
|
||||
// ---- Album cover -------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SpectrumBand — the compact 42px spectrum strip above the Large-NPB cover.
|
||||
// Split out of SidebarNowPlayingDock.qml: the dock owns the layout arithmetic
|
||||
// and the cover, this file owns the three render modes and their motion.
|
||||
// QML port of the `spectrum := Rectangle` block in
|
||||
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:122-183 (VBar / WaveCol).
|
||||
//
|
||||
// PERF — this strip redraws next to a full-window ambient canvas, so:
|
||||
// 1. The FFT streams REPLACE their QList each publish, so a Repeater bound to
|
||||
// QbzViz.bars would rebuild every delegate every frame. The Repeaters use
|
||||
// STATIC models (column counts) and bind only each bar's HEIGHT.
|
||||
// 2. Each mode marshals its QList into JS ONCE per publish (a `target`
|
||||
// binding), never once per delegate — the 512-float waveform read is the
|
||||
// single most expensive thing in the dock.
|
||||
// 3. ONE shared Gradient for every bar, not one object per delegate.
|
||||
// 4. Motion comes from ONE VizSettle driver per mode, NOT 28 per-bar
|
||||
// Behaviors (the Slint dock animates each bar; one driver is the cheap
|
||||
// equivalent — see VizSettle.qml for the full argument).
|
||||
// 5. Only the ACTIVE mode is instantiated, and only while the band is shown:
|
||||
// hiding it unloads the delegates, and viz_qt.rs only ever publishes the
|
||||
// one stream the visible mode consumes.
|
||||
// 6. Capture itself is gated by the dock's vizShouldRun — a hidden band costs
|
||||
// zero: the producer parks and the drain thread sleeps.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: band
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Shown/hidden by the cover's eye toggle (dock-owned).
|
||||
property bool shown: true
|
||||
// Mirrors the dock's capture gate: false = the stream is frozen, so the
|
||||
// interpolator must park too.
|
||||
property bool capturing: false
|
||||
|
||||
visible: band.shown
|
||||
radius: theme.radiusMd
|
||||
color: "#59000000"
|
||||
clip: true
|
||||
|
||||
// Album-derived gradient, reusing the ambient triad (ambient_qt.rs) instead
|
||||
// of a second album-color pipeline — the Slint dock reads
|
||||
// ImmersiveState.spectrum-primary/secondary, which is the same idea.
|
||||
readonly property color topColor: QbzShell.ambientPrimary
|
||||
readonly property color bottomColor: QbzShell.ambientSecondary
|
||||
|
||||
// ONE gradient instance shared by every bar (perf note 3).
|
||||
Gradient {
|
||||
id: barGradient
|
||||
GradientStop { position: 0.0; color: band.topColor }
|
||||
GradientStop { position: 1.0; color: band.bottomColor }
|
||||
}
|
||||
|
||||
// Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the 16-bin
|
||||
// FFT leaves {1, 15} empty; SpectrumPanel.slint parity).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: band.shown && QbzShell.largeSpectrumMode === 0
|
||||
sourceComponent: Item {
|
||||
id: barsMode
|
||||
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
readonly property real slot: band.width / 28
|
||||
|
||||
// Marshal the QList into JS ONCE per publish (perf note 2). Already
|
||||
// clamped 0..1 in Rust (processor.rs map_to_log_bars).
|
||||
VizSettle {
|
||||
id: barsSettle
|
||||
target: QbzViz.bars
|
||||
live: band.capturing
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: 28
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
// Mirror: columns 14..27 replay 13..0.
|
||||
readonly property int bin: index < 14
|
||||
? barsMode.activeBins[index]
|
||||
: barsMode.activeBins[27 - index]
|
||||
x: index * barsMode.slot + 1
|
||||
width: barsMode.slot - 2
|
||||
height: Math.max(2, barsSettle.at(bin) * band.height)
|
||||
y: band.height - height
|
||||
radius: 1
|
||||
gradient: barGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1 — Waveform: 48 downsampled L columns, mirrored about the band's
|
||||
// vertical centre (the L half is samples 0..255).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: band.shown && QbzShell.largeSpectrumMode === 1
|
||||
sourceComponent: Item {
|
||||
id: waveMode
|
||||
readonly property real slot: band.width / 48
|
||||
|
||||
VizSettle {
|
||||
id: waveSettle
|
||||
live: band.capturing
|
||||
// The |sample| envelope is folded in HERE, once per publish (48
|
||||
// ops), instead of an abs+min per column per rendered frame.
|
||||
target: {
|
||||
var s = QbzViz.waveform;
|
||||
var out = new Array(48);
|
||||
for (var i = 0; i < 48; ++i) {
|
||||
var v = s[i * 5] || 0;
|
||||
if (v < 0) v = -v;
|
||||
out[i] = v > 1 ? 1 : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: 48
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
x: index * waveMode.slot + 1
|
||||
width: waveMode.slot - 2
|
||||
height: Math.max(2, waveSettle.at(index) * band.height)
|
||||
y: (band.height - height) / 2
|
||||
radius: 1
|
||||
color: band.topColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 2 — Energy: the 5 semantic bands (sub-bass .. air).
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: band.shown && QbzShell.largeSpectrumMode === 2
|
||||
sourceComponent: Item {
|
||||
id: energyMode
|
||||
readonly property real slot: band.width / 5
|
||||
|
||||
VizSettle {
|
||||
id: energySettle
|
||||
target: QbzViz.energy
|
||||
live: band.capturing
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: 5
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
x: index * energyMode.slot + 3
|
||||
width: energyMode.slot - 6
|
||||
height: Math.max(2, energySettle.at(index) * band.height)
|
||||
y: band.height - height
|
||||
radius: 1
|
||||
gradient: barGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click the band → cycle Bars -> Waveform -> Energy (persisted).
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.largeCycleSpectrum()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// VizSettle — ONE shared inter-frame interpolator for a whole visualizer stream.
|
||||
//
|
||||
// WHY THIS EXISTS. The FFT producer in qbz-audio publishes at TARGET_FPS = 30
|
||||
// and the drain is signal-driven (one publish per produced frame), so the bar
|
||||
// heights STEP 30 times a second while the compositor draws 60+. The step is
|
||||
// what reads as "low frame rate" — not missing data (see the bandwidth note
|
||||
// below). This object replays each 30 Hz frame across the render frames that
|
||||
// follow it, so the bars move at the display rate off 30 Hz data.
|
||||
//
|
||||
// PARITY: the Slint dock does the same thing per bar —
|
||||
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:45
|
||||
// `animate bar-h { duration: 90ms; easing: ease; }` (VBar)
|
||||
// `animate col-h { duration: 70ms; easing: ease; }` (WaveCol)
|
||||
// The QML port dropped that as "28 concurrent animations would buy nothing";
|
||||
// what it actually bought was the smooth motion. This restores the behaviour
|
||||
// with ONE driver for the whole stream instead of one animation per bar.
|
||||
//
|
||||
// WHY NOT A HIGHER PRODUCER RATE. qbz-audio smooths the bars in Rust with a
|
||||
// per-frame EMA (attack 0.7 / decay 0.65, processor.rs:174-184). At 30 fps the
|
||||
// decay time constant is 33.3ms / -ln(0.65) = 77ms → -3dB at 2.1Hz; the fast
|
||||
// attack is 28ms → 5.8Hz. The published signal therefore carries nothing above
|
||||
// ~6Hz, and 30 Hz sampling is already >5x oversampled: doubling TARGET_FPS adds
|
||||
// no information, it only halves those time constants (a re-tune, not a frame
|
||||
// rate) and doubles the FFT cost — for BOTH frontends, since the constant is
|
||||
// shared. Interpolating is the correct fix for a step artefact.
|
||||
//
|
||||
// COST CONTRACT.
|
||||
// - Zero allocation per rendered frame: `from`/`to` are REFERENCE SWAPS of the
|
||||
// arrays the bridge already marshals once per publish. Only `progress`
|
||||
// changes per frame.
|
||||
// - SELF-PARKING, exactly like the Rust drain: the driver starts on a publish
|
||||
// and stops itself the moment `progress` reaches 1. A paused player, a hidden
|
||||
// band or a disabled tap publishes nothing, so the driver never runs and the
|
||||
// render loop goes back to sleep. No idle wakeups.
|
||||
// - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and `progress`
|
||||
// unconditionally on every path — QML captures those three as dependencies,
|
||||
// so a `progress` change re-evaluates the bar heights and instantiates
|
||||
// nothing.
|
||||
//
|
||||
// LATENCY: the band renders between the last two published frames, i.e. one
|
||||
// producer period (33ms) behind the tap. Against the pipeline that already
|
||||
// exists (46ms FFT window + 77ms smoothing) that is not perceptible, and it is
|
||||
// what buys motion with no overshoot and no extra damping.
|
||||
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: settle
|
||||
|
||||
// ---- input -------------------------------------------------------------
|
||||
// Newest published frame, DISPLAY-READY (0..1). Replaced ~30x/s. Whatever
|
||||
// per-sample mapping a mode needs (abs, downsample) belongs in the binding
|
||||
// that feeds this, so it runs once per publish instead of once per bar per
|
||||
// frame.
|
||||
property var target: []
|
||||
// Master gate — mirror the band's capture gate. false freezes on the last
|
||||
// frame and guarantees the driver is stopped.
|
||||
property bool live: true
|
||||
|
||||
// ---- output (read via at(i)) -------------------------------------------
|
||||
property var from: []
|
||||
property var to: []
|
||||
property real progress: 1.0
|
||||
|
||||
// Publish period estimate, ms. Seeded at 1000/TARGET_FPS and tracked with a
|
||||
// slow EMA so a producer that drifts off 30 Hz still lands on time.
|
||||
property real periodMs: 33.3
|
||||
property real lastTickMs: 0
|
||||
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
// Interpolated, clamped amplitude for bar `i`. Reads all three outputs on
|
||||
// every path — do NOT add an early return, it would drop the dependency.
|
||||
function at(i) {
|
||||
var a = settle.from[i] || 0;
|
||||
var b = settle.to[i] || 0;
|
||||
var v = a + (b - a) * settle.progress;
|
||||
return v < 0 ? 0 : (v > 1 ? 1 : v);
|
||||
}
|
||||
|
||||
onTargetChanged: {
|
||||
if (!settle.live) {
|
||||
settle.from = settle.target;
|
||||
settle.to = settle.target;
|
||||
settle.progress = 1.0;
|
||||
return;
|
||||
}
|
||||
var now = Date.now();
|
||||
var dt = now - settle.lastTickMs;
|
||||
// Ignore the resume/first-frame gap and any absurd outlier; 8..200ms is
|
||||
// 5..125 fps, everything a sane producer can emit.
|
||||
if (dt > 8 && dt < 200)
|
||||
settle.periodMs = settle.periodMs * 0.8 + dt * 0.2;
|
||||
// Reference swap — no copy, no allocation.
|
||||
settle.from = settle.to;
|
||||
settle.to = settle.target;
|
||||
settle.lastTickMs = now;
|
||||
settle.progress = 0.0;
|
||||
if (!driver.running)
|
||||
driver.running = true;
|
||||
}
|
||||
|
||||
onLiveChanged: {
|
||||
if (!settle.live) {
|
||||
driver.running = false;
|
||||
settle.progress = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Wall clock, not the driver's elapsedTime: elapsedTime only advances on a
|
||||
// rendered frame, so using it would quantise the segment start to vsync and
|
||||
// re-introduce the beat we are removing.
|
||||
FrameAnimation {
|
||||
id: driver
|
||||
running: false
|
||||
onTriggered: {
|
||||
var p = (Date.now() - settle.lastTickMs) / settle.periodMs;
|
||||
if (p >= 1.0) {
|
||||
settle.progress = 1.0;
|
||||
driver.running = false; // park until the next publish
|
||||
} else {
|
||||
settle.progress = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user