Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+284
View File
@@ -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; },
};
})();
+80
View File
@@ -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 };
})();
+750
View File
@@ -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 &amp; 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 &amp; 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(/&amp;/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,
};
};