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:
vicrodh committed 2026-07-28 15:23:44 -06:00
1 parent 5c631000b7
commit 4b34b70f70
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 -------------------------------------------------------
+170
View File
@@ -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()
}
}
+128
View File
@@ -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;
}
}
}
}