Spaces:
Runtime error
Runtime error
| /** | |
| * 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); | |