feedBack/plugins/tuner/utils/audio.js
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* 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>
2026-06-23 11:03:01 +02:00

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; },
};
})();