From 079d574b663d6706e5dd94562cbd2531a4714999 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 13 Sep 2026 18:52:21 +0200 Subject: [PATCH] Overhaul pitch detection and note matching - Pick detector per instrument range: ACF2PLUS (robust down to ~40 Hz) for normal instruments, YIN only for the whistle register. pitchfinder's YIN latches onto tau=2 (~20 kHz garbage) below ~100 Hz and for quiet input; the old fixed 4096 window masked this, the smaller window exposed it. - RMS-normalize analysis input for the YIN path only; ACF2PLUS keeps the raw signal so its internal unpitched gate rejects quiet decayed audio instead of amplifying noise into confident wrong pitches (tuner stuck on 'a note'). - Adaptive analysis window sized to the instrument's lowest note (~2 periods): half the latency, a quarter of the CPU for guitar/piano/voice; bass keeps the full window for E1. - Reject detections implausibly below the instrument floor (mains hum). - Clear stale pitch readings after 200 ms instead of keeping them forever. - Shared AudioEngine singleton for all playback (was: two AudioContexts); tracks audibility so the app stops hearing its own speaker (ear-training self-scoring) and gates matching plus display during playback. - MatchTracker: grace-period based note hold instead of reset-on-every-flicker (YIN flickers during attacks/decays; the old hard reset made the 50 ms threshold fire unreliably). - Display damping in the producer (EMA for cents/level, 3-frame note-name hysteresis); display components render directly without CSS transitions. - Regression tests: synthetic signals through the exact pipeline helpers (MatchTracker + pitchDetection test suites). --- src/AppConfig.ts | 67 ++++++++- src/audio/AudioEngine.ts | 84 +++++++++++ src/audio/PitchAnalyzer.ts | 176 ++++++++++++++++++++--- src/audio/pitchDetection.test.ts | 156 +++++++++++++++++++++ src/game/MatchTracker.test.ts | 84 +++++++++++ src/game/MatchTracker.ts | 60 ++++++++ src/hooks/useAudioPlayer.ts | 59 ++------ src/hooks/useGameLogic.ts | 112 ++++++++++----- src/hooks/useMetronome.ts | 13 +- src/hooks/usePitchDetector.ts | 232 ++++++++++++++++++++++--------- src/types/SettingsTypes.ts | 7 + src/types/pitchfinder.d.ts | 5 +- 12 files changed, 873 insertions(+), 182 deletions(-) create mode 100644 src/audio/AudioEngine.ts create mode 100644 src/audio/pitchDetection.test.ts create mode 100644 src/game/MatchTracker.test.ts create mode 100644 src/game/MatchTracker.ts diff --git a/src/AppConfig.ts b/src/AppConfig.ts index 48b0b25..c6fa146 100644 --- a/src/AppConfig.ts +++ b/src/AppConfig.ts @@ -2,10 +2,73 @@ * Global application configuration */ -// How long (in ms) a note must be held to be accepted as correct -// How long (in ms) a note must be held to be accepted as correct +// How long (in ms) the target note must be detected to count as correct. +// Protects against spurious single-frame detections. export const NOTE_MATCH_THRESHOLD_MS = 50; +// Grace period (in ms): brief detection dropouts or wrong frames while holding +// a note do NOT reset the match timer. Pitch detectors (YIN) routinely flicker +// for a frame or two during note attacks and decays; a hard reset on every +// flicker makes the threshold above feel broken ("shows green but won't register"). +export const NOTE_MATCH_GRACE_MS = 150; + +// How long (in ms) after speaker playback ends that mic-based matching stays +// gated, so the app doesn't "hear itself" (ear-training auto-play, Play Note +// button, virtual instruments). +export const PLAYBACK_GATE_TAIL_MS = 400; + +// Safety cap for the "wait for silence" option: if the mic never goes quiet +// (continuously detectable sound in the room), the next reference note plays +// anyway after this delay so the app can't stall indefinitely. +export const REFERENCE_QUIET_MAX_WAIT_MS = 8000; + +// Display smoothing (exponential moving average factor, 0-1) applied to the +// cents reading and the audio level in the detection loop (~60 fps). Keeps +// the tuner needle and level meter readable without CSS transitions — a +// transition on a value that changes every frame is interrupted 60x per +// second, which Safari/WebKit handles by leaving the element stuck. +// 0.25 ≈ 80ms effective smoothing: damps jitter but stays very responsive. +export const DISPLAY_EMA_FACTOR = 0.25; + +// How many consecutive detection frames a different note must persist before +// the displayed note name switches (~50ms at 60fps). Prevents the note name +// from flickering between adjacent notes when playing right at a semitone +// boundary. Matching is unaffected (it uses the raw midi value). +export const NOTE_DISPLAY_STABLE_FRAMES = 3; + +// How long (in ms) the last detected pitch is kept on screen after the signal +// disappears, before the readout is cleared. Prevents flicker between frames +// without leaving stale notes hanging forever. +export const PITCH_READING_HOLD_MS = 200; + +// Pitch analysis window (samples @ AudioContext rate). +// The analyzer adapts the window down from BUFFER_SIZE based on the lowest +// frequency that must be detected (~2 periods minimum). Smaller window = +// lower latency and 4x less CPU per analysis. 4096 covers bass E1 (~41 Hz). +export const PITCH_BUFFER_SIZE = 4096; +export const PITCH_MIN_WINDOW = 1024; + +// Fallback for the lowest detectable frequency before the app configures +// the actual instrument range. ~70 Hz covers guitar low E with margin. +export const PITCH_DEFAULT_LOWEST_HZ = 70; + +// Instruments whose lowest note is at/above this frequency use YIN instead of +// ACF2PLUS: ACF2PLUS picks subharmonics above ~2 kHz, while YIN's weakness +// below ~100 Hz is irrelevant for high registers (e.g. whistle). +export const PITCH_HIGH_REGISTER_HZ = 300; + +// Target RMS for normalizing the analysis buffer before pitch detection +// (YIN/high-register path only — see selectDetector in PitchAnalyzer). +// pitchfinder's YIN force-sets yinBuffer[1] = 1 before normalization, so at +// low absolute amplitudes its difference function gets dwarfed and it latches +// onto tau=2 (~20 kHz garbage). A constant operating level fixes this. +export const PITCH_NORMALIZE_TARGET_RMS = 0.3; + +// Detected pitches below (instrument floor × this factor) are rejected as +// implausible — e.g. 50/60 Hz mains hum showing up as a confident wrong note +// while a guitar (floor ~78 Hz) is not playing. ~4 semitones of margin. +export const PITCH_BELOW_FLOOR_REJECT_FACTOR = 2 ** (-4 / 12); // ≈ 0.794 + // Microphone Sensitivity Configuration // Sensitivity Scale: 0.0 (Low) to 1.0 (High) // Linear mapping to dB thresholds diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts new file mode 100644 index 0000000..fa04eed --- /dev/null +++ b/src/audio/AudioEngine.ts @@ -0,0 +1,84 @@ +import { midiToFrequency } from '../music/NoteUtils'; +import { PLAYBACK_GATE_TAIL_MS } from '../AppConfig'; + +/** + * Create an AudioContext with a typed webkit fallback (older Safari). + */ +export function createAudioContext(): AudioContext { + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + return new Ctor({ latencyHint: 'interactive' }); +} + +/** + * Shared audio playback engine (singleton). + * + * All speaker output goes through a single AudioContext (previously App and + * useGameLogic each created their own via separate useAudioPlayer instances). + * Playback is tracked so pitch detection can ignore the microphone while the + * app is audible — otherwise the app hears its own reference notes through + * the speaker and scores them as correct (ear-training feedback loop). + */ +class AudioEngine { + private ctx: AudioContext | null = null; + /** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */ + private audibleUntil = 0; + + private ensureContext(): AudioContext { + if (!this.ctx) { + this.ctx = createAudioContext(); + } + if (this.ctx.state === 'suspended') { + void this.ctx.resume(); + } + return this.ctx; + } + + playNote(midi: number, duration = 0.5, volume = 0.3): void { + const ctx = this.ensureContext(); + + const osc = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + osc.frequency.value = midiToFrequency(midi); + // Triangle wave is often nicer than sine for music training + osc.type = 'triangle'; + + osc.connect(gainNode); + gainNode.connect(ctx.destination); + + const now = ctx.currentTime; + + // Attack + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(volume, now + 0.02); + // Sustain -> Release + gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.start(now); + osc.stop(now + duration + 0.1); // Stop slightly after fade out + + // The note decays to -60dB at `now + duration`; keep the mic gated for + // an additional tail so room reverb / speaker bleed can't self-trigger. + this.audibleUntil = Math.max( + this.audibleUntil, + now + duration + PLAYBACK_GATE_TAIL_MS / 1000 + ); + } + + /** + * True while speaker output (plus gate tail) may still be audible at the + * microphone. Mic-based note matching should be ignored during this time. + * + * A suspended AudioContext (autoplay policy, no user gesture yet) reports + * currentTime = 0 forever — without the state check the gate would be + * stuck "audible" permanently and matching would never engage. + */ + isAudible(): boolean { + if (!this.ctx || this.ctx.state !== 'running') return false; + return this.ctx.currentTime < this.audibleUntil; + } +} + +export const audioEngine = new AudioEngine(); diff --git a/src/audio/PitchAnalyzer.ts b/src/audio/PitchAnalyzer.ts index 153f11b..a75a52b 100644 --- a/src/audio/PitchAnalyzer.ts +++ b/src/audio/PitchAnalyzer.ts @@ -1,5 +1,15 @@ -import { YIN } from "pitchfinder"; -import { MIC_SENSITIVITY_DB_RANGE, AUDIO_CONSTRAINTS } from "../AppConfig"; +import { YIN, ACF2PLUS } from "pitchfinder"; +import { + MIC_SENSITIVITY_DB_RANGE, + AUDIO_CONSTRAINTS, + PITCH_BUFFER_SIZE, + PITCH_MIN_WINDOW, + PITCH_DEFAULT_LOWEST_HZ, + PITCH_HIGH_REGISTER_HZ, + PITCH_NORMALIZE_TARGET_RMS, + PITCH_BELOW_FLOOR_REJECT_FACTOR, +} from "../AppConfig"; +import { createAudioContext } from "./AudioEngine"; export interface MicrophoneDebugInfo { audioContextState: AudioContextState | 'inactive'; @@ -12,13 +22,85 @@ export interface MicrophoneDebugInfo { isCapturing: boolean; } +export type PitchDetectorFn = (buffer: Float32Array) => number | null; + +export interface DetectorSpec { + detector: PitchDetectorFn; + /** Whether the analysis buffer must be RMS-normalized before detection */ + normalize: boolean; +} + +/** + * Smallest power-of-two analysis window that still fits ~2 periods of the + * lowest required frequency. Smaller window = lower latency and ~4x less CPU + * per analysis frame. Clamped to [PITCH_MIN_WINDOW, PITCH_BUFFER_SIZE]. + */ +export function chooseAnalysisWindowSize(sampleRate: number, lowestHz: number): number { + const needed = Math.ceil((2 * sampleRate) / lowestHz); + const pow2 = 2 ** Math.ceil(Math.log2(Math.max(1, needed))); + return Math.max(PITCH_MIN_WINDOW, Math.min(PITCH_BUFFER_SIZE, pow2)); +} + +/** + * Pick a detector for the instrument's range. + * + * pitchfinder's YIN force-sets yinBuffer[1] = 1 before cumulative + * normalization, which makes its absolute-threshold search latch onto tau=2 + * (~20 kHz garbage) for low fundamentals — below ~100 Hz it is unreliable no + * matter the window size. ACF2PLUS is robust from ~40 Hz up to ~2 kHz, but + * tends to pick subharmonics above that. So: ACF2PLUS for normal instruments, + * YIN only for high registers (e.g. whistle) where its low-end weakness is + * irrelevant. + * + * Only the YIN path gets normalized input: ACF2PLUS has an internal + * "rms < 0.01 -> unpitched" gate that must see the RAW signal — normalizing + * first would amplify room hum/noise floors into confident wrong pitches + * (the tuner then appears "stuck" on a note nobody played). + */ +export function selectDetector(sampleRate: number, lowestHz: number): DetectorSpec { + if (lowestHz >= PITCH_HIGH_REGISTER_HZ) { + return { detector: YIN({ sampleRate }), normalize: true }; + } + return { detector: ACF2PLUS({ sampleRate }), normalize: false }; +} + +/** Kept for backwards compatibility with existing tests. */ +export function createPitchDetector(sampleRate: number, lowestHz: number): PitchDetectorFn { + return selectDetector(sampleRate, lowestHz).detector; +} + +/** + * Scale the buffer in place to a constant RMS operating level (clamped to + * [-1, 1]). Detectors are sensitive to absolute amplitude; without this, + * quiet low-frequency input makes YIN return ~20 kHz nonsense. + */ +export function normalizeSignal(buffer: Float32Array, targetRms?: number): void { + let rms = 0; + for (let i = 0; i < buffer.length; i++) rms += buffer[i] * buffer[i]; + rms = Math.sqrt(rms / buffer.length); + if (rms < 1e-6) return; + const target = targetRms ?? PITCH_NORMALIZE_TARGET_RMS; + const gain = target / rms; + for (let i = 0; i < buffer.length; i++) { + const v = buffer[i] * gain; + buffer[i] = v > 1 ? 1 : v < -1 ? -1 : v; + } +} + export class PitchAnalyzer { - private detector: (buffer: Float32Array) => number | null; + private detector: PitchDetectorFn | null = null; + private normalizeInput = false; private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; private mediaStream: MediaStream | null = null; private source: MediaStreamAudioSourceNode | null = null; - private buffer: Float32Array; + + /** Full-size backing buffer (fftSize samples) */ + private buffer = new Float32Array(PITCH_BUFFER_SIZE); + /** Tail-view into `buffer`; only the most recent windowSize samples are analyzed */ + private window: Float32Array = this.buffer; + private windowSize = PITCH_BUFFER_SIZE; + private lowestFrequency = PITCH_DEFAULT_LOWEST_HZ; // Debug info private inputDeviceLabel: string = 'Not detected'; @@ -26,11 +108,6 @@ export class PitchAnalyzer { private permissionState: PermissionState | 'unknown' = 'unknown'; private currentRmsLevel: number = 0; - constructor() { - this.detector = YIN({ sampleRate: 44100 }); // Default, will update on start - this.buffer = new Float32Array(2048); // Standard size - } - private sensitivityThreshold = 0.03; // Default /** @@ -49,6 +126,43 @@ export class PitchAnalyzer { this.sensitivityThreshold = Math.pow(10, db / 20); } + /** + * Configure the lowest frequency that must be detectable (Hz) and shrink + * the analysis window accordingly. + * + * Pitch detection needs ~2 periods of the target frequency in the window. + * A fixed 4096-sample window always analyzes ~90ms of audio history, which + * makes the tuner feel sluggish. Guitar/piano/voice (>= ~65 Hz) only need + * 2048 samples (~45ms) — half the latency at a quarter of the CPU cost. + * Bass (E1 ~ 41 Hz) keeps the full 4096 window. + */ + setLowestFrequency(hz: number) { + if (hz > 0) { + this.lowestFrequency = hz; + this.updateWindowSize(); + this.rebuildDetector(); + } + } + + private updateWindowSize() { + if (!this.audioContext) return; // applied on start() + const size = chooseAnalysisWindowSize(this.audioContext.sampleRate, this.lowestFrequency); + if (size !== this.windowSize) { + this.windowSize = size; + // View over the tail of the backing buffer: getFloatTimeDomainData + // fills it with the most recent `size` samples. + const byteOffset = (PITCH_BUFFER_SIZE - size) * Float32Array.BYTES_PER_ELEMENT; + this.window = new Float32Array(this.buffer.buffer, byteOffset, size); + } + } + + private rebuildDetector() { + if (!this.audioContext) return; // applied on start() + const spec = selectDetector(this.audioContext.sampleRate, this.lowestFrequency); + this.detector = spec.detector; + this.normalizeInput = spec.normalize; + } + async start(): Promise { if (this.audioContext) return; @@ -63,8 +177,9 @@ export class PitchAnalyzer { this.permissionState = 'unknown'; } - this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); - this.detector = YIN({ sampleRate: this.audioContext.sampleRate }); + this.audioContext = createAudioContext(); + this.updateWindowSize(); + this.rebuildDetector(); try { // Use configured audio constraints @@ -98,8 +213,7 @@ export class PitchAnalyzer { this.source = this.audioContext.createMediaStreamSource(this.mediaStream); this.analyser = this.audioContext.createAnalyser(); - this.analyser.fftSize = 4096; // Higher FFT size for better resolution at low frequencies - this.buffer = new Float32Array(this.analyser.fftSize); + this.analyser.fftSize = PITCH_BUFFER_SIZE; // Large buffer; analyzed window adapts per instrument this.source.connect(this.analyser); @@ -123,6 +237,9 @@ export class PitchAnalyzer { this.audioContext.close(); this.audioContext = null; } + this.detector = null; + this.analyser = null; + this.source = null; this.currentRmsLevel = 0; this.inputDeviceLabel = 'Not detected'; this.inputDeviceId = ''; @@ -159,26 +276,43 @@ export class PitchAnalyzer { * getPitch returns the detected frequency in Hz, or null if no pitch detected */ getPitch(): number | null { - if (!this.analyser) return null; - this.analyser.getFloatTimeDomainData(this.buffer as any); + if (!this.analyser || !this.detector) return null; + // Always copy the full fftSize buffer (universally supported usage), + // then analyze only the tail view = most recent windowSize samples. + // (Passing an undersized array directly relies on browser-specific + // behavior of getFloatTimeDomainData.) + this.analyser.getFloatTimeDomainData(this.buffer); - // Simple RMS volume check to avoid noise + // Simple RMS volume check to avoid noise (on the analyzed window) let rms = 0; - for (let i = 0; i < this.buffer.length; i++) { - rms += this.buffer[i] * this.buffer[i]; + for (let i = 0; i < this.window.length; i++) { + rms += this.window[i] * this.window[i]; } - rms = Math.sqrt(rms / this.buffer.length); + rms = Math.sqrt(rms / this.window.length); // Store RMS for level meter this.currentRmsLevel = rms; if (rms < this.sensitivityThreshold) return null; - const pitch = this.detector(this.buffer); + // Normalize only on the YIN/high-register path. ACF2PLUS must see the + // raw signal so its internal "rms < 0.01 -> unpitched" gate keeps + // rejecting quiet decayed audio instead of amplifying it into a + // confident wrong pitch. + if (this.normalizeInput) { + normalizeSignal(this.window); + } + + const pitch = this.detector(this.window); // Widen range for Bass (E1 ~41Hz, but A0 is 27.5Hz) and Whistle (C8 ~4186Hz, Harmonics go higher) // Range 25Hz - 8000Hz covers Piano A0 to well above highest fundamental - if (pitch && (pitch < 25 || pitch > 8000)) return null; + // (Also filters out the -1 some detectors return for "unpitched".) + if (pitch === null || pitch < 25 || pitch > 8000) return null; + + // Implausible for the current instrument (e.g. 50/60 Hz mains hum + // while playing guitar): reject readings far below the configured floor. + if (pitch < this.lowestFrequency * PITCH_BELOW_FLOOR_REJECT_FACTOR) return null; return pitch; } diff --git a/src/audio/pitchDetection.test.ts b/src/audio/pitchDetection.test.ts new file mode 100644 index 0000000..28c079c --- /dev/null +++ b/src/audio/pitchDetection.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { + chooseAnalysisWindowSize, + selectDetector, + normalizeSignal, +} from './PitchAnalyzer'; +import { + PITCH_BUFFER_SIZE, + PITCH_MIN_WINDOW, + PITCH_BELOW_FLOOR_REJECT_FACTOR, +} from '../AppConfig'; + +/** + * Regression tests for the pitch detection pipeline. + * + * pitchfinder's YIN returns ~20 kHz garbage for low fundamentals (its + * forced yinBuffer[1] = 1 breaks the absolute-threshold search below + * ~100 Hz) and for quiet input. The app therefore uses ACF2PLUS for normal + * instruments, YIN only for high registers, and normalizes the analysis + * buffer to a constant RMS. These tests synthesize instrument-like signals + * (fundamental + harmonics + pluck decay, deterministic) and run them + * through the exact helpers PitchAnalyzer uses. + */ + +/** Deterministic instrument-like test signal (no RNG → no flaky tests). */ +function synth(freq: number, sampleRate: number, length: number, amp: number): Float32Array { + const buf = new Float32Array(length); + for (let i = 0; i < length; i++) { + const t = i / sampleRate; + const env = Math.exp(-t * 3); // pluck-like decay + buf[i] = amp * env * ( + Math.sin(2 * Math.PI * freq * t) + + 0.5 * Math.sin(2 * Math.PI * freq * 2 * t) + + 0.25 * Math.sin(2 * Math.PI * freq * 3 * t) + + 0.1 * Math.sin(2 * Math.PI * freq * 4 * t) + + 0.02 * Math.sin(2 * Math.PI * 7.13 * i / length) // deterministic "noise" dither + ); + } + return buf; +} + +/** Mirror of PitchAnalyzer.getPitch, minus microphone plumbing. */ +function detect(freq: number, sampleRate: number, lowestHz: number, amp = 0.3): number | null { + const size = chooseAnalysisWindowSize(sampleRate, lowestHz); + const buffer = synth(freq, sampleRate, size, amp); + const { detector, normalize } = selectDetector(sampleRate, lowestHz); + if (normalize) normalizeSignal(buffer); + const pitch = detector(buffer); + if (pitch === null || pitch < 25 || pitch > 8000) return null; + if (pitch < lowestHz * PITCH_BELOW_FLOOR_REJECT_FACTOR) return null; + return pitch; +} + +function expectCloseTo(actual: number | null, expected: number) { + expect(actual).not.toBeNull(); + expect(Math.abs(actual! - expected) / expected).toBeLessThan(0.03); +} + +describe('chooseAnalysisWindowSize', () => { + it('adapts to the instrument floor', () => { + // Guitar low E2 (~78 Hz w/ margin) -> half window at 48k + expect(chooseAnalysisWindowSize(48000, 77.8)).toBe(2048); + // Bass low E1 (~39 Hz w/ margin) -> full window + expect(chooseAnalysisWindowSize(48000, 38.9)).toBe(PITCH_BUFFER_SIZE); + // Whistle register -> minimum window + expect(chooseAnalysisWindowSize(48000, 622)).toBe(PITCH_MIN_WINDOW); + }); +}); + +describe('pitch detection across instruments (sample rate 48000)', () => { + const SR = 48000; + + it('guitar (low E2)', () => { + const floor = 77.8; // E2 minus one semitone + expectCloseTo(detect(82.4, SR, floor), 82.4); // E2 — the regression case + expectCloseTo(detect(110.0, SR, floor), 110); // A2 + expectCloseTo(detect(329.6, SR, floor), 329.6); // E4 + }); + + it('guitar at low input level (quiet pluck tail)', () => { + expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4); + expectCloseTo(detect(440, SR, 77.8, 0.05), 440); + }); + + it('bass (low E1)', () => { + const floor = 38.9; + expectCloseTo(detect(41.2, SR, floor), 41.2); // E1 — broken even before the rewrite + expectCloseTo(detect(55.0, SR, floor), 55); // A1 + }); + + it('piano', () => { + const floor = 61.7; // C2 minus one semitone + expectCloseTo(detect(65.4, SR, floor), 65.4); // C2 + expectCloseTo(detect(261.6, SR, floor), 261.6); // C4 + expectCloseTo(detect(1046.5, SR, floor), 1046.5); // C6 + }); + + it('whistle register (uses YIN, small window)', () => { + const floor = 622; // E5 minus one semitone + expectCloseTo(detect(659.3, SR, floor), 659.3); // E5 + expectCloseTo(detect(2093.0, SR, floor), 2093); // C7 + expectCloseTo(detect(4186.0, SR, floor), 4186); // C8 + }); +}); + +describe('pitch detection (sample rate 44100)', () => { + it('still works', () => { + expectCloseTo(detect(82.4, 44100, 77.8), 82.4); + expectCloseTo(detect(41.2, 44100, 38.9), 41.2); + expectCloseTo(detect(440, 44100, 77.8), 440); + }); +}); + +describe('normalizeSignal', () => { + it('scales to the target RMS', () => { + const buf = synth(440, 48000, 2048, 0.02); + normalizeSignal(buf, 0.3); + let rms = 0; + for (const v of buf) rms += v * v; + rms = Math.sqrt(rms / buf.length); + expect(rms).toBeGreaterThan(0.2); // ~0.3 modulo decay envelope shape + }); + + it('leaves silence untouched', () => { + const buf = new Float32Array(1024); + normalizeSignal(buf); + expect(buf.every(v => v === 0)).toBe(true); + }); +}); + +describe('noise-floor rejection (tuner freeze regression)', () => { + const SR = 48000; + + it('rejects 50 Hz mains hum for guitar (floor ~78 Hz)', () => { + expect(detect(50, SR, 77.8)).toBeNull(); + }); + + it('rejects 60 Hz mains hum for guitar', () => { + expect(detect(60, SR, 77.8)).toBeNull(); + }); + + it('rejects hum for the whistle register too', () => { + expect(detect(50, SR, 622)).toBeNull(); + expect(detect(120, SR, 622)).toBeNull(); + }); + + it('returns null for very quiet decayed signal instead of a confident wrong pitch', () => { + // Raw RMS ~0.003: below ACF2PLUS's internal unpitched gate. + // (Normalization must NOT rescue this into a bogus reading.) + expect(detect(82.4, SR, 77.8, 0.005)).toBeNull(); + }); + + it('still detects the string when it rings at moderate level', () => { + expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4); + }); +}); diff --git a/src/game/MatchTracker.test.ts b/src/game/MatchTracker.test.ts new file mode 100644 index 0000000..9f9fa7a --- /dev/null +++ b/src/game/MatchTracker.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { MatchTracker } from './MatchTracker'; + +const THRESHOLD = 50; +const GRACE = 150; + +describe('MatchTracker', () => { + it('does not trigger before the threshold', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + expect(t.update(true, 0)).toBe(false); + expect(t.update(true, 49)).toBe(false); + }); + + it('triggers once the threshold is reached', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + expect(t.update(true, 0)).toBe(false); + expect(t.update(true, 50)).toBe(true); + }); + + it('triggers exactly once, then starts fresh', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); + expect(t.update(true, 60)).toBe(true); + // After triggering, tracker is reset: next frame must not re-trigger + expect(t.update(true, 70)).toBe(false); + }); + + it('survives a brief dropout within the grace period', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); // start matching + t.update(false, 30); // one flickered frame + expect(t.update(true, 60)).toBe(true); // 60ms elapsed > 50ms threshold + }); + + it('survives several short dropouts', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); + t.update(false, 16); + t.update(true, 32); + t.update(false, 48); + expect(t.update(true, 64)).toBe(true); + }); + + it('resets when the interruption exceeds the grace period', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); + t.update(false, 10); + // 200ms of silence later the user plays the note again: + // must NOT inherit the 200ms-old start time (that would fire instantly) + expect(t.update(true, 210)).toBe(false); + expect(t.update(true, 259)).toBe(false); + expect(t.update(true, 260)).toBe(true); + }); + + it('resets when a wrong note persists beyond grace', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); + t.update(false, 20); + t.update(false, 200); // wrong note held: grace exceeded + expect(t.update(true, 210)).toBe(false); // restart from ~210 + expect(t.update(true, 261)).toBe(true); + }); + + it('never triggers on non-matching frames alone', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + expect(t.update(false, 0)).toBe(false); + expect(t.update(false, 1000)).toBe(false); + }); + + it('handles frames arriving long after the last one (throttled tab)', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + // No frames for a while, then a single matching frame: fresh start. + expect(t.update(true, 5000)).toBe(false); + }); + + it('reset() clears accumulated progress', () => { + const t = new MatchTracker(THRESHOLD, GRACE); + t.update(true, 0); + t.update(true, 40); + t.reset(); + expect(t.update(true, 50)).toBe(false); // restarted at 50 + expect(t.update(true, 100)).toBe(true); + }); +}); diff --git a/src/game/MatchTracker.ts b/src/game/MatchTracker.ts new file mode 100644 index 0000000..b1ef233 --- /dev/null +++ b/src/game/MatchTracker.ts @@ -0,0 +1,60 @@ +/** + * Tracks whether a target note has been held long enough to count as a match. + * + * Pitch detection flickers: YIN regularly drops a frame or reports a wrong + * octave for 1-2 frames during note attacks and decays. A naive "consecutive + * frames" timer resets on every flicker, so the note display shows green + * while the match timer keeps restarting and success never fires. + * + * This tracker instead tolerates mismatches/dropouts up to `graceMs`: + * progress survives short interruptions, but an interruption longer than + * the grace period resets the timer. The timer only *advances* on matching + * frames, so success can never fire during silence or a wrong note. + */ +export class MatchTracker { + private readonly thresholdMs: number; + private readonly graceMs: number; + private matchStart: number | null = null; + private mismatchSince: number | null = null; + + constructor(thresholdMs: number, graceMs: number) { + this.thresholdMs = thresholdMs; + this.graceMs = graceMs; + } + + /** + * Feed one detection frame. + * + * @param matching whether the detected pitch currently equals the target + * @param now timestamp in ms (e.g. Date.now()) + * @returns true exactly once when the note has been held for thresholdMs + */ + update(matching: boolean, now: number): boolean { + if (matching) { + const interruptedTooLong = + this.mismatchSince !== null && now - this.mismatchSince > this.graceMs; + + if (this.matchStart === null || interruptedTooLong) { + this.matchStart = now; + } + this.mismatchSince = null; + + if (now - this.matchStart >= this.thresholdMs) { + this.reset(); + return true; + } + } else if (this.matchStart !== null && this.mismatchSince === null) { + // Start of a potential interruption. Whether it is fatal is decided + // lazily on the next matching frame, so no timer callback is needed + // when no frames arrive at all. + this.mismatchSince = now; + } + return false; + } + + /** Forget all accumulated progress (e.g. when the target note changes). */ + reset(): void { + this.matchStart = null; + this.mismatchSince = null; + } +} diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts index 5dcb6bf..ba37aa4 100644 --- a/src/hooks/useAudioPlayer.ts +++ b/src/hooks/useAudioPlayer.ts @@ -1,61 +1,18 @@ -import { useRef, useEffect, useCallback } from 'react'; -import { midiToFrequency } from '../music/NoteUtils'; +import { useCallback } from 'react'; +import { audioEngine } from '../audio/AudioEngine'; interface AudioPlayerHandle { playNote: (midi: number, duration?: number, volume?: number) => void; } +/** + * Thin hook wrapper around the shared AudioEngine singleton. + * All playback goes through one AudioContext and is tracked there, so pitch + * detection can gate itself while the app's own speaker output is audible. + */ export function useAudioPlayer(): AudioPlayerHandle { - const audioContext = useRef(null); - - // Initialize AudioContext lazily or on user interaction if possible, - // but here we just ensure it exists when we try to play. - const playNote = useCallback((midi: number, duration: number = 0.5, volume: number = 0.3) => { - if (!audioContext.current) { - audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)(); - } - - const ctx = audioContext.current; - if (ctx.state === 'suspended') { - ctx.resume(); - } - - const osc = ctx.createOscillator(); - const gainNode = ctx.createGain(); - - const freq = midiToFrequency(midi); - - osc.frequency.value = freq; - // Triangle wave is often nicer than sine for music training - osc.type = 'triangle'; - - osc.connect(gainNode); - gainNode.connect(ctx.destination); - - const now = ctx.currentTime; - - // Attack - gainNode.gain.setValueAtTime(0, now); - gainNode.gain.linearRampToValueAtTime(volume, now + 0.02); - - // Sustain -> Release - // Note: For a "pluck" or single hit, we just ramp down. - gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.start(now); - osc.stop(now + duration + 0.1); // Stop slightly after fade out - - }, []); - - // Cleanup - useEffect(() => { - return () => { - if (audioContext.current) { - audioContext.current.close(); - audioContext.current = null; - } - }; + audioEngine.playNote(midi, duration, volume); }, []); return { playNote }; diff --git a/src/hooks/useGameLogic.ts b/src/hooks/useGameLogic.ts index 05d593e..78bba54 100644 --- a/src/hooks/useGameLogic.ts +++ b/src/hooks/useGameLogic.ts @@ -1,7 +1,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { useSettings } from '../context/SettingsContext'; +import { useSettings } from '../context/useSettings'; import { useAudioPlayer } from './useAudioPlayer'; import { useMetronome } from './useMetronome'; +import { audioEngine } from '../audio/AudioEngine'; +import { MatchTracker } from '../game/MatchTracker'; import { getRandomNote, getNoteDetails @@ -12,7 +14,11 @@ import { getFirstPositionNotes, } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; -import { NOTE_MATCH_THRESHOLD_MS } from '../AppConfig'; +import { + NOTE_MATCH_THRESHOLD_MS, + NOTE_MATCH_GRACE_MS, + REFERENCE_QUIET_MAX_WAIT_MS, +} from '../AppConfig'; export const useGameLogic = ( pitchData: { midi: number; note: string; cents: number; frequency: number } | null @@ -21,12 +27,14 @@ export const useGameLogic = ( const { playNote } = useAudioPlayer(); const [targetMidi, setTargetMidi] = useState(60); - const [matchStartTime, setMatchStartTime] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(""); const [revealed, setRevealed] = useState(false); const [virtualNote, setVirtualNote] = useState(null); const feedbackTimeoutRef = useRef | null>(null); + // Flicker-tolerant match timer: brief detection dropouts don't reset progress + const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS)); + // --- Valid Notes Calculation --- const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; @@ -94,19 +102,18 @@ export const useGameLogic = ( const min = validNotes[0]; const max = validNotes[validNotes.length - 1]; - const newNote = getRandomNote(min, max, validNotes); - if (newNote === targetMidi && validNotes.length > 1) { - const retry = getRandomNote(min, max, validNotes); - setTargetMidi(retry); - } else { - setTargetMidi(newNote); - } - setMatchStartTime(null); + // Avoid repeating the previous note (a single retry could still land on it) + const candidates = validNotes.length > 1 + ? validNotes.filter(n => n !== targetMidi) + : validNotes; + setTargetMidi(getRandomNote(min, max, candidates)); + + tracker.reset(); if (!keepFeedback) { setFeedbackMessage(""); } setRevealed(false); - }, [validNotes, targetMidi]); + }, [validNotes, targetMidi, tracker]); // --- Metronome Logic --- const effectiveBpm = useMemo(() => { @@ -129,16 +136,54 @@ export const useGameLogic = ( }); // --- Audio Auto-Play --- - useEffect(() => { - const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading); + // Boolean summary of "is the mic currently detecting a sounding note". + // The effect below depends on this BOOLEAN rather than on `pitchData` + // itself (which is a new object every frame — it would reset the 100ms + // play timer 60 times per second and the note would never play). + const isMicQuiet = pitchData === null; - if (shouldAutoPlay && !revealed) { - const timer = setTimeout(() => { - playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5); - }, 100); - return () => clearTimeout(timer); + // Remembers which target the reference note was already played for, + // so re-running the effect (e.g. the quiet-state flipping) can't play it twice. + const autoPlayedForRef = useRef(null); + + useEffect(() => { + const shouldAutoPlay = + settings.gameMode === 'ear_training' || + (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading); + + if (!shouldAutoPlay || revealed) return; + if (autoPlayedForRef.current === targetMidi) return; // already played + + const duration = settings.referenceNoteDuration ?? 1.5; + const volume = settings.autoPlayVolume ?? 0.5; + + // "Wait for silence": hold the next reference note while the user's + // instrument is still ringing, so it can't mask the new note. + if (settings.waitForQuiet && !isMicQuiet) { + const cap = setTimeout(() => { + if (autoPlayedForRef.current === targetMidi) return; + autoPlayedForRef.current = targetMidi; + playNote(targetMidi, duration, volume); + }, REFERENCE_QUIET_MAX_WAIT_MS); + return () => clearTimeout(cap); } - }, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote, settings.autoPlayVolume]); + + const timer = setTimeout(() => { + autoPlayedForRef.current = targetMidi; + playNote(targetMidi, duration, volume); + }, 100); + return () => clearTimeout(timer); + }, [ + targetMidi, + revealed, + isMicQuiet, + settings.gameMode, + settings.autoPlaySightReading, + settings.referenceNoteDuration, + settings.autoPlayVolume, + settings.waitForQuiet, + playNote, + ]); // --- Init / Reset --- useEffect(() => { @@ -156,7 +201,6 @@ export const useGameLogic = ( const noteDetails = getNoteDetails(targetMidi); setFeedbackMessage(`Good! ${noteDetails.name}`); setRevealed(true); - setMatchStartTime(null); const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; const isTimerMode = settings.rhythm.mode === 'seconds'; @@ -178,7 +222,6 @@ export const useGameLogic = ( feedbackTimeoutRef.current = setTimeout(() => { setFeedbackMessage(""); }, 1500); - setMatchStartTime(null); } } }, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]); @@ -201,25 +244,22 @@ export const useGameLogic = ( // --- Match Checking Loop --- + // Tolerant of flicker: MatchTracker keeps accumulating hold time across + // brief dropouts instead of resetting on every single bad frame. useEffect(() => { - if (!pitchData) { - setMatchStartTime(null); + // Ignore the microphone while the app itself is making sound — the + // speaker output would otherwise be picked up and scored as correct + // (ear-training auto-play, Play Note button, virtual instruments). + if (audioEngine.isAudible()) { + tracker.reset(); return; } - if (pitchData.midi === targetMidi) { - if (matchStartTime === null) { - setMatchStartTime(Date.now()); - } else { - const duration = Date.now() - matchStartTime; - if (duration > NOTE_MATCH_THRESHOLD_MS) { - handleMatchSuccess(); - } - } - } else { - setMatchStartTime(null); + const matching = pitchData !== null && pitchData.midi === targetMidi; + if (tracker.update(matching, Date.now())) { + handleMatchSuccess(); } - }, [pitchData, targetMidi, matchStartTime, handleMatchSuccess]); + }, [pitchData, targetMidi, handleMatchSuccess, tracker]); return { targetMidi, diff --git a/src/hooks/useMetronome.ts b/src/hooks/useMetronome.ts index 26c8457..72a8240 100644 --- a/src/hooks/useMetronome.ts +++ b/src/hooks/useMetronome.ts @@ -1,4 +1,5 @@ import { useRef, useEffect, useCallback } from 'react'; +import { createAudioContext } from '../audio/AudioEngine'; interface MetronomeOptions { bpm: number; @@ -53,6 +54,10 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions) onTickRef.current(currentBeat.current); }, [volume]); + // Indirection so the scheduler can reschedule itself without referencing + // its own variable during initialization + const schedulerRef = useRef<() => void>(() => { }); + const scheduler = useCallback(() => { if (!audioContext.current) return; @@ -62,9 +67,13 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions) playClick(nextNoteTime.current); nextNote(); } - timerID.current = window.setTimeout(scheduler, lookahead); + timerID.current = window.setTimeout(() => schedulerRef.current(), lookahead); }, [nextNote, playClick]); + useEffect(() => { + schedulerRef.current = scheduler; + }, [scheduler]); + const restart = useCallback(() => { if (audioContext.current) { // Reset to play one beat interval from NOW @@ -78,7 +87,7 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions) useEffect(() => { if (playing) { if (!audioContext.current) { - audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioContext.current = createAudioContext(); } // Resume if suspended (browser autoplay policy) diff --git a/src/hooks/usePitchDetector.ts b/src/hooks/usePitchDetector.ts index 7c31b5e..81ceae1 100644 --- a/src/hooks/usePitchDetector.ts +++ b/src/hooks/usePitchDetector.ts @@ -1,6 +1,12 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer'; import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils'; +import { + MIC_DEFAULT_SENSITIVITY, + PITCH_READING_HOLD_MS, + DISPLAY_EMA_FACTOR, + NOTE_DISPLAY_STABLE_FRAMES, +} from '../AppConfig'; interface PitchData { frequency: number; @@ -10,98 +16,186 @@ interface PitchData { clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it } -import { MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; - export type { MicrophoneDebugInfo }; -export function usePitchDetector(active: boolean, sensitivity: number = MIC_DEFAULT_SENSITIVITY) { +export function usePitchDetector( + active: boolean, + sensitivity: number = MIC_DEFAULT_SENSITIVITY, + minFrequencyHz?: number +) { const analyzerRef = useRef(null); const [pitchData, setPitchData] = useState(null); const [isListening, setIsListening] = useState(false); const [error, setError] = useState(null); const [audioLevel, setAudioLevel] = useState(0); const [debugInfo, setDebugInfo] = useState(null); - const animationRef = useRef(null); - // Update sensitivity when it changes + // Latest values for the capture effect (refs keep its dep list minimal so + // changing them does not restart the microphone) + const sensitivityRef = useRef(sensitivity); + const minFrequencyRef = useRef(minFrequencyHz); + + // Apply sensitivity live without restarting capture useEffect(() => { - if (analyzerRef.current) { - analyzerRef.current.setSensitivity(sensitivity); - } + sensitivityRef.current = sensitivity; + analyzerRef.current?.setSensitivity(sensitivity); }, [sensitivity]); - const updatePitch = useCallback(() => { - if (!analyzerRef.current) return; - - const freq = analyzerRef.current.getPitch(); - if (freq) { - const midi = frequencyToMidi(freq); - const { scientific } = getNoteDetails(midi); - - const cents = getCentDifference(freq, midi); - - setPitchData({ - frequency: freq, - midi, - note: scientific, - cents, - clarity: 1 - }); + // Adapt the analysis window to the instrument's lowest note, live + useEffect(() => { + minFrequencyRef.current = minFrequencyHz; + if (minFrequencyHz) { + analyzerRef.current?.setLowestFrequency(minFrequencyHz); } - - // Always update level and debug info - setAudioLevel(analyzerRef.current.getCurrentLevel()); - setDebugInfo(analyzerRef.current.getDebugInfo()); - - animationRef.current = requestAnimationFrame(updatePitch); - }, []); + }, [minFrequencyHz]); useEffect(() => { - if (active) { - if (!analyzerRef.current) { - analyzerRef.current = new PitchAnalyzer(); - analyzerRef.current.setSensitivity(sensitivity); + if (!active) { + setIsListening(false); + setPitchData(null); + setAudioLevel(0); + setDebugInfo(null); + return; + } + + let cancelled = false; + let rafId = 0; + let frameCount = 0; + let lastPitchAt = 0; + // Display smoothing: pitch/cents/level arrive on every detection frame + // (~60 fps) and jitter slightly. A short exponential moving average keeps + // the tuner needle and level meter readable. Done here (in the producer) + // so the display components can render directly without CSS transitions — + // transitions on values that change every frame get interrupted 60x/sec, + // which some engines (Safari/WebKit) punish with a visually stuck needle. + let smoothedCents: number | null = null; + let smoothedLevel = 0; + // Note-name stability: the displayed note only switches after the raw + // detection reports a different note for several consecutive frames. + // Prevents D#2/E2-style flicker at semitone boundaries. The raw midi + // (used for matching) is unaffected. + let stableMidi: number | null = null; + let candidateMidi: number | null = null; + let candidateCount = 0; + + const analyzer = new PitchAnalyzer(); + analyzer.setSensitivity(sensitivityRef.current); + if (minFrequencyRef.current) { + analyzer.setLowestFrequency(minFrequencyRef.current); + } + analyzerRef.current = analyzer; + + function tick() { + if (cancelled) return; + + const freq = analyzer.getPitch(); + const now = performance.now(); + + if (freq) { + lastPitchAt = now; + const midi = frequencyToMidi(freq); + + // Note-name hysteresis: only switch the displayed note after + // the new one persists for a few consecutive frames. + if (midi === stableMidi) { + candidateMidi = null; + candidateCount = 0; + } else if (midi === candidateMidi) { + if (++candidateCount >= NOTE_DISPLAY_STABLE_FRAMES) { + stableMidi = midi; + candidateMidi = null; + candidateCount = 0; + // Re-anchor the cents average: a new note means a new + // reference (the needle must not sweep across the scale). + smoothedCents = null; + } + } else { + candidateMidi = midi; + candidateCount = 1; + } + + // Cents are displayed relative to the STABLE note (like real + // tuners): the needle sweeps to ±50 and only then the note flips. + const displayMidi = stableMidi ?? midi; + const { scientific } = getNoteDetails(displayMidi); + const cents = getCentDifference(freq, displayMidi); + + smoothedCents = smoothedCents === null + ? cents + : smoothedCents + (cents - smoothedCents) * DISPLAY_EMA_FACTOR; + + setPitchData({ + frequency: freq, + midi, // raw — matching uses this and must stay unsmoothed + note: scientific, + cents: smoothedCents, // smoothed — display only + clarity: 1 + }); + } else if (lastPitchAt !== 0 && now - lastPitchAt > PITCH_READING_HOLD_MS) { + // Clear stale readings so the UI doesn't keep displaying + // (and matching against) a note that stopped sounding long ago. + lastPitchAt = 0; + smoothedCents = null; + stableMidi = null; + candidateMidi = null; + candidateCount = 0; + setPitchData(null); } - analyzerRef.current.start() - .then(() => { - setIsListening(true); - setError(null); - updatePitch(); - }) - .catch(err => { - console.error(err); - // Provide more specific error messages - if (err.name === 'NotAllowedError') { - setError("Microphone access denied. Please allow microphone access."); - } else if (err.name === 'NotFoundError') { - setError("No microphone found. Please connect a microphone."); - } else if (err.name === 'NotReadableError') { - setError("Microphone is in use by another app."); - } else { - setError("Could not access microphone."); - } - setIsListening(false); - }); - } else { - if (analyzerRef.current) { - analyzerRef.current.stop(); + // Always update level (smoothed) + smoothedLevel += (analyzer.getCurrentLevel() - smoothedLevel) * DISPLAY_EMA_FACTOR; + setAudioLevel(smoothedLevel); + + // Debug info is only shown in the settings modal; ~4 Hz is plenty + // and avoids a third state update (full re-render) every frame. + if (++frameCount % 15 === 0) { + setDebugInfo(analyzer.getDebugInfo()); + } + + rafId = requestAnimationFrame(tick); + } + + analyzer.start() + .then(() => { + if (cancelled) { + // StrictMode double-mount or quick toggle-off while + // getUserMedia was pending: don't leave the mic running. + analyzer.stop(); + return; + } + setIsListening(true); + setError(null); + tick(); + }) + .catch((err: Error) => { + if (cancelled) return; + console.error(err); + // Provide more specific error messages + if (err.name === 'NotAllowedError') { + setError("Microphone access denied. Please allow microphone access."); + } else if (err.name === 'NotFoundError') { + setError("No microphone found. Please connect a microphone."); + } else if (err.name === 'NotReadableError') { + setError("Microphone is in use by another app."); + } else { + setError("Could not access microphone."); + } + setIsListening(false); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + analyzer.stop(); + if (analyzerRef.current === analyzer) { analyzerRef.current = null; } setIsListening(false); setPitchData(null); setAudioLevel(0); setDebugInfo(null); - if (animationRef.current) cancelAnimationFrame(animationRef.current); - } - - return () => { - if (animationRef.current) cancelAnimationFrame(animationRef.current); - if (analyzerRef.current) { - analyzerRef.current.stop(); - } }; - }, [active, updatePitch]); + }, [active]); return { pitchData, isListening, error, audioLevel, debugInfo }; } diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index d2a6441..22ba400 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -25,6 +25,11 @@ export interface AppSettings { customMaxFret?: number; autoPlaySightReading?: boolean; autoPlayVolume?: number; + /** Length of the played reference note in seconds */ + referenceNoteDuration?: number; + /** Hold the next reference note until the microphone detects silence, + * so a still-ringing instrument doesn't mask it */ + waitForQuiet?: boolean; virtualGuitarVolume?: number; virtualGuitarMute?: boolean; micSensitivity?: number; @@ -55,6 +60,8 @@ export const DEFAULT_SETTINGS: AppSettings = { customMaxFret: 12, autoPlaySightReading: false, autoPlayVolume: 0.5, + referenceNoteDuration: 1.5, + waitForQuiet: false, virtualGuitarVolume: 0.5, virtualGuitarMute: false, micSensitivity: 0.5, diff --git a/src/types/pitchfinder.d.ts b/src/types/pitchfinder.d.ts index 63cb34b..0c45e16 100644 --- a/src/types/pitchfinder.d.ts +++ b/src/types/pitchfinder.d.ts @@ -4,11 +4,14 @@ declare module 'pitchfinder' { frequency?: number; minFrequency?: number; maxFrequency?: number; + threshold?: number; + probabilityThreshold?: number; } type Detector = (float32Array: Float32Array) => number | null; export function YIN(config?: PitchFinderConfig): Detector; export function AMDF(config?: PitchFinderConfig): Detector; - export function MACLEOD(config?: PitchFinderConfig): Detector; + export function ACF2PLUS(config?: PitchFinderConfig): Detector; + export function Macleod(config?: PitchFinderConfig): (buffer: Float32Array) => { frequency: number; probability: number } | null; }