feedBack/plugins/tuner/utils/audio.js
2026-06-16 18:47:13 +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.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; },
};
})();