mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
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).
This commit is contained in:
@@ -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
|
||||
|
||||
84
src/audio/AudioEngine.ts
Normal file
84
src/audio/AudioEngine.ts
Normal file
@@ -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();
|
||||
@@ -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<ArrayBuffer> = 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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
156
src/audio/pitchDetection.test.ts
Normal file
156
src/audio/pitchDetection.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
84
src/game/MatchTracker.test.ts
Normal file
84
src/game/MatchTracker.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
60
src/game/MatchTracker.ts
Normal file
60
src/game/MatchTracker.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AudioContext | null>(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 };
|
||||
|
||||
@@ -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<number>(60);
|
||||
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
||||
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<number | null>(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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PitchAnalyzer | null>(null);
|
||||
const [pitchData, setPitchData] = useState<PitchData | null>(null);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [audioLevel, setAudioLevel] = useState<number>(0);
|
||||
const [debugInfo, setDebugInfo] = useState<MicrophoneDebugInfo | null>(null);
|
||||
const animationRef = useRef<number | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
src/types/pitchfinder.d.ts
vendored
5
src/types/pitchfinder.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user