mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,750 @@
|
||||
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.slopsmith?.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.slopsmith?.songTuningContext === 'function')
|
||||
? window.slopsmith.songTuningContext(info)
|
||||
: {
|
||||
stringCount: info.stringCount,
|
||||
arrangement: info.arrangement,
|
||||
arrangement_smart_name: info.arrangement_smart_name,
|
||||
};
|
||||
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
|
||||
? window.slopsmith.isBassArrangement(ctx)
|
||||
: (info.arrangement || '').toLowerCase().includes('bass');
|
||||
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
|
||||
? window.slopsmith.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.slopsmith && window.slopsmith.emit) {
|
||||
window.slopsmith.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.slopsmith && window.slopsmith.emit) {
|
||||
window.slopsmith.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.slopsmith?.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.slopsmith) {
|
||||
window.slopsmith.on('song:play', handlePlay);
|
||||
window.slopsmith.on('song:pause', handleStop);
|
||||
window.slopsmith.on('song:ended', handleStop);
|
||||
window.slopsmith.on('screen:changed', (e) => {
|
||||
if (e.detail.id === 'player') { handlePlay(); injectPlayerButton(); }
|
||||
else handleStop();
|
||||
});
|
||||
|
||||
if (window.slopsmith.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.slopsmith && window.slopsmith.uiVersion === 'v3');
|
||||
let slot = null;
|
||||
if (isV3 && window.slopsmith.ui && typeof window.slopsmith.ui.playerControlSlot === 'function') {
|
||||
try { const _s = window.slopsmith.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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user