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

+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()
}
}