feedBack/plugins/tuner/workers/yin.js
2026-06-16 18:47:13 +02:00

90 lines
3.8 KiB
JavaScript

/**
* YIN pitch detection worker.
*
* Receives { samples: Float32Array, sampleRate: number }, posts back
* { freq, confidence, rms }. The samples ArrayBuffer should be passed
* as transferable so no copy occurs across the worker boundary.
*/
// Guard allows the file to be required in Node.js test environments where
// `self` is not defined; the worker message handler only runs in the browser.
if (typeof self !== 'undefined') {
self.onmessage = (e) => {
const { samples, sampleRate } = e.data;
self.postMessage(_yinDetect(samples, sampleRate));
};
}
function _yinDetect(buffer, sampleRate) {
const threshold = 0.15;
const halfLen = Math.floor(buffer.length / 2);
const yinBuffer = new Float32Array(halfLen);
let rms = 0;
for (let i = 0; i < buffer.length; i++) rms += buffer[i] * buffer[i];
rms = Math.sqrt(rms / buffer.length);
if (rms < 0.01) return { freq: 0, confidence: 0, rms };
let runningSum = 0;
yinBuffer[0] = 1;
for (let tau = 1; tau < halfLen; tau++) {
let sum = 0;
for (let i = 0; i < halfLen; i++) {
const delta = buffer[i] - buffer[i + tau];
sum += delta * delta;
}
yinBuffer[tau] = sum;
runningSum += sum;
yinBuffer[tau] = runningSum > 0 ? yinBuffer[tau] * tau / runningSum : 1;
}
// Canonical YIN absolute-threshold step: walk tau upward and take the FIRST
// local minimum that drops below the threshold — NOT the globally deepest
// dip. The fundamental period is the smallest tau that satisfies the
// difference function; its sub-octaves (2T, 3T, …) sit at LARGER tau and
// often dip just as deep or deeper (the waveform realigns over two full
// periods), so picking the deepest dip is what produced the octave-low
// errors — D1 reported for a D2 string, or the common sub-harmonic of a
// two-note pluck. Choosing the first qualifying dip rejects those at the
// source. We still track the global minimum as a fallback for signals where
// nothing crosses the threshold.
let tau = -1;
let minVal = 1, minTau = -1;
for (let t = 2; t < halfLen; t++) {
if (yinBuffer[t] < minVal) { minVal = yinBuffer[t]; minTau = t; }
if (yinBuffer[t] < threshold) {
// Descend to the bottom of this first qualifying dip, keeping the
// global-min tracker current for the indices we skip (otherwise the
// fallback below could interpolate around a non-minimum).
while (t + 1 < halfLen && yinBuffer[t + 1] < yinBuffer[t]) {
t++;
if (yinBuffer[t] < minVal) { minVal = yinBuffer[t]; minTau = t; }
}
tau = t;
break;
}
}
if (tau === -1) {
// Nothing crossed the threshold — fall back to the global minimum so a
// weak but periodic signal still yields an estimate (confidence will be
// correspondingly low and is filtered downstream).
if (minTau === -1) return { freq: 0, confidence: 0, rms };
tau = minTau;
}
const s0 = yinBuffer[tau - 1];
const s1 = yinBuffer[tau];
const s2 = tau + 1 < halfLen ? yinBuffer[tau + 1] : yinBuffer[tau];
const denom = s0 - 2 * s1 + s2;
let betterTau = denom === 0 ? tau : tau + (s0 - s2) / (2 * denom);
// A near-zero (but nonzero) denom can fling the parabolic estimate far
// outside the bracket; the true minimum is within ±1 sample of tau. The
// negated test also rejects NaN.
if (!(betterTau >= tau - 1 && betterTau <= tau + 1)) betterTau = tau;
return { freq: sampleRate / betterTau, confidence: 1 - yinBuffer[tau], rms };
}
// Allow direct import in Node.js test environments; harmless in browser workers
// where the `module` global is undefined.
if (typeof module !== 'undefined') module.exports = { _yinDetect };