/** * 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);