perf(qt): bring the immersive onto the pulse (83% -> 25% GPU)

- AlbumReactivePanel: drop the eight 100ms Behaviors on 30 Hz data (a
  retriggered Behavior animates at display rate and every animation
  frame is a full-window present), and read the settle — not the
  publishing bridge list — in the energy formulas (the .length guard
  re-evaluated the visual bindings on every FFT publish).
- LyricsSyncEngine: the fast cadence rides the pulse; the 250 ms idle
  re-check keeps its own timer.
- EqualizerBars: the phase accumulates on the pulse and the 'active'
  gate is host-provided (invisible subtrees still dirty the window).

Measured on the owner's machine, immersive reactive panel open:
132 -> ~33 presents/s, 83% -> ~25% GPU.

km
This commit is contained in:
vicrodh committed 2026-08-13 13:02:10 -06:00
1 parent f46c5c95d4
commit a2a99a72cc
4 files changed
+89 -39

No files matched your search

@@ -13,10 +13,12 @@
// player clearance(132) — keep it in sync with the paddings below (:106-112).
//
// FFT CONSUMPTION (trap 6): energy arrives through ONE VizSettle on
// QbzViz.energy — NEVER a Repeater.model on a QbzViz list. The visual
// settle is then reproduced by the 100ms eases on width/height/opacity/blur
// (:138-139,:160), exactly as the Slint panel reproduces the Svelte EMA with
// its 100ms `animate`s.
// QbzViz.energy — NEVER a Repeater.model on a QbzViz list. The scalars apply
// DIRECTLY at the pulse rate: the 100ms eases the Slint panel uses to
// reproduce the Svelte EMA (:138-139,:160) were dropped 2026-08-13 — a
// Behavior retriggered at 30 Hz animates at display rate, and every
// animation frame is a whole-window present (measured: 132 presents/s, 83%
// GPU, immersive open). The Rust EMA already provides the smoothing.
//
// SPEAKER-CONE PULSE: the art grows from its CENTER via animated
// width/height; x/y are PLAIN bindings ((parent - size)/2), NEVER animated —
@@ -51,25 +53,34 @@ Item {
live: root.visible && QbzImmersive.open
}
readonly property real globalEnergy: {
if (QbzViz.energy.length < 5)
// Read the SETTLE, never QbzViz.energy: a binding that reads the
// bridge list re-evaluates on every 30 Hz publish — OFF the pulse —
// and this one feeds geometry (artScale/glow), so each publish was a
// whole-window present of its own (measured: the immersive ran at
// 57 presents/s with 1 ms-apart pairs, 2026-08-13).
if (energySettle.to.length < 5)
return 0.0
return (energySettle.at(0) + energySettle.at(1) + energySettle.at(2)
+ energySettle.at(3) + energySettle.at(4)) / 5.0
}
readonly property real bassEnergy: {
if (QbzViz.energy.length < 2)
if (energySettle.to.length < 2)
return 0.0
return (energySettle.at(0) + energySettle.at(1)) / 2.0
}
// --- Reactive formulas (:62-66) ----------------------------------------
// NO 100ms Behaviors here (2026-08-13): a Behavior retriggered by 30 Hz
// data animates at DISPLAY rate, and every animation frame is a
// whole-window present — measured with the immersive open: ~132
// presents/s and 83% GPU, versus ~33/s when these apply directly. The
// Rust EMA (attack 0.7 / decay 0.65) already band-limits the stream to
// ~6 Hz, so the values step at the pulse rate and still read smooth.
// The Slint `animate 100ms` is unaffordable in Qt Quick — its ease only
// repaints the rect; ours presents the window.
readonly property real artScale: 1.0 + root.globalEnergy * 0.25
// NOT readonly: the 100ms Behavior below must attach (a Behavior on a
// readonly property is a load-time "Invalid property assignment").
property real glowSpread: 15.0 + root.bassEnergy * 200.0
Behavior on glowSpread { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
property real glowOpacity: Math.min(0.1 + root.globalEnergy * 1.5, 1.0)
Behavior on glowOpacity { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
// --- Base art box (:84-92) ---------------------------------------------
// Native source resolution — cap the RESTING art so the cover is never
@@ -113,6 +124,11 @@ Item {
height: root.baseArt * 1.25
// Static black art shadow 32/#00000080/8 (:157-159).
// NO 100ms width/height Behaviors (2026-08-13) — a Behavior
// retriggered by 30 Hz data animates at display rate, and
// each animation frame is a whole-window present. The art
// pulse steps at the pulse rate; see the note by the
// reactive formulas above.
Rectangle {
width: root.artSize
height: root.artSize
@@ -126,13 +142,12 @@ Item {
blurMax: 32
blur: 1.0
}
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
}
// Glow halo (:123-140): same size/position as the art, glow
// color + reactive opacity, blurred. x/y NOT animated (see
// the header's speaker-cone note).
// the header's speaker-cone note). No size Behaviors either
// (see the reactive-formulas note).
Rectangle {
width: root.artSize
height: root.artSize
@@ -147,19 +162,16 @@ Item {
blurMax: 64
blur: Math.min(1, root.glowSpread / 64)
}
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
}
// Artwork (:147-170). Grows from center via animated
// width/height; x/y plain bindings (lockstep, zero drift).
// Artwork (:147-170). Grows from center via width/height
// bindings applied at the pulse rate; x/y plain bindings
// (lockstep, zero drift). No Behaviors (see above).
Item {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
RoundedImage {
anchors.fill: parent
radius: 8
+34 -13
View File
@@ -9,19 +9,22 @@
// 0 / 0.25 / 0.125 / 0.375 (the Svelte animation-delays 0.0/0.2/0.1/0.3 of
// the 0.8s period, as fractions).
//
// The Slint loop is pull-based off animation-tick(); the Qt twin is ONE
// 800ms linear NumberAnimation on `t` (0->1), so the whole equalizer is a
// single animation node. The wave is the Slint formula verbatim:
// The Slint loop is pull-based off animation-tick(); the Qt twin accumulates
// `t` on the shared shell pulse (see the note at the property), so the whole
// equalizer ticks at the shell's 30 Hz instead of the display rate. The wave
// is the Slint formula verbatim:
// 0.3 + 0.7 * (0.5 - 0.5*cos((t+phase mod 1) * 2π))
// (Svelte keyframes 0/100% -> 0.3, 50% -> 1.0). The loop runs only while
// the item is visible — every mount site gates visibility on npPlaying
// (ImmersiveTrackInfo.slint:85,:100), so a paused player animates nothing.
// (Svelte keyframes 0/100% -> 0.3, 50% -> 1.0). The loop runs only while the
// item is visible AND the immersive is open — every mount site gates
// visibility on npPlaying (ImmersiveTrackInfo.slint:85,:100), so a paused
// player animates nothing.
// Reduce-motion: the seam EXISTS now — `QbzShell.reduceMotion`, built by the
// cortinilla-parity contract (§5.g). This surface is simply not a consumer
// yet; wiring it is a one-liner (throttle the tick when it is true) and its
// own change, not a side effect of someone else's commit.
import QtQuick
import com.blitzfc.qbz
Item {
id: root
@@ -34,14 +37,32 @@ Item {
implicitWidth: 18
implicitHeight: 14
// Loop phase 0..1, 800ms (ImmersiveTrackInfo.slint:32).
// Loop phase 0..1, 800ms (ImmersiveTrackInfo.slint:32). Advanced on the
// SHELL PULSE (QbzShell.pulseMs), not a NumberAnimation: an 800ms
// infinite animation is serviced by the animation driver at DISPLAY rate
// (180 Hz on the owner's panel) and every update is a whole-window
// present. The wave's period is 800 ms, so the 30 Hz pulse samples it
// ~24 times per loop — indistinguishable. Riding the pulse also means
// ZERO extra presents: the bars move in the same frame as everything
// else the pulse moves.
//
// The `active` gate belongs to the HOST, because this component mounts
// in always-alive-but-invisible trees (the immersive overlay) where a
// local `visible` check alone keeps ticking with the overlay closed —
// and invisible geometry changes still dirty the whole window (the
// 2026-08-13 SpectrumPanel finding). ImmersiveTrackMeta passes
// `QbzImmersive.open`; the track row passes its playing-row predicate.
// `_tickMs` must match settings_qt::shell_pulse_ms (default 33) — a
// mismatch only mis-speeds the loop, never the present rate.
property bool active: true
property real t: 0
NumberAnimation on t {
from: 0
to: 1
duration: 800
loops: Animation.Infinite
running: root.visible
readonly property int _tickMs: 33
Connections {
target: QbzShell
function onPulseMsChanged() {
if (root.active && root.visible)
root.t = (root.t + root._tickMs / 800.0) % 1.0
}
}
// Slint wave() verbatim (:34-36).
@@ -43,6 +43,9 @@ Column {
height: 14
EqualizerBars {
tint: root.equalizerTint
// The gate lives at the mount (EqualizerBars.qml explains
// why): this host sits inside the always-mounted ImmersiveView.
active: QbzImmersive.open
anchors.verticalCenter: parent.verticalCenter
}
Text {
+21 -7
View File
@@ -8,11 +8,14 @@
// the same `qbz_lyrics::sync` pure functions, over the same Rust-held parsed
// document, so native Qobuz wsync word stamps drive the fill).
//
// Cadence is ADAPTIVE, 1:1 with the Slint engine:
// - ACTIVE 33ms (~30Hz) while the gate is open (surface shown + doc synced
// + ready + effectively playing);
// - IDLE 250ms otherwise — the tick then only re-checks the gate.
// Every tick recomputes from the ABSOLUTE position, which makes seeks
// Cadence is ADAPTIVE:
// - ACTIVE: the shell pulse (QbzShell.pulseMs, ~30 Hz) while the gate is
// open and playback runs — NOT a private 33 ms Timer: a second 33 ms
// clock beats against the pulse and doubles the present rate (measured
// with the immersive open, 2026-08-13: 57 presents/s with 1 ms-apart
// pairs = the pulse and this timer landing back-to-back).
// - IDLE 250ms Timer otherwise — the tick then only re-checks the gate.
// Every pass recomputes from the ABSOLUTE position, which makes seeks
// inherently safe: `fillAnimMs` is zeroed across discontinuities (line change
// or backward move) so the fill first-paints exactly at the reported
// progress, and smoothed (45ms, just above the tick) while continuous.
@@ -50,11 +53,22 @@ Item {
width: 0
height: 0
// The ACTIVE cadence rides the shared pulse — one frame per period for
// the whole shell, lyrics fill included.
Connections {
target: QbzShell
function onPulseMsChanged() {
if (engine.gateOpen && engine._fast)
engine.pump(true)
}
}
// The IDLE cadence keeps its own slow timer: with nothing playing it
// only re-checks the gate, and 4/s of no-write ticks cost no frames.
Timer {
id: tick
interval: engine._fast ? 33 : 250
interval: 250
repeat: true
running: engine.gateOpen
running: engine.gateOpen && !engine._fast
onTriggered: engine.pump(true)
}