mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-28 15:42:35 +00:00
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.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; },
|
|
};
|
|
})();
|