notepilot / static /pitch-processor.js
jasonmdong's picture
Sync from GitHub via hub-sync
8028d40 verified
Raw
History Blame Contribute Delete
19.8 kB
/**
* pitch-processor.js
* AudioWorkletProcessor — runs entirely in the audio rendering thread.
*
* Design (score-following oriented, attack-driven):
* 1. Accumulate 128-sample render quanta into a 2048-sample rolling window.
* 2. Every PROCESS_INTERVAL (256) samples, run an envelope follower that
* detects NOTE ATTACKS (a sharp rise above the running level).
* 3. Fire exactly ONE note per attack — keyed to the strike, not the pitch —
* so repeated same-pitch notes (the bane of mic following) each register,
* the way a MIDI key-down would.
* 4. Identify the struck note shortly after the attack: blind YIN+Goertzel,
* biased toward the note the app says it expects, with a matched-filter
* "is the expected note's tone present?" rescue for quiet notes YIN misses.
*
* Receive from main thread: { type: 'threshold'|'expectedMidi'|'expectedPitches' }
* Post to main thread: { type: 'note'|'chord'|'silence'|'level'|'debug' }
*/
const MIN_FREQ = 55;
const MAX_FREQ = 1760;
const YIN_THRESHOLD = 0.12;
const MIN_ACCEPTED_CLARITY = 0.30;
// A 1024-sample (~21 ms) window. Short on purpose: a long window holds the
// PREVIOUS note while you play the next, so YIN locks onto stale audio and the
// note never registers until you pause and let it clear. 21 ms still covers a
// couple of periods down to ~C3, and the note is identified ~20 ms after the
// attack — by which point the window is essentially all of the new note.
const ANALYSIS_SIZE = 1024;
const PROCESS_INTERVAL = 256; // analysis every ~5.3 ms @ 48 kHz
// ── Note-attack detector ───────────────────────────────────────────────────
// A note is emitted once per attack. The trigger is a spike in HIGH-FREQUENCY
// CONTENT — the percussive transient of a hammer strike — which is present on
// EVERY strike, including re-articulating a still-ringing note, regardless of
// whether the overall loudness rose. (An amplitude-only trigger missed repeated
// notes: once the baseline sat at your playing level, the next same-volume
// strike didn't "rise above" anything.) An amplitude rise is also accepted, and
// a short refractory window stops one strike from firing twice.
const HFC_RATIO = 1.7; // transient content must exceed its baseline ×this
const AMP_RATIO = 1.4; // …OR loudness must exceed its baseline ×this
const BASELINE_ALPHA = 0.12; // baseline tracker weight (catches up within a few frames)
const ATTACK_MIN_LEVEL_MULT = 1.5; // attack must be at least this ×threshold (ignore noise)
const IDENTIFY_DELAY_MS = 20; // wait this long after an attack, then identify
const MIN_ATTACK_INTERVAL_MS = 55; // refractory: ignore new attacks within this of the last
const INSTANT_WINDOW = 256; // samples used for the "instant" level / transient
// ── Note identification ────────────────────────────────────────────────────
const EXPECTED_NOTE_MATCH_BONUS = 0.18;
const EXPECTED_NOTE_NEAR_BONUS = 0.10;
const EXPECTED_NOTE_FAR_PENALTY = 0.08;
// Matched-filter rescue: the expected note counts as "present" if its harmonic
// energy is a real fraction of the signal AND it dominates its own octaves.
const EXPECTED_PRESENT_RATIO = 0.12;
const CHORD_MIN_RELATIVE_AMP = 0.10;
const CHORD_MIN_RMS_RATIO = 0.018;
function freqToMidi(f) { return Math.round(69 + 12 * Math.log2(f / 440)); }
function midiToFreq(m) { return 440 * Math.pow(2, (m - 69) / 12); }
/**
* Goertzel DFT — amplitude of `freq` in `buf` at sample rate `sr`.
* O(N): much cheaper than a full FFT for evaluating individual bins.
*/
function goertzelAmp(buf, freq, sr) {
const N = buf.length;
const coeff = 2 * Math.cos(2 * Math.PI * freq / sr);
let s1 = 0, s2 = 0;
for (let i = 0; i < N; i++) {
const s0 = buf[i] + coeff * s1 - s2;
s2 = s1; s1 = s0;
}
return Math.sqrt(s1 * s1 + s2 * s2 - s1 * s2 * coeff) / N;
}
class PitchProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
const opts = options.processorOptions || {};
this._threshold = opts.threshold ?? 0.01;
this._expectedMidi = null;
this._expectedPitches = [];
this._timeBuf = new Float32Array(ANALYSIS_SIZE);
this._workBuf = new Float32Array(ANALYSIS_SIZE); // Hann-windowed, for Goertzel
this._yinBuf = new Float32Array(ANALYSIS_SIZE); // DC-removed raw, for YIN
this._diffBuf = new Float32Array(ANALYSIS_SIZE);
this._cmndf = new Float32Array(ANALYSIS_SIZE);
this._accumulated = 0;
this._totalSamples = 0;
// Silence gate
this._silentFrames = 0;
this._silentThresh = 2;
this._wasJustSilent = true;
// Attack-detector state
this._ampBaseline = 0; // running baseline loudness
this._hfcBaseline = 0; // running baseline transient (high-freq) content
this._pendingAttackAt = -1; // sample index of an attack awaiting identify
this._lastAttackAt = -1e9; // sample index of the last registered attack
this._identifyDelay = Math.round(IDENTIFY_DELAY_MS / 1000 * sampleRate);
this._minAttackInterval = Math.round(MIN_ATTACK_INTERVAL_MS / 1000 * sampleRate);
this._lastMidi = -1; // last fired pitch (debug / level only)
this._lastFreq = 0;
this.port.onmessage = ({ data }) => {
if (data.type === 'threshold') this._threshold = data.value;
if (data.type === 'expectedMidi') this._expectedMidi = data.value;
if (data.type === 'expectedPitches') {
this._expectedPitches = Array.isArray(data.value)
? [...new Set(data.value.map((pitch) => Math.round(Number(pitch))).filter((pitch) => Number.isFinite(pitch) && pitch >= 21 && pitch <= 108))]
: [];
}
};
}
process(inputs) {
const ch = inputs[0]?.[0];
if (!ch || ch.length === 0) return true;
this._pushChunk(ch);
this._accumulated += ch.length;
this._totalSamples += ch.length;
if (this._accumulated >= PROCESS_INTERVAL) {
this._accumulated = 0;
this._tick();
}
return true;
}
_pushChunk(chunk) {
if (this._wasJustSilent) {
// Zero-fill on onset so YIN doesn't see old silence mixed with new audio.
this._timeBuf.fill(0);
this._wasJustSilent = false;
}
if (chunk.length >= ANALYSIS_SIZE) {
this._timeBuf.set(chunk.subarray(chunk.length - ANALYSIS_SIZE));
} else {
this._timeBuf.copyWithin(0, chunk.length);
this._timeBuf.set(chunk, ANALYSIS_SIZE - chunk.length);
}
}
_tick() {
const rms = this._rms(this._timeBuf);
this.port.postMessage({ type: 'level', rms });
if (rms < this._threshold) {
this._silentFrames++;
if (this._silentFrames >= this._silentThresh) {
const had = this._lastMidi !== -1;
this._lastMidi = -1; this._lastFreq = 0;
this._ampBaseline = 0; this._hfcBaseline = 0; this._pendingAttackAt = -1;
this._wasJustSilent = true;
if (had) this.port.postMessage({ type: 'silence' });
}
return;
}
this._silentFrames = 0;
// ── Note-attack detection ────────────────────────────────────────────────
// Compare this frame to the baselines BEFORE folding it in, so a strike
// (which spikes while the baseline still lags) reads as a sharp rise. The
// baselines then catch up within the refractory window, so a steady sustain
// doesn't keep re-triggering. The primary trigger is the high-frequency
// transient of the hammer strike, which fires even when re-articulating a
// ringing note at the same loudness.
const inst = this._recentRms(INSTANT_WINDOW);
const hfc = this._hfc(INSTANT_WINDOW);
const prevAmp = this._ampBaseline;
const prevHfc = this._hfcBaseline;
this._ampBaseline = prevAmp + (inst - prevAmp) * BASELINE_ALPHA;
this._hfcBaseline = prevHfc + (hfc - prevHfc) * BASELINE_ALPHA;
const floor = Math.max(this._threshold, 1e-4) * ATTACK_MIN_LEVEL_MULT;
const isAttack = inst > floor && (hfc > prevHfc * HFC_RATIO || inst > prevAmp * AMP_RATIO);
if (isAttack && this._totalSamples - this._lastAttackAt > this._minAttackInterval) {
this._lastAttackAt = this._totalSamples;
this._pendingAttackAt = this._totalSamples;
}
// Identify shortly after the attack, once the tone has settled in the buffer.
if (this._pendingAttackAt >= 0 && this._totalSamples - this._pendingAttackAt >= this._identifyDelay) {
this._pendingAttackAt = -1;
this._identifyAndFire(rms);
}
}
_identifyAndFire(rms) {
const windowed = this._window(this._timeBuf);
// Chord checkpoint: confirm every expected pitch is present in the attack.
if (this._expectedPitches.length >= 2) {
const chord = this._chordPresent(windowed, rms);
if (chord) {
this.port.postMessage({
type: 'debug',
text: `chord ${chord.pitches.map((p) => this._noteName(p)).join('/')} q:${chord.confidence.toFixed(2)}`,
});
this.port.postMessage({ type: 'chord', ...chord });
return;
}
// else fall through — maybe only the melody note of the chord was played.
}
// What was actually struck (octave-disambiguated, biased toward expected)?
const result = this._detect();
if (result && result.clarity >= MIN_ACCEPTED_CLARITY && result.midi >= 24 && result.midi <= 108) {
this._lastMidi = result.midi; this._lastFreq = result.freq;
this.port.postMessage({
type: 'debug',
text: `${this._noteName(result.midi)} ${result.freq.toFixed(1)}Hz q:${result.clarity.toFixed(2)}`,
});
this.port.postMessage({ type: 'note', midi: result.midi, freq: result.freq, clarity: result.clarity, yin: result.yinClarity, goertzel: result.goertzelScore });
return;
}
// YIN was unsure (soft / ambiguous attack). If the EXPECTED note's tone is
// clearly present, accept it — this rescues quiet notes the blind detector
// drops, which is most of the perceived "it didn't hear me" inconsistency.
if (this._expectedMidi !== null && this._notePresent(windowed, this._expectedMidi, rms)) {
const f = midiToFreq(this._expectedMidi);
this._lastMidi = this._expectedMidi; this._lastFreq = f;
this.port.postMessage({
type: 'debug',
text: `${this._noteName(this._expectedMidi)} (matched)`,
});
this.port.postMessage({ type: 'note', midi: this._expectedMidi, freq: f, clarity: MIN_ACCEPTED_CLARITY, matched: true });
}
}
/** Is `midi`'s tone clearly present and the dominant octave? (matched filter) */
_notePresent(windowed, midi, rms) {
const f = midiToFreq(midi);
if (f < MIN_FREQ || f > MAX_FREQ) return false;
const expAmp = this._expectedPitchAmp(windowed, midi);
const upAmp = this._expectedPitchAmp(windowed, midi + 12);
const downAmp = this._expectedPitchAmp(windowed, midi - 12);
return expAmp >= Math.max(rms * EXPECTED_PRESENT_RATIO, 1e-6)
&& expAmp >= upAmp * 0.9
&& expAmp >= downAmp * 0.9;
}
/**
* Weighted energy across a candidate fundamental's harmonic series. Weights
* decrease with harmonic number so the strong low partials count most for the
* candidate whose fundamental they actually belong to — this is what resolves
* octave errors in either direction.
*/
_harmonicSum(windowed, f) {
if (f < MIN_FREQ) return 0;
const weights = [1.0, 0.8, 0.6, 0.5, 0.4, 0.3];
let sum = 0;
for (let h = 0; h < weights.length; h++) {
const hf = f * (h + 1);
if (hf > 6000 || hf > sampleRate / 2) break;
sum += goertzelAmp(windowed, hf, sampleRate) * weights[h];
}
return sum;
}
/** Harmonic-sum amplitude for one pitch (fundamental + 2nd + 3rd). */
_expectedPitchAmp(windowed, midi) {
const freq = midiToFreq(midi);
if (freq < MIN_FREQ || freq > MAX_FREQ) return 0;
let score = goertzelAmp(windowed, freq, sampleRate);
if (freq * 2 <= MAX_FREQ) score += goertzelAmp(windowed, freq * 2, sampleRate) * 0.45;
if (freq * 3 <= MAX_FREQ) score += goertzelAmp(windowed, freq * 3, sampleRate) * 0.22;
return score;
}
/** All expected chord pitches present in the current attack? */
_chordPresent(windowed, rms) {
const pitches = this._expectedPitches;
const amps = pitches.map((midi) => ({ midi, amp: this._expectedPitchAmp(windowed, midi) }));
const maxAmp = Math.max(...amps.map((entry) => entry.amp), 1e-9);
const allPresent = amps.every((entry) =>
entry.amp >= maxAmp * CHORD_MIN_RELATIVE_AMP &&
entry.amp >= Math.max(1e-7, rms * CHORD_MIN_RMS_RATIO)
);
if (!allPresent) return null;
const minRatio = Math.min(...amps.map((entry) => entry.amp / maxAmp));
return {
pitches: [...pitches],
confidence: Math.max(0, Math.min(1, minRatio * 1.8)),
rms,
amps: amps.map((entry) => ({ midi: entry.midi, amp: entry.amp })),
};
}
_detect() {
// YIN must run on the raw (DC-removed, UN-tapered) signal. A Hann taper
// makes x[i] and x[i+tau] unequal even for a perfectly periodic tone, and
// the mismatch grows with tau — i.e. with lower pitch — which collapses the
// clarity of low notes (e.g. C3 ≈ 131 Hz). The Hann window is only used by
// the Goertzel octave check below.
const yinInput = this._dcRemoved(this._timeBuf);
const yin = this._yin(yinInput);
if (!yin || yin.clarity < 0.25) return null;
const windowed = this._window(this._timeBuf);
// ── Octave disambiguation via HARMONIC-SUM scoring ──────────────────────
// Evaluate {freq/2, freq, freq×2} but score each by the energy across its
// whole harmonic series, not the single bin. Scoring a single bin picks the
// loudest partial, which on a laptop mic (weak low end) is often the 2nd
// harmonic — that's the D3→D4 octave-up error. The harmonic sum favors the
// true fundamental: D3's series captures its strong ~440 Hz 3rd harmonic,
// which D4's series (294/588/882…) does not, and the decreasing weights mean
// a sub-octave candidate can't win by borrowing the real note's partials.
const opts = [yin.freq / 2, yin.freq, yin.freq * 2]
.filter((f) => f >= MIN_FREQ && f <= MAX_FREQ)
.map((f) => ({ f, amp: this._harmonicSum(windowed, f), midi: freqToMidi(f) }));
const maxAmp = Math.max(...opts.map((o) => o.amp));
let best = null;
for (const o of opts) {
let score = o.amp;
if (this._expectedMidi !== null) {
const d = Math.abs(o.midi - this._expectedMidi);
if (d <= 1) score += maxAmp * 0.55;
else if (d <= 3) score += maxAmp * 0.18;
else if (d >= 7) score -= maxAmp * 0.28;
}
if (!best || score > best.score) best = { ...o, score };
}
let clarity = yin.clarity;
if (this._expectedMidi !== null) {
const d = Math.abs(best.midi - this._expectedMidi);
if (d <= 1) clarity = Math.min(1, clarity + EXPECTED_NOTE_MATCH_BONUS);
else if (d <= 3) clarity = Math.min(1, clarity + EXPECTED_NOTE_NEAR_BONUS);
else if (d >= 7) clarity = Math.max(0, clarity - EXPECTED_NOTE_FAR_PENALTY);
}
return {
freq: best.f,
midi: best.midi,
clarity,
yinClarity: yin.clarity,
goertzelScore: best.amp,
};
}
/** CMNDF-YIN period estimator. Returns { freq, clarity } or null. */
_yin(buf) {
const n = buf.length;
const sr = sampleRate;
const tMin = Math.max(2, Math.floor(sr / MAX_FREQ));
const tMax = Math.min(Math.floor(sr / MIN_FREQ), n - 2);
if (tMax <= tMin) return null;
this._cmndf[0] = 1;
let runSum = 0;
for (let tau = tMin; tau <= tMax; tau++) {
let d = 0;
const count = n - tau;
for (let i = 0; i < count; i++) {
const delta = buf[i] - buf[i + tau];
d += delta * delta;
}
// Normalize by the number of overlapping samples. Without this, d(tau)
// shrinks as tau grows (fewer terms summed), biasing the estimate toward
// the LONGEST period — which makes every note collapse to ~55 Hz / A1,
// badly so with a short window. Dividing by `count` makes it a mean-square
// difference that is comparable across all tau.
d = count > 0 ? d / count : 0;
this._diffBuf[tau] = d;
runSum += d;
this._cmndf[tau] = runSum > 0 ? (d * tau) / runSum : 1;
}
let bestTau = -1;
for (let tau = tMin + 1; tau < tMax; tau++) {
const v = this._cmndf[tau];
if (v < YIN_THRESHOLD && v <= this._cmndf[tau - 1] && v < this._cmndf[tau + 1]) {
bestTau = tau; break;
}
}
if (bestTau === -1) {
let bv = Infinity;
for (let tau = tMin; tau <= tMax; tau++) {
if (this._cmndf[tau] < bv) { bv = this._cmndf[tau]; bestTau = tau; }
}
if (bestTau === -1 || this._cmndf[bestTau] > 0.35) return null;
}
const refined = this._parabolicInterp(this._cmndf, bestTau, false);
if (!Number.isFinite(refined) || refined <= 0) return null;
return { freq: sr / refined, clarity: 1 - Math.min(1, this._cmndf[bestTau]) };
}
/** DC removal only (no taper) — the correct input for YIN. Returns _yinBuf. */
_dcRemoved(input) {
let mean = 0;
for (let i = 0; i < input.length; i++) mean += input[i];
mean /= input.length;
for (let i = 0; i < input.length; i++) this._yinBuf[i] = input[i] - mean;
return this._yinBuf;
}
/** Hann window + DC removal. Writes _workBuf in place and returns it. */
_window(input) {
let mean = 0;
for (let i = 0; i < input.length; i++) mean += input[i];
mean /= input.length;
const N = input.length - 1;
for (let i = 0; i < input.length; i++) {
this._workBuf[i] = (input[i] - mean) * (0.5 - 0.5 * Math.cos(2 * Math.PI * i / N));
}
return this._workBuf;
}
_parabolicInterp(vals, idx, isMax) {
const l = vals[idx - 1] ?? vals[idx];
const m = vals[idx];
const r = vals[idx + 1] ?? vals[idx];
const d = l - 2 * m + r;
if (Math.abs(d) < 1e-9) return idx;
if (!isMax && d < 0) return idx;
if ( isMax && d > 0) return idx;
return idx + Math.max(-1, Math.min(1, 0.5 * (l - r) / d));
}
/** RMS of the most recent `n` samples (the fast "instant" level). */
_recentRms(n) {
let s = 0;
const start = ANALYSIS_SIZE - n;
for (let i = start; i < ANALYSIS_SIZE; i++) s += this._timeBuf[i] * this._timeBuf[i];
return Math.sqrt(s / n);
}
/**
* High-frequency content of the most recent `n` samples, via the first
* difference (a cheap high-pass). A hammer strike injects a burst of
* sample-to-sample change that a steady, ringing tone does not — so this
* spikes on every attack, including re-striking a held note, which is what
* lets repeated notes register without depending on a loudness rise.
*/
_hfc(n) {
let s = 0;
const start = ANALYSIS_SIZE - n;
for (let i = start; i < ANALYSIS_SIZE; i++) {
const d = this._timeBuf[i] - this._timeBuf[i - 1];
s += d * d;
}
return Math.sqrt(s / n);
}
_rms(buf) {
let s = 0;
for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i];
return Math.sqrt(s / buf.length);
}
_noteName(midi) {
return ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'][midi % 12]
+ (Math.floor(midi / 12) - 1);
}
}
registerProcessor('pitch-processor', PitchProcessor);