Files
qbz/crates/qbz-qt/qml/shell/SpectrumBand.qml
T
vicrodh 6beb1e695e build(qt): slash-normalise qrc aliases and find qsb.exe
qt-build-utils writes the collected path verbatim as the qrc alias, and
read_dir joins with a backslash on Windows, so none of the 1469 aliases
resolved: 1106 icons, 9 fonts and 21 shaders would each fail with
"Cannot open". Normalise only when the build host is Windows, since a
backslash is a legal filename character on Unix.

Also probe qsb.exe and QT_ROOT_DIR/bin, because Path::is_file never
appends .exe, and fix the SpectrumBand hand-bake comment, whose command
lacked --hlsl 50 and the GLES variants that build.rs actually passes.

cc
2026-08-26 23:06:20 -06:00

476 lines
21 KiB
QML

// 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 five render modes and their motion.
// QML port of the `spectrum := Rectangle` block in
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:122-183 (VBar / WaveCol).
//
// F3 RENDER PATH (2026-08-02) — ONE node, ONE draw call per mode. The modes
// used to be Repeaters of 28/48/5 Rectangle delegates: on macOS (Metal RHI)
// the per-frame batch churn of 28-48 height-mutating nodes was a measured
// slice of the ~2.5 ms render-thread frame (62 fps). Each mode is now ONE
// ShaderEffect — a single scene-graph node — whose fragment shader draws
// every bar itself: the bar index comes from the x coordinate, the gap
// geometry is per mode (bars = 1px-gapped columns, waveform = centred
// columns, energy = 5 wide bars), the height comes from scalar uniforms
// b0..bN (QML maps `property real bN` onto a GLSL uniform BY NAME — uniform
// arrays are NOT supported by ShaderEffect, hence N loose properties), and
// the fill is the SAME vertical topColor->bottomColor gradient plus the 1px
// radius the delegates had (rounded-rect SDF with a 1px AA edge, including
// Rectangle's clamp of r to half the short side). Heights are pushed in ONE
// JS pass per VizSettle application — the push hooks the exact same
// from/to/progress triplet the delegates' at(i) bindings used to capture —
// so a shell-pulse edge dirties ONE node instead of 28-48. No Canvas anywhere:
// Canvas is CPU raster and is banned on this path.
//
// THE SHADER IS A .qsb, NOT AN INLINE STRING. Qt 6's ShaderEffect takes the
// vertexShader/fragmentShader properties as URLs of .qsb files produced by
// the qsb tool — an inline GLSL string is parsed as a URL (the leading
// `#version` even becomes a URL fragment) and the effect dies with
// "Failed to deserialize QShader … status Error". The GLSL 440 sources live
// in qml/assets/shaders/spectrum_{bars,wave,energy}.frag (+ the shared
// spectrum.vert — Qt 6.11's built-in default vertex stage emits no
// qt_TexCoord0, so the program link fails without an explicit one) and the
// baked shaders next to them as .qsb (GLSL 440 for the Linux OpenGL RHI,
// MSL 1.2 for Metal, SPIR-V always embedded for Vulkan). Regenerate after
// ANY shader-source edit, from crates/qbz-qt:
// for m in bars wave energy; do \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 \
// -o qml/assets/shaders/spectrum_$m.frag.qsb \
// qml/assets/shaders/spectrum_$m.frag; \
// done
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 -b \
// -o qml/assets/shaders/spectrum.vert.qsb qml/assets/shaders/spectrum.vert
// (macOS: qsb is at $(brew --prefix qt)/bin/qsb.) A .qsb is keyed to the Qt
// Shader Tools version that baked it — rebake when the system Qt moves.
//
// FALLBACK ARM. ShaderEffect draws nothing where shaders do not exist —
// the software scene-graph backend (the Settings "Software" renderer,
// QT_QUICK_BACKEND=software, the offscreen/VNC platforms), detected exactly
// like theme/RoundedImage.qml via GraphicsInfo.api — or where the shader
// fails to load (ShaderEffect.status === Error; this fired HERE during
// development when the shader was an inline string, and the band kept
// rendering on the Repeater arm — a load failure must never blank the
// band). In both cases the ORIGINAL Repeater delegates take over (`model`
// flips from 0 back to N), so the band renders the same everywhere. The
// fallback is also what an offscreen/VNC smoke sees.
//
// PERF — notes 1-8 predate the shader arm. They now describe the FALLBACK
// Repeaters and the VizSettle feeding, which are byte-for-byte unchanged:
// 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. (On
// the shader arm the gradient is two vec4 uniforms mixed in GLSL; the
// Gradient object remains for the fallback Repeaters.)
// 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. (This is also what keeps an
// inactive mode's SHADER from loading: the Loader owns the effect.)
// 6. Capture itself is gated by the dock's vizShouldRun — a hidden band costs
// zero: the producer parks and the drain thread sleeps.
// 7. ONE JS binding per bar, not two (fallback arm). `y` used to be a DERIVED
// binding on `height`, so every height change evaluated two bindings per
// bar; a static anchor (bottom / verticalCenter) does the same arithmetic
// in C++ and evaluates nothing. On the shader arm the anchor math is one
// line of GLSL (`px.y - (bandH - h)` / `* 0.5`).
// 8. `at()` indexes a PLAIN JS ARRAY: VizSettle materialises each published
// QList_f32 frame once per publish (30/s) instead of letting every `at(i)`
// cross into the sequence wrapper twice per bar per tick. The waveform arm
// already builds a plain Array in its own `target` binding, so it is not
// copied twice (VizSettle branches on Array.isArray).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
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
// A stored scope choice survives a Software session, but the live band
// resolves it to Bars. The Rust renderer report publishes the same
// fallback; keeping it here makes the drawing arm safe during that
// report's event-loop turn too.
readonly property int effectiveMode: !QbzShell.gpuTier
&& QbzShell.largeSpectrumMode >= 3 ? 0 : QbzShell.largeSpectrumMode
visible: band.shown
radius: theme.radiusMd
color: "#59000000"
// No clip: the shader arm is anchors.fill, and the fallback bars take
// settle.at(i) hard-clamped to 0..1 (VizSettle.qml:86-91) against a
// hardcoded 42px band, so nothing can poke out.
// 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
// No-shader renderer detection, verbatim from theme/RoundedImage.qml (read
// its ARM SELECTION note): test Software/Null NEGATIVELY — under the RHI an
// OpenGL backend reports GraphicsInfo.OpenGL and Metal reports Metal, so
// "not software" is the only test that does not break the Mac path.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// ONE gradient instance shared by every fallback 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 && band.effectiveMode === 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
}
// ONE JS pass per VizSettle re-evaluation, writing the 14 heights
// into the shader's scalar uniforms (the shader does the 14->28
// mirroring). The trigger is the same dependency triplet the
// delegates' at(i) bindings captured: from, to, progress.
function push() {
for (var i = 0; i < 14; ++i)
barsFx["b" + i] = barsSettle.at(barsMode.activeBins[i]);
}
Connections {
target: barsSettle
// ONE handler since VizSettle eases instead of stepping: it
// applies into `cur` and the three-property from/to/progress
// triple is gone. Three handlers on properties that no longer
// exist is not a compile error — QML warns at RUNTIME and the
// Connections simply never fires, which is exactly how this
// band went dead in all three modes.
function onCurChanged() { barsMode.push() }
}
// Shader arm health: no shaders at all (software backend) or a
// rejected compile -> the fallback Repeater below renders.
readonly property bool fxOk: !band._noShaders && barsFx.status !== ShaderEffect.Error
ShaderEffect {
id: barsFx
anchors.fill: parent
visible: barsMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property color bottomColor: band.bottomColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
property real b5: 0.0
property real b6: 0.0
property real b7: 0.0
property real b8: 0.0
property real b9: 0.0
property real b10: 0.0
property real b11: 0.0
property real b12: 0.0
property real b13: 0.0
Component.onCompleted: barsMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: bars shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_bars.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_bars.frag.qsb"
// Paired vertex stage (Qt 6.11's built-in default emits no
// qt_TexCoord0); the GL link requires both stages to declare
// the SAME uniform block (see spectrum_bars.vert).
vertexShader: "../assets/shaders/spectrum_bars.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+1, width slot-2, bottom-anchored, radius 1.
Repeater {
model: barsMode.fxOk ? 0 : 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)
anchors.bottom: parent.bottom
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 && band.effectiveMode === 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;
}
}
// ONE JS pass per VizSettle re-evaluation (48 uniforms).
function push() {
for (var i = 0; i < 48; ++i)
waveFx["b" + i] = waveSettle.at(i);
}
Connections {
target: waveSettle
// ONE handler since VizSettle eases instead of stepping: it
// applies into `cur` and the three-property from/to/progress
// triple is gone. Three handlers on properties that no longer
// exist is not a compile error — QML warns at RUNTIME and the
// Connections simply never fires, which is exactly how this
// band went dead in all three modes.
function onCurChanged() { waveMode.push() }
}
readonly property bool fxOk: !band._noShaders && waveFx.status !== ShaderEffect.Error
ShaderEffect {
id: waveFx
anchors.fill: parent
visible: waveMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
property real b5: 0.0
property real b6: 0.0
property real b7: 0.0
property real b8: 0.0
property real b9: 0.0
property real b10: 0.0
property real b11: 0.0
property real b12: 0.0
property real b13: 0.0
property real b14: 0.0
property real b15: 0.0
property real b16: 0.0
property real b17: 0.0
property real b18: 0.0
property real b19: 0.0
property real b20: 0.0
property real b21: 0.0
property real b22: 0.0
property real b23: 0.0
property real b24: 0.0
property real b25: 0.0
property real b26: 0.0
property real b27: 0.0
property real b28: 0.0
property real b29: 0.0
property real b30: 0.0
property real b31: 0.0
property real b32: 0.0
property real b33: 0.0
property real b34: 0.0
property real b35: 0.0
property real b36: 0.0
property real b37: 0.0
property real b38: 0.0
property real b39: 0.0
property real b40: 0.0
property real b41: 0.0
property real b42: 0.0
property real b43: 0.0
property real b44: 0.0
property real b45: 0.0
property real b46: 0.0
property real b47: 0.0
Component.onCompleted: waveMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: waveform shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_wave.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_wave.frag.qsb"
// Paired vertex stage — same uniform block in both stages
// (the GL link requires it; see spectrum_wave.vert).
vertexShader: "../assets/shaders/spectrum_wave.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+1, width slot-2, vertically centred, flat topColor.
Repeater {
model: waveMode.fxOk ? 0 : 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)
anchors.verticalCenter: parent.verticalCenter
radius: 1
color: band.topColor
}
}
}
}
// Mode 2 — Energy: the 5 semantic bands (sub-bass .. air).
Loader {
anchors.fill: parent
active: band.shown && band.effectiveMode === 2
sourceComponent: Item {
id: energyMode
readonly property real slot: band.width / 5
VizSettle {
id: energySettle
target: QbzViz.energy
live: band.capturing
}
// ONE JS pass per VizSettle re-evaluation (5 uniforms).
function push() {
for (var i = 0; i < 5; ++i)
energyFx["b" + i] = energySettle.at(i);
}
Connections {
target: energySettle
// ONE handler since VizSettle eases instead of stepping: it
// applies into `cur` and the three-property from/to/progress
// triple is gone. Three handlers on properties that no longer
// exist is not a compile error — QML warns at RUNTIME and the
// Connections simply never fires, which is exactly how this
// band went dead in all three modes.
function onCurChanged() { energyMode.push() }
}
readonly property bool fxOk: !band._noShaders && energyFx.status !== ShaderEffect.Error
ShaderEffect {
id: energyFx
anchors.fill: parent
visible: energyMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property color bottomColor: band.bottomColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
Component.onCompleted: energyMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: energy shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_energy.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_energy.frag.qsb"
// Paired vertex stage — same uniform block in both stages
// (the GL link requires it; see spectrum_energy.vert).
vertexShader: "../assets/shaders/spectrum_energy.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+3, width slot-6, bottom-anchored, radius 1.
Repeater {
model: energyMode.fxOk ? 0 : 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)
anchors.bottom: parent.bottom
radius: 1
gradient: barGradient
}
}
}
}
// Modes 3/4 — real-time scopes. Each Loader owns a single scene-graph
// line strip; when inactive the item does not exist and Rust also disables
// that scope's DSP bit.
Loader {
anchors.fill: parent
active: band.shown && band.effectiveMode === 3
sourceComponent: ScopePanel {
compact: true
scopeMode: 0
traceColor: band.topColor
}
}
Loader {
anchors.fill: parent
active: band.shown && band.effectiveMode === 4
sourceComponent: ScopePanel {
compact: true
scopeMode: 1
traceColor: band.topColor
}
}
// Click the band → cycle all five modes on GPU, the three compatible
// modes on Software (persisted by the Rust handler).
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.largeCycleSpectrum()
}
}