feedBack/plugins/tuner/visualization/strobe.js
Byron Gamatos a732e1f9d2
perf(tuner): idle the always-on tuner viz rAF when there's no signal (#647)
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) <noreply@anthropic.com>
2026-06-29 23:12:42 +02:00

212 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Strobe tuner visualization for the FeedBack tuner plugin.
*
* Contract: window._tunerViz_strobe(container) → { update(note, cents, freq), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
*/
window._tunerViz_strobe = function (container) {
'use strict';
// ── LCD segment map ───────────────────────────────────────────────
const _SEGMENT_MAP = {
'A': [1,1,1,1,0,0,1,1,1,1,0,0,0,0,0,0],
'B': [1,1,1,1,1,1,0,0,0,1,1,1,0,0,0,0],
'C': [1,1,0,0,1,1,1,1,0,0,0,0,0,0,0,0],
'D': [1,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0],
'E': [1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,0],
'F': [1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0],
'G': [1,1,0,1,1,1,1,1,0,1,0,0,0,0,0,0],
'-': [0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0],
' ': [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
};
// Subtle glow for lit LCD elements; currentColor matches each element's own color
const _LIT_GLOW = 'drop-shadow(0 0 4px currentColor)';
function _createLCDDigit() {
const digit = document.createElement('div');
digit.className = 'segment-digit relative w-12 h-16 flex-shrink-0';
// Glow rides on the rendered segments; unlit ones (opacity 0.05) stay dark
digit.style.filter = _LIT_GLOW;
const seg = 'absolute bg-current transition-opacity duration-150 rounded-sm';
digit.innerHTML = `
<div class="${seg} top-0 left-0.5 w-[calc(50%-1px)] h-2 rounded-tl-md" data-seg="0"></div>
<div class="${seg} top-0 right-0.5 w-[calc(50%-1px)] h-2 rounded-tr-md" data-seg="1"></div>
<div class="${seg} bottom-0 right-0.5 w-[calc(50%-1px)] h-2 rounded-br-md" data-seg="4"></div>
<div class="${seg} bottom-0 left-0.5 w-[calc(50%-1px)] h-2 rounded-bl-md" data-seg="5"></div>
<div class="${seg} top-1 left-0 w-2 h-[calc(50%-1.5px)]" data-seg="7"></div>
<div class="${seg} bottom-1 left-0 w-2 h-[calc(50%-1.5px)]" data-seg="6"></div>
<div class="${seg} top-1 right-0 w-2 h-[calc(50%-1.5px)]" data-seg="2"></div>
<div class="${seg} bottom-1 right-0 w-2 h-[calc(50%-1.5px)]" data-seg="3"></div>
<div class="${seg} top-1/2 left-1.5 w-[calc(50%-2px)] h-2 -translate-y-1/2" data-seg="8"></div>
<div class="${seg} top-1/2 right-1.5 w-[calc(50%-2px)] h-2 -translate-y-1/2" data-seg="9"></div>
<div class="${seg} top-1.5 left-1/2 w-2 h-[calc(50%-2.5px)] -translate-x-1/2" data-seg="10"></div>
<div class="${seg} bottom-1.5 left-1/2 w-2 h-[calc(50%-2.5px)] -translate-x-1/2" data-seg="11"></div>
<svg class="absolute inset-0 w-full h-full pointer-events-none overflow-visible" viewBox="0 0 48 64">
<line x1="10" y1="10" x2="22" y2="30" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="12"/>
<line x1="38" y1="10" x2="26" y2="30" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="13"/>
<line x1="10" y1="54" x2="22" y2="34" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="14"/>
<line x1="38" y1="54" x2="26" y2="34" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="15"/>
</svg>
`;
return digit;
}
function _updateSegmentDigit(el, char) {
const active = _SEGMENT_MAP[char.toUpperCase()] || _SEGMENT_MAP[' '];
el.querySelectorAll('[data-seg]').forEach((s) => {
s.style.opacity = active[parseInt(s.dataset.seg)] ? '1' : '0.05';
});
}
// ── DOM ─────────────────────────────────────────────────────────
const wrap = document.createElement('div');
wrap.className = 'w-full h-32 bg-dark-900 border border-gray-800 rounded-lg relative overflow-hidden mb-3 flex flex-col items-center justify-end pb-4';
const noteSegmented = document.createElement('div');
noteSegmented.className = 'flex items-center justify-center gap-2 text-accent z-30';
const spacer = document.createElement('div');
spacer.className = 'w-8 flex-shrink-0';
noteSegmented.appendChild(spacer);
const digit = _createLCDDigit();
noteSegmented.appendChild(digit);
const sharp = document.createElement('div');
sharp.className = 'relative w-8 h-16 flex-shrink-0';
sharp.innerHTML = `
<div class="sharp-segments absolute inset-0 opacity-5 transition-opacity duration-150">
<div class="absolute top-[30%] left-0 right-0 h-2.5 bg-current -rotate-12 rounded-full"></div>
<div class="absolute bottom-[30%] left-0 right-0 h-2.5 bg-current -rotate-12 rounded-full"></div>
<div class="absolute top-0 bottom-0 left-[30%] w-2.5 bg-current rotate-12 rounded-full"></div>
<div class="absolute top-0 bottom-0 right-[30%] w-2.5 bg-current rotate-12 rounded-full"></div>
</div>
`;
const sharpSegsEl = sharp.querySelector('.sharp-segments');
const flatSegEl = document.createElement('div');
flatSegEl.style.cssText = 'position:absolute;inset:0;display:none;align-items:flex-end;justify-content:center;font-size:2.4rem;font-weight:900;line-height:1;color:currentColor;transition:opacity 0.15s;';
flatSegEl.textContent = '♭';
sharp.appendChild(flatSegEl);
noteSegmented.appendChild(sharp);
wrap.appendChild(noteSegmented);
const scanlines = document.createElement('div');
scanlines.className = 'absolute inset-0 z-40 pointer-events-none opacity-20';
scanlines.style.backgroundImage = 'repeating-linear-gradient(90deg,#000 0px,#000 1px,transparent 1px,transparent 3px),repeating-linear-gradient(0deg,#000 0px,#000 1px,transparent 1px,transparent 3px)';
wrap.appendChild(scanlines);
const stripeGrad = 'linear-gradient(90deg,currentColor 0%,currentColor 45%,transparent 45%,transparent 100%)';
const strobeEl = document.createElement('div');
strobeEl.className = 'absolute top-0 left-0 w-full h-full text-accent';
strobeEl.style.cssText = `transition:opacity 0.3s ease,filter 0.3s ease;background-image:${stripeGrad},${stripeGrad};background-size:40px 15%,80px 15%;background-position:0 5%,0 21%;background-repeat:repeat-x;opacity:0`;
// Strobe glow scales with its brightness state (set in update); none while hidden
const _STROBE_GLOW_IN_TUNE = 'drop-shadow(0 0 3px currentColor)';
const _STROBE_GLOW_OUT = 'drop-shadow(0 0 1px currentColor)';
wrap.appendChild(strobeEl);
container.appendChild(wrap);
// ── State ─────────────────────────────────────────────────────────
let strobePhase = 0;
let smoothedCents = 0;
let currentCents = 0;
let strobeActive = false;
let lastAnimateTime = performance.now();
let lastSignalTime = performance.now();
let rafId = null;
function _animate() {
const now = performance.now();
let dt = (now - lastAnimateTime) / 1000;
if (dt > 0.1) dt = 0.016;
lastAnimateTime = now;
const lerpFactor = 1 - Math.exp(-10 * dt);
smoothedCents = smoothedCents * (1 - lerpFactor) + currentCents * lerpFactor;
const signalTimeout = (now - lastSignalTime) > 1000;
if (strobeActive && !signalTimeout) {
const absCents = Math.min(100, Math.abs(smoothedCents));
const maxSpeed = 2500;
const base = 10;
let speed = maxSpeed * (Math.pow(base, absCents / 100) - 1) / (base - 1);
if (smoothedCents < 0) speed = -speed;
strobePhase = ((strobePhase + speed * dt) % 80 + 80) % 80;
strobeEl.style.backgroundPosition = `${strobePhase}px 5%,${strobePhase}px 21%`;
} else if (signalTimeout && strobeActive) {
strobeActive = false;
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 ────────────────────────────────────────────────────
function _setAccidental(acc) {
if (acc === '#') {
sharpSegsEl.style.opacity = '1';
sharpSegsEl.style.filter = _LIT_GLOW;
flatSegEl.style.display = 'none';
} else if (acc === 'b') {
sharpSegsEl.style.opacity = '0.05';
sharpSegsEl.style.filter = 'none';
flatSegEl.style.display = 'flex';
flatSegEl.style.filter = _LIT_GLOW;
} else {
sharpSegsEl.style.opacity = '0.05';
sharpSegsEl.style.filter = 'none';
flatSegEl.style.display = 'none';
}
}
function update(note, cents, freq) {
if (note === null) {
strobeActive = false;
strobeEl.style.opacity = '0';
_updateSegmentDigit(digit, '-');
_setAccidental('');
currentCents = 0;
return;
}
lastSignalTime = performance.now();
strobeActive = true;
currentCents = cents;
_updateSegmentDigit(digit, note[0]);
_setAccidental(note.length > 1 ? note[1] : '');
strobeEl.style.backgroundImage = `${stripeGrad},${stripeGrad}`;
strobeEl.style.backgroundSize = '40px 15%,80px 15%';
strobeEl.style.backgroundPosition = `${strobePhase}px 5%,${strobePhase}px 21%`;
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() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
wrap.remove();
}
return { update, destroy };
};