Files
feedBack/plugins/tuner/visualization/analogue-gauge.js
T
af2949677a rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

389 lines
18 KiB
JavaScript
Raw 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.
/**
* 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 _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);
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';
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';
}
}
function destroy() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
panel.remove();
}
return { update: update, destroy: destroy };
};
})();