mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +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>
704 lines
38 KiB
JavaScript
704 lines
38 KiB
JavaScript
/**
|
||
* CHEF MT-3 tuner visualization for the FeedBack tuner plugin.
|
||
*
|
||
* Inspired by classic chromatic pedal tuners:
|
||
* - Shiny black rectangular panel with chamfered edges and corner screws
|
||
* - 90° curved glass gauge arc spanning between the screw inner edges
|
||
* - 51 tick marks; glow fades in/out with audio signal presence
|
||
* - Red 7-segment display (bottom centre) with "#" symbol
|
||
* - Two rubber buttons flanked by panel labels: MODE (left), BRGHT. (right)
|
||
* - Standard mode: nearest tick cluster glows for current deviation
|
||
* - Strobe mode: groups of 3 bright ticks + lightspill drift with deviation
|
||
*
|
||
* Contract: window['_tunerViz_chef-mt3'](container) → { update(note, cents, freq, mode), destroy() }
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ── Module-level constants ─────────────────────────────────────────
|
||
var _TUNER_MT3_IN_TUNE_THR = 2;
|
||
var _TUNER_MT3_GAUGE_CENTS = 50;
|
||
var _TUNER_MT3_TICK_COUNT = 51; // 0¢ centre + 25×2¢ per side
|
||
var _TUNER_MT3_STROBE_GROUP_COUNT = 5;
|
||
|
||
// ── Colours ────────────────────────────────────────────────────────
|
||
var _MT3_COL_BG = '#0a0a0a';
|
||
var _MT3_COL_GAUGE_ARC = 'rgba(255,255,255,0.18)';
|
||
var _MT3_COL_TICK_DIM = 'rgba(255,255,255,0.45)';
|
||
// Tick glow colours are computed per-tick at factory construction (orange→yellow gradient)
|
||
var _MT3_COL_SEG_UNLIT = '#2a0000';
|
||
|
||
// Brightness levels: [brightScale for tick glow, SVG filter flood-opacity, lit segment fill, segment drop-shadow]
|
||
var _MT3_BRIGHTNESS = [
|
||
{ brightScale: 0.66, floodOpacity: '0.45', litFill: '#cc1500', glow: 'drop-shadow(0 0 2px #bb1100)' }, // low
|
||
{ brightScale: 1.0, floodOpacity: '0.65', litFill: '#ff2200', glow: 'drop-shadow(0 0 5px #ff2200)' }, // medium
|
||
{ brightScale: 1.0, floodOpacity: '1.0', litFill: '#ff5533', glow: 'drop-shadow(0 0 9px #ff4400)' }, // high
|
||
];
|
||
var _MT3_COL_BUTTON = '#1a1a1a';
|
||
var _MT3_COL_LABEL = '#c8c8c8';
|
||
|
||
// ── Gauge SVG geometry ─────────────────────────────────────────────
|
||
// viewBox "0 0 200 66": ratio 3.03 matches SVG element (width:100% height:auto on 5:3 panel)
|
||
// 90° arc: 225°(−50¢) → 270°(apex) → 315°(+50¢); R=124, cy=138 → apex at y=14
|
||
var _MT3_cx = 100;
|
||
var _MT3_cy = 138;
|
||
var _MT3_ARC_R = 124;
|
||
var _MT3_ARC_START = 5 * Math.PI / 4;
|
||
var _MT3_ARC_SPAN = Math.PI / 2;
|
||
|
||
var _MT3_ARC_SX = _MT3_cx + _MT3_ARC_R * Math.cos(_MT3_ARC_START);
|
||
var _MT3_ARC_SY = _MT3_cy + _MT3_ARC_R * Math.sin(_MT3_ARC_START);
|
||
var _MT3_ARC_EX = _MT3_cx + _MT3_ARC_R * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN);
|
||
var _MT3_ARC_EY = _MT3_cy + _MT3_ARC_R * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN);
|
||
|
||
var _SVG_NS = 'http://www.w3.org/2000/svg';
|
||
|
||
// ── 8-segment lookup table ─────────────────────────────────────────
|
||
var _TUNER_MT3_SEGMENTS = {
|
||
// a b c d e f g1 g2
|
||
'A': [ true, true, true, false, true, true, true, true ],
|
||
'B': [ false, false, true, true, true, true, true, true ],
|
||
'C': [ true, false, false, true, true, true, false, false ],
|
||
'D': [ false, true, true, true, true, false, true, true ],
|
||
'E': [ true, false, false, true, true, true, true, false ],
|
||
'F': [ true, false, false, false, true, true, true, false ],
|
||
'G': [ true, false, true, true, true, true, false, true ],
|
||
' ': [ false, false, false, false, false, false, false, false ],
|
||
};
|
||
var _segKeys = ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'];
|
||
|
||
window['_tunerViz_chef-mt3'] = function (container) {
|
||
'use strict';
|
||
|
||
// ── Root panel ────────────────────────────────────────────────
|
||
var panel = document.createElement('div');
|
||
panel.style.position = 'relative';
|
||
panel.style.overflow = 'hidden';
|
||
panel.style.aspectRatio = '5 / 3';
|
||
panel.style.minHeight = '120px';
|
||
panel.style.backgroundColor = _MT3_COL_BG;
|
||
panel.style.border = '2px solid #505050';
|
||
panel.style.borderRadius = '6px';
|
||
panel.style.userSelect = 'none';
|
||
panel.style.fontFamily = 'monospace';
|
||
|
||
// ── Corner screws ─────────────────────────────────────────────
|
||
[['top','left'],['top','right'],['bottom','left'],['bottom','right']].forEach(function (pos) {
|
||
var s = document.createElement('div');
|
||
s.style.position = 'absolute';
|
||
s.style[pos[0]] = '3%';
|
||
s.style[pos[1]] = '2%';
|
||
s.style.width = '4%';
|
||
s.style.height = '0';
|
||
s.style.paddingBottom = '4%';
|
||
s.style.borderRadius = '50%';
|
||
s.style.background = 'radial-gradient(circle at 35% 35%, #666, #222)';
|
||
s.style.boxShadow = '0 1px 3px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.12)';
|
||
s.style.zIndex = '5';
|
||
var slot = document.createElement('div');
|
||
slot.style.position = 'absolute';
|
||
slot.style.top = '45%';
|
||
slot.style.left = '15%';
|
||
slot.style.right = '15%';
|
||
slot.style.height = '10%';
|
||
slot.style.backgroundColor = '#111';
|
||
s.appendChild(slot);
|
||
panel.appendChild(s);
|
||
});
|
||
|
||
// ── Gauge SVG ─────────────────────────────────────────────────
|
||
var gaugeSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
gaugeSvg.setAttribute('viewBox', '0 0 200 66');
|
||
gaugeSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
gaugeSvg.style.position = 'absolute';
|
||
gaugeSvg.style.top = '5%';
|
||
gaugeSvg.style.left = '0';
|
||
gaugeSvg.style.width = '100%';
|
||
gaugeSvg.style.height = 'auto';
|
||
gaugeSvg.style.overflow = 'visible';
|
||
|
||
// SVG glow filter — applied to the glow tick group as a whole
|
||
var _glowId = 'mt3-glow-' + Math.random().toString(36).slice(2, 7);
|
||
var _svgDefs = document.createElementNS(_SVG_NS, 'defs');
|
||
var _svgFilter = document.createElementNS(_SVG_NS, 'filter');
|
||
_svgFilter.setAttribute('id', _glowId);
|
||
_svgFilter.setAttribute('x', '-60%'); _svgFilter.setAttribute('y', '-60%');
|
||
_svgFilter.setAttribute('width', '220%'); _svgFilter.setAttribute('height', '220%');
|
||
var _sfBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
|
||
_sfBlur.setAttribute('stdDeviation', '1.8'); _sfBlur.setAttribute('result', 'blur');
|
||
var _sfFlood = document.createElementNS(_SVG_NS, 'feFlood');
|
||
_sfFlood.setAttribute('flood-color', '#ff8800'); _sfFlood.setAttribute('flood-opacity', '0.65');
|
||
_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');
|
||
var _sfMn1 = document.createElementNS(_SVG_NS, 'feMergeNode'); _sfMn1.setAttribute('in', 'glow');
|
||
var _sfMn2 = document.createElementNS(_SVG_NS, 'feMergeNode'); _sfMn2.setAttribute('in', 'SourceGraphic');
|
||
_sfMerge.appendChild(_sfMn1); _sfMerge.appendChild(_sfMn2);
|
||
_svgFilter.appendChild(_sfBlur); _svgFilter.appendChild(_sfFlood);
|
||
_svgFilter.appendChild(_sfComp); _svgFilter.appendChild(_sfMerge);
|
||
// Shared blur filter for highlight and shadow arcs
|
||
var _glassBlurId = 'mt3-gb-' + Math.random().toString(36).slice(2, 7);
|
||
var _glassBlur = document.createElementNS(_SVG_NS, 'filter');
|
||
_glassBlur.setAttribute('id', _glassBlurId);
|
||
_glassBlur.setAttribute('x', '-30%'); _glassBlur.setAttribute('y', '-30%');
|
||
_glassBlur.setAttribute('width', '160%'); _glassBlur.setAttribute('height', '160%');
|
||
var _gbFe = document.createElementNS(_SVG_NS, 'feGaussianBlur');
|
||
_gbFe.setAttribute('stdDeviation', '1.8');
|
||
_glassBlur.appendChild(_gbFe);
|
||
_svgDefs.appendChild(_glassBlur);
|
||
|
||
// Gradient for specular highlight: transparent at arc ends, white at centre
|
||
var _hlGradId = 'mt3-hl-' + Math.random().toString(36).slice(2, 7);
|
||
var _hlGrad = document.createElementNS(_SVG_NS, 'linearGradient');
|
||
_hlGrad.setAttribute('id', _hlGradId);
|
||
_hlGrad.setAttribute('gradientUnits', 'userSpaceOnUse');
|
||
_hlGrad.setAttribute('x1', _MT3_ARC_SX.toFixed(2)); _hlGrad.setAttribute('y1', '0');
|
||
_hlGrad.setAttribute('x2', _MT3_ARC_EX.toFixed(2)); _hlGrad.setAttribute('y2', '0');
|
||
[['0%','rgba(255,255,255,0.65)'],['4%','rgba(255,255,255,0)'],['20%','rgba(255,255,255,0)'],['35%','rgba(255,255,255,0.68)'],['65%','rgba(255,255,255,0.68)'],['80%','rgba(255,255,255,0)'],['96%','rgba(255,255,255,0)'],['100%','rgba(255,255,255,0.65)']]
|
||
.forEach(function(s){var st=document.createElementNS(_SVG_NS,'stop');st.setAttribute('offset',s[0]);st.setAttribute('stop-color',s[1]);_hlGrad.appendChild(st);});
|
||
_svgDefs.appendChild(_hlGrad);
|
||
|
||
// Gradient for outer shadow: transparent at arc ends, dark at centre
|
||
var _shGradId = 'mt3-sh-' + Math.random().toString(36).slice(2, 7);
|
||
var _shGrad = document.createElementNS(_SVG_NS, 'linearGradient');
|
||
_shGrad.setAttribute('id', _shGradId);
|
||
_shGrad.setAttribute('gradientUnits', 'userSpaceOnUse');
|
||
_shGrad.setAttribute('x1', _MT3_ARC_SX.toFixed(2)); _shGrad.setAttribute('y1', '0');
|
||
_shGrad.setAttribute('x2', _MT3_ARC_EX.toFixed(2)); _shGrad.setAttribute('y2', '0');
|
||
[['0%','rgba(0,0,0,0.65)'],['4%','rgba(0,0,0,0)'],['20%','rgba(0,0,0,0.68)'],['80%','rgba(0,0,0,0.68)'],['96%','rgba(0,0,0,0)'],['100%','rgba(0,0,0,0.65)']]
|
||
.forEach(function(s){var st=document.createElementNS(_SVG_NS,'stop');st.setAttribute('offset',s[0]);st.setAttribute('stop-color',s[1]);_shGrad.appendChild(st);});
|
||
_svgDefs.appendChild(_shGrad);
|
||
|
||
_svgDefs.appendChild(_svgFilter);
|
||
gaugeSvg.appendChild(_svgDefs);
|
||
|
||
var _arcD = 'M ' + _MT3_ARC_SX.toFixed(2) + ' ' + _MT3_ARC_SY.toFixed(2) +
|
||
' A ' + _MT3_ARC_R + ' ' + _MT3_ARC_R + ' 0 0 1 ' +
|
||
_MT3_ARC_EX.toFixed(2) + ' ' + _MT3_ARC_EY.toFixed(2);
|
||
|
||
// Glass arc body
|
||
var arcBody = document.createElementNS(_SVG_NS, 'path');
|
||
arcBody.setAttribute('d', _arcD);
|
||
arcBody.setAttribute('fill', 'none');
|
||
arcBody.setAttribute('stroke', _MT3_COL_GAUGE_ARC);
|
||
arcBody.setAttribute('stroke-width', '16');
|
||
arcBody.setAttribute('stroke-linecap', 'round');
|
||
gaugeSvg.appendChild(arcBody);
|
||
|
||
// Inner shadow — dark stroke on the inner-lower edge, simulates less light reaching far side
|
||
var _shadowR = _MT3_ARC_R - 6;
|
||
// Align shadow gradient vector to shadow arc's own endpoints (not the main arc's)
|
||
_shGrad.setAttribute('x1', (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START)).toFixed(2));
|
||
_shGrad.setAttribute('x2', (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
|
||
var arcShadow = document.createElementNS(_SVG_NS, 'path');
|
||
arcShadow.setAttribute('d',
|
||
'M ' + (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START)).toFixed(2) + ' ' +
|
||
(_MT3_cy + _shadowR * Math.sin(_MT3_ARC_START)).toFixed(2) +
|
||
' A ' + _shadowR + ' ' + _shadowR + ' 0 0 1 ' +
|
||
(_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2) + ' ' +
|
||
(_MT3_cy + _shadowR * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
|
||
arcShadow.setAttribute('fill', 'none');
|
||
arcShadow.setAttribute('stroke', 'url(#' + _shGradId + ')');
|
||
arcShadow.setAttribute('stroke-width', '3.5');
|
||
arcShadow.setAttribute('stroke-linecap', 'round');
|
||
arcShadow.setAttribute('filter', 'url(#' + _glassBlurId + ')');
|
||
|
||
// Specular highlight — bright white arc fading to transparent at ends
|
||
var _hlR = _MT3_ARC_R + 2;
|
||
var arcHighlight = document.createElementNS(_SVG_NS, 'path');
|
||
arcHighlight.setAttribute('d',
|
||
'M ' + (_MT3_cx + _hlR * Math.cos(_MT3_ARC_START)).toFixed(2) + ' ' +
|
||
(_MT3_cy + _hlR * Math.sin(_MT3_ARC_START)).toFixed(2) +
|
||
' A ' + _hlR + ' ' + _hlR + ' 0 0 1 ' +
|
||
(_MT3_cx + _hlR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2) + ' ' +
|
||
(_MT3_cy + _hlR * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
|
||
arcHighlight.setAttribute('fill', 'none');
|
||
arcHighlight.setAttribute('stroke', 'url(#' + _hlGradId + ')');
|
||
arcHighlight.setAttribute('stroke-width', '2.5');
|
||
arcHighlight.setAttribute('stroke-linecap', 'round');
|
||
arcHighlight.setAttribute('filter', 'url(#' + _glassBlurId + ')');
|
||
|
||
// dimGroup: base tick lines always shown at dim colour — constructed once, never updated
|
||
var _dimGroup = document.createElementNS(_SVG_NS, 'g');
|
||
|
||
// glowGroup: lit tick overlay with shared glow filter, opacity animated 0→1
|
||
var _glowGroup = document.createElementNS(_SVG_NS, 'g');
|
||
_glowGroup.setAttribute('filter', 'url(#' + _glowId + ')');
|
||
_glowGroup.setAttribute('opacity', '0');
|
||
|
||
// Z-order: arcBody → dimGroup (unlit ticks) → arcShadow → glowGroup (lit ticks) → arcHighlight
|
||
gaugeSvg.appendChild(_dimGroup);
|
||
gaugeSvg.appendChild(arcShadow);
|
||
gaugeSvg.appendChild(_glowGroup);
|
||
|
||
var _mt3GlowTickEls = [];
|
||
|
||
for (var i = 0; i < _TUNER_MT3_TICK_COUNT; i++) {
|
||
var isMajor = (i % 5 === 0);
|
||
var halfLen = isMajor ? 5 : 3;
|
||
var a = _MT3_ARC_START + (_MT3_ARC_SPAN / (_TUNER_MT3_TICK_COUNT - 1)) * i;
|
||
var cosA = Math.cos(a), sinA = Math.sin(a);
|
||
var x1 = _MT3_cx + (_MT3_ARC_R - halfLen) * cosA;
|
||
var y1 = _MT3_cy + (_MT3_ARC_R - halfLen) * sinA;
|
||
var x2 = _MT3_cx + (_MT3_ARC_R + halfLen) * cosA;
|
||
var y2 = _MT3_cy + (_MT3_ARC_R + halfLen) * sinA;
|
||
|
||
var dimTick = document.createElementNS(_SVG_NS, 'line');
|
||
dimTick.setAttribute('x1', String(x1)); dimTick.setAttribute('y1', String(y1));
|
||
dimTick.setAttribute('x2', String(x2)); dimTick.setAttribute('y2', String(y2));
|
||
dimTick.setAttribute('stroke', _MT3_COL_TICK_DIM);
|
||
dimTick.setAttribute('stroke-width', '1');
|
||
dimTick.setAttribute('stroke-linecap', 'round');
|
||
_dimGroup.appendChild(dimTick);
|
||
|
||
var glowTick = document.createElementNS(_SVG_NS, 'line');
|
||
glowTick.setAttribute('x1', String(x1)); glowTick.setAttribute('y1', String(y1));
|
||
glowTick.setAttribute('x2', String(x2)); glowTick.setAttribute('y2', String(y2));
|
||
glowTick.setAttribute('stroke', 'none');
|
||
glowTick.setAttribute('stroke-width', '1');
|
||
glowTick.setAttribute('stroke-linecap', 'round');
|
||
_glowGroup.appendChild(glowTick);
|
||
_mt3GlowTickEls.push(glowTick);
|
||
}
|
||
|
||
// Per-tick gradient colours: orange (#ff7700) at arc edges → yellow (#ffee00) at centre
|
||
// d = distance from centre (0 = centre tick, 1 = end ticks)
|
||
var _mt3TickColors = [];
|
||
var _mt3TickSpillColors = [];
|
||
for (var tc = 0; tc < _TUNER_MT3_TICK_COUNT; tc++) {
|
||
var d = Math.abs((tc / (_TUNER_MT3_TICK_COUNT - 1)) - 0.5) * 2;
|
||
var tg = Math.round(238 * (1 - d) + 119 * d); // G channel: 238 (yellow) → 119 (orange)
|
||
_mt3TickColors.push('rgb(255,' + tg + ',0)');
|
||
_mt3TickSpillColors.push('rgba(255,' + tg + ',0,0.52)');
|
||
}
|
||
|
||
// Arc-following labels — inside the arc at R−18 (below the tube's inner edge with a gap)
|
||
// Tube inner edge at R−8; labels at R−18 give ~10 SVG-unit gap at apex, ~7 at ends
|
||
[
|
||
{ t: 0.0, text: '-50' },
|
||
{ t: 0.5, text: '0' },
|
||
{ t: 1.0, text: '+50' },
|
||
].forEach(function (lbl) {
|
||
var ang = _MT3_ARC_START + lbl.t * _MT3_ARC_SPAN;
|
||
var r = _MT3_ARC_R - 18;
|
||
var lx = _MT3_cx + r * Math.cos(ang);
|
||
var ly = _MT3_cy + r * Math.sin(ang);
|
||
var el = document.createElementNS(_SVG_NS, 'text');
|
||
el.setAttribute('x', String(lx));
|
||
el.setAttribute('y', String(ly));
|
||
el.setAttribute('text-anchor', 'middle');
|
||
el.setAttribute('dominant-baseline', 'middle');
|
||
el.setAttribute('font-size', '7');
|
||
el.setAttribute('fill', 'rgba(255,255,255,0.50)');
|
||
el.textContent = lbl.text;
|
||
gaugeSvg.appendChild(el);
|
||
});
|
||
|
||
// arcHighlight is topmost — appended last so it renders above ticks
|
||
gaugeSvg.appendChild(arcHighlight);
|
||
|
||
panel.appendChild(gaugeSvg);
|
||
|
||
// ── 7-segment display ─────────────────────────────────────────
|
||
var displayWrap = document.createElement('div');
|
||
displayWrap.style.position = 'absolute';
|
||
displayWrap.style.bottom = '5%';
|
||
displayWrap.style.left = '50%';
|
||
displayWrap.style.transform = 'translateX(-50%)';
|
||
displayWrap.style.width = '18%';
|
||
displayWrap.style.height = '45%';
|
||
displayWrap.style.background = '#0d0000';
|
||
displayWrap.style.borderRadius = '3px';
|
||
displayWrap.style.border = '1px solid #2a0000';
|
||
displayWrap.style.display = 'flex';
|
||
displayWrap.style.alignItems = 'center';
|
||
displayWrap.style.justifyContent = 'center';
|
||
displayWrap.style.padding = '4%';
|
||
displayWrap.style.boxSizing = 'border-box';
|
||
displayWrap.style.boxShadow = 'inset 0 0 8px #000';
|
||
panel.appendChild(displayWrap);
|
||
|
||
var segSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
segSvg.setAttribute('viewBox', '0 0 100 200');
|
||
segSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
segSvg.style.width = '90%';
|
||
segSvg.style.aspectRatio = '1 / 2';
|
||
segSvg.style.flexShrink = '0';
|
||
segSvg.style.overflow = 'visible';
|
||
displayWrap.appendChild(segSvg);
|
||
|
||
var _mt3SegEls = {};
|
||
function _makeSegPoly(key, points) {
|
||
var el = document.createElementNS(_SVG_NS, 'polygon');
|
||
el.setAttribute('points', points);
|
||
el.setAttribute('fill', _MT3_COL_SEG_UNLIT);
|
||
el.setAttribute('shape-rendering', 'crispEdges');
|
||
segSvg.appendChild(el);
|
||
_mt3SegEls[key] = el;
|
||
}
|
||
_makeSegPoly('a', '11,5 89,5 95,13 89,21 11,21 5,13');
|
||
_makeSegPoly('b', '87,26 95,32 95,81 87,87 79,81 79,32');
|
||
_makeSegPoly('c', '87,113 95,119 95,168 87,174 79,168 79,119');
|
||
_makeSegPoly('d', '11,179 89,179 95,187 89,195 11,195 5,187');
|
||
_makeSegPoly('e', '13,113 21,119 21,168 13,174 5,168 5,119');
|
||
_makeSegPoly('f', '13,26 21,32 21,81 13,87 5,81 5,32');
|
||
_makeSegPoly('g1', '11,92 42.5,92 48.5,100 42.5,108 11,108 5,100');
|
||
_makeSegPoly('g2', '57.5,92 89,92 95,100 89,108 57.5,108 51.5,100');
|
||
|
||
var sharpSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
sharpSvg.setAttribute('viewBox', '0 0 90 90');
|
||
sharpSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
sharpSvg.style.position = 'absolute';
|
||
sharpSvg.style.top = '57%';
|
||
sharpSvg.style.right = '3%';
|
||
sharpSvg.style.width = '23%';
|
||
sharpSvg.style.aspectRatio = '1 / 1';
|
||
sharpSvg.style.overflow = 'visible';
|
||
sharpSvg.style.pointerEvents = 'none';
|
||
displayWrap.appendChild(sharpSvg);
|
||
|
||
var _mt3SharpParts = [];
|
||
function _makeSharpPoly(pts) {
|
||
var el = document.createElementNS(_SVG_NS, 'polygon');
|
||
el.setAttribute('points', pts);
|
||
el.setAttribute('fill', _MT3_COL_SEG_UNLIT);
|
||
el.setAttribute('shape-rendering', 'crispEdges');
|
||
sharpSvg.appendChild(el);
|
||
_mt3SharpParts.push(el);
|
||
}
|
||
_makeSharpPoly('28.3,0 33.3,4 33.3,86 28.3,90 23.3,86 23.3,4');
|
||
_makeSharpPoly('61.7,0 66.7,4 66.7,86 61.7,90 56.7,86 56.7,4');
|
||
_makeSharpPoly('4,23.3 86,23.3 90,28.3 86,33.3 4,33.3 0,28.3');
|
||
_makeSharpPoly('4,56.7 86,56.7 90,61.7 86,66.7 4,66.7 0,61.7');
|
||
|
||
// "♭" symbol — same position as sharpSvg, shown in place of it for flat notes
|
||
var flatSvg = document.createElementNS(_SVG_NS, 'svg');
|
||
flatSvg.setAttribute('viewBox', '0 0 90 90');
|
||
flatSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
flatSvg.style.position = 'absolute';
|
||
flatSvg.style.top = '57%';
|
||
flatSvg.style.right = '3%';
|
||
flatSvg.style.width = '23%';
|
||
flatSvg.style.aspectRatio = '1 / 1';
|
||
flatSvg.style.overflow = 'visible';
|
||
flatSvg.style.pointerEvents = 'none';
|
||
flatSvg.style.display = 'none';
|
||
displayWrap.appendChild(flatSvg);
|
||
var _mt3FlatText = document.createElementNS(_SVG_NS, 'text');
|
||
_mt3FlatText.setAttribute('x', '45');
|
||
_mt3FlatText.setAttribute('y', '82');
|
||
_mt3FlatText.setAttribute('text-anchor', 'middle');
|
||
_mt3FlatText.setAttribute('font-size', '85');
|
||
_mt3FlatText.setAttribute('font-family', 'Georgia, serif');
|
||
_mt3FlatText.setAttribute('fill', _MT3_COL_SEG_UNLIT);
|
||
_mt3FlatText.textContent = '♭';
|
||
flatSvg.appendChild(_mt3FlatText);
|
||
|
||
// ── Buttons ───────────────────────────────────────────────────
|
||
// Strobe indicator LED — centred above the MODE button, lit when strobe mode is active
|
||
// Button: bottom:24%, height:8% → top edge at bottom:32%; LED sits at bottom:33.5%
|
||
// Button left:calc(50%-22%), width:10% → centre at calc(50%-17%); LED width:2.5% → left:calc(50%-18.25%)
|
||
var _mt3StrobeLed = document.createElement('div');
|
||
_mt3StrobeLed.style.position = 'absolute';
|
||
_mt3StrobeLed.style.bottom = '33.5%';
|
||
_mt3StrobeLed.style.left = 'calc(50% - 18.25%)';
|
||
_mt3StrobeLed.style.width = '2.5%';
|
||
_mt3StrobeLed.style.height = '0';
|
||
_mt3StrobeLed.style.paddingBottom = '2.5%';
|
||
_mt3StrobeLed.style.borderRadius = '50%';
|
||
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #2a0000, #0a0000)';
|
||
_mt3StrobeLed.style.boxShadow = 'none';
|
||
_mt3StrobeLed.style.pointerEvents = 'none';
|
||
panel.appendChild(_mt3StrobeLed);
|
||
|
||
var _mt3ModeBtn = document.createElement('div');
|
||
_mt3ModeBtn.style.position = 'absolute';
|
||
_mt3ModeBtn.style.bottom = '24%';
|
||
_mt3ModeBtn.style.left = 'calc(50% - 22%)';
|
||
_mt3ModeBtn.style.width = '10%';
|
||
_mt3ModeBtn.style.height = '8%';
|
||
_mt3ModeBtn.style.backgroundColor = _MT3_COL_BUTTON;
|
||
_mt3ModeBtn.style.borderRadius = '3px';
|
||
_mt3ModeBtn.style.border = '1px solid #333';
|
||
_mt3ModeBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
|
||
_mt3ModeBtn.style.cursor = 'pointer';
|
||
panel.appendChild(_mt3ModeBtn);
|
||
|
||
var brightBtn = document.createElement('div');
|
||
brightBtn.style.position = 'absolute';
|
||
brightBtn.style.bottom = '24%';
|
||
brightBtn.style.left = 'calc(50% + 12%)';
|
||
brightBtn.style.width = '10%';
|
||
brightBtn.style.height = '8%';
|
||
brightBtn.style.backgroundColor = _MT3_COL_BUTTON;
|
||
brightBtn.style.borderRadius = '3px';
|
||
brightBtn.style.border = '1px solid #333';
|
||
brightBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
|
||
brightBtn.style.cursor = 'pointer';
|
||
panel.appendChild(brightBtn);
|
||
|
||
[{text: 'MODE', left: 'calc(50% - 22%)'}, {text: 'BRGHT', left: 'calc(50% + 12%)'}]
|
||
.forEach(function (lbl) {
|
||
var el = document.createElement('div');
|
||
el.style.position = 'absolute';
|
||
el.style.bottom = '14%';
|
||
el.style.left = lbl.left;
|
||
el.style.width = '10%';
|
||
el.style.textAlign = 'center';
|
||
el.style.color = _MT3_COL_LABEL;
|
||
el.style.fontSize = '0.75em';
|
||
el.style.letterSpacing = '0.05em';
|
||
el.style.pointerEvents = 'none';
|
||
el.textContent = lbl.text;
|
||
panel.appendChild(el);
|
||
});
|
||
|
||
var brandLbl = document.createElement('div');
|
||
brandLbl.style.position = 'absolute';
|
||
brandLbl.style.top = '2%';
|
||
brandLbl.style.right = '8%';
|
||
brandLbl.style.color = _MT3_COL_LABEL;
|
||
brandLbl.style.fontSize = '0.75em';
|
||
brandLbl.style.fontWeight = '600';
|
||
brandLbl.style.letterSpacing = '0.06em';
|
||
brandLbl.textContent = 'CHEF MT-3';
|
||
panel.appendChild(brandLbl);
|
||
|
||
container.appendChild(panel);
|
||
|
||
// ── Animation / glow state ────────────────────────────────────
|
||
var _mt3Mode = 'standard';
|
||
var _mt3CurrentCents = 0;
|
||
var _mt3SmoothedCents = 0;
|
||
var _mt3HasSignal = false;
|
||
var _mt3GlowOpacity = 0;
|
||
var _mt3RafId = null;
|
||
var _mt3BrightnessIdx = 1; // 0=low, 1=medium, 2=high
|
||
var _mt3LastLetter = ' '; // for re-render on brightness change
|
||
var _mt3LastSharp = false;
|
||
var _mt3LastFlat = false;
|
||
var _mt3LastTime = null;
|
||
var _mt3StrobeOffset = 0;
|
||
|
||
// Tick state: 0=dim, 1=spill, 2=bright (computed each frame, never persisted between calls)
|
||
var _mt3TickState = [];
|
||
var _mt3LastTickState = [];
|
||
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) {
|
||
_mt3TickState.push(0);
|
||
_mt3LastTickState.push(-1); // -1 = unrendered, forces first paint
|
||
}
|
||
|
||
// ── Segment helpers ───────────────────────────────────────────
|
||
function _setSegment(el, lit) {
|
||
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
|
||
el.setAttribute('fill', lit ? brt.litFill : _MT3_COL_SEG_UNLIT);
|
||
el.style.filter = lit ? brt.glow : 'drop-shadow(0 0 0px transparent)';
|
||
}
|
||
function _renderNote(letter) {
|
||
var map = _TUNER_MT3_SEGMENTS[letter] || _TUNER_MT3_SEGMENTS[' '];
|
||
for (var k = 0; k < _segKeys.length; k++) { _setSegment(_mt3SegEls[_segKeys[k]], map[k]); }
|
||
}
|
||
function _setSharp(lit) {
|
||
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
|
||
for (var p = 0; p < _mt3SharpParts.length; p++) {
|
||
_mt3SharpParts[p].setAttribute('fill', lit ? brt.litFill : _MT3_COL_SEG_UNLIT);
|
||
}
|
||
sharpSvg.style.filter = lit ? brt.glow : 'none';
|
||
}
|
||
function _setFlat(lit) {
|
||
flatSvg.style.display = lit ? '' : 'none';
|
||
if (lit) {
|
||
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
|
||
_mt3FlatText.setAttribute('fill', brt.litFill);
|
||
flatSvg.style.filter = brt.glow;
|
||
} else {
|
||
flatSvg.style.filter = 'none';
|
||
}
|
||
}
|
||
function _applyAccidental() {
|
||
if (_mt3LastFlat) {
|
||
sharpSvg.style.display = 'none';
|
||
_setFlat(true);
|
||
} else {
|
||
sharpSvg.style.display = '';
|
||
_setSharp(_mt3LastSharp);
|
||
_setFlat(false);
|
||
}
|
||
}
|
||
|
||
// ── Tick state helpers ────────────────────────────────────────
|
||
function _clearTickStates() {
|
||
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) { _mt3TickState[ti] = 0; }
|
||
}
|
||
|
||
// Never downgrade: a bright centre tick won't be overwritten by a spill from another group
|
||
function _setTickState(idx, level) {
|
||
if (idx < 0 || idx >= _TUNER_MT3_TICK_COUNT) { return; }
|
||
if (level > _mt3TickState[idx]) { _mt3TickState[idx] = level; }
|
||
}
|
||
|
||
// Standard mode: 1 bright + ±1 spill
|
||
function _computeStandardStates(cents, hasSignal) {
|
||
_clearTickStates();
|
||
if (!hasSignal) { return; }
|
||
var clamped = Math.max(-_TUNER_MT3_GAUGE_CENTS, Math.min(_TUNER_MT3_GAUGE_CENTS, cents));
|
||
var targetIdx = Math.round((clamped + _TUNER_MT3_GAUGE_CENTS) /
|
||
(2 * _TUNER_MT3_GAUGE_CENTS / (_TUNER_MT3_TICK_COUNT - 1)));
|
||
targetIdx = Math.max(0, Math.min(_TUNER_MT3_TICK_COUNT - 1, targetIdx));
|
||
_setTickState(targetIdx, 2);
|
||
_setTickState(targetIdx - 1, 1);
|
||
_setTickState(targetIdx + 1, 1);
|
||
}
|
||
|
||
// Strobe mode: 3 bright ticks per group + ±1 spill on each outer edge
|
||
function _computeStrobeStates() {
|
||
_clearTickStates();
|
||
for (var g = 0; g < _TUNER_MT3_STROBE_GROUP_COUNT; g++) {
|
||
var baseAngle = _MT3_ARC_START + (g / _TUNER_MT3_STROBE_GROUP_COUNT) * _MT3_ARC_SPAN + _mt3StrobeOffset;
|
||
var relAngle = ((baseAngle - _MT3_ARC_START) % _MT3_ARC_SPAN + _MT3_ARC_SPAN) % _MT3_ARC_SPAN;
|
||
var nearIdx = Math.max(0, Math.min(_TUNER_MT3_TICK_COUNT - 1,
|
||
Math.round(relAngle / _MT3_ARC_SPAN * (_TUNER_MT3_TICK_COUNT - 1))));
|
||
// 3 bright ticks centred at nearIdx
|
||
_setTickState(nearIdx - 1, 2);
|
||
_setTickState(nearIdx, 2);
|
||
_setTickState(nearIdx + 1, 2);
|
||
// Lightspill beyond the cluster
|
||
_setTickState(nearIdx - 2, 1);
|
||
_setTickState(nearIdx + 2, 1);
|
||
}
|
||
}
|
||
|
||
// Apply current tick states to the glowGroup DOM — only write changed ticks
|
||
function _applyTickStates() {
|
||
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) {
|
||
var state = _mt3TickState[ti];
|
||
if (state === _mt3LastTickState[ti]) { continue; }
|
||
_mt3LastTickState[ti] = state;
|
||
var el = _mt3GlowTickEls[ti];
|
||
if (state === 2) {
|
||
el.setAttribute('stroke', _mt3TickColors[ti]);
|
||
el.setAttribute('stroke-width', '3');
|
||
} else if (state === 1) {
|
||
el.setAttribute('stroke', _mt3TickSpillColors[ti]);
|
||
el.setAttribute('stroke-width', '2');
|
||
} else {
|
||
el.setAttribute('stroke', 'none');
|
||
el.setAttribute('stroke-width', '1');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── RAF loop ──────────────────────────────────────────────────
|
||
function _animateStrobe(now) {
|
||
if (_mt3LastTime === null) { _mt3LastTime = now; }
|
||
var dt = Math.min((now - _mt3LastTime) / 1000, 0.1);
|
||
_mt3LastTime = now;
|
||
|
||
// Smooth cents for strobe drift
|
||
var lerpFactor = 1 - Math.exp(-10 * dt);
|
||
_mt3SmoothedCents += (_mt3CurrentCents - _mt3SmoothedCents) * lerpFactor;
|
||
|
||
// Animate glow opacity: fast fade-in (~120ms), slow fade-out (~400ms)
|
||
var opacityTarget = _mt3HasSignal ? 1.0 : 0.0;
|
||
var opacityRate = _mt3HasSignal ? 8.0 : 2.5;
|
||
_mt3GlowOpacity += (opacityTarget - _mt3GlowOpacity) * (1 - Math.exp(-opacityRate * dt));
|
||
var _scaledOpacity = Math.min(1, _mt3GlowOpacity * _MT3_BRIGHTNESS[_mt3BrightnessIdx].brightScale);
|
||
_glowGroup.setAttribute('opacity', _scaledOpacity.toFixed(3));
|
||
|
||
// Advance strobe offset
|
||
if (_mt3Mode === 'strobe' && Math.abs(_mt3SmoothedCents) > 0.1) {
|
||
var absCents = Math.min(_TUNER_MT3_GAUGE_CENTS, Math.abs(_mt3SmoothedCents));
|
||
var normalized = Math.max(0, absCents - _TUNER_MT3_IN_TUNE_THR) / (_TUNER_MT3_GAUGE_CENTS - _TUNER_MT3_IN_TUNE_THR);
|
||
var speed = _MT3_ARC_SPAN * Math.pow(normalized, 0.9);
|
||
if (_mt3SmoothedCents < 0) { speed = -speed; }
|
||
_mt3StrobeOffset = ((_mt3StrobeOffset + speed * dt) % _MT3_ARC_SPAN + _MT3_ARC_SPAN) % _MT3_ARC_SPAN;
|
||
}
|
||
|
||
// Recompute and apply strobe tick states each frame
|
||
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 ───────────────────────────────────────────────
|
||
_mt3ModeBtn.addEventListener('click', function () {
|
||
_mt3ModeBtn.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.9)';
|
||
setTimeout(function () {
|
||
_mt3ModeBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
|
||
}, 120);
|
||
if (_mt3Mode === 'standard') {
|
||
_mt3Mode = 'strobe';
|
||
_mt3StrobeOffset = 0;
|
||
_clearTickStates();
|
||
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #ff4444, #cc0000)';
|
||
_mt3StrobeLed.style.boxShadow = '0 0 4px 2px #ff2200, 0 0 8px 3px #880000';
|
||
} else {
|
||
_mt3Mode = 'standard';
|
||
_computeStandardStates(_mt3CurrentCents, _mt3HasSignal);
|
||
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #2a0000, #0a0000)';
|
||
_mt3StrobeLed.style.boxShadow = 'none';
|
||
}
|
||
});
|
||
|
||
// ── BRGHT button — cycle low/medium/high brightness ──────────
|
||
brightBtn.addEventListener('click', function () {
|
||
brightBtn.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.9)';
|
||
setTimeout(function () {
|
||
brightBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
|
||
}, 120);
|
||
_mt3BrightnessIdx = (_mt3BrightnessIdx + 1) % _MT3_BRIGHTNESS.length;
|
||
// Update SVG tick glow filter intensity
|
||
_sfFlood.setAttribute('flood-opacity', _MT3_BRIGHTNESS[_mt3BrightnessIdx].floodOpacity);
|
||
// Re-render segment display with new brightness
|
||
_renderNote(_mt3LastLetter);
|
||
_applyAccidental();
|
||
});
|
||
|
||
// ── Public: update ────────────────────────────────────────────
|
||
function update(note, cents) {
|
||
var hasNote = (note !== null && note !== undefined);
|
||
_mt3HasSignal = hasNote;
|
||
_mt3CurrentCents = hasNote ? (cents || 0) : 0;
|
||
if (_mt3Mode === 'standard') { _computeStandardStates(_mt3CurrentCents, hasNote); }
|
||
if (hasNote) {
|
||
_mt3LastLetter = note.charAt(0);
|
||
_mt3LastSharp = note.charAt(1) === '#';
|
||
_mt3LastFlat = note.charAt(1) === 'b';
|
||
_renderNote(_mt3LastLetter);
|
||
_applyAccidental();
|
||
} else {
|
||
_mt3LastLetter = ' ';
|
||
_mt3LastSharp = false;
|
||
_mt3LastFlat = false;
|
||
_renderNote(' ');
|
||
_applyAccidental();
|
||
}
|
||
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
|
||
}
|
||
|
||
// ── Public: destroy ───────────────────────────────────────────
|
||
function destroy() {
|
||
if (_mt3RafId) { cancelAnimationFrame(_mt3RafId); _mt3RafId = null; }
|
||
panel.remove();
|
||
}
|
||
|
||
return { update: update, destroy: destroy };
|
||
};
|
||
})();
|