Files
qbz/crates/qbz-qt/qml/shell/VizSettle.qml
T
vicrodh 931cb06d38 feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.

FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.

So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.

The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.

PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).

Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.

The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.

But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.

The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.

Release build clean, offscreen smoke green, 421/421.

cc
2026-08-19 13:53:57 -06:00

208 lines
9.8 KiB
QML

// VizSettle — ONE shared frame-applier for a whole visualizer stream.
//
// WHAT CHANGED 2026-08-13 (the single-pulse redesign). This object used to
// REPLAY each 30 Hz published frame across the render frames that followed
// it, driven by a private 16 ms Timer (~62 Hz) — the QML answer to the Slint
// dock's per-bar `animate bar-h { 90ms }` (SidebarNowPlayingDock.slint:45).
// That driver was the shell's dominant GPU term: Qt Quick has no dirty-region
// rendering, so every tick repainted the WHOLE window, and 62 ticks/s against
// the atmosphere's own 33 ms Timer — unsynchronised — is what the
// 2026-08-11 GPU investigation measured as ~62 presents/s, render = 2 ms,
// swap = 12-24 ms, 93-97% GPU on the owner's 4070. Interpolating between 30
// Hz publishes is a luxury that only pays when the repaint is a rectangle;
// when the repaint is the window, the spectrum moves at the rate the data
// arrives and that is the deal (GPU-COST-INVESTIGATION.md §4).
//
// THE CONTRACT NOW:
// - A publish (onTargetChanged) STASHES the frame in a plain JS slot and
// writes NO bound property — a publish alone never schedules a frame.
// - The shared shell pulse (QbzShell.pulseMs, shell_bridge.rs) is the ONLY
// driver. On its edge, and only if a frame is pending, the latest frame is
// applied: `from`/`to` swap and every `at(i)` binding re-evaluates in the
// SAME event-loop turn as every other pulse consumer, so the window
// presents ONCE per period for the whole shell.
// - SELF-PARKING is structural: a paused player, a hidden band or a
// disabled tap publishes nothing (viz_qt.rs), nothing goes pending, and
// the pulse handler writes nothing — zero frames, zero timers to park.
// The `live` gate below — fed by the dock's `vizShouldRun`, which drops
// when the window deactivates — keeps an occluded window at zero
// repaints. Do not weaken it.
// - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and
// `progress` unconditionally on every path — QML captures those three as
// dependencies, so a pulse re-evaluates the bar heights and instantiates
// nothing.
//
// SMOOTHNESS NOTE, CORRECTED 2026-08-19. This paragraph used to argue that
// dropping the interpolator did not cost smoothness, because qbz-audio already
// band-limits the bars in Rust (attack 0.7 / decay 0.65,
// processor.rs:174-184). That reasoning was wrong about what the eye reads: a
// band-limited signal APPLIED AS A STEP still moves in visible jumps, and the
// owner's side-by-side against the Slint build named it exactly — "no es que
// se vea mas rapido, la transicion entre estados es mas suave". The
// interpolation is back, on the pulse; see `easeK`.
//
// LATENCY: a frame is applied on the first pulse after its publish, i.e. up
// to one period (33 ms) behind the tap. Against the pipeline that already
// exists (46 ms FFT window + 77 ms smoothing) that is not perceptible.
import QtQuick
import com.blitzfc.qbz
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 = frozen: the
// publish path writes NOTHING (an invisible consumer's writes still dirty
// the whole window — see onTargetChanged), the pulse handler returns
// early, and the last applied frame holds.
property bool live: true
// ---- output (read via at(i)) -------------------------------------------
//
// ONE applied array, eased toward the newest published frame. It replaced
// a from/to/progress triple that only ever held progress = 1.0, i.e. a
// step — see the EASING note above `easeK`.
property var cur: []
/// Newest published frame the ease is chasing.
property var tgt: null
/// PING-PONG buffers. Assigning a property a DIFFERENT array reference is
/// what notifies QML; mutating one in place does not. Two persistent
/// buffers give both — a new reference every pulse, and no allocation
/// after the first two.
readonly property var bufs: ({ a: [], b: [], flip: false })
// The stash. `property var` because QML object state has no other home;
// NOTHING binds to it, so writing it emits a notify nobody hears and —
// the whole point — schedules no frame.
property var pending: null
visible: false
width: 0
height: 0
// EASING — the transition between states, which is what reads as
// "fluid", and which is NOT the frame rate.
//
// Measured 2026-08-19, and it falsified the standing assumption: driven at
// QBZ_PULSE_MS=10 the shell presented ~100 frames/s (qt-batches confirmed
// it) and the bars looked exactly as they had at 30. They would: each
// published frame was applied as a STEP, so the extra presents repainted
// the same discrete values three times over. What Slint does differently
// is not rate, it is `animate bar-h { 90ms }` — it moves BETWEEN values.
//
// Restoring that costs NOTHING here, and that is the whole point. The old
// interpolator was expensive because of its private 16 ms Timer, not
// because of the interpolation: it presented at its own rate, unsynchronised
// with everything else. Easing ON THE PULSE presents exactly as often as
// stepping on the pulse did — one repaint per period either way. Only the
// VALUE differs.
//
// The price is LAG, and it is the honest one: an exponential ease at
// k = 0.45 is ~83% of the way there in 3 pulses (~100 ms), matching the
// reference's 90 ms. Against the 46 ms FFT window and 77 ms of Rust
// smoothing already in the chain, it is the same order as what is already
// accepted.
property real easeK: 0.45
// Applied amplitude for bar `i`. Reads `cur` on every path — do NOT add an
// early return, it would drop the dependency.
function at(i) {
var v = settle.cur[i] || 0;
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
onTargetChanged: {
if (!settle.live) {
// FROZEN — write NOTHING. The 2026-08-13 measurement that earns
// this early return: an invisible consumer (the immersive panels
// stay mounted while the overlay is closed) that writes from/to
// on every publish still re-evaluates its delegates' geometry
// bindings, and an invisible item's geometry change marks the
// WHOLE WINDOW dirty — 28 bars x 30 Hz of phantom presents,
// unsynchronised with the pulse (qt.quick.dirty named the nodes).
// The last applied frame simply holds; the next live edge picks
// up whatever publishes after it.
return;
}
// Plain-JS copy, ONCE per publish (30/s), so `at()` indexes a JS array
// instead of crossing into the QList_f32 sequence wrapper per bar.
// The waveform arm ALREADY hands over a plain Array (it folds |sample|
// in its own binding, SpectrumBand.qml:108-117), so skip the copy
// there instead of paying a second 48-element pass per publish.
var t = settle.target;
var arr;
if (Array.isArray(t)) {
arr = t;
} else {
var n = t ? t.length : 0;
arr = new Array(n);
for (var i = 0; i < n; ++i)
arr[i] = t[i];
}
settle.pending = arr;
}
onLiveChanged: {
if (!settle.live) {
settle.pending = null;
settle.tgt = null;
}
}
// THE driver: the shell's shared repaint pulse. Every consumer of this
// edge (the ambient background drift included) dirties the scene in this
// same event-loop turn, and the window presents once for all of them.
// Writing nothing when there is no pending frame is what keeps a silent
// or occluded shell at zero presents — the pulse NOTIFY alone never
// schedules a repaint.
Connections {
target: QbzShell
function onPulseMsChanged() {
if (!settle.live)
return;
// Adopt the newest publish as the TARGET; the ease below is what
// actually moves. A publish arriving mid-ease simply moves the
// target — no from/to bookkeeping, no discontinuity.
if (settle.pending !== null) {
settle.tgt = settle.pending;
settle.pending = null;
}
var t = settle.tgt;
if (t === null)
return;
var n = t.length;
if (n === 0)
return;
var src = settle.cur;
var dst = settle.bufs.flip ? settle.bufs.a : settle.bufs.b;
if (dst.length !== n)
dst.length = n;
var k = settle.easeK;
var moved = false;
for (var i = 0; i < n; ++i) {
var a = src[i] || 0;
var d = t[i] - a;
// SELF-PARKING, preserved. Once the ease has converged the
// values stop changing, this writes nothing and the pulse
// schedules no repaint — the same zero-presents-when-silent
// property the step version had. Without this guard a held
// signal would repaint forever chasing a target it already
// reached.
if (d > 0.0005 || d < -0.0005)
moved = true;
dst[i] = a + d * k;
}
if (!moved)
return;
settle.bufs.flip = !settle.bufs.flip;
settle.cur = dst;
}
}
}