feedBack/static/v3/tuner-core.js
2026-06-16 18:47:13 +02:00

97 lines
4.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* fee[dB]ack v0.3.0 — shared tuner DSP/math.
*
* A small, dependency-free YIN pitch detector + frequency→note/cents helper,
* used by the topbar tuner badge (and reusable by anything else that needs a
* lightweight readout). This intentionally does NOT reach into the external
* note_detect plugin's internals — it's a standalone copy of the standard
* algorithm (constitution P-II, plugin isolation). Browser-global as
* `window.tunerCore`; also CommonJS-exported for node tests (tests/js).
*/
(function (root) {
'use strict';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
/**
* YIN pitch detection on a Float32 time-domain buffer.
* @returns {{frequency:number, confidence:number}} frequency in Hz (0 if
* none found), confidence in 0..1 (1 - aperiodicity).
*/
function yinDetect(buffer, sampleRate, threshold) {
threshold = threshold == null ? 0.12 : threshold;
const n = buffer.length;
const halfN = Math.floor(n / 2);
const yin = new Float32Array(halfN);
// Difference function.
for (let tau = 0; tau < halfN; tau++) {
let sum = 0;
for (let i = 0; i < halfN; i++) {
const delta = buffer[i] - buffer[i + tau];
sum += delta * delta;
}
yin[tau] = sum;
}
// Cumulative mean normalized difference.
yin[0] = 1;
let running = 0;
for (let tau = 1; tau < halfN; tau++) {
running += yin[tau];
yin[tau] = running > 0 ? yin[tau] * tau / running : 1;
}
// Absolute threshold: first dip below `threshold`, then the local min.
let tauEstimate = -1;
for (let tau = 2; tau < halfN; tau++) {
if (yin[tau] < threshold) {
while (tau + 1 < halfN && yin[tau + 1] < yin[tau]) tau++;
tauEstimate = tau;
break;
}
}
if (tauEstimate === -1) return { frequency: 0, confidence: 0 };
// Parabolic interpolation around the dip for sub-sample accuracy.
const x0 = tauEstimate > 0 ? tauEstimate - 1 : tauEstimate;
const x2 = tauEstimate + 1 < halfN ? tauEstimate + 1 : tauEstimate;
let betterTau = tauEstimate;
if (x0 !== tauEstimate && x2 !== tauEstimate) {
const s0 = yin[x0], s1 = yin[tauEstimate], s2 = yin[x2];
const denom = 2 * (2 * s1 - s2 - s0);
if (denom !== 0) betterTau = tauEstimate + (s2 - s0) / denom;
}
// A near-zero denom can fling betterTau out of [x0, x2] or non-finite,
// making frequency Infinity/NaN; clamp it back to the dip neighbourhood
// and treat a non-positive tau as "no detection" (frequency 0).
if (!Number.isFinite(betterTau) || betterTau < x0 || betterTau > x2) betterTau = tauEstimate;
return {
frequency: betterTau > 0 ? sampleRate / betterTau : 0,
confidence: Math.max(0, Math.min(1, 1 - yin[tauEstimate])),
};
}
/**
* Map a frequency to the nearest note + cents deviation.
* @param {number} freq Hz
* @param {number} [referencePitch=440] A4 reference (430450)
* @returns {{name,octave,note,midi,cents,targetFreq}|null}
*/
function freqToNote(freq, referencePitch) {
if (!freq || freq <= 0) return null;
const a4 = referencePitch || 440;
const midiFloat = 69 + 12 * Math.log2(freq / a4);
const midi = Math.round(midiFloat);
const targetFreq = a4 * Math.pow(2, (midi - 69) / 12);
const cents = Math.round(1200 * Math.log2(freq / targetFreq));
const name = NOTE_NAMES[((midi % 12) + 12) % 12];
const octave = Math.floor(midi / 12) - 1;
return { name, octave, note: name + octave, midi, cents, targetFreq };
}
const api = { yinDetect, freqToNote, NOTE_NAMES };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.tunerCore = api;
})(typeof window !== 'undefined' ? window : null);