mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
- 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).
112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import { useRef, useEffect, useCallback } from 'react';
|
|
import { createAudioContext } from '../audio/AudioEngine';
|
|
|
|
interface MetronomeOptions {
|
|
bpm: number;
|
|
volume: number; // 0.0 to 1.0
|
|
playing: boolean;
|
|
onTick: (beat: number) => void;
|
|
}
|
|
|
|
export interface MetronomeHandle {
|
|
restart: () => void;
|
|
}
|
|
|
|
export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions): MetronomeHandle {
|
|
const audioContext = useRef<AudioContext | null>(null);
|
|
const nextNoteTime = useRef<number>(0.0);
|
|
const timerID = useRef<number | null>(null);
|
|
const lookahead = 25.0; // How frequently to call scheduling function (in milliseconds)
|
|
const scheduleAheadTime = 0.1; // How far ahead to schedule audio (sec)
|
|
const currentBeat = useRef<number>(0);
|
|
|
|
// Keep callback fresh without re-triggering effect
|
|
const onTickRef = useRef(onTick);
|
|
useEffect(() => {
|
|
onTickRef.current = onTick;
|
|
}, [onTick]);
|
|
|
|
const nextNote = useCallback(() => {
|
|
const secondsPerBeat = 60.0 / bpm;
|
|
nextNoteTime.current += secondsPerBeat;
|
|
currentBeat.current++;
|
|
}, [bpm]);
|
|
|
|
const playClick = useCallback((time: number) => {
|
|
if (!audioContext.current) return;
|
|
|
|
const osc = audioContext.current.createOscillator();
|
|
const gainNode = audioContext.current.createGain();
|
|
|
|
osc.connect(gainNode);
|
|
gainNode.connect(audioContext.current.destination);
|
|
|
|
// Click sound: high pitch, very short decay
|
|
osc.frequency.value = 880;
|
|
|
|
// Simple envelope
|
|
gainNode.gain.setValueAtTime(volume, time);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
|
|
|
|
osc.start(time);
|
|
osc.stop(time + 0.05);
|
|
|
|
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;
|
|
|
|
// while there are notes that will need to play before the next interval,
|
|
// schedule them and advance the pointer.
|
|
while (nextNoteTime.current < audioContext.current.currentTime + scheduleAheadTime) {
|
|
playClick(nextNoteTime.current);
|
|
nextNote();
|
|
}
|
|
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
|
|
const secondsPerBeat = 60.0 / bpm;
|
|
nextNoteTime.current = audioContext.current.currentTime + secondsPerBeat;
|
|
// Should we reset currentBeat?
|
|
currentBeat.current = 0;
|
|
}
|
|
}, [bpm]);
|
|
|
|
useEffect(() => {
|
|
if (playing) {
|
|
if (!audioContext.current) {
|
|
audioContext.current = createAudioContext();
|
|
}
|
|
|
|
// Resume if suspended (browser autoplay policy)
|
|
if (audioContext.current.state === 'suspended') {
|
|
audioContext.current.resume();
|
|
}
|
|
|
|
currentBeat.current = 0;
|
|
nextNoteTime.current = audioContext.current.currentTime + 0.05;
|
|
scheduler();
|
|
|
|
return () => {
|
|
if (timerID.current) window.clearTimeout(timerID.current);
|
|
};
|
|
} else {
|
|
if (timerID.current) window.clearTimeout(timerID.current);
|
|
}
|
|
}, [playing, scheduler]);
|
|
|
|
return { restart };
|
|
}
|