mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user