From a732e1f9d2ceaf39a9467f52cf96f45a48ef6a1c Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 23:12:42 +0200 Subject: [PATCH] perf(tuner): idle the always-on tuner viz rAF when there's no signal (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 home tuner card runs continuously, and every tuner visualization drove a self-rescheduling 60fps requestAnimationFrame loop that never stopped — pinning a renderer core even on a silent home screen with the needle/strobe at rest. Make each viz idle its loop once there's nothing left to animate, and re-kick it from update() on the next reading that actually moves it: - analogue-gauge: stop when the needle + drum strip have settled on their targets (|target-current| below a sub-visible epsilon); restart when a new reading moves the target. - strobe / mace-fx-iii / chef-mt3: stop when there's no live signal and the strobe drift (and glow fade) have fully decayed; restart on the next note. - toilet-tuner: stop when silent and the plunger has eased back to centre; restart on the next reading (guarded so repeated no-signal updates don't re-kick a parked loop). Active tuning is unchanged — the loop runs whenever a note is sounding or the indicator is still moving. Bumps tuner 1.3.1 -> 1.3.2. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/tuner/plugin.json | 2 +- plugins/tuner/visualization/analogue-gauge.js | 29 +++++++++++++++++++ plugins/tuner/visualization/chef-mt3.js | 13 +++++++++ plugins/tuner/visualization/mace-fx-iii.js | 10 +++++++ plugins/tuner/visualization/strobe.js | 12 ++++++++ plugins/tuner/visualization/toilet-tuner.js | 21 ++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/plugins/tuner/plugin.json b/plugins/tuner/plugin.json index 02a779b..93830da 100644 --- a/plugins/tuner/plugin.json +++ b/plugins/tuner/plugin.json @@ -1,7 +1,7 @@ { "id": "tuner", "name": "Guitar/Bass Tuner", - "version": "1.3.1", + "version": "1.3.2", "bundled": true, "private": false, "script": "screen.js", diff --git a/plugins/tuner/visualization/analogue-gauge.js b/plugins/tuner/visualization/analogue-gauge.js index 4605a42..e0783d6 100644 --- a/plugins/tuner/visualization/analogue-gauge.js +++ b/plugins/tuner/visualization/analogue-gauge.js @@ -18,6 +18,8 @@ // ── Constants ───────────────────────────────────────────────────── var _TUNER_LABEL_H = 12; // px height of each drum label var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart) + var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible) + var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold var _TUNER_IN_TUNE_THRESHOLD = 2; var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6 @@ -321,9 +323,34 @@ currentAngle += (targetAngle - currentAngle) * lf; _setNeedle(currentAngle); + // Stop once the needle has settled on its target — a static needle + // needs no repaint. update() re-kicks the loop when a new reading + // moves the target, so this idles the always-on tuner (no signal / + // steady pitch) instead of pinning a core at 60 fps forever. + if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y + && Math.abs(targetAngle - currentAngle) <= _SETTLE_A) { + currentDrumY = targetDrumY; currentAngle = targetAngle; + freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; + noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; + _setNeedle(currentAngle); + rafId = null; + return; + } rafId = requestAnimationFrame(_animate); } + // Restart the loop only when there's actually something to animate toward + // (a new target). Reset lastTime so the first frame after an idle gap + // doesn't take one big easing step. + function _kick() { + if (rafId === null + && (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y + || Math.abs(targetAngle - currentAngle) > _SETTLE_A)) { + lastTime = performance.now(); + rafId = requestAnimationFrame(_animate); + } + } + rafId = requestAnimationFrame(_animate); // ── Public API ──────────────────────────────────────────────── @@ -360,6 +387,7 @@ bulbEl.style.backgroundColor = '#2a1010'; bulbEl.style.border = '2px solid #4a2020'; bulbEl.style.boxShadow = 'none'; + _kick(); // animate back to rest, then the loop self-stops return; } @@ -376,6 +404,7 @@ bulbEl.style.border = '2px solid #4a2020'; bulbEl.style.boxShadow = 'none'; } + _kick(); // a new reading moved the target → run until it settles } function destroy() { diff --git a/plugins/tuner/visualization/chef-mt3.js b/plugins/tuner/visualization/chef-mt3.js index 19121cc..7732980 100644 --- a/plugins/tuner/visualization/chef-mt3.js +++ b/plugins/tuner/visualization/chef-mt3.js @@ -620,8 +620,20 @@ if (_mt3Mode === 'strobe') { _computeStrobeStates(); } _applyTickStates(); + + // No signal and both the glow and strobe drift have fully settled → + // idle the loop. update() re-kicks it on the next note. + if (!_mt3HasSignal && _mt3GlowOpacity < 0.004 + && Math.abs(_mt3SmoothedCents) <= 0.1) { + _mt3RafId = null; + _mt3LastTime = null; + return; + } _mt3RafId = requestAnimationFrame(_animateStrobe); } + function _kick() { + if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); } + } _mt3RafId = requestAnimationFrame(_animateStrobe); // ── MODE button ─────────────────────────────────────────────── @@ -677,6 +689,7 @@ _renderNote(' '); _applyAccidental(); } + if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled } // ── Public: destroy ─────────────────────────────────────────── diff --git a/plugins/tuner/visualization/mace-fx-iii.js b/plugins/tuner/visualization/mace-fx-iii.js index 8c3a23b..3ca5244 100644 --- a/plugins/tuner/visualization/mace-fx-iii.js +++ b/plugins/tuner/visualization/mace-fx-iii.js @@ -354,10 +354,19 @@ if (_smoothedCents > 0) { speed = -speed; } _strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash; arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset)); + } else if (_currentCents === 0) { + // Fully decelerated and no live signal → idle the loop instead of + // rescheduling forever. update() re-kicks it on the next note. + _rafId = null; + _lastTime = null; + return; } _rafId = requestAnimationFrame(_animateStrobe); } + function _kick() { + if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); } + } _rafId = requestAnimationFrame(_animateStrobe); // ── Helper: derive octave number from frequency ─────────────── @@ -439,6 +448,7 @@ // Strobe state — smoothed animation decelerates naturally when _currentCents → 0 _currentCents = hasNote ? cents : 0; + if (hasNote) { _kick(); } // new signal → restart the decel loop if idled } // ── Public: destroy ─────────────────────────────────────────── diff --git a/plugins/tuner/visualization/strobe.js b/plugins/tuner/visualization/strobe.js index f33340a..24e6ce2 100644 --- a/plugins/tuner/visualization/strobe.js +++ b/plugins/tuner/visualization/strobe.js @@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) { strobeEl.style.opacity = '0'; } + // Idle the loop when there's no live signal — the strobe only needs to + // paint while a note is sounding. update() re-kicks it on the next note, + // so a silent tuner stops repainting instead of spinning at 60 fps. + if (!strobeActive) { rafId = null; return; } rafId = requestAnimationFrame(_animate); } + function _kick() { + if (rafId === null) { + lastAnimateTime = performance.now(); + rafId = requestAnimationFrame(_animate); + } + } + rafId = requestAnimationFrame(_animate); // ── Public API ──────────────────────────────────────────────────── @@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) { const inTune = Math.abs(cents) < 5; strobeEl.style.opacity = inTune ? '1' : '0.6'; strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT; + _kick(); } function destroy() { diff --git a/plugins/tuner/visualization/toilet-tuner.js b/plugins/tuner/visualization/toilet-tuner.js index 44d5283..43180f1 100644 --- a/plugins/tuner/visualization/toilet-tuner.js +++ b/plugins/tuner/visualization/toilet-tuner.js @@ -122,6 +122,26 @@ plungerEl.style.left = _leftPct.toFixed(2) + '%'; plungerEl.style.top = _topPct.toFixed(2) + '%'; + // No live signal and the plunger has eased back to its resting centre + // → idle the loop. update() re-kicks it on the next note. + if (_currentNote === null && !_plungerDipped + && Math.abs(targetLeft - _leftPct) < 0.05) { + _leftPct = targetLeft; + plungerEl.style.left = _leftPct.toFixed(2) + '%'; + _rafId = null; + _lastTime = null; + return; + } + + _rafId = requestAnimationFrame(_animate); + } + + function _kick() { + if (_rafId !== null) return; + // Already parked at rest with no signal → nothing to animate, stay idle. + if (_currentNote === null && !_plungerDipped + && Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return; + _lastTime = null; _rafId = requestAnimationFrame(_animate); } @@ -130,6 +150,7 @@ _currentNote = note; _currentCents = note === null ? 0 : cents; if (!_plungerDipped) { noteEl.textContent = note || '–'; } + _kick(); // a new reading may move the plunger → ensure the loop runs } function destroy() {