mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
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>
463 lines
24 KiB
JavaScript
463 lines
24 KiB
JavaScript
/**
|
||
* Mace Fx III style tuner visualization for the FeedBack tuner plugin.
|
||
*
|
||
* Inspired by hardware rack tuner displays:
|
||
* - Dark navy LCD background
|
||
* - Horizontal chromatic tick-mark gauge (top)
|
||
* - Inward-pointing directional arrows (▶ ◀) below gauge
|
||
* - Large note name (lower-left) and octave number (lower-right)
|
||
* - Orange dashed strobe circle (bottom centre)
|
||
* - Mode tabs Free / Auto / Manual (top-right)
|
||
*
|
||
* Contract: window['_tunerViz_mace-fx-iii'](container) → { update(note, cents, freq, mode), destroy() }
|
||
* - note: string | null (null = no signal)
|
||
* - cents: number (deviation from target, −50…+50)
|
||
* - freq: number (detected frequency in Hz)
|
||
* - mode: 'free' | 'auto' | 'manual' (tuning mode from screen.js)
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────
|
||
var _TUNER_TICK_COUNT = 11;
|
||
var _TUNER_STROBE_N = 4; // segments fitting in 180° (plus one trailing gap)
|
||
var _TUNER_STROBE_R = 38; // radius in SVG units (full circle, fits in 120×120 viewBox)
|
||
var _TUNER_IN_TUNE_THR = 2; // cents threshold for in-tune state
|
||
var _TUNER_ARROW_THR = 3; // cents threshold for arrow direction
|
||
|
||
var _SVG_NS = 'http://www.w3.org/2000/svg';
|
||
|
||
// ── Colours (custom palette; no Tailwind token equivalents) ──────
|
||
var _COL_BG = '#0e0e0e'; // dark gray background
|
||
var _COL_TICK = '#7ad400'; // yellow-green gauge ticks
|
||
var _COL_MARKER = '#ffffff'; // white pitch-position marker
|
||
var _COL_NOTE = '#ffffff'; // white note/octave text
|
||
var _COL_ARROW_WH = '#e8e8e8'; // lit arrow colour
|
||
var _COL_ARROW_DIM = '#1e3030'; // dimmed arrow colour
|
||
var _COL_STROBE = '#e87020'; // orange strobe circle
|
||
var _COL_TAB_ACT_BG = '#505868'; // active tab background (slate-gray)
|
||
var _COL_TAB_ACT_FG = '#ffffff'; // active tab text
|
||
var _COL_TAB_DIM = '#506080'; // inactive tab text
|
||
|
||
window['_tunerViz_mace-fx-iii'] = function (container) {
|
||
'use strict';
|
||
|
||
// ── Root panel ────────────────────────────────────────────────
|
||
var panel = document.createElement('div');
|
||
panel.className = 'relative w-full overflow-hidden font-mono select-none';
|
||
panel.style.backgroundColor = _COL_BG;
|
||
panel.style.aspectRatio = '16 / 9';
|
||
panel.style.minHeight = '120px';
|
||
|
||
// ── Mode tabs (full-width bar, ~12.5% height) ────────────────
|
||
var tabsWrap = document.createElement('div');
|
||
tabsWrap.style.position = 'absolute';
|
||
tabsWrap.style.top = '0';
|
||
tabsWrap.style.left = '0';
|
||
tabsWrap.style.right = '0';
|
||
tabsWrap.style.height = '12.5%';
|
||
tabsWrap.style.display = 'flex';
|
||
tabsWrap.style.alignItems = 'flex-end';
|
||
tabsWrap.style.justifyContent = 'flex-end';
|
||
tabsWrap.style.borderBottom = '2px solid ' + _COL_TAB_ACT_BG;
|
||
tabsWrap.style.zIndex = '10';
|
||
|
||
var _tabNames = ['Free', 'Auto', 'Manual'];
|
||
var _tabEls = _tabNames.map(function (name) {
|
||
var tab = document.createElement('span');
|
||
tab.className = 'px-2 py-px text-xs leading-none';
|
||
tab.style.borderRadius = '2px 2px 0 0';
|
||
tab.style.cursor = 'default';
|
||
tab.textContent = name;
|
||
tabsWrap.appendChild(tab);
|
||
return tab;
|
||
});
|
||
panel.appendChild(tabsWrap);
|
||
|
||
// ── Gauge + arrows zone (25%→50% from top) ───────────────────
|
||
var gaugeZone = document.createElement('div');
|
||
gaugeZone.style.position = 'absolute';
|
||
gaugeZone.style.top = '25%';
|
||
gaugeZone.style.left = '0';
|
||
gaugeZone.style.right = '0';
|
||
gaugeZone.style.height = '25%';
|
||
gaugeZone.style.display = 'flex';
|
||
gaugeZone.style.flexDirection = 'column';
|
||
gaugeZone.style.justifyContent = 'center';
|
||
gaugeZone.style.zIndex = '5';
|
||
|
||
// Chromatic gauge — ends at 12.5% from each edge
|
||
var gaugeOuter = document.createElement('div');
|
||
gaugeOuter.style.position = 'relative';
|
||
gaugeOuter.style.marginLeft = '12.5%';
|
||
gaugeOuter.style.marginRight = '12.5%';
|
||
gaugeOuter.style.flexShrink = '0';
|
||
|
||
var gaugeBg = document.createElement('div');
|
||
gaugeBg.style.position = 'absolute';
|
||
gaugeBg.style.top = '22.5%'; // (100% - 55%) / 2, matches flex items-center
|
||
gaugeBg.style.left = '0';
|
||
gaugeBg.style.right = '0';
|
||
gaugeBg.style.height = '55%';
|
||
gaugeBg.style.backgroundColor = 'rgba(0,60,20,0.7)';
|
||
gaugeBg.style.borderRadius = '2px';
|
||
gaugeOuter.appendChild(gaugeBg);
|
||
|
||
var gaugeWrap = document.createElement('div');
|
||
gaugeWrap.className = 'relative flex items-center justify-between';
|
||
gaugeWrap.style.height = '1.4em';
|
||
|
||
var _tickEls = [];
|
||
for (var i = 0; i < _TUNER_TICK_COUNT; i++) {
|
||
var isCentre = (i === Math.floor(_TUNER_TICK_COUNT / 2));
|
||
var tick = document.createElement('div');
|
||
tick.style.width = '2px';
|
||
tick.style.height = isCentre ? '100%' : '55%';
|
||
tick.style.backgroundColor = _COL_TICK;
|
||
tick.style.borderRadius = '1px';
|
||
tick.style.flexShrink = '0';
|
||
tick.style.filter = 'drop-shadow(0 0 4px rgba(122,212,0,0.35))';
|
||
gaugeWrap.appendChild(tick);
|
||
_tickEls.push(tick);
|
||
}
|
||
|
||
var marker = document.createElement('div');
|
||
marker.style.position = 'absolute';
|
||
marker.style.top = '0';
|
||
marker.style.bottom = '0';
|
||
marker.style.width = '3px';
|
||
marker.style.backgroundColor = _COL_MARKER;
|
||
marker.style.left = '50%';
|
||
marker.style.transform = 'translateX(-50%)';
|
||
marker.style.display = 'none';
|
||
marker.style.zIndex = '6';
|
||
marker.style.boxShadow = '0 0 6px 1px rgba(255,255,255,0.6)';
|
||
gaugeWrap.appendChild(marker);
|
||
|
||
gaugeOuter.appendChild(gaugeWrap);
|
||
|
||
// Spacer: 1/3 of regular tick height (1/3 * 55% * 1.4em ≈ 0.257em)
|
||
var gaugeArrowGap = document.createElement('div');
|
||
gaugeArrowGap.style.height = '0.257em';
|
||
gaugeArrowGap.style.flexShrink = '0';
|
||
|
||
// Direction arrows SVG — outer edges at ±10¢, gap 15% (~3¢)
|
||
var arrowSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
arrowSvg.setAttribute('viewBox', '0 0 100 10');
|
||
arrowSvg.setAttribute('preserveAspectRatio', 'none');
|
||
arrowSvg.style.alignSelf = 'center';
|
||
arrowSvg.style.width = '15%';
|
||
arrowSvg.style.height = '0.77rem';
|
||
arrowSvg.style.flexShrink = '0';
|
||
arrowSvg.style.overflow = 'visible';
|
||
|
||
// SVG glow filter — applied per-polygon so only lit arrows glow
|
||
var _arrowGlowId = 'arrow-glow-' + Math.random().toString(36).slice(2, 8);
|
||
var _arrowDefs = document.createElementNS(_SVG_NS, 'defs');
|
||
var _arrowFilter = document.createElementNS(_SVG_NS, 'filter');
|
||
_arrowFilter.setAttribute('id', _arrowGlowId);
|
||
_arrowFilter.setAttribute('x', '-80%'); _arrowFilter.setAttribute('y', '-80%');
|
||
_arrowFilter.setAttribute('width', '260%'); _arrowFilter.setAttribute('height', '260%');
|
||
var _fBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
|
||
_fBlur.setAttribute('stdDeviation', '1.2'); _fBlur.setAttribute('result', 'blur');
|
||
var _fFlood = document.createElementNS(_SVG_NS, 'feFlood');
|
||
_fFlood.setAttribute('flood-color', 'white'); _fFlood.setAttribute('flood-opacity', '0.5'); _fFlood.setAttribute('result', 'col');
|
||
var _fComp = document.createElementNS(_SVG_NS, 'feComposite');
|
||
_fComp.setAttribute('in', 'col'); _fComp.setAttribute('in2', 'blur'); _fComp.setAttribute('operator', 'in'); _fComp.setAttribute('result', 'glow');
|
||
var _fMerge = document.createElementNS(_SVG_NS, 'feMerge');
|
||
[['glow'], ['SourceGraphic']].forEach(function (n) {
|
||
var mn = document.createElementNS(_SVG_NS, 'feMergeNode'); mn.setAttribute('in', n[0]); _fMerge.appendChild(mn);
|
||
});
|
||
_arrowFilter.appendChild(_fBlur); _arrowFilter.appendChild(_fFlood);
|
||
_arrowFilter.appendChild(_fComp); _arrowFilter.appendChild(_fMerge);
|
||
_arrowDefs.appendChild(_arrowFilter);
|
||
arrowSvg.appendChild(_arrowDefs);
|
||
|
||
var arrowLPoly = document.createElementNS(_SVG_NS, 'polygon');
|
||
arrowLPoly.setAttribute('points', '0,0 0,10 42.5,5');
|
||
arrowLPoly.setAttribute('fill', _COL_ARROW_DIM);
|
||
|
||
var arrowRPoly = document.createElementNS(_SVG_NS, 'polygon');
|
||
arrowRPoly.setAttribute('points', '100,0 100,10 57.5,5');
|
||
arrowRPoly.setAttribute('fill', _COL_ARROW_DIM);
|
||
|
||
arrowSvg.appendChild(arrowLPoly);
|
||
arrowSvg.appendChild(arrowRPoly);
|
||
// Order: arrows → spacer → gauge (arrows on top, gauge underneath)
|
||
gaugeZone.appendChild(arrowSvg);
|
||
gaugeZone.appendChild(gaugeArrowGap);
|
||
gaugeZone.appendChild(gaugeOuter);
|
||
panel.appendChild(gaugeZone);
|
||
|
||
var arrowL = arrowLPoly;
|
||
var arrowR = arrowRPoly;
|
||
var _arrowGlowUrl = 'url(#' + _arrowGlowId + ')';
|
||
|
||
// ── Note name display ─────────────────────────────────────────
|
||
// Horizontal: center of note letter at 12.5% from left.
|
||
// font-size set on wrapper so `ch` resolves to the note character width.
|
||
// noteLetter is width:1ch so the accidental never shifts the F position.
|
||
var noteWrap = document.createElement('div');
|
||
noteWrap.style.position = 'absolute';
|
||
noteWrap.style.left = 'calc(12.5% - 0.5ch)';
|
||
noteWrap.style.top = '67%';
|
||
noteWrap.style.transform = 'translateY(-50%)';
|
||
noteWrap.style.height = '25%';
|
||
noteWrap.style.display = 'flex';
|
||
noteWrap.style.alignItems = 'center';
|
||
noteWrap.style.fontSize = '3.2rem';
|
||
noteWrap.style.color = _COL_NOTE;
|
||
noteWrap.style.zIndex = '5';
|
||
noteWrap.style.overflow = 'visible';
|
||
noteWrap.style.textShadow = '0 0 5px rgba(255,255,255,0.4)';
|
||
|
||
var noteLetter = document.createElement('span');
|
||
noteLetter.style.display = 'inline-block';
|
||
noteLetter.style.width = '1ch';
|
||
noteLetter.style.flexShrink = '0';
|
||
noteLetter.style.fontWeight = '700';
|
||
noteLetter.style.lineHeight = '1';
|
||
noteLetter.textContent = '-';
|
||
|
||
var noteAccidental = document.createElement('span');
|
||
noteAccidental.style.fontSize = '1.7rem';
|
||
noteAccidental.style.fontWeight = '700';
|
||
noteAccidental.style.lineHeight = '1';
|
||
noteAccidental.style.alignSelf = 'flex-start';
|
||
noteAccidental.style.marginTop = '0.15em';
|
||
noteAccidental.textContent = '';
|
||
|
||
noteWrap.appendChild(noteLetter);
|
||
noteWrap.appendChild(noteAccidental);
|
||
panel.appendChild(noteWrap);
|
||
|
||
// ── Octave display ────────────────────────────────────────────
|
||
// Center of digit at 12.5% from right; width:1ch pins the element size.
|
||
var octaveEl = document.createElement('div');
|
||
octaveEl.style.position = 'absolute';
|
||
octaveEl.style.right = 'calc(12.5% - 0.5ch)';
|
||
octaveEl.style.top = '67%';
|
||
octaveEl.style.transform = 'translateY(-50%)';
|
||
octaveEl.style.height = '25%';
|
||
octaveEl.style.width = '1ch';
|
||
octaveEl.style.display = 'flex';
|
||
octaveEl.style.alignItems = 'center';
|
||
octaveEl.style.fontSize = '3.2rem';
|
||
octaveEl.style.fontWeight = '700';
|
||
octaveEl.style.lineHeight = '1';
|
||
octaveEl.style.color = _COL_NOTE;
|
||
octaveEl.style.zIndex = '5';
|
||
octaveEl.style.textShadow = '0 0 5px rgba(255,255,255,0.4)';
|
||
octaveEl.textContent = '-';
|
||
panel.appendChild(octaveEl);
|
||
|
||
// ── Strobe circle SVG (bottom-centre) ────────────────────────
|
||
// Full dashed circle, centered in the SVG viewBox so no part is clipped.
|
||
// gap = (2/3)*dash; using full circumference for dash calculation.
|
||
var _sVB_W = 120, _sVB_H = 120;
|
||
var _scx = 60, _scy = 60;
|
||
var _halfCirc = 2 * Math.PI * _TUNER_STROBE_R; // full circumference
|
||
var _dashLen = 3 * _halfCirc / 20;
|
||
var _gapLen = (2 / 3) * _dashLen;
|
||
|
||
var strobeSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
strobeSvg.setAttribute('viewBox', '0 0 ' + _sVB_W + ' ' + _sVB_H);
|
||
strobeSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
strobeSvg.setAttribute('class', 'absolute');
|
||
strobeSvg.style.bottom = '4%';
|
||
strobeSvg.style.left = '50%';
|
||
strobeSvg.style.transform = 'translateX(-50%)';
|
||
strobeSvg.style.width = '22%';
|
||
strobeSvg.style.overflow = 'visible';
|
||
strobeSvg.style.zIndex = '4';
|
||
|
||
// SVG glow filter for arc — avoids CSS filter viewport clipping
|
||
var _strobeGlowId = 'strobe-glow-' + Math.random().toString(36).slice(2, 8);
|
||
var _strobeDefs = document.createElementNS(_SVG_NS, 'defs');
|
||
var _strobeFilter = document.createElementNS(_SVG_NS, 'filter');
|
||
_strobeFilter.setAttribute('id', _strobeGlowId);
|
||
_strobeFilter.setAttribute('x', '-30%'); _strobeFilter.setAttribute('y', '-30%');
|
||
_strobeFilter.setAttribute('width', '160%'); _strobeFilter.setAttribute('height', '160%');
|
||
var _sfBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
|
||
_sfBlur.setAttribute('stdDeviation', '2'); _sfBlur.setAttribute('result', 'blur');
|
||
var _sfFlood = document.createElementNS(_SVG_NS, 'feFlood');
|
||
_sfFlood.setAttribute('flood-color', _COL_STROBE); _sfFlood.setAttribute('flood-opacity', '0.45'); _sfFlood.setAttribute('result', 'col');
|
||
var _sfComp = document.createElementNS(_SVG_NS, 'feComposite');
|
||
_sfComp.setAttribute('in', 'col'); _sfComp.setAttribute('in2', 'blur'); _sfComp.setAttribute('operator', 'in'); _sfComp.setAttribute('result', 'glow');
|
||
var _sfMerge = document.createElementNS(_SVG_NS, 'feMerge');
|
||
[['glow'], ['SourceGraphic']].forEach(function (n) {
|
||
var mn = document.createElementNS(_SVG_NS, 'feMergeNode'); mn.setAttribute('in', n[0]); _sfMerge.appendChild(mn);
|
||
});
|
||
_strobeFilter.appendChild(_sfBlur); _strobeFilter.appendChild(_sfFlood);
|
||
_strobeFilter.appendChild(_sfComp); _strobeFilter.appendChild(_sfMerge);
|
||
_strobeDefs.appendChild(_strobeFilter);
|
||
strobeSvg.appendChild(_strobeDefs);
|
||
|
||
// Full dashed circle — <circle> supports stroke-dashoffset identically to <path>
|
||
var arcPath = document.createElementNS(_SVG_NS, 'circle');
|
||
arcPath.setAttribute('cx', String(_scx));
|
||
arcPath.setAttribute('cy', String(_scy));
|
||
arcPath.setAttribute('r', String(_TUNER_STROBE_R));
|
||
arcPath.setAttribute('fill', 'none');
|
||
arcPath.setAttribute('stroke', _COL_STROBE);
|
||
arcPath.setAttribute('stroke-width', String(_dashLen));
|
||
arcPath.setAttribute('stroke-dasharray', _dashLen + ' ' + _gapLen);
|
||
arcPath.setAttribute('stroke-linecap', 'butt');
|
||
arcPath.setAttribute('filter', 'url(#' + _strobeGlowId + ')');
|
||
strobeSvg.appendChild(arcPath);
|
||
panel.appendChild(strobeSvg);
|
||
|
||
// ── LCD grid overlay ──────────────────────────────────────────
|
||
// Spans everything below the tab bar (top: 12.5%), 3px cell, bg colour lines.
|
||
var lcdGrid = document.createElement('div');
|
||
lcdGrid.style.position = 'absolute';
|
||
lcdGrid.style.top = '12.5%';
|
||
lcdGrid.style.left = '0';
|
||
lcdGrid.style.right = '0';
|
||
lcdGrid.style.bottom = '0';
|
||
lcdGrid.style.zIndex = '50';
|
||
lcdGrid.style.pointerEvents = 'none';
|
||
lcdGrid.style.backgroundImage = [
|
||
'repeating-linear-gradient(0deg, rgba(14,14,14,0.33) 0px, rgba(14,14,14,0.33) 1px, transparent 1px, transparent 2px)',
|
||
'repeating-linear-gradient(90deg, rgba(14,14,14,0.33) 0px, rgba(14,14,14,0.33) 1px, transparent 1px, transparent 2px)'
|
||
].join(',');
|
||
lcdGrid.style.backgroundPosition = '12.5% 0';
|
||
panel.appendChild(lcdGrid);
|
||
|
||
container.appendChild(panel);
|
||
|
||
// ── Internal state ────────────────────────────────────────────
|
||
var _rafId = null;
|
||
var _currentMode = 'free';
|
||
var _strobeOffset = 0; // stroke-dashoffset accumulator (SVG length units)
|
||
var _currentCents = 0; // target cents (0 when no signal)
|
||
var _smoothedCents = 0; // lerped cents — drives speed, decays to 0 on stop
|
||
var _lastTime = null;
|
||
var _totalDash = _dashLen + _gapLen; // one dash-cycle period
|
||
|
||
// ── Strobe RAF animation loop ─────────────────────────────────
|
||
// _smoothedCents lerps toward _currentCents every frame (mirrors strobe.js).
|
||
// When signal stops, _currentCents = 0 → _smoothedCents decays → speed → 0.
|
||
// The strobe always decelerates smoothly rather than snapping to a freeze.
|
||
function _animateStrobe(now) {
|
||
if (_lastTime === null) { _lastTime = now; }
|
||
var dt = Math.min((now - _lastTime) / 1000, 0.1);
|
||
_lastTime = now;
|
||
|
||
var lerpFactor = 1 - Math.exp(-10 * dt);
|
||
_smoothedCents += (_currentCents - _smoothedCents) * lerpFactor;
|
||
|
||
if (Math.abs(_smoothedCents) > 0.1) {
|
||
var absCents = Math.min(50, Math.abs(_smoothedCents));
|
||
var normalized = Math.max(0, absCents - _TUNER_IN_TUNE_THR) / (50 - _TUNER_IN_TUNE_THR);
|
||
var speed = _halfCirc * Math.pow(normalized, 0.9);
|
||
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 ───────────────
|
||
function _freqToOctave(freq) {
|
||
if (!freq || freq <= 0) return '-';
|
||
var midi = Math.round(69 + 12 * Math.log2(freq / 440));
|
||
return String(Math.floor(midi / 12) - 1);
|
||
}
|
||
|
||
// ── Helper: update mode tab highlights ────────────────────────
|
||
function _updateTabs(mode) {
|
||
var map = { free: 0, auto: 1, manual: 2 };
|
||
var active = (map[mode] !== undefined) ? map[mode] : 0;
|
||
_tabEls.forEach(function (tab, i) {
|
||
if (i === active) {
|
||
tab.style.backgroundColor = _COL_TAB_ACT_BG;
|
||
tab.style.color = _COL_TAB_ACT_FG;
|
||
} else {
|
||
tab.style.backgroundColor = 'transparent';
|
||
tab.style.color = _COL_TAB_DIM;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Initialise tabs
|
||
_updateTabs('free');
|
||
|
||
// ── Public: update ────────────────────────────────────────────
|
||
function update(note, cents, freq, mode, targetFreq) {
|
||
var hasNote = (note !== null && note !== undefined);
|
||
|
||
// Mode tabs
|
||
if (mode !== undefined) { _currentMode = mode; }
|
||
_updateTabs(_currentMode);
|
||
|
||
// Gauge marker — clamp cents to [-50,50] so marker stays within gauge bounds
|
||
if (hasNote) {
|
||
marker.style.left = Math.max(0, Math.min(100, cents + 50)) + '%';
|
||
marker.style.display = 'block';
|
||
} else {
|
||
marker.style.display = 'none';
|
||
}
|
||
|
||
// Direction arrows — use setAttribute('filter','none') not removeAttribute
|
||
// so the filter is explicitly cleared on every dim transition
|
||
if (!hasNote) {
|
||
arrowL.setAttribute('fill', _COL_ARROW_DIM); arrowL.setAttribute('filter', 'none');
|
||
arrowR.setAttribute('fill', _COL_ARROW_DIM); arrowR.setAttribute('filter', 'none');
|
||
} else if (cents <= -_TUNER_ARROW_THR) {
|
||
arrowL.setAttribute('fill', _COL_ARROW_WH); arrowL.setAttribute('filter', _arrowGlowUrl);
|
||
arrowR.setAttribute('fill', _COL_ARROW_DIM); arrowR.setAttribute('filter', 'none');
|
||
} else if (cents >= _TUNER_ARROW_THR) {
|
||
arrowL.setAttribute('fill', _COL_ARROW_DIM); arrowL.setAttribute('filter', 'none');
|
||
arrowR.setAttribute('fill', _COL_ARROW_WH); arrowR.setAttribute('filter', _arrowGlowUrl);
|
||
} else {
|
||
arrowL.setAttribute('fill', _COL_ARROW_WH); arrowL.setAttribute('filter', _arrowGlowUrl);
|
||
arrowR.setAttribute('fill', _COL_ARROW_WH); arrowR.setAttribute('filter', _arrowGlowUrl);
|
||
}
|
||
|
||
// Note display
|
||
if (hasNote) {
|
||
noteLetter.textContent = note.charAt(0);
|
||
noteAccidental.textContent = note.slice(1);
|
||
} else {
|
||
noteLetter.textContent = '-';
|
||
noteAccidental.textContent = '';
|
||
}
|
||
|
||
// Octave display — show target octave in auto/manual, detected octave in free
|
||
if (hasNote) {
|
||
if ((_currentMode === 'auto' || _currentMode === 'manual') && targetFreq) {
|
||
octaveEl.textContent = _freqToOctave(targetFreq);
|
||
} else {
|
||
octaveEl.textContent = _freqToOctave(freq);
|
||
}
|
||
} else {
|
||
octaveEl.textContent = '-';
|
||
}
|
||
|
||
// 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 ───────────────────────────────────────────
|
||
function destroy() {
|
||
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
||
panel.remove();
|
||
}
|
||
|
||
return { update: update, destroy: destroy };
|
||
};
|
||
})();
|