/** * 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 — supports stroke-dashoffset identically to 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 }; }; })();