/** * Analogue gauge tuner visualization for the FeedBack tuner plugin. * * Contract: window['_tunerViz_analogue-gauge'](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) * * Layout (vintage analogue instrument panel): * - Off-white panel face * - Full-width black gauge section; frequency drum window centred inside it * - Red SVG needle sweeps over the freq drum window * - Note name drum + lightbulb below the gauge */ (function () { 'use strict'; // ── 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 var _TUNER_NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; var _TUNER_NOTE_FLAT = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B']; // SVG gauge geometry — viewBox 200 × 110, pivot at bottom-centre // R=95 keeps arc endpoints ~5 SVG units from the viewBox edges to prevent clipping var _SVG_CX = 100, _SVG_CY = 110, _SVG_R = 95, _SVG_NEEDLE_LEN = 88; window['_tunerViz_analogue-gauge'] = function (container) { 'use strict'; var svgNS = 'http://www.w3.org/2000/svg'; function _midiToFreq(m) { return Math.pow(2, (m - 69) / 12) * 440; } // ── Panel (off-white, vintage) ──────────────────────────────── var panel = document.createElement('div'); panel.className = 'w-full relative flex flex-col items-center gap-2 p-3 rounded-lg'; panel.style.backgroundColor = '#e8e0cc'; panel.style.border = '2px solid #b0a080'; // ── AUTO lamp (top-left; lit in free-tune mode) ────────────── var autoWrap = document.createElement('div'); autoWrap.style.position = 'absolute'; autoWrap.style.top = '8px'; autoWrap.style.left = '10px'; autoWrap.style.zIndex = '20'; autoWrap.style.display = 'flex'; autoWrap.style.alignItems = 'center'; autoWrap.style.gap = '4px'; var autoLamp = document.createElement('div'); autoLamp.style.width = '8px'; autoLamp.style.height = '8px'; autoLamp.style.backgroundColor = '#2a0000'; autoLamp.style.border = '1px solid #5a2020'; autoLamp.style.flexShrink = '0'; var autoLabel = document.createElement('span'); autoLabel.style.fontSize = '9px'; autoLabel.style.fontFamily = 'monospace'; autoLabel.style.fontWeight = 'bold'; autoLabel.style.color = '#888'; autoLabel.textContent = 'AUTO'; autoWrap.appendChild(autoLamp); autoWrap.appendChild(autoLabel); panel.appendChild(autoWrap); // ── A=440 label (top-right) ─────────────────────────────────── var refLabel = document.createElement('span'); refLabel.style.position = 'absolute'; refLabel.style.top = '8px'; refLabel.style.right = '10px'; refLabel.style.zIndex = '20'; refLabel.style.fontSize = '9px'; refLabel.style.fontFamily = 'monospace'; refLabel.style.fontWeight = 'bold'; refLabel.style.color = '#888'; refLabel.textContent = 'A=440'; panel.appendChild(refLabel); // ── Gauge section (full-width, black face) ──────────────────── var gaugeFace = document.createElement('div'); gaugeFace.className = 'w-full relative'; gaugeFace.style.backgroundColor = '#e8e0cc'; gaugeFace.style.height = '95px'; // matches cropped viewBox height (110-15) // Frequency drum window — centred inside the gauge, behind the needle var freqWindow = document.createElement('div'); freqWindow.style.position = 'absolute'; freqWindow.style.overflow = 'hidden'; freqWindow.style.backgroundColor = '#fff'; freqWindow.style.border = '1px solid #bbb'; freqWindow.style.width = '104px'; freqWindow.style.height = (_TUNER_LABEL_H * 2) + 'px'; freqWindow.style.left = 'calc(50% - 52px)'; freqWindow.style.top = '39px'; // half needle from pivot: 95 - 88/2 - 24/2 = 39 freqWindow.style.zIndex = '1'; // Inset shadows top & bottom → suggests a curved drum surface receding at the edges. // Top is shorter/lighter, bottom taller/darker — reads as a drum lit from above. freqWindow.style.boxShadow = 'inset 0 4px 5px -4px rgba(0,0,0,0.35), inset 0 -7px 7px -4px rgba(0,0,0,0.55)'; var freqStrip = document.createElement('div'); freqStrip.style.position = 'absolute'; freqStrip.style.width = '100%'; // "---" is index 0; actual notes start at index 1 function _makeDrumLabel(text) { var el = document.createElement('div'); el.style.height = _TUNER_LABEL_H + 'px'; el.style.display = 'flex'; el.style.alignItems = 'center'; el.style.justifyContent = 'center'; el.style.userSelect = 'none'; el.textContent = text; return el; } var fIdleLabel = _makeDrumLabel('---'); fIdleLabel.style.fontSize = '11px'; fIdleLabel.style.fontFamily = 'monospace'; fIdleLabel.style.fontWeight = 'bold'; fIdleLabel.style.color = '#111'; freqStrip.appendChild(fIdleLabel); freqStrip.appendChild(_makeDrumLabel('')); // separator: keeps real labels out of view at idle for (var fm = _TUNER_STRIP_START_MIDI; fm <= _TUNER_STRIP_END_MIDI; fm++) { var fLabel = _makeDrumLabel(_midiToFreq(fm).toFixed(1) + ' Hz'); fLabel.style.fontSize = '11px'; fLabel.style.fontFamily = 'monospace'; fLabel.style.fontWeight = 'bold'; fLabel.style.color = '#111'; freqStrip.appendChild(fLabel); } freqWindow.appendChild(freqStrip); gaugeFace.appendChild(freqWindow); // SVG — arc, tick marks, needle, pivot (z above freq window) var svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('viewBox', '0 15 200 95'); // crop 15px dead space above arc top svg.setAttribute('preserveAspectRatio', 'none'); svg.style.position = 'absolute'; svg.style.top = '0'; svg.style.left = '0'; svg.style.width = '100%'; svg.style.height = '100%'; svg.style.zIndex = '2'; svg.style.overflow = 'visible'; // prevent viewBox from clipping arc edges // Arc: R=95 keeps endpoints ~5 SVG units from the viewBox edges var arcPath = document.createElementNS(svgNS, 'path'); arcPath.setAttribute('d', 'M 5 110 A 95 95 0 0 1 195 110'); arcPath.setAttribute('fill', 'none'); arcPath.setAttribute('stroke', '#222'); arcPath.setAttribute('stroke-width', '1.5'); svg.appendChild(arcPath); // Tick marks: long every 10 cents, 4 short between each (every 2 cents). // 5 outermost marks on each side (|c| >= 42) in red. for (var tc = -50; tc <= 50; tc += 2) { var isLong = (tc % 10 === 0); var isRed = Math.abs(tc) >= 42; var tLen = isLong ? 10 : 5; var tColor = isRed ? '#cc2200' : '#222'; var tWidth = isLong ? 1.5 : 1; var tAngleRad = ((tc / 50) * _TUNER_NEEDLE_HALF_SWEEP - 90) * Math.PI / 180; var ttick = document.createElementNS(svgNS, 'line'); ttick.setAttribute('x1', (_SVG_CX + (_SVG_R - tLen) * Math.cos(tAngleRad)).toFixed(1)); ttick.setAttribute('y1', (_SVG_CY + (_SVG_R - tLen) * Math.sin(tAngleRad)).toFixed(1)); ttick.setAttribute('x2', (_SVG_CX + _SVG_R * Math.cos(tAngleRad)).toFixed(1)); ttick.setAttribute('y2', (_SVG_CY + _SVG_R * Math.sin(tAngleRad)).toFixed(1)); ttick.setAttribute('stroke', tColor); ttick.setAttribute('stroke-width', String(tWidth)); svg.appendChild(ttick); } // Inner labels — dominant-baseline="central" so y = vertical centre of text [ { c: -50, text: '-50', extreme: true }, { c: -30, text: '-30', yOff: -1 }, { c: 0, text: '0', yOff: -1 }, { c: 30, text: '+30', yOff: -1 }, { c: 50, text: '+50', extreme: true } ].forEach(function (m) { var aRad = ((m.c / 50) * _TUNER_NEEDLE_HALF_SWEEP - 90) * Math.PI / 180; var lx = (_SVG_CX + 76 * Math.cos(aRad)).toFixed(1); // extreme labels: centre at arc baseline (y=_SVG_CY) // others: on arc circle at r=76 plus per-label vertical nudge var ly = m.extreme ? String(_SVG_CY) : (_SVG_CY + 76 * Math.sin(aRad) + (m.yOff || 0)).toFixed(1); var lbl = document.createElementNS(svgNS, 'text'); lbl.setAttribute('x', lx); lbl.setAttribute('y', ly); lbl.setAttribute('text-anchor', 'middle'); lbl.setAttribute('dominant-baseline', 'central'); lbl.setAttribute('font-size', '8'); lbl.setAttribute('font-family', 'monospace'); lbl.setAttribute('fill', m.extreme ? '#cc2200' : '#555'); lbl.textContent = m.text; svg.appendChild(lbl); }); // Needle line (pivot at SVG bottom-centre; x2/y2 updated in RAF) var needleLine = document.createElementNS(svgNS, 'line'); needleLine.setAttribute('x1', '100'); needleLine.setAttribute('y1', '110'); needleLine.setAttribute('x2', '100'); needleLine.setAttribute('y2', String(110 - _SVG_NEEDLE_LEN)); // initial: 0 cents needleLine.setAttribute('stroke', '#cc2200'); needleLine.setAttribute('stroke-width', '2'); needleLine.setAttribute('stroke-linecap', 'round'); svg.appendChild(needleLine); // Pivot cap var pivotCap = document.createElementNS(svgNS, 'circle'); pivotCap.setAttribute('cx', '100'); pivotCap.setAttribute('cy', '110'); pivotCap.setAttribute('r', '5'); pivotCap.setAttribute('fill', '#cc2200'); svg.appendChild(pivotCap); gaugeFace.appendChild(svg); panel.appendChild(gaugeFace); // ── Note drum + lightbulb row (below gauge) ─────────────────── var noteRow = document.createElement('div'); noteRow.className = 'w-full relative flex justify-center items-center'; var noteWindow = document.createElement('div'); noteWindow.style.position = 'relative'; noteWindow.style.overflow = 'hidden'; noteWindow.style.backgroundColor = '#fff'; noteWindow.style.border = '1px solid #999'; noteWindow.style.width = '48px'; noteWindow.style.height = (_TUNER_LABEL_H * 2) + 'px'; // Inset shadows top & bottom → suggests a curved drum surface receding at the edges. // Top is shorter/lighter, bottom taller/darker — reads as a drum lit from above. noteWindow.style.boxShadow = 'inset 0 4px 5px -4px rgba(0,0,0,0.35), inset 0 -7px 7px -4px rgba(0,0,0,0.55)'; var noteStrip = document.createElement('div'); noteStrip.style.position = 'absolute'; noteStrip.style.width = '100%'; var nIdleLabel = _makeDrumLabel('---'); nIdleLabel.style.fontSize = '10px'; nIdleLabel.style.fontWeight = 'bold'; nIdleLabel.style.color = '#111'; noteStrip.appendChild(nIdleLabel); noteStrip.appendChild(_makeDrumLabel('')); // separator var _drumLabels = []; // {el, nm} — for flat/sharp relabeling for (var nm = _TUNER_STRIP_START_MIDI; nm <= _TUNER_STRIP_END_MIDI; nm++) { var nLabel = _makeDrumLabel(_TUNER_NOTE_NAMES[nm % 12]); nLabel.style.fontSize = '10px'; nLabel.style.fontWeight = 'bold'; nLabel.style.color = '#111'; noteStrip.appendChild(nLabel); _drumLabels.push({ el: nLabel, nm: nm }); } noteWindow.appendChild(noteStrip); // Lightbulb — absolutely offset from panel centre so note window stays centred // noteWindow is 48px wide → bulb left edge = 50% + 24px (half window) + 6px gap var bulbEl = document.createElement('div'); bulbEl.style.position = 'absolute'; bulbEl.style.left = 'calc(50% + 30px)'; bulbEl.style.top = '50%'; bulbEl.style.transform = 'translateY(-50%)'; bulbEl.style.width = '20px'; bulbEl.style.height = '20px'; bulbEl.style.borderRadius = '50%'; bulbEl.style.backgroundColor = '#2a1010'; bulbEl.style.border = '2px solid #4a2020'; noteRow.appendChild(noteWindow); noteRow.appendChild(bulbEl); panel.appendChild(noteRow); container.appendChild(panel); // ── State ───────────────────────────────────────────────────── // Must be defined before currentDrumY initialisation (var hoisting trap) var _IDLE_DRUM_Y = _TUNER_LABEL_H * 0.5; // centres --- label (index 0) in window var currentDrumY = _IDLE_DRUM_Y, targetDrumY = _IDLE_DRUM_Y; var _lastUseFlats = false; var currentAngle = 0, targetAngle = 0; var lastTime = performance.now(); var rafId = null; // ── Needle SVG update ───────────────────────────────────────── function _setNeedle(angleDeg) { var rad = (angleDeg - 90) * Math.PI / 180; needleLine.setAttribute('x2', (_SVG_CX + _SVG_NEEDLE_LEN * Math.cos(rad)).toFixed(1)); needleLine.setAttribute('y2', (_SVG_CY + _SVG_NEEDLE_LEN * Math.sin(rad)).toFixed(1)); } // ── Drum position ───────────────────────────────────────────── function _computeDrumY(freq, cents) { if (!freq || freq <= 0) return _IDLE_DRUM_Y; var midi = 69 + 12 * Math.log2(freq / 440); var targetMidi = midi - cents / 100; var clamped = Math.max(-50, Math.min(50, cents)); // +2: index 0 = ---, index 1 = separator, index 2+ = real notes var idx = Math.max(2, Math.min(_TUNER_STRIP_END_MIDI - _TUNER_STRIP_START_MIDI + 2, Math.round(targetMidi) - _TUNER_STRIP_START_MIDI + 2)); return _TUNER_LABEL_H * (0.5 - idx) - (clamped / 50) * (_TUNER_LABEL_H / 2); } // ── Animation loop ──────────────────────────────────────────── function _animate() { var now = performance.now(); var dt = Math.min((now - lastTime) / 1000, 0.1); lastTime = now; var lf = 1 - Math.exp(-10 * dt); currentDrumY += (targetDrumY - currentDrumY) * lf; freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; 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 ──────────────────────────────────────────────── function _setAutoLamp(lit) { autoLamp.style.backgroundColor = lit ? '#cc2200' : '#2a0000'; autoLamp.style.border = lit ? '1px solid #ff4422' : '1px solid #5a2020'; autoLamp.style.boxShadow = lit ? '0 0 5px 2px rgba(200,50,0,0.7)' : 'none'; } function update(note, cents, freq, mode, targetFreq, referencePitch, useFlats) { if (typeof referencePitch === 'number' && referencePitch > 0) { refLabel.textContent = 'A=' + Math.round(referencePitch); } var wantFlats = !!useFlats; if (wantFlats !== _lastUseFlats) { _lastUseFlats = wantFlats; var names = wantFlats ? _TUNER_NOTE_FLAT : _TUNER_NOTE_NAMES; for (var _di = 0; _di < _drumLabels.length; _di++) { _drumLabels[_di].el.textContent = names[_drumLabels[_di].nm % 12]; } } // AUTO lamp: free → always lit; auto → lit on signal; manual/unknown → off if (mode === 'free') { _setAutoLamp(true); } else if (mode === 'auto') { _setAutoLamp(note !== null); } else { _setAutoLamp(false); } if (note === null) { targetDrumY = _IDLE_DRUM_Y; targetAngle = 0; 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; } targetDrumY = _computeDrumY(freq, cents); targetAngle = (Math.max(-50, Math.min(50, cents)) / 50) * _TUNER_NEEDLE_HALF_SWEEP; if (Math.abs(cents) <= _TUNER_IN_TUNE_THRESHOLD) { bulbEl.style.backgroundColor = '#cc3300'; bulbEl.style.border = '2px solid #ff5522'; bulbEl.style.boxShadow = '0 0 10px 4px rgba(200,50,0,0.85)'; } else { bulbEl.style.backgroundColor = '#2a1010'; bulbEl.style.border = '2px solid #4a2020'; bulbEl.style.boxShadow = 'none'; } _kick(); // a new reading moved the target → run until it settles } function destroy() { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } panel.remove(); } return { update: update, destroy: destroy }; }; })();