mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
* 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>
751 lines
38 KiB
JavaScript
751 lines
38 KiB
JavaScript
window._tunerUI = function(state, actions) {
|
||
const _AUTO_TARGET_HYSTERESIS_CENTS = 40;
|
||
let _lastAutoTargetFreq = null;
|
||
let _lastTuningRef = null;
|
||
|
||
// Octave-aware nearest-string match. YIN frequently reports a sub-octave on
|
||
// low strings (D1 instead of D2) and a common sub-harmonic on polyphonic
|
||
// plucks (D+G together → G0), which a raw-distance match snaps to the wrong
|
||
// (lower) string. Fold the detected frequency into each candidate string's
|
||
// octave before measuring distance so an octave-off reading still resolves
|
||
// to the right string. Ties — a tuning with the same pitch class in two
|
||
// octaves, e.g. guitar E2/E4 — resolve to the smallest octave shift, i.e.
|
||
// the octave actually being played. Returns the matched string frequency
|
||
// and the detected frequency folded into that string's octave.
|
||
function _matchString(detected, strings) {
|
||
let bestFreq = null, bestResidual = Infinity, bestShift = Infinity;
|
||
for (const f of strings) {
|
||
if (!Number.isFinite(f) || f <= 0) continue; // skip malformed tuning entries
|
||
const shift = Math.round(Math.log2(f / detected));
|
||
const corrected = detected * Math.pow(2, shift);
|
||
const residual = Math.abs(Math.log2(corrected / f)) * 1200;
|
||
if (residual < bestResidual - 1
|
||
|| (Math.abs(residual - bestResidual) <= 1 && Math.abs(shift) < bestShift)) {
|
||
bestFreq = f; bestResidual = residual; bestShift = Math.abs(shift);
|
||
}
|
||
}
|
||
return bestFreq; // null when no usable string frequency exists
|
||
}
|
||
|
||
// Fold a detected frequency into the same octave as a known target, so cents
|
||
// and the displayed Hz reflect deviation within the octave rather than a
|
||
// ±1200 swing when the detector reports the wrong octave.
|
||
function _foldToOctaveOf(detected, target) {
|
||
return detected * Math.pow(2, Math.round(Math.log2(target / detected)));
|
||
}
|
||
|
||
const _INSTRUMENT_DISPLAY = {
|
||
'guitar-6': 'Guitar (6)', 'guitar-7': 'Guitar (7)', 'guitar-8': 'Guitar (8)',
|
||
'bass-4': 'Bass (4)', 'bass-5': 'Bass (5)',
|
||
};
|
||
|
||
function _freqsEqual(a, b) {
|
||
if (!a || !b || a.length !== b.length) return false;
|
||
return a.every(function(f, i) { return Math.round(f * 100) === Math.round(b[i] * 100); });
|
||
}
|
||
|
||
function _tuningAlreadyKnown(freqs) {
|
||
if (!freqs || !freqs.length) return false;
|
||
const instrument = state.selectedInstrument;
|
||
const known = (state._allTunings && state._allTunings[instrument]) || {};
|
||
for (var name in known) {
|
||
if (_freqsEqual(freqs, known[name])) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function _updateInstrumentDisplay() {
|
||
if (state._instrumentSentinel) {
|
||
state._instrumentSentinel.textContent = _INSTRUMENT_DISPLAY[state.selectedInstrument] || state.selectedInstrument;
|
||
if (state.instrumentSelect) state.instrumentSelect.value = '__display__';
|
||
}
|
||
}
|
||
|
||
function _syncStringHighlight(targetFreq) {
|
||
if (!state.stringNoteContainer) return;
|
||
Array.from(state.stringNoteContainer.children).forEach(btn => {
|
||
const match = targetFreq !== null && Math.abs(parseFloat(btn.dataset.freq) - targetFreq) < 0.1;
|
||
btn.className = match
|
||
? 'flex-1 py-1.5 text-xs font-bold rounded bg-accent text-white border border-accent transition-colors'
|
||
: 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
|
||
});
|
||
}
|
||
|
||
function _syncActiveStringFromFreq(targetFreq, isManual) {
|
||
if (!state.stringNoteContainer) return;
|
||
Array.from(state.stringNoteContainer.children).forEach(btn => {
|
||
const match = Math.abs(parseFloat(btn.dataset.freq) - targetFreq) < 0.1;
|
||
if (match) {
|
||
btn.className = isManual
|
||
? 'flex-1 py-1.5 text-xs font-bold rounded bg-accent text-white border border-accent transition-colors'
|
||
: 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-accent border border-accent transition-colors';
|
||
} else {
|
||
btn.className = 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
|
||
}
|
||
});
|
||
}
|
||
|
||
function _updateSaveAsCustomVisibility() {
|
||
if (!state.saveAsCustomContainer) return;
|
||
const show = state.selectedTuningName === '_current'
|
||
&& state.selectedTuning
|
||
&& state.selectedTuning.length > 0
|
||
&& !_tuningAlreadyKnown(state.selectedTuning);
|
||
if (show) {
|
||
state.saveAsCustomContainer.classList.remove('hidden');
|
||
} else {
|
||
state.saveAsCustomContainer.classList.add('hidden');
|
||
const inp = state.saveAsCustomContainer.querySelector('.tuner-save-inline');
|
||
if (inp) inp.remove();
|
||
}
|
||
}
|
||
|
||
function _showSaveAsCustomInput() {
|
||
if (state.saveAsCustomContainer.querySelector('.tuner-save-inline')) return;
|
||
|
||
const labelBtn = state.saveAsCustomContainer.querySelector('.tuner-save-label');
|
||
if (labelBtn) labelBtn.classList.add('hidden');
|
||
|
||
const inline = document.createElement('div');
|
||
inline.className = 'tuner-save-inline flex gap-2 w-full';
|
||
|
||
const suggestedName = (state.currentSongOffsets && window._tunerUtils)
|
||
? (window._tunerUtils.getTuningName(state.currentSongOffsets) || 'Custom Tuning')
|
||
: 'Custom Tuning';
|
||
|
||
const nameInput = document.createElement('input');
|
||
nameInput.type = 'text';
|
||
nameInput.value = suggestedName;
|
||
nameInput.className = 'flex-1 bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-xs text-fb-text outline-none focus:border-accent';
|
||
|
||
const confirmBtn = document.createElement('button');
|
||
confirmBtn.textContent = 'Save';
|
||
confirmBtn.className = 'bg-accent/20 hover:bg-accent/30 border border-accent/40 text-accent text-xs px-3 py-1 rounded transition-colors';
|
||
|
||
const doSave = async () => {
|
||
const name = nameInput.value.trim();
|
||
if (!name || !state.selectedTuning || state.selectedTuning.length === 0) return;
|
||
const rounded = state.selectedTuning.map(f => Math.round(f * 100) / 100);
|
||
const sc = rounded.length;
|
||
const instrument = (sc === 4 || sc === 5)
|
||
? (state.currentSongIsBass ? 'bass-' + sc : 'guitar-6')
|
||
: (sc === 7 ? 'guitar-7' : sc === 8 ? 'guitar-8' : 'guitar-6');
|
||
try {
|
||
const config = await fetch('/api/plugins/tuner/config').then(r => r.json());
|
||
const custom = config.customTunings || {};
|
||
custom[name] = { instrument, strings: rounded };
|
||
await fetch('/api/plugins/tuner/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ customTunings: custom }),
|
||
});
|
||
state.selectedInstrument = instrument;
|
||
state.selectedTuningName = name;
|
||
state.selectedTuning = rounded;
|
||
_updateInstrumentDisplay();
|
||
await actions.loadConfig();
|
||
state.selectedTuningName = name;
|
||
state.selectedTuning = state.tunings[name] || rounded;
|
||
if (state.tuningSelect) state.tuningSelect.value = name;
|
||
renderStringNotes();
|
||
actions.saveConfig();
|
||
window.feedBack?.emit('tunings:updated');
|
||
} catch (e) {
|
||
console.error('Tuner: Failed to save custom tuning', e);
|
||
}
|
||
};
|
||
|
||
confirmBtn.onclick = doSave;
|
||
nameInput.onkeydown = (e) => { if (e.key === 'Enter') doSave(); };
|
||
|
||
inline.appendChild(nameInput);
|
||
inline.appendChild(confirmBtn);
|
||
state.saveAsCustomContainer.appendChild(inline);
|
||
nameInput.focus();
|
||
nameInput.select();
|
||
}
|
||
|
||
function renderInstrumentOptions() {
|
||
if (!state.instrumentSelect) return;
|
||
state.instrumentSelect.innerHTML = '';
|
||
|
||
state._instrumentSentinel = document.createElement('option');
|
||
state._instrumentSentinel.value = '__display__';
|
||
state._instrumentSentinel.textContent = _INSTRUMENT_DISPLAY[state.selectedInstrument] || state.selectedInstrument;
|
||
state._instrumentSentinel.style.display = 'none';
|
||
state.instrumentSelect.appendChild(state._instrumentSentinel);
|
||
|
||
const guitarGroup = document.createElement('optgroup');
|
||
guitarGroup.label = 'Guitar';
|
||
[['guitar-6', '6-string'], ['guitar-7', '7-string'], ['guitar-8', '8-string']].forEach(([val, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = val; opt.textContent = label;
|
||
guitarGroup.appendChild(opt);
|
||
});
|
||
|
||
const bassGroup = document.createElement('optgroup');
|
||
bassGroup.label = 'Bass';
|
||
[['bass-4', '4-string'], ['bass-5', '5-string']].forEach(([val, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = val; opt.textContent = label;
|
||
bassGroup.appendChild(opt);
|
||
});
|
||
|
||
state.instrumentSelect.appendChild(guitarGroup);
|
||
state.instrumentSelect.appendChild(bassGroup);
|
||
state.instrumentSelect.value = '__display__';
|
||
}
|
||
|
||
function renderTuningOptions() {
|
||
if (!state.tuningSelect) return;
|
||
state.tuningSelect.innerHTML = '';
|
||
|
||
const isPlayer = document.getElementById('player')?.classList.contains('active');
|
||
if (isPlayer && typeof window.highway?.getSongInfo === 'function') {
|
||
const info = window.highway.getSongInfo();
|
||
if (info && info.tuning) {
|
||
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||
? window.feedBack.songTuningContext(info)
|
||
: {
|
||
stringCount: info.stringCount,
|
||
arrangement: info.arrangement,
|
||
arrangement_smart_name: info.arrangement_smart_name,
|
||
};
|
||
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||
? window.feedBack.isBassArrangement(ctx)
|
||
: (info.arrangement || '').toLowerCase().includes('bass');
|
||
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||
? window.feedBack.effectiveStringCount(info.tuning, ctx)
|
||
: (info.stringCount || info.tuning.length);
|
||
const sliced = info.tuning.slice(0, sc);
|
||
const freqs = window._tunerUtils.offsetsToFreqs(sliced, isBass);
|
||
const tName = (typeof window.displayTuningName === 'function')
|
||
? window.displayTuningName(null, sliced)
|
||
: window._tunerUtils.getTuningName(sliced);
|
||
|
||
const opt = document.createElement('option');
|
||
opt.value = '_current';
|
||
opt.textContent = `Current Song [${tName || 'Custom Tuning'}]`;
|
||
state.tuningSelect.appendChild(opt);
|
||
|
||
if (state.selectedTuningName === '_current') state.selectedTuning = freqs;
|
||
} else if (state.selectedTuningName === '_current') {
|
||
state.selectedTuning = null;
|
||
}
|
||
} else if (state.selectedTuningName === '_current') {
|
||
state.selectedTuning = null;
|
||
}
|
||
|
||
const freeTuneOpt = document.createElement('option');
|
||
freeTuneOpt.value = 'free-tune';
|
||
freeTuneOpt.textContent = 'Free Tune';
|
||
state.tuningSelect.appendChild(freeTuneOpt);
|
||
|
||
Object.keys(state.tunings).forEach(name => {
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = name;
|
||
state.tuningSelect.appendChild(opt);
|
||
});
|
||
|
||
if (state.selectedTuningName) state.tuningSelect.value = state.selectedTuningName;
|
||
}
|
||
|
||
function _noteLabelForFreq(f) {
|
||
const midi = window._tunerUtils.freqToMidi(f);
|
||
const rounded = Math.round(midi);
|
||
const name = window._tunerUtils.midiToNote(rounded, state.useFlats);
|
||
const octave = Math.floor(rounded / 12) - 1;
|
||
return name + octave;
|
||
}
|
||
|
||
function _noteNameOnly(f) {
|
||
return window._tunerUtils.midiToNote(
|
||
Math.round(window._tunerUtils.freqToMidi(f)),
|
||
state.useFlats
|
||
);
|
||
}
|
||
|
||
function _stringOrdinal(n) {
|
||
const v = n % 100;
|
||
if (v >= 11 && v <= 13) return n + 'th';
|
||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||
return n + suffix;
|
||
}
|
||
|
||
function _stringButtonLabel(index, total, f) {
|
||
if (state.selectedTuningName === '_current') {
|
||
const stringNum = total - index;
|
||
const note = _noteNameOnly(f);
|
||
return {
|
||
text: note,
|
||
title: _stringOrdinal(stringNum) + ' string: ' + _noteLabelForFreq(f),
|
||
};
|
||
}
|
||
return { text: _noteLabelForFreq(f), title: '' };
|
||
}
|
||
|
||
function _syncStringOrderHelp(total) {
|
||
if (!state.stringOrderHelpContainer) return;
|
||
const show = state.selectedTuningName === '_current'
|
||
&& state.selectedTuning
|
||
&& state.selectedTuning.length > 0;
|
||
if (!show) {
|
||
state.stringOrderHelpContainer.classList.add('hidden');
|
||
state.stringOrderHelpContainer.innerHTML = '';
|
||
return;
|
||
}
|
||
const count = total || state.selectedTuning.length;
|
||
const notes = state.selectedTuning.map((f) => _noteNameOnly(f)).join(' ');
|
||
state.stringOrderHelpContainer.classList.remove('hidden');
|
||
state.stringOrderHelpContainer.innerHTML =
|
||
'<div class="text-fb-textDim">Tune low-to-high: <span class="text-fb-text font-semibold tracking-wide">' + notes + '</span></div>'
|
||
+ '<div class="text-fb-textDim/70 mt-0.5">' + _stringOrdinal(count) + ' string → 1st string</div>';
|
||
}
|
||
|
||
function renderStringNotes() {
|
||
if (!state.stringNoteContainer) return;
|
||
state.stringNoteContainer.innerHTML = '';
|
||
if (!state.selectedTuning || state.selectedTuning.length === 0) {
|
||
_syncStringOrderHelp(0);
|
||
return;
|
||
}
|
||
|
||
const total = state.selectedTuning.length;
|
||
state.selectedTuning.forEach((f, index) => {
|
||
const btn = document.createElement('button');
|
||
btn.dataset.freq = f;
|
||
btn.className = 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
|
||
const label = _stringButtonLabel(index, total, f);
|
||
btn.textContent = label.text;
|
||
if (label.title) btn.title = label.title;
|
||
btn.onclick = () => {
|
||
state.manualTargetFreq = state.manualTargetFreq === f ? null : f;
|
||
_syncStringHighlight(state.manualTargetFreq);
|
||
};
|
||
state.stringNoteContainer.appendChild(btn);
|
||
});
|
||
_syncStringOrderHelp(total);
|
||
}
|
||
|
||
function updateUI(result) {
|
||
const { smoothedFreq, rms, hasSignal } = result;
|
||
const vizMode = state.manualTargetFreq ? 'manual'
|
||
: (state.freeTune || !(state.selectedTuning && state.selectedTuning.length > 0) ? 'free' : 'auto');
|
||
|
||
// Treat null / non-finite / non-positive as no-signal. A 0, NaN or
|
||
// negative frequency would otherwise propagate through log2/division
|
||
// below into NaN cents and a garbage readout.
|
||
const referencePitch = state.referencePitch || 440;
|
||
if (smoothedFreq === null || !Number.isFinite(smoothedFreq) || smoothedFreq <= 0) {
|
||
_lastAutoTargetFreq = null;
|
||
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode, null, referencePitch);
|
||
_syncStringHighlight(state.manualTargetFreq);
|
||
if (window.feedBack && window.feedBack.emit) {
|
||
window.feedBack.emit('tuner:frame', { note: null, cents: 0, freq: 0, hasSignal: false });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Reset the committed target when the tuning selection changes.
|
||
if (state.selectedTuning !== _lastTuningRef) {
|
||
_lastAutoTargetFreq = null;
|
||
_lastTuningRef = state.selectedTuning;
|
||
}
|
||
|
||
let targetFreq, isManual = false, displayFreq = smoothedFreq;
|
||
if (state.manualTargetFreq) {
|
||
targetFreq = state.manualTargetFreq;
|
||
isManual = true;
|
||
} else if (!state.freeTune && state.selectedTuning && state.selectedTuning.length > 0
|
||
&& _matchString(smoothedFreq, state.selectedTuning) !== null) {
|
||
let nearest = _matchString(smoothedFreq, state.selectedTuning);
|
||
// Hysteresis: once committed to a string, only switch when the new
|
||
// match is clearly closer (≥40 cents), so a pluck that lands between
|
||
// two strings can't flicker. Octave-aware residuals keep an octave
|
||
// error from ever masquerading as a different string.
|
||
if (_lastAutoTargetFreq !== null && nearest !== _lastAutoTargetFreq) {
|
||
const residualNew = Math.abs(Math.log2(_foldToOctaveOf(smoothedFreq, nearest) / nearest)) * 1200;
|
||
const residualPrev = Math.abs(Math.log2(_foldToOctaveOf(smoothedFreq, _lastAutoTargetFreq) / _lastAutoTargetFreq)) * 1200;
|
||
if (residualPrev - residualNew < _AUTO_TARGET_HYSTERESIS_CENTS) nearest = _lastAutoTargetFreq;
|
||
}
|
||
_lastAutoTargetFreq = nearest;
|
||
targetFreq = nearest;
|
||
} else {
|
||
targetFreq = window._tunerUtils.midiToFreq(Math.round(window._tunerUtils.freqToMidi(smoothedFreq)));
|
||
}
|
||
|
||
// Fold the reading into the target's octave so a sub-octave detection
|
||
// (D1 read for a D2 string) shows the right note and real cents.
|
||
if (!isManual && targetFreq) displayFreq = _foldToOctaveOf(smoothedFreq, targetFreq);
|
||
|
||
const cents = (window._tunerUtils.freqToMidi(displayFreq) - window._tunerUtils.freqToMidi(targetFreq)) * 100;
|
||
const note = window._tunerUtils.midiToNote(window._tunerUtils.freqToMidi(targetFreq), state.useFlats);
|
||
|
||
if (state.activeViz) state.activeViz.update(note, cents, displayFreq, vizMode, targetFreq, referencePitch, state.useFlats);
|
||
if (state.freeTune) _syncStringHighlight(null);
|
||
else _syncActiveStringFromFreq(targetFreq, isManual);
|
||
if (window.feedBack && window.feedBack.emit) {
|
||
window.feedBack.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
|
||
}
|
||
}
|
||
|
||
function updateFloatingButtonVisibility() {
|
||
const btn = document.getElementById('tuner-toggle-btn');
|
||
if (!btn) return;
|
||
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
|
||
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
|
||
btn.classList.add('hidden');
|
||
} else {
|
||
btn.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function updateFloatingButton() {
|
||
const btn = document.getElementById('tuner-toggle-btn');
|
||
if (!btn) return;
|
||
const isHidden = btn.classList.contains('hidden');
|
||
btn.className = state.enabled
|
||
? 'fixed bottom-5 right-5 px-4 py-2.5 bg-accent/20 hover:bg-accent/30 border border-accent text-accent rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-2xl z-[1001]'
|
||
: 'fixed bottom-5 right-5 px-4 py-2.5 bg-dark-700 hover:bg-dark-500 border border-gray-800 text-gray-300 hover:text-white rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-2xl z-[1001]';
|
||
if (isHidden) btn.classList.add('hidden');
|
||
updateFloatingButtonVisibility();
|
||
}
|
||
|
||
function updatePlayerButton() {
|
||
const btn = document.getElementById('btn-tuner-player');
|
||
if (!btn) return;
|
||
btn.className = state.enabled
|
||
? 'px-3 py-1.5 bg-accent/20 hover:bg-accent/30 border border-accent rounded-lg text-xs text-accent transition'
|
||
: 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition';
|
||
}
|
||
|
||
function showSettings() {
|
||
let panel = state.uiContainer.querySelector('.tuner-settings-panel');
|
||
if (panel) { panel.remove(); return; }
|
||
|
||
panel = document.createElement('div');
|
||
panel.className = 'tuner-settings-panel w-full bg-fb-cardMuted border border-fb-border/30 rounded-lg p-3 mb-3 text-xs';
|
||
panel.innerHTML = `
|
||
<div class="mb-2">
|
||
<span class="text-fb-textDim font-semibold uppercase tracking-tighter">Audio Settings</span>
|
||
</div>
|
||
<div class="tuner-mic-section">
|
||
<label class="block text-fb-textDim mb-1">Microphone</label>
|
||
<select class="tuner-device-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text mb-2 outline-none focus:border-accent">
|
||
<option value="">Default</option>
|
||
</select>
|
||
</div>
|
||
<div class="tuner-channel-section">
|
||
<label class="block text-fb-textDim mb-1">Input Channel</label>
|
||
<select class="tuner-channel-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text outline-none focus:border-accent">
|
||
<option value="mono" ${state.selectedChannel === 'mono' ? 'selected' : ''}>Mono (mix both)</option>
|
||
<option value="left" ${state.selectedChannel === 'left' ? 'selected' : ''}>Left (Channel 1)</option>
|
||
<option value="right" ${state.selectedChannel === 'right' ? 'selected' : ''}>Right (Channel 2)</option>
|
||
</select>
|
||
</div>
|
||
<label class="block text-fb-textDim mb-1 mt-2">Visualization</label>
|
||
<select class="tuner-viz-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text outline-none focus:border-accent">
|
||
<option value="default" ${state.visualizationMode === 'default' ? 'selected' : ''}>Default</option>
|
||
<option value="strobe" ${state.visualizationMode === 'strobe' ? 'selected' : ''}>Strobe</option>
|
||
<option value="analogue-gauge" ${state.visualizationMode === 'analogue-gauge' ? 'selected' : ''}>Analogue Gauge</option>
|
||
<option value="mace-fx-iii" ${state.visualizationMode === 'mace-fx-iii' ? 'selected' : ''}>Mace-Fx III</option>
|
||
<option value="pp-tiny" ${state.visualizationMode === 'pp-tiny' ? 'selected' : ''}>Bender PP-Tiny</option>
|
||
<option value="chef-mt3" ${state.visualizationMode === 'chef-mt3' ? 'selected' : ''}>CHEF MT-3</option>
|
||
<option value="toilet-tuner" ${state.visualizationMode === 'toilet-tuner' ? 'selected' : ''}>Toilet Tuner</option>
|
||
</select>
|
||
`;
|
||
|
||
state.uiContainer.insertBefore(panel, state.stringNoteContainer);
|
||
|
||
panel.querySelector('.tuner-device-select').onchange = (e) => {
|
||
state.selectedDeviceId = e.target.value;
|
||
actions.saveSettings();
|
||
if (state.enabled) actions.restartAudio();
|
||
};
|
||
panel.querySelector('.tuner-channel-select').onchange = (e) => {
|
||
state.selectedChannel = e.target.value;
|
||
actions.saveSettings();
|
||
if (state.enabled) actions.restartAudio();
|
||
};
|
||
panel.querySelector('.tuner-viz-select').onchange = async (e) => {
|
||
state.visualizationMode = e.target.value;
|
||
await actions.setVisualization(state.visualizationMode);
|
||
actions.saveConfig();
|
||
const vizMode = state.manualTargetFreq ? 'manual' : (state.freeTune || !(state.selectedTuning && state.selectedTuning.length > 0) ? 'free' : 'auto');
|
||
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode);
|
||
};
|
||
|
||
populateDevices(panel);
|
||
|
||
if (window._tunerAudio && window._tunerAudio.usingBridge) {
|
||
var micSec = panel.querySelector('.tuner-mic-section');
|
||
var chanSec = panel.querySelector('.tuner-channel-section');
|
||
if (micSec) micSec.classList.add('hidden');
|
||
if (chanSec) chanSec.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function populateDevices(panel) {
|
||
try {
|
||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
const sel = panel.querySelector('.tuner-device-select');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">Default</option>';
|
||
for (const d of devices) {
|
||
if (d.kind !== 'audioinput') continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = d.deviceId;
|
||
opt.textContent = d.label || `Input ${d.deviceId.slice(0, 8)}`;
|
||
if (d.deviceId === state.selectedDeviceId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
} catch (e) { /* permission not yet granted */ }
|
||
}
|
||
|
||
function showMicError(e) {
|
||
const name = e?.name || '';
|
||
let msg, hint;
|
||
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
|
||
msg = 'Microphone access denied.';
|
||
hint = 'On macOS open System Settings → Privacy & Security → Microphone and enable your browser, then refresh the page.';
|
||
} else if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
|
||
msg = 'No audio input found.';
|
||
hint = 'Make sure your Real Tone Cable (or other audio input) is plugged in and recognised by macOS (check Audio MIDI Setup).';
|
||
} else if (name === 'NotReadableError' || name === 'AbortError' || name === 'TrackStartError') {
|
||
msg = 'Could not open the audio device.';
|
||
hint = 'On macOS: (1) open Audio MIDI Setup (Applications → Utilities) and confirm the device appears with a compatible sample rate (44100 or 48000 Hz); (2) check System Settings → Privacy & Security → Microphone — your browser must be listed and enabled; (3) try unplugging and replugging the cable.';
|
||
} else {
|
||
msg = 'Could not access microphone.';
|
||
hint = `Error: ${name || e?.message || 'unknown'}`;
|
||
}
|
||
if (!state.uiContainer) { alert(`Tuner: ${msg}\n${hint.replace(/&/g, '&')}`); return; }
|
||
let errEl = state.uiContainer.querySelector('.tuner-mic-error');
|
||
if (!errEl) {
|
||
errEl = document.createElement('div');
|
||
errEl.className = 'tuner-mic-error relative w-full mt-2 p-3 bg-red-900/40 border border-red-700/60 rounded-lg text-xs text-red-300 leading-relaxed';
|
||
state.uiContainer.appendChild(errEl);
|
||
}
|
||
errEl.innerHTML = `<strong>${msg}</strong><br>${hint}`;
|
||
const dismissBtn = document.createElement('button');
|
||
dismissBtn.className = 'absolute top-1.5 right-2 text-red-400 hover:text-red-200 text-sm font-bold leading-none';
|
||
dismissBtn.textContent = '×';
|
||
dismissBtn.onclick = () => errEl.remove();
|
||
errEl.appendChild(dismissBtn);
|
||
state.uiContainer.classList.remove('hidden');
|
||
state.uiContainer.classList.add('flex');
|
||
}
|
||
|
||
function updateFreeTuneUI() {
|
||
if (!state.freeTuneToggle) return;
|
||
const on = state.freeTune;
|
||
state.freeTuneToggle.style.backgroundColor = on ? '#4080e0' : '#334155';
|
||
state.freeTuneToggle.setAttribute('aria-checked', String(on));
|
||
const knob = state.freeTuneToggle.querySelector('span');
|
||
if (knob) knob.style.left = on ? '18px' : '2px';
|
||
if (state.stringNoteContainer) {
|
||
state.stringNoteContainer.style.opacity = on ? '0.35' : '';
|
||
state.stringNoteContainer.style.pointerEvents = on ? 'none' : '';
|
||
state.stringNoteContainer.style.transition = 'opacity 0.15s';
|
||
}
|
||
_syncStringOrderHelp();
|
||
}
|
||
|
||
function initUI() {
|
||
if (state.uiContainer) return;
|
||
|
||
state.uiContainer = document.createElement('div');
|
||
state.uiContainer.id = 'tuner-plugin-ui';
|
||
state.uiContainer.className = 'fixed w-72 bg-fb-card border border-fb-border/50 rounded-xl p-4 text-white z-[1000] hidden flex-col items-center shadow-2xl backdrop-blur-md';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'flex justify-center items-center w-full mb-3 relative';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'font-bold text-xs text-fb-textDim uppercase tracking-wider';
|
||
title.textContent = 'TUNER';
|
||
header.appendChild(title);
|
||
|
||
const settingsBtn = document.createElement('button');
|
||
settingsBtn.className = 'absolute right-0 text-fb-textDim hover:text-fb-text transition-colors';
|
||
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;
|
||
settingsBtn.onclick = showSettings;
|
||
header.appendChild(settingsBtn);
|
||
state.uiContainer.appendChild(header);
|
||
|
||
state.stringOrderHelpContainer = document.createElement('div');
|
||
state.stringOrderHelpContainer.className = 'tuner-string-order-help hidden w-full mb-2 text-center text-[10px] leading-snug';
|
||
state.uiContainer.appendChild(state.stringOrderHelpContainer);
|
||
|
||
state.stringNoteContainer = document.createElement('div');
|
||
state.stringNoteContainer.className = 'flex justify-between w-full mb-3 gap-1';
|
||
state.uiContainer.appendChild(state.stringNoteContainer);
|
||
renderStringNotes();
|
||
|
||
state.saveAsCustomContainer = document.createElement('div');
|
||
state.saveAsCustomContainer.className = 'w-full mb-3 hidden';
|
||
|
||
const labelBtn = document.createElement('button');
|
||
labelBtn.className = 'tuner-save-label w-full text-[11px] text-accent/70 hover:text-accent border border-accent/20 hover:border-accent/50 rounded-lg py-1.5 transition-colors';
|
||
labelBtn.textContent = 'Save as Custom Tuning';
|
||
labelBtn.onclick = _showSaveAsCustomInput;
|
||
state.saveAsCustomContainer.appendChild(labelBtn);
|
||
state.uiContainer.appendChild(state.saveAsCustomContainer);
|
||
|
||
const freeTuneRow = document.createElement('div');
|
||
freeTuneRow.className = 'flex items-center justify-between w-full mb-3';
|
||
|
||
const freeTuneLabel = document.createElement('span');
|
||
freeTuneLabel.className = 'text-xs text-fb-textDim select-none';
|
||
freeTuneLabel.textContent = 'Free Tune';
|
||
|
||
const toggleTrack = document.createElement('button');
|
||
toggleTrack.type = 'button';
|
||
toggleTrack.setAttribute('role', 'switch');
|
||
toggleTrack.setAttribute('aria-checked', String(state.freeTune));
|
||
toggleTrack.style.cssText = 'position:relative;width:2.25rem;height:1.25rem;border-radius:9999px;border:none;cursor:pointer;transition:background-color 0.15s;outline:none;flex-shrink:0;background-color:' + (state.freeTune ? '#4080e0' : '#334155');
|
||
|
||
const toggleKnob = document.createElement('span');
|
||
toggleKnob.style.cssText = 'position:absolute;top:2px;width:1rem;height:1rem;border-radius:9999px;background:white;transition:left 0.15s;left:' + (state.freeTune ? '18px' : '2px');
|
||
toggleTrack.appendChild(toggleKnob);
|
||
state.freeTuneToggle = toggleTrack;
|
||
|
||
toggleTrack.addEventListener('click', () => {
|
||
state.freeTune = !state.freeTune;
|
||
if (state.freeTune) state.manualTargetFreq = null;
|
||
updateFreeTuneUI();
|
||
actions.saveConfig();
|
||
});
|
||
|
||
freeTuneRow.appendChild(freeTuneLabel);
|
||
freeTuneRow.appendChild(toggleTrack);
|
||
state.uiContainer.appendChild(freeTuneRow);
|
||
|
||
state.vizContainer = document.createElement('div');
|
||
state.vizContainer.className = 'w-full';
|
||
state.uiContainer.appendChild(state.vizContainer);
|
||
|
||
document.body.appendChild(state.uiContainer);
|
||
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
|
||
}
|
||
|
||
function positionPanel() {
|
||
if (!state.uiContainer) return;
|
||
const isPlayer = document.getElementById('player')?.classList.contains('active');
|
||
const playerEl = document.getElementById('player');
|
||
const wrap = document.getElementById('v3-tuner-wrap');
|
||
|
||
// #player is a full-screen overlay (z-index 100) that covers the v3
|
||
// topbar — anchoring the panel to #v3-tuner-wrap hides it underneath.
|
||
if (isPlayer && playerEl) {
|
||
if (state.uiContainer.parentElement !== document.body) {
|
||
document.body.appendChild(state.uiContainer);
|
||
}
|
||
state.uiContainer.className = state.uiContainer.className
|
||
.replace('absolute', 'fixed')
|
||
.replace('right-0', '')
|
||
.replace('top-full', '')
|
||
.trim();
|
||
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
|
||
return;
|
||
}
|
||
|
||
if (!wrap) {
|
||
// Non-v3 fallback: fixed bottom-right above the floating button.
|
||
if (state.uiContainer.parentElement !== document.body) document.body.appendChild(state.uiContainer);
|
||
state.uiContainer.className = state.uiContainer.className
|
||
.replace('absolute', 'fixed')
|
||
.replace('right-0', '')
|
||
.replace('top-full', '')
|
||
.trim();
|
||
state.uiContainer.style.cssText = 'bottom:5rem;right:1.25rem';
|
||
return;
|
||
}
|
||
// Move panel into the relative wrapper so right-0 aligns its right edge
|
||
// with the right edge of the badge button, exactly like the instrument panel.
|
||
if (state.uiContainer.parentElement !== wrap) wrap.appendChild(state.uiContainer);
|
||
state.uiContainer.className = state.uiContainer.className
|
||
.replace('fixed', 'absolute')
|
||
.trim();
|
||
state.uiContainer.style.cssText = 'top:100%;right:0;margin-top:8px';
|
||
}
|
||
|
||
function addButton() {
|
||
if (document.getElementById('tuner-toggle-btn')) return;
|
||
|
||
const btn = document.createElement('button');
|
||
btn.id = 'tuner-toggle-btn';
|
||
btn.textContent = 'Tuner';
|
||
btn.title = 'Open Tuner';
|
||
btn.onclick = window.tuner.toggle;
|
||
document.body.appendChild(btn);
|
||
updateFloatingButton();
|
||
updateFloatingButtonVisibility();
|
||
|
||
const handlePlay = () => {
|
||
updateFloatingButtonVisibility();
|
||
if (state.enabled) actions.disable();
|
||
};
|
||
const handleStop = () => updateFloatingButtonVisibility();
|
||
|
||
if (window.feedBack) {
|
||
window.feedBack.on('song:play', handlePlay);
|
||
window.feedBack.on('song:pause', handleStop);
|
||
window.feedBack.on('song:ended', handleStop);
|
||
window.feedBack.on('screen:changed', (e) => {
|
||
if (e.detail.id === 'player') { handlePlay(); injectPlayerButton(); }
|
||
else handleStop();
|
||
});
|
||
|
||
if (window.feedBack.isPlaying || document.querySelector('.screen.active')?.id === 'player') {
|
||
handlePlay();
|
||
if (document.querySelector('.screen.active')?.id === 'player') injectPlayerButton();
|
||
} else {
|
||
updateFloatingButtonVisibility();
|
||
}
|
||
}
|
||
}
|
||
|
||
function injectPlayerButton() {
|
||
// v3: mount into the host's stable plugin-control slot (Plugins rail
|
||
// popover). The legacy `button:last-child` anchor resolves to a NESTED
|
||
// transport button in v3 and would throw on insertBefore; the slot is
|
||
// always present in v3, so that anchor is only used in the classic UI.
|
||
const isV3 = !!(window.feedBack && window.feedBack.uiVersion === 'v3');
|
||
let slot = null;
|
||
if (isV3 && window.feedBack.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
|
||
try { const _s = window.feedBack.ui.playerControlSlot(); if (_s instanceof Element) slot = _s; }
|
||
catch (_e) { /* host slot API failure → fall back to legacy container */ }
|
||
}
|
||
const controls = slot || document.getElementById('player-controls');
|
||
if (!controls || document.getElementById('btn-tuner-player')) return;
|
||
|
||
const btn = document.createElement('button');
|
||
btn.id = 'btn-tuner-player';
|
||
btn.textContent = 'Tuner';
|
||
btn.title = 'Open Tuner';
|
||
btn.onclick = window.tuner.toggle;
|
||
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
|
||
if (closeBtn) controls.insertBefore(btn, closeBtn);
|
||
else controls.appendChild(btn);
|
||
updatePlayerButton();
|
||
}
|
||
|
||
return {
|
||
initUI,
|
||
positionPanel,
|
||
renderInstrumentOptions,
|
||
renderTuningOptions,
|
||
renderStringNotes,
|
||
updateUI,
|
||
updateInstrumentDisplay: _updateInstrumentDisplay,
|
||
updateSaveAsCustomVisibility: _updateSaveAsCustomVisibility,
|
||
updateFreeTuneUI,
|
||
updateFloatingButton,
|
||
updatePlayerButton,
|
||
updateFloatingButtonVisibility,
|
||
showMicError,
|
||
addButton,
|
||
injectPlayerButton,
|
||
};
|
||
};
|