mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
(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.feedBackDesktop : 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; },
|
|
};
|
|
})();
|