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,284 @@
|
||||
(function() {
|
||||
const _TUNER_MIN_YIN_SAMPLES = 4096;
|
||||
const _TUNER_FRAME_SIZE = 2048;
|
||||
const _TUNER_MIN_DETECTABLE_HZ = 20;
|
||||
const _FREQ_HISTORY_LEN = 3;
|
||||
const _WARMUP_FRAMES = 2;
|
||||
// If a frame is posted to the worker and no message/error comes back within
|
||||
// this window, clear the in-flight guard so the poll loop can't latch shut.
|
||||
const _FRAME_WATCHDOG_MS = 500;
|
||||
|
||||
let _audioCtx = null;
|
||||
let _sourceNode = null;
|
||||
let _stream = null;
|
||||
let _processor = null;
|
||||
let _gainNode = null;
|
||||
let _accumBuffer = new Float32Array(0);
|
||||
let _pendingBuffer = null;
|
||||
let _detectInterval = null;
|
||||
let _processingFrame = false;
|
||||
let _yinWorker = null;
|
||||
let _freqHistory = [];
|
||||
let _validFrameCount = 0;
|
||||
let _lastFreq = 0;
|
||||
let _onResult = null;
|
||||
let _usingDesktopBridge = false;
|
||||
let _bridgeInterval = null;
|
||||
// Bumped on every start/stop. An async start captures the value and aborts
|
||||
// if it changes mid-flight, so a re-entrant start()/restart() can't orphan
|
||||
// a worker + interval created by a superseded call.
|
||||
let _startGen = 0;
|
||||
// Timestamp of the last frame posted to the worker (watchdog, see above).
|
||||
let _frameSentAt = 0;
|
||||
|
||||
function _octaveFold(freq, ref) {
|
||||
if (!ref || freq <= 0) return freq;
|
||||
while (freq > ref * 1.414) freq /= 2;
|
||||
while (freq < ref / 1.414) freq *= 2;
|
||||
return freq;
|
||||
}
|
||||
|
||||
function _median(arr) {
|
||||
if (!arr.length) return 0;
|
||||
var s = arr.slice().sort(function(a, b) { return a - b; });
|
||||
var mid = Math.floor(s.length / 2);
|
||||
return s.length % 2 !== 0 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
function _handleYinResult(result) {
|
||||
const rms = result ? result.rms : 0;
|
||||
const hasSignal = rms > 0.01;
|
||||
|
||||
if (!result || (!hasSignal && result.confidence < 0.5) || (result.freq < _TUNER_MIN_DETECTABLE_HZ && result.freq !== 0)) {
|
||||
_validFrameCount = 0; _freqHistory = []; _lastFreq = 0;
|
||||
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.confidence < 0.5 && hasSignal) {
|
||||
_validFrameCount = 0; _freqHistory = []; _lastFreq = 0;
|
||||
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal: false });
|
||||
return;
|
||||
}
|
||||
|
||||
_freqHistory.push(_octaveFold(result.freq, _lastFreq));
|
||||
if (_freqHistory.length > _FREQ_HISTORY_LEN) _freqHistory.shift();
|
||||
_validFrameCount++;
|
||||
|
||||
if (_validFrameCount <= _WARMUP_FRAMES) {
|
||||
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal });
|
||||
return;
|
||||
}
|
||||
|
||||
const smoothedFreq = _median(_freqHistory);
|
||||
_lastFreq = smoothedFreq;
|
||||
if (_onResult) _onResult({ smoothedFreq, rms, hasSignal });
|
||||
}
|
||||
|
||||
// True while a frame is being processed by the worker, unless that frame is
|
||||
// older than the watchdog window — in which case the worker is assumed wedged
|
||||
// and the guard is released so the poll loop can recover.
|
||||
function _frameBusy() {
|
||||
if (!_processingFrame) return false;
|
||||
if (Date.now() - _frameSentAt > _FRAME_WATCHDOG_MS) { _processingFrame = false; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _tryBridgeStart(audioInputMode, myGen) {
|
||||
if (audioInputMode === 'browser') return false;
|
||||
var desktop = (typeof window !== 'undefined') ? window.slopsmithDesktop : null;
|
||||
if (!desktop || !desktop.isDesktop || !desktop.audio
|
||||
|| typeof desktop.audio.isAvailable !== 'function') return false;
|
||||
|
||||
var available = false;
|
||||
try { available = await desktop.audio.isAvailable(); } catch (_) {}
|
||||
if (myGen !== _startGen) return false;
|
||||
if (!available) return false;
|
||||
|
||||
// The tuner runs its own tuning-optimised YIN over the raw sample frame.
|
||||
// The engine's getRawPitch endpoint is deliberately NOT used as a
|
||||
// fallback — it produces a jittery readout. A build without
|
||||
// getRawAudioFrame can't feed our pipeline, so fall back to getUserMedia
|
||||
// instead of claiming the bridge.
|
||||
if (typeof desktop.audio.getRawAudioFrame !== 'function') return false;
|
||||
|
||||
var started = false;
|
||||
try {
|
||||
var running = typeof desktop.audio.isAudioRunning === 'function'
|
||||
? await desktop.audio.isAudioRunning() : false;
|
||||
if (!running && typeof desktop.audio.startAudio === 'function') {
|
||||
await desktop.audio.startAudio();
|
||||
started = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed startAudio means frames will never arrive — surface it
|
||||
// rather than silently claiming a dead bridge.
|
||||
console.warn('[tuner] bridge startAudio failed:', e && e.message ? e.message : e);
|
||||
}
|
||||
if (myGen !== _startGen) {
|
||||
// Superseded by a newer start/stop while awaiting — undo any engine
|
||||
// start we triggered and bail without claiming the bridge.
|
||||
if (started && typeof desktop.audio.stopAudio === 'function') {
|
||||
try { desktop.audio.stopAudio(); } catch (_) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var bridgeSampleRate = 48000;
|
||||
try {
|
||||
if (typeof desktop.audio.getSampleRate === 'function') {
|
||||
var sr = await desktop.audio.getSampleRate();
|
||||
if (typeof sr === 'number' && Number.isFinite(sr) && sr > 0) bridgeSampleRate = sr;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (myGen !== _startGen) return false;
|
||||
|
||||
_usingDesktopBridge = true;
|
||||
console.log('[tuner] using desktop JUCE bridge with raw audio + YIN');
|
||||
|
||||
_yinWorker = new Worker('/api/plugins/tuner/workers/yin.js');
|
||||
_yinWorker.onmessage = function(e) { _handleYinResult(e.data); _processingFrame = false; };
|
||||
_yinWorker.onerror = function(e) { console.error('Tuner: YIN worker error', e); _processingFrame = false; };
|
||||
|
||||
_bridgeInterval = setInterval(async function() {
|
||||
if (_frameBusy() || !_yinWorker) return;
|
||||
try {
|
||||
var samples = await desktop.audio.getRawAudioFrame(_TUNER_MIN_YIN_SAMPLES);
|
||||
if (!_yinWorker) return; // torn down while awaiting the frame
|
||||
if (!(samples instanceof Float32Array) || samples.length < _TUNER_MIN_YIN_SAMPLES) return;
|
||||
// Copy rather than transfer: the engine may hand back a view onto
|
||||
// a buffer it reuses, and transferring would detach it. A 4096-
|
||||
// sample copy every 30 ms is negligible.
|
||||
var frame = samples.slice();
|
||||
_processingFrame = true;
|
||||
_frameSentAt = Date.now();
|
||||
_yinWorker.postMessage({ samples: frame, sampleRate: bridgeSampleRate }, [frame.buffer]);
|
||||
} catch (e) {
|
||||
console.warn('[tuner] bridge raw audio poll failed:', e && e.message ? e.message : e);
|
||||
if (_onResult) _onResult({ smoothedFreq: null, rms: 0, hasSignal: false });
|
||||
}
|
||||
}, 30);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _doStart(deviceId, channel, audioInputMode) {
|
||||
// Tear down any existing session first so a double start() can't orphan a
|
||||
// worker/interval; _doStop bumps _startGen, which also aborts any start
|
||||
// still suspended on an await.
|
||||
_doStop();
|
||||
const myGen = _startGen;
|
||||
|
||||
var bridgeStarted = await _tryBridgeStart(audioInputMode || 'auto', myGen);
|
||||
if (myGen !== _startGen) return; // superseded while probing the bridge
|
||||
if (bridgeStarted) return;
|
||||
const constraints = {
|
||||
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 2 }
|
||||
};
|
||||
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
|
||||
|
||||
try {
|
||||
_stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (e) {
|
||||
if (e.name === 'OverconstrainedError' && deviceId) {
|
||||
delete constraints.audio.deviceId;
|
||||
delete constraints.audio.channelCount;
|
||||
} else if (e.name === 'NotFoundError' && deviceId) {
|
||||
delete constraints.audio.deviceId;
|
||||
} else if (e.name === 'OverconstrainedError') {
|
||||
delete constraints.audio.channelCount;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
_stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
|
||||
if (myGen !== _startGen) {
|
||||
// Superseded while awaiting mic permission — release the stream.
|
||||
if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
_sourceNode = _audioCtx.createMediaStreamSource(_stream);
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 1.0;
|
||||
|
||||
if (_sourceNode.channelCount >= 2 && channel !== 'mono') {
|
||||
const splitter = _audioCtx.createChannelSplitter(2);
|
||||
const merger = _audioCtx.createChannelMerger(1);
|
||||
_sourceNode.connect(splitter);
|
||||
splitter.connect(merger, channel === 'left' ? 0 : 1, 0);
|
||||
merger.connect(_gainNode);
|
||||
} else {
|
||||
_sourceNode.connect(_gainNode);
|
||||
}
|
||||
|
||||
_processor = _audioCtx.createScriptProcessor(_TUNER_FRAME_SIZE, 1, 1);
|
||||
_processor.onaudioprocess = (e) => {
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
const combined = new Float32Array(_accumBuffer.length + input.length);
|
||||
combined.set(_accumBuffer);
|
||||
combined.set(input, _accumBuffer.length);
|
||||
if (combined.length >= _TUNER_MIN_YIN_SAMPLES) {
|
||||
_pendingBuffer = combined.slice(combined.length - _TUNER_MIN_YIN_SAMPLES);
|
||||
_accumBuffer = combined.slice(input.length);
|
||||
} else {
|
||||
_accumBuffer = combined;
|
||||
}
|
||||
};
|
||||
|
||||
_gainNode.connect(_processor);
|
||||
_processor.connect(_audioCtx.destination);
|
||||
|
||||
_yinWorker = new Worker('/api/plugins/tuner/workers/yin.js');
|
||||
_yinWorker.onmessage = (e) => { _handleYinResult(e.data); _processingFrame = false; };
|
||||
_yinWorker.onerror = (e) => { console.error('Tuner: YIN worker error', e); _processingFrame = false; };
|
||||
|
||||
_detectInterval = setInterval(() => {
|
||||
if (_frameBusy() || !_pendingBuffer || !_yinWorker) return;
|
||||
const buf = _pendingBuffer;
|
||||
_pendingBuffer = null;
|
||||
_processingFrame = true;
|
||||
_frameSentAt = Date.now();
|
||||
_yinWorker.postMessage({ samples: buf, sampleRate: _audioCtx.sampleRate }, [buf.buffer]);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function _doStop() {
|
||||
// Invalidate any start still suspended on an await so it aborts instead
|
||||
// of installing a worker/interval after we've torn down.
|
||||
_startGen++;
|
||||
if (_bridgeInterval) { clearInterval(_bridgeInterval); _bridgeInterval = null; }
|
||||
_usingDesktopBridge = false;
|
||||
if (_detectInterval) { clearInterval(_detectInterval); _detectInterval = null; }
|
||||
if (_yinWorker) { _yinWorker.terminate(); _yinWorker = null; }
|
||||
_processingFrame = false;
|
||||
_pendingBuffer = null;
|
||||
_accumBuffer = new Float32Array(0);
|
||||
_freqHistory = [];
|
||||
_validFrameCount = 0;
|
||||
_lastFreq = 0;
|
||||
if (_processor) { _processor.disconnect(); _processor = null; }
|
||||
if (_gainNode) { _gainNode.disconnect(); _gainNode = null; }
|
||||
if (_sourceNode) { _sourceNode.disconnect(); _sourceNode = null; }
|
||||
if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; }
|
||||
if (_audioCtx) { _audioCtx.close(); _audioCtx = null; }
|
||||
}
|
||||
|
||||
window._tunerAudio = {
|
||||
start: async function(options, onResult) {
|
||||
_onResult = onResult;
|
||||
await _doStart(options.deviceId, options.channel, options.audioInputMode || 'auto');
|
||||
},
|
||||
stop: function() {
|
||||
_onResult = null;
|
||||
_doStop();
|
||||
},
|
||||
restart: async function(options) {
|
||||
_doStop();
|
||||
await _doStart(options.deviceId, options.channel, options.audioInputMode || 'auto');
|
||||
},
|
||||
get usingBridge() { return _usingDesktopBridge; },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,80 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
function freqToMidi(f) { return 69 + 12 * Math.log2(f / 440); }
|
||||
function midiToFreq(m) { return Math.pow(2, (m - 69) / 12) * 440; }
|
||||
|
||||
const _NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||
const _NOTE_FLAT = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B'];
|
||||
// Returns true when the tuning name implies flat notation (e.g. "Eb Standard", "Bb Standard").
|
||||
function preferFlats(tuningName) {
|
||||
return typeof tuningName === 'string' && /\b[A-G]b\b/.test(tuningName);
|
||||
}
|
||||
function midiToNote(m, useFlats) {
|
||||
return (useFlats ? _NOTE_FLAT : _NOTE_NAMES)[((Math.round(m) % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
|
||||
// Per-string-count standard open-string MIDI arrays
|
||||
const _BASE_MIDI = {
|
||||
4: [28, 33, 38, 43], // bass 4: E1 A1 D2 G2
|
||||
5: [23, 28, 33, 38, 43], // bass 5: B0 E1 A1 D2 G2
|
||||
6: [40, 45, 50, 55, 59, 64], // guitar 6: E2 A2 D3 G3 B3 E4
|
||||
7: [35, 40, 45, 50, 55, 59, 64], // guitar 7: B1 E2 A2 D3 G3 B3 E4
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64], // guitar 8: F#1 B1 E2 A2 D3 G3 B3 E4
|
||||
};
|
||||
|
||||
function offsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _BASE_MIDI[len] : _BASE_MIDI[6];
|
||||
} else {
|
||||
base = _BASE_MIDI[len] || _BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return midiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function getTuningName(offsets) {
|
||||
if (!offsets || offsets.length === 0) return 'Unknown';
|
||||
const len = offsets.length;
|
||||
if (len < 4 || len > 8) return offsets.join(' ');
|
||||
|
||||
// First-string open MIDI for this length (same table as _BASE_MIDI column 0).
|
||||
const firstStringMidi = (_BASE_MIDI[len] || _BASE_MIDI[6])[0];
|
||||
const noteNames = ['C','C#','D','Eb','E','F','F#','G','Ab','A','A#','B'];
|
||||
|
||||
// All-equal: name by the note the lowest string becomes at this offset.
|
||||
if (offsets.every(o => o === offsets[0])) {
|
||||
const noteIdx = ((firstStringMidi + offsets[0]) % 12 + 12) % 12;
|
||||
return noteNames[noteIdx] + ' Standard';
|
||||
}
|
||||
|
||||
// Drop tuning: first string is exactly 2 semitones below the rest (all equal).
|
||||
if (offsets[0] === offsets[1] - 2 && offsets.slice(1).every(o => o === offsets[1])) {
|
||||
const noteIdx = ((firstStringMidi + offsets[0]) % 12 + 12) % 12;
|
||||
return 'Drop ' + noteNames[noteIdx];
|
||||
}
|
||||
|
||||
// Named lookup table for specific patterns
|
||||
const named = {
|
||||
// 6-string
|
||||
'-2,0,0,0,0,0': 'Drop D', '-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D', '0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D', '-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E', '-2,0,0,2,3,2': 'Open D (alt)',
|
||||
// 7-string
|
||||
'-2,0,0,0,0,0,0': 'Drop A',
|
||||
// 8-string
|
||||
'-2,0,0,0,0,0,0,0': 'Drop E',
|
||||
};
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
|
||||
return offsets.join(' ');
|
||||
}
|
||||
|
||||
window._tunerUtils = { freqToMidi, midiToFreq, midiToNote, offsetsToFreqs, getTuningName, preferFlats };
|
||||
})();
|
||||
@@ -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