mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-04 22:33:54 +01:00
Compare commits
4 Commits
d8602e0bd8
...
141487e3df
| Author | SHA1 | Date | |
|---|---|---|---|
| 141487e3df | |||
| 3cc9a0c43d | |||
| 6e60ced477 | |||
| 079d574b66 |
10
README.md
10
README.md
@@ -7,11 +7,13 @@ A web-based application designed to help musicians practice sight-reading and ea
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Sight Reading Mode**: Read notes from the interactive staff and play them on your instrument. The app listens and confirms when you hit the correct note.
|
- **Sight Reading Mode**: Read notes from the interactive staff and play them on your instrument. The app listens and confirms when you hit the correct note.
|
||||||
- **Ear Training Mode**: Listen to a reference note and try to reproduce it.
|
- **Ear Training Mode**: Listen to a reference note and try to reproduce it. The reference note duration is configurable, and it can optionally wait for your instrument to go quiet before the next one plays.
|
||||||
- **Real-time Pitch Detection**: Uses your device's microphone to detect notes instantly.
|
- **Real-time Pitch Detection**: Uses your device's microphone to detect notes instantly, with an analysis window and detector adapted to your instrument.
|
||||||
- **Instrument Support**: Optimized for Guitar and Piano, with configurable tunings for guitar.
|
- **Chromatic Tuner**: Tunable needle with cents display, always-on when enabled.
|
||||||
|
- **Instrument Support**: Optimized for Guitar, Bass, Piano, Voice and Whistle, with configurable tunings for guitar and bass.
|
||||||
- **Virtual Instruments**: On-screen interactive guitar fretboard and piano keys for visual reference or touch input.
|
- **Virtual Instruments**: On-screen interactive guitar fretboard and piano keys for visual reference or touch input.
|
||||||
- **Customizable**: Adjust difficulty (range, accidentals), key signatures, rhythm/metronome settings, and more.
|
- **Customizable**: Difficulty (range, accidentals), key signatures, rhythm/metronome settings, reference note duration, microphone sensitivity, and more.
|
||||||
|
- **Dark & Light Themes**: Full theme support including native form controls.
|
||||||
- **Zen Mode**: Distraction-free practice interface.
|
- **Zen Mode**: Distraction-free practice interface.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built
|
|||||||
- State management is primarily handled via React's `useState` and `useReducer` at the `App` component level, with props drilling for simpler hierarchies.
|
- State management is primarily handled via React's `useState` and `useReducer` at the `App` component level, with props drilling for simpler hierarchies.
|
||||||
|
|
||||||
2. **Audio Processing**:
|
2. **Audio Processing**:
|
||||||
- **Pitch Detection**: Utilizes the `pitchfinder` library (YIN algorithm) to detect pitch from the user's microphone in real-time. This processing happens in a dedicated hook/worker to keep the main thread responsive.
|
- **Pitch Detection** (`src/audio/PitchAnalyzer.ts`): Uses the `pitchfinder` library with a detector chosen per instrument range — ACF2PLUS for normal instruments (robust down to ~40 Hz; pitchfinder's YIN returns ~20 kHz garbage below ~100 Hz and on quiet input), YIN only for high registers (whistle). The analysis window adapts to the instrument's lowest note (~2 periods, power of two): roughly half the latency and a quarter of the CPU of the previous fixed 4096-sample window for guitar/piano/voice, while bass keeps the full window for E1. Readings implausibly below the instrument's floor (e.g. mains hum) are rejected. Detection runs on the requestAnimationFrame loop inside `usePitchDetector`, with display-only damping (EMA for cents/level, short note-name hysteresis) applied in the producer.
|
||||||
- **Audio Synthesis**: Uses standard Web Audio API for generating simple tones (sine/triangle waves) for playback and feedback.
|
- **Note Matching** (`src/game/MatchTracker.ts`): A note counts as matched when held for `NOTE_MATCH_THRESHOLD_MS` (50 ms), tolerated across brief detection dropouts up to `NOTE_MATCH_GRACE_MS` (150 ms). Pitch detectors flicker during note attacks and decays; a naive consecutive-frame timer resets on every flicker.
|
||||||
|
- **Audio Synthesis** (`src/audio/AudioEngine.ts`): All speaker output goes through a single shared AudioContext (triangle waves). The engine tracks when output may still be audible so the app can ignore its own speaker: while a reference note is sounding (plus a 0.4 s decay tail), microphone-based matching and live feedback are gated — otherwise ear training would score the reference note it just played through the speaker. A suspended AudioContext (autoplay policy) is treated as inaudible so the gate can never deadlock.
|
||||||
|
- **Ear training auto-play**: The next reference note can optionally wait for the microphone to detect silence ("Wait for Silence" setting, 8 s safety cap), and its duration is configurable ("Reference Note Duration", 0.5–4 s).
|
||||||
|
|
||||||
3. **Music Rendering**:
|
3. **Music Rendering**:
|
||||||
- **VexFlow**: The standard library for rendering music notation on the web. It is used in the `SheetMusic` component to draw the staff, notes, clefs, and key signatures dynamically based on the current state.
|
- **VexFlow**: The standard library for rendering music notation on the web. It is used in the `SheetMusic` component to draw the staff, notes, clefs, and key signatures dynamically based on the current state.
|
||||||
@@ -34,15 +36,24 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built
|
|||||||
### UI & Icons
|
### UI & Icons
|
||||||
* **[Lucide React](https://lucide.dev/)**: A clean and consistent icon library for the interface.
|
* **[Lucide React](https://lucide.dev/)**: A clean and consistent icon library for the interface.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
* **Unit/integration** ([Vitest](https://vitest.dev/)): `src/game/MatchTracker.test.ts` covers the flicker-tolerance matching logic; `src/audio/pitchDetection.test.ts` feeds synthetic instrument-like signals (guitar/bass/piano/whistle, quiet plucks, mains hum) through the exact pipeline helpers used by `PitchAnalyzer`; `src/music/NoteUtils.test.ts` covers note math and key signatures.
|
||||||
|
* **End-to-end**: the microphone can be stubbed in a real browser by replacing `navigator.mediaDevices.getUserMedia` with a `MediaStreamDestination` fed by a scripted oscillator — frequency ramps simulate tuning, envelopes simulate plucks. This setup (puppeteer-core + system Chrome, headless) reproduced and verified several real bugs: YIN's low-frequency garbage mode, noise-floor amplification freezing the tuner on a "note nobody played", and WebKit/Safari leaving constantly-interrupted CSS transitions visually stuck.
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # Reusable UI components (SheetMusic, Fretboard, etc.)
|
├── audio/ # PitchAnalyzer (detection pipeline), AudioEngine (shared playback + mic gating)
|
||||||
├── hooks/ # Custom React hooks (usePitchDetector, useAudioPlayer, etc.)
|
├── components/ # Reusable UI components (SheetMusic, Fretboard, TuningMeter, etc.)
|
||||||
├── music/ # Music logic, tuning definitions, and note utilities
|
├── context/ # Settings context (split into context/Provider/hook files for fast-refresh)
|
||||||
├── styles/ # Global styles and component-specific CSS
|
├── game/ # MatchTracker: flicker-tolerant note-hold matching (unit-tested)
|
||||||
├── assets/ # Static assets
|
├── hooks/ # Custom React hooks (usePitchDetector, useGameLogic, useAudioPlayer, etc.)
|
||||||
|
├── music/ # Music logic, tuning definitions, and note utilities
|
||||||
|
├── styles/ # Global styles and component-specific CSS
|
||||||
|
├── types/ # Shared TypeScript types + pitchfinder module declarations
|
||||||
|
├── assets/ # Static assets
|
||||||
├── App.tsx # Main application logic and layout
|
├── App.tsx # Main application logic and layout
|
||||||
└── main.tsx # Entry point
|
└── main.tsx # Entry point
|
||||||
```
|
```
|
||||||
|
|||||||
30
src/App.css
30
src/App.css
@@ -203,7 +203,18 @@ button {
|
|||||||
.pitch-readout {
|
.pitch-readout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
/* Fixed height: the note display (3rem note + Hz line ≈ 70px) and the
|
||||||
|
"Listening..." placeholder differ in height — without a reserved height
|
||||||
|
everything below this element jumps up and down whenever a note
|
||||||
|
starts or stops sounding. */
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pitch-readout .placeholder {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detected-note {
|
.detected-note {
|
||||||
@@ -285,10 +296,18 @@ button {
|
|||||||
.control-select {
|
.control-select {
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid #ddd;
|
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-input {
|
||||||
|
color: var(--color-text-main);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button {
|
.control-button {
|
||||||
@@ -330,7 +349,9 @@ button {
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background-color: #ddd;
|
/* Themed track: dark in dark mode, gray in light mode
|
||||||
|
(was hardcoded #ddd — glowed in dark mode) */
|
||||||
|
background-color: color-mix(in srgb, var(--color-text-main) 25%, transparent);
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
@@ -347,7 +368,8 @@ button {
|
|||||||
.switch-thumb {
|
.switch-thumb {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background-color: white;
|
/* Themed: contrasts with both the off-track and the active track */
|
||||||
|
background-color: var(--color-surface);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
|
|||||||
81
src/App.tsx
81
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||||
import { Logo } from './components/Logo';
|
import { Logo } from './components/Logo';
|
||||||
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
||||||
|
|
||||||
@@ -9,9 +9,10 @@ import { SettingsModal } from './components/SettingsModal';
|
|||||||
import { OpenSourceModal } from './components/OpenSourceModal';
|
import { OpenSourceModal } from './components/OpenSourceModal';
|
||||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||||
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
||||||
import { useSettings } from './context/SettingsContext';
|
import { useSettings } from './context/useSettings';
|
||||||
import { useGameLogic } from './hooks/useGameLogic';
|
import { useGameLogic } from './hooks/useGameLogic';
|
||||||
import { getNoteDetails } from './music/NoteUtils';
|
import { audioEngine } from './audio/AudioEngine';
|
||||||
|
import { getNoteDetails, midiToFrequency } from './music/NoteUtils';
|
||||||
import {
|
import {
|
||||||
TUNINGS,
|
TUNINGS,
|
||||||
getFretboardPositions,
|
getFretboardPositions,
|
||||||
@@ -35,9 +36,30 @@ function App() {
|
|||||||
// Alias to keep existing code working with minimal changes
|
// Alias to keep existing code working with minimal changes
|
||||||
const setSettings = updateSettings;
|
const setSettings = updateSettings;
|
||||||
|
|
||||||
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity);
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||||
|
|
||||||
// Game Logic Hook
|
// Lowest note the instrument can produce: the pitch analysis window adapts
|
||||||
|
// to it (shorter window = lower latency for instruments without deep bass)
|
||||||
|
const instrumentMinMidi = useMemo(() => {
|
||||||
|
let min = Infinity;
|
||||||
|
for (const r of currentInstrumentDef.ranges) {
|
||||||
|
if (r.min !== undefined) min = Math.min(min, r.min);
|
||||||
|
}
|
||||||
|
if (currentTuning) {
|
||||||
|
for (const stringMidi of currentTuning.strings) min = Math.min(min, stringMidi);
|
||||||
|
}
|
||||||
|
return min === Infinity ? 40 : min; // Fallback: guitar low E
|
||||||
|
}, [currentInstrumentDef, currentTuning]);
|
||||||
|
|
||||||
|
// One semitone of margin below the lowest note (detuned strings, flat playing)
|
||||||
|
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(
|
||||||
|
listening,
|
||||||
|
settings.micSensitivity,
|
||||||
|
midiToFrequency(instrumentMinMidi - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Game Logic Hook (matching uses the raw detection; display is gated below)
|
||||||
const {
|
const {
|
||||||
targetMidi,
|
targetMidi,
|
||||||
feedbackMessage,
|
feedbackMessage,
|
||||||
@@ -47,6 +69,13 @@ function App() {
|
|||||||
handleVirtualInstrumentPlay
|
handleVirtualInstrumentPlay
|
||||||
} = useGameLogic(pitchData);
|
} = useGameLogic(pitchData);
|
||||||
|
|
||||||
|
// Live feedback shows only what the USER plays. While the app itself makes
|
||||||
|
// sound (ear-training reference note, Play Note, virtual instruments), the
|
||||||
|
// mic picks up the speaker — displaying that would leak the target note in
|
||||||
|
// ear training. The moment playback ends (plus decay tail), the note the
|
||||||
|
// user is playing appears again. Re-evaluated on every detection frame.
|
||||||
|
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
|
||||||
|
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
||||||
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
||||||
@@ -63,8 +92,12 @@ function App() {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Theme support
|
// Theme support — MUST be a layout effect: children (e.g. SheetMusic) read the
|
||||||
useEffect(() => {
|
// resolved theme colors from getComputedStyle inside their own passive
|
||||||
|
// effects. Layout effects run before any passive effects, so the
|
||||||
|
// data-theme attribute is applied by the time they read it — otherwise the
|
||||||
|
// rendered notation lags one theme switch behind.
|
||||||
|
useLayoutEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const theme = settings.theme || 'auto';
|
const theme = settings.theme || 'auto';
|
||||||
if (theme === 'auto') {
|
if (theme === 'auto') {
|
||||||
@@ -94,9 +127,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentTuning = TUNINGS[settings.tuningId];
|
|
||||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
|
||||||
|
|
||||||
// Resolve overrides
|
// Resolve overrides
|
||||||
const currentRangeDef = useMemo(() => {
|
const currentRangeDef = useMemo(() => {
|
||||||
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||||
@@ -128,7 +158,7 @@ function App() {
|
|||||||
break;
|
break;
|
||||||
case 'r': // Replay
|
case 'r': // Replay
|
||||||
case 'p': // Play
|
case 'p': // Play
|
||||||
playNote(targetMidi, 1.0);
|
playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5);
|
||||||
break;
|
break;
|
||||||
case 'm': // Toggle Game Mode
|
case 'm': // Toggle Game Mode
|
||||||
setSettings(s => ({
|
setSettings(s => ({
|
||||||
@@ -157,7 +187,9 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]);
|
// `settings` (not individual fields): the handler reads several settings
|
||||||
|
// (gameMode, referenceNoteDuration, ...) and re-attaching a listener is cheap.
|
||||||
|
}, [settings, showHelp, generateNewNote, playNote, targetMidi, setSettings]);
|
||||||
|
|
||||||
// Hint text construction
|
// Hint text construction
|
||||||
const hintPositions = useMemo(() => {
|
const hintPositions = useMemo(() => {
|
||||||
@@ -181,10 +213,12 @@ function App() {
|
|||||||
return getFretboardPositions(targetMidi, currentTuning);
|
return getFretboardPositions(targetMidi, currentTuning);
|
||||||
}, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
|
}, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
|
||||||
|
|
||||||
// Auto-enable mic when tuner is turned on
|
// Auto-enable mic when tuner is turned on (rAF defers the state update
|
||||||
|
// out of the effect phase to avoid cascading renders)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.showTuningMeter && !listening) {
|
if (settings.showTuningMeter && !listening) {
|
||||||
setListening(true);
|
const id = requestAnimationFrame(() => setListening(true));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
}
|
}
|
||||||
}, [settings.showTuningMeter, listening]);
|
}, [settings.showTuningMeter, listening]);
|
||||||
|
|
||||||
@@ -250,7 +284,9 @@ function App() {
|
|||||||
<div className="sheet-music-container">
|
<div className="sheet-music-container">
|
||||||
<SheetMusic
|
<SheetMusic
|
||||||
targetMidi={targetMidi}
|
targetMidi={targetMidi}
|
||||||
playedMidi={virtualNote ?? pitchData?.midi}
|
// Virtual fretboard taps are explicit user input and always
|
||||||
|
// show; the mic preview is gated during speaker playback.
|
||||||
|
playedMidi={virtualNote ?? displayedPitch?.midi}
|
||||||
keySignature={settings.keySignature}
|
keySignature={settings.keySignature}
|
||||||
clef={activeClef}
|
clef={activeClef}
|
||||||
transpose={activeTranspose}
|
transpose={activeTranspose}
|
||||||
@@ -262,6 +298,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
||||||
hoverMidi={settings.showHint ? hoveredMidi : null}
|
hoverMidi={settings.showHint ? hoveredMidi : null}
|
||||||
|
theme={settings.theme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,7 +391,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="hint-button"
|
className="hint-button"
|
||||||
onClick={() => playNote(targetMidi, 1.0)}
|
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
||||||
title="Keyboard Shortcut: P or R"
|
title="Keyboard Shortcut: P or R"
|
||||||
>
|
>
|
||||||
<Volume2 size={18} />
|
<Volume2 size={18} />
|
||||||
@@ -382,13 +419,13 @@ function App() {
|
|||||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={`pitch-readout ${pitchData ? 'active' : ''}`}>
|
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
||||||
{pitchData ? (
|
{displayedPitch ? (
|
||||||
<>
|
<>
|
||||||
<span className={`detected-note ${pitchData.midi === targetMidi ? 'match' : ''}`}>
|
<span className={`detected-note ${displayedPitch.midi === targetMidi ? 'match' : ''}`}>
|
||||||
{pitchData.note}
|
{displayedPitch.note}
|
||||||
</span>
|
</span>
|
||||||
<span className="detected-hz">{Math.round(pitchData.frequency)} Hz</span>
|
<span className="detected-hz">{Math.round(displayedPitch.frequency)} Hz</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="placeholder">{listening ? "Listening..." : "Mic Off"}</span>
|
<span className="placeholder">{listening ? "Listening..." : "Mic Off"}</span>
|
||||||
@@ -402,7 +439,7 @@ function App() {
|
|||||||
{!settings.zenMode && (
|
{!settings.zenMode && (
|
||||||
<footer className="settings-footer">
|
<footer className="settings-footer">
|
||||||
<Controls
|
<Controls
|
||||||
currentPitch={pitchData ? { note: pitchData.note, cents: pitchData.cents } : null}
|
currentPitch={displayedPitch ? { note: displayedPitch.note, cents: displayedPitch.cents } : null}
|
||||||
/>
|
/>
|
||||||
<div className="app-subtitle">
|
<div className="app-subtitle">
|
||||||
<button className="link-button" onClick={() => setIsOpenSourceModalOpen(true)}>Open Source Libraries</button>
|
<button className="link-button" onClick={() => setIsOpenSourceModalOpen(true)}>Open Source Libraries</button>
|
||||||
|
|||||||
@@ -2,10 +2,73 @@
|
|||||||
* Global application configuration
|
* Global application configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 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.
|
||||||
// How long (in ms) a note must be held to be accepted as correct
|
// Protects against spurious single-frame detections.
|
||||||
export const NOTE_MATCH_THRESHOLD_MS = 50;
|
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
|
// Microphone Sensitivity Configuration
|
||||||
// Sensitivity Scale: 0.0 (Low) to 1.0 (High)
|
// Sensitivity Scale: 0.0 (Low) to 1.0 (High)
|
||||||
// Linear mapping to dB thresholds
|
// 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 { YIN, ACF2PLUS } from "pitchfinder";
|
||||||
import { MIC_SENSITIVITY_DB_RANGE, AUDIO_CONSTRAINTS } from "../AppConfig";
|
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 {
|
export interface MicrophoneDebugInfo {
|
||||||
audioContextState: AudioContextState | 'inactive';
|
audioContextState: AudioContextState | 'inactive';
|
||||||
@@ -12,13 +22,85 @@ export interface MicrophoneDebugInfo {
|
|||||||
isCapturing: boolean;
|
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 {
|
export class PitchAnalyzer {
|
||||||
private detector: (buffer: Float32Array) => number | null;
|
private detector: PitchDetectorFn | null = null;
|
||||||
|
private normalizeInput = false;
|
||||||
private audioContext: AudioContext | null = null;
|
private audioContext: AudioContext | null = null;
|
||||||
private analyser: AnalyserNode | null = null;
|
private analyser: AnalyserNode | null = null;
|
||||||
private mediaStream: MediaStream | null = null;
|
private mediaStream: MediaStream | null = null;
|
||||||
private source: MediaStreamAudioSourceNode | 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
|
// Debug info
|
||||||
private inputDeviceLabel: string = 'Not detected';
|
private inputDeviceLabel: string = 'Not detected';
|
||||||
@@ -26,11 +108,6 @@ export class PitchAnalyzer {
|
|||||||
private permissionState: PermissionState | 'unknown' = 'unknown';
|
private permissionState: PermissionState | 'unknown' = 'unknown';
|
||||||
private currentRmsLevel: number = 0;
|
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
|
private sensitivityThreshold = 0.03; // Default
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +126,43 @@ export class PitchAnalyzer {
|
|||||||
this.sensitivityThreshold = Math.pow(10, db / 20);
|
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> {
|
async start(): Promise<void> {
|
||||||
if (this.audioContext) return;
|
if (this.audioContext) return;
|
||||||
|
|
||||||
@@ -63,8 +177,9 @@ export class PitchAnalyzer {
|
|||||||
this.permissionState = 'unknown';
|
this.permissionState = 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
this.audioContext = createAudioContext();
|
||||||
this.detector = YIN({ sampleRate: this.audioContext.sampleRate });
|
this.updateWindowSize();
|
||||||
|
this.rebuildDetector();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use configured audio constraints
|
// Use configured audio constraints
|
||||||
@@ -98,8 +213,7 @@ export class PitchAnalyzer {
|
|||||||
|
|
||||||
this.source = this.audioContext.createMediaStreamSource(this.mediaStream);
|
this.source = this.audioContext.createMediaStreamSource(this.mediaStream);
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 4096; // Higher FFT size for better resolution at low frequencies
|
this.analyser.fftSize = PITCH_BUFFER_SIZE; // Large buffer; analyzed window adapts per instrument
|
||||||
this.buffer = new Float32Array(this.analyser.fftSize);
|
|
||||||
|
|
||||||
this.source.connect(this.analyser);
|
this.source.connect(this.analyser);
|
||||||
|
|
||||||
@@ -123,6 +237,9 @@ export class PitchAnalyzer {
|
|||||||
this.audioContext.close();
|
this.audioContext.close();
|
||||||
this.audioContext = null;
|
this.audioContext = null;
|
||||||
}
|
}
|
||||||
|
this.detector = null;
|
||||||
|
this.analyser = null;
|
||||||
|
this.source = null;
|
||||||
this.currentRmsLevel = 0;
|
this.currentRmsLevel = 0;
|
||||||
this.inputDeviceLabel = 'Not detected';
|
this.inputDeviceLabel = 'Not detected';
|
||||||
this.inputDeviceId = '';
|
this.inputDeviceId = '';
|
||||||
@@ -159,26 +276,43 @@ export class PitchAnalyzer {
|
|||||||
* getPitch returns the detected frequency in Hz, or null if no pitch detected
|
* getPitch returns the detected frequency in Hz, or null if no pitch detected
|
||||||
*/
|
*/
|
||||||
getPitch(): number | null {
|
getPitch(): number | null {
|
||||||
if (!this.analyser) return null;
|
if (!this.analyser || !this.detector) return null;
|
||||||
this.analyser.getFloatTimeDomainData(this.buffer as any);
|
// 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;
|
let rms = 0;
|
||||||
for (let i = 0; i < this.buffer.length; i++) {
|
for (let i = 0; i < this.window.length; i++) {
|
||||||
rms += this.buffer[i] * this.buffer[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
|
// Store RMS for level meter
|
||||||
this.currentRmsLevel = rms;
|
this.currentRmsLevel = rms;
|
||||||
|
|
||||||
if (rms < this.sensitivityThreshold) return null;
|
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)
|
// 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
|
// 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;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
|||||||
import { getTempoMarking } from '../music/TempoMarkings';
|
import { getTempoMarking } from '../music/TempoMarkings';
|
||||||
import { TuningMeter } from './TuningMeter';
|
import { TuningMeter } from './TuningMeter';
|
||||||
|
|
||||||
import { useSettings } from '../context/SettingsContext';
|
import { useSettings } from '../context/useSettings';
|
||||||
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
||||||
|
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
value={settings.customMinFret ?? 0}
|
value={settings.customMinFret ?? 0}
|
||||||
onChange={(e) => onUpdateSettings({ ...settings, customMinFret: parseInt(e.target.value) || 0 })}
|
onChange={(e) => onUpdateSettings({ ...settings, customMinFret: parseInt(e.target.value) || 0 })}
|
||||||
className="control-input"
|
className="control-input"
|
||||||
style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
|
style={{ width: '100%', padding: '4px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
@@ -179,7 +179,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
value={settings.customMaxFret ?? 12}
|
value={settings.customMaxFret ?? 12}
|
||||||
onChange={(e) => onUpdateSettings({ ...settings, customMaxFret: parseInt(e.target.value) || 0 })}
|
onChange={(e) => onUpdateSettings({ ...settings, customMaxFret: parseInt(e.target.value) || 0 })}
|
||||||
className="control-input"
|
className="control-input"
|
||||||
style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
|
style={{ width: '100%', padding: '4px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,14 +204,12 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
<div className="switch-thumb" />
|
<div className="switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{settings.showTuningMeter && currentPitch && (
|
{settings.showTuningMeter && (
|
||||||
<div style={{ paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
<div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)' }}>
|
||||||
<TuningMeter cents={currentPitch.cents} noteName={currentPitch.note} />
|
<TuningMeter
|
||||||
</div>
|
cents={currentPitch ? currentPitch.cents : null}
|
||||||
)}
|
noteName={currentPitch ? currentPitch.note : null}
|
||||||
{settings.showTuningMeter && !currentPitch && (
|
/>
|
||||||
<div style={{ paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: '11px', opacity: 0.5, textAlign: 'center' }}>
|
|
||||||
Listening...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -266,11 +264,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
style={{
|
style={{
|
||||||
width: '48px',
|
width: '48px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '2px',
|
padding: '2px'
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function PianoKeys({
|
|||||||
// Sort specifically so black keys are last (on top in SVG z-order)
|
// Sort specifically so black keys are last (on top in SVG z-order)
|
||||||
return rects.sort((a, b) => (a.isBlack === b.isBlack) ? 0 : a.isBlack ? 1 : -1);
|
return rects.sort((a, b) => (a.isBlack === b.isBlack) ? 0 : a.isBlack ? 1 : -1);
|
||||||
|
|
||||||
}, [keys, whiteKeyWidth, viewMode]);
|
}, [keys, whiteKeyWidth]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor } from 'lucide-react';
|
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock } from 'lucide-react';
|
||||||
import type { AppSettings } from '../types/SettingsTypes';
|
import type { AppSettings } from '../types/SettingsTypes';
|
||||||
import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
|
import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
|
||||||
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
|
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
|
||||||
@@ -19,6 +19,9 @@ interface SettingsModalProps {
|
|||||||
const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isActive }) => {
|
const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isActive }) => {
|
||||||
// Convert RMS to dB for display, then normalize to 0-100%
|
// Convert RMS to dB for display, then normalize to 0-100%
|
||||||
// RMS of 0.001 = -60dB, RMS of 1.0 = 0dB
|
// RMS of 0.001 = -60dB, RMS of 1.0 = 0dB
|
||||||
|
// `level` is already EMA-smoothed in the detection loop — no CSS transition
|
||||||
|
// here (a transition on a value that changes every frame gets interrupted
|
||||||
|
// 60x/sec, which Safari/WebKit handles by leaving the bar stuck).
|
||||||
const db = level > 0 ? 20 * Math.log10(level) : -120;
|
const db = level > 0 ? 20 * Math.log10(level) : -120;
|
||||||
|
|
||||||
// Map full range from -100dB to 0dB => 0% to 100%
|
// Map full range from -100dB to 0dB => 0% to 100%
|
||||||
@@ -47,8 +50,7 @@ const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isA
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
width: `${percentage}%`,
|
width: `${percentage}%`,
|
||||||
background: percentage > 80 ? '#ef4444' : percentage > 50 ? '#f59e0b' : '#22c55e',
|
background: percentage > 80 ? '#ef4444' : percentage > 50 ? '#f59e0b' : '#22c55e',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px'
|
||||||
transition: 'width 0.05s ease-out'
|
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7, minWidth: '45px', textAlign: 'right' }}>
|
<span style={{ fontSize: '11px', opacity: 0.7, minWidth: '45px', textAlign: 'right' }}>
|
||||||
@@ -91,7 +93,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
|||||||
].map(mode => (
|
].map(mode => (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
onClick={() => onUpdateSettings({ ...settings, theme: mode.id as any })}
|
onClick={() => onUpdateSettings({ ...settings, theme: mode.id as AppSettings['theme'] })}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -213,6 +215,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
|||||||
: "Automatically plays the note audio when a new note appears."}
|
: "Automatically plays the note audio when a new note appears."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Reference note duration */}
|
||||||
|
<div className="control-group" style={{ marginTop: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<label className="control-label" style={{ marginBottom: 0, fontSize: '16px' }}>
|
||||||
|
<span>Reference Note Duration</span>
|
||||||
|
</label>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, minWidth: '40px', textAlign: 'right' }}>
|
||||||
|
{settings.referenceNoteDuration ?? 1.5}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<Clock size={20} style={{ opacity: 0.7 }} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="4"
|
||||||
|
step="0.25"
|
||||||
|
value={settings.referenceNoteDuration ?? 1.5}
|
||||||
|
onChange={(e) => onUpdateSettings({ ...settings, referenceNoteDuration: parseFloat(e.target.value) })}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
aria-label="Reference note duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wait for silence before the next reference note */}
|
||||||
|
<div className="control-group" style={{ marginTop: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<label className="control-label" style={{ marginBottom: 0, fontSize: '16px' }}>
|
||||||
|
<span>Wait for Silence</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className={`switch-button ${settings.waitForQuiet ? 'active' : ''}`}
|
||||||
|
onClick={() => onUpdateSettings({ ...settings, waitForQuiet: !settings.waitForQuiet })}
|
||||||
|
title={settings.waitForQuiet ? "Play the next reference note immediately" : "Hold the next reference note until you stop playing"}
|
||||||
|
aria-label="Wait for silence"
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '12px', opacity: 0.7, margin: 0 }}>
|
||||||
|
Holds the next reference note until your instrument has gone quiet,
|
||||||
|
so a still-ringing string can't mask it. (Requires the microphone.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
|
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
|
||||||
|
|
||||||
{/* Microphone Toggle */}
|
{/* Microphone Toggle */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector, GhostNote, Annotation } from 'vexflow';
|
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector, GhostNote, Annotation } from 'vexflow';
|
||||||
import { getNoteInKey } from '../music/NoteUtils';
|
import { getNoteInKey } from '../music/NoteUtils';
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface SheetMusicProps {
|
|||||||
keySignature?: string;
|
keySignature?: string;
|
||||||
hideTargetNote?: boolean;
|
hideTargetNote?: boolean;
|
||||||
hoverMidi?: number | null;
|
hoverMidi?: number | null;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SheetMusic: React.FC<SheetMusicProps> = ({
|
export const SheetMusic: React.FC<SheetMusicProps> = ({
|
||||||
@@ -24,10 +25,23 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
transpose = 12, // Default to +1 octave (Guitar Notation)
|
transpose = 12, // Default to +1 octave (Guitar Notation)
|
||||||
keySignature = 'C',
|
keySignature = 'C',
|
||||||
hideTargetNote = false,
|
hideTargetNote = false,
|
||||||
hoverMidi = null
|
hoverMidi = null,
|
||||||
|
theme = 'auto'
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// SVG presentation attributes cannot resolve CSS var() (VexFlow writes
|
||||||
|
// colors as `fill="..."` attributes), so the actual color is read from
|
||||||
|
// getComputedStyle at draw time. Re-render when the theme changes —
|
||||||
|
// including system scheme flips while theme === 'auto'.
|
||||||
|
const [schemeVersion, setSchemeVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onChange = () => setSchemeVersion(v => v + 1);
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
@@ -38,9 +52,24 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
renderer.resize(width, height);
|
renderer.resize(width, height);
|
||||||
const context = renderer.getContext();
|
const context = renderer.getContext();
|
||||||
|
|
||||||
// IMPORTANT: Set global styles for the context to ensure everything draws with correct color
|
// IMPORTANT: resolve the theme color to a concrete value — SVG
|
||||||
context.setFillStyle("var(--color-text-main)");
|
// attributes don't support var(). Without this the notation falls
|
||||||
context.setStrokeStyle("var(--color-text-main)");
|
// back to black, which is unreadable in dark mode.
|
||||||
|
const resolvedColor =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--color-text-main')
|
||||||
|
.trim() || '#333333';
|
||||||
|
|
||||||
|
// Set global styles for the context to ensure everything draws with correct color
|
||||||
|
context.setFillStyle(resolvedColor);
|
||||||
|
context.setStrokeStyle(resolvedColor);
|
||||||
|
|
||||||
|
// VexFlow hardcodes ledger lines ("the extra lines" above/below the
|
||||||
|
// staff) to a dark gray default that is unreadable in dark mode.
|
||||||
|
// getDefaultLedgerLineStyle() merges this default OVER the stave style,
|
||||||
|
// so it must be overridden explicitly on every stave we create.
|
||||||
|
const ledgerStyle = { strokeStyle: resolvedColor, lineWidth: 2 };
|
||||||
|
const applyLedgerStyle = (stave: Stave) => stave.setDefaultLedgerLineStyle(ledgerStyle);
|
||||||
|
|
||||||
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
|
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
|
||||||
const getGrandStaffClef = (midi: number): 'treble' | 'bass' => {
|
const getGrandStaffClef = (midi: number): 'treble' | 'bass' => {
|
||||||
@@ -72,11 +101,13 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
if (clef === 'grand') {
|
if (clef === 'grand') {
|
||||||
// Measure 1: Treble
|
// Measure 1: Treble
|
||||||
const m1Treble = new Stave(startX, trebleY, measureWidth);
|
const m1Treble = new Stave(startX, trebleY, measureWidth);
|
||||||
|
applyLedgerStyle(m1Treble);
|
||||||
m1Treble.addClef('treble').addKeySignature(keySignature);
|
m1Treble.addClef('treble').addKeySignature(keySignature);
|
||||||
m1Treble.setContext(context).draw();
|
m1Treble.setContext(context).draw();
|
||||||
|
|
||||||
// Measure 1: Bass
|
// Measure 1: Bass
|
||||||
const m1Bass = new Stave(startX, bassY, measureWidth);
|
const m1Bass = new Stave(startX, bassY, measureWidth);
|
||||||
|
applyLedgerStyle(m1Bass);
|
||||||
m1Bass.addClef('bass').addKeySignature(keySignature);
|
m1Bass.addClef('bass').addKeySignature(keySignature);
|
||||||
m1Bass.setContext(context).draw();
|
m1Bass.setContext(context).draw();
|
||||||
|
|
||||||
@@ -99,6 +130,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
if (numMeasures === 2) {
|
if (numMeasures === 2) {
|
||||||
// Measure 2: Treble
|
// Measure 2: Treble
|
||||||
const m2Treble = new Stave(startX + measureWidth, trebleY, measureWidth);
|
const m2Treble = new Stave(startX + measureWidth, trebleY, measureWidth);
|
||||||
|
applyLedgerStyle(m2Treble);
|
||||||
// No clef/key sig repeated typically for just next measure in same system,
|
// No clef/key sig repeated typically for just next measure in same system,
|
||||||
// UNLESS it's a new system. Here it's same system.
|
// UNLESS it's a new system. Here it's same system.
|
||||||
// But VexFlow might require setting context context.
|
// But VexFlow might require setting context context.
|
||||||
@@ -106,6 +138,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
|
|
||||||
// Measure 2: Bass
|
// Measure 2: Bass
|
||||||
const m2Bass = new Stave(startX + measureWidth, bassY, measureWidth);
|
const m2Bass = new Stave(startX + measureWidth, bassY, measureWidth);
|
||||||
|
applyLedgerStyle(m2Bass);
|
||||||
m2Bass.setContext(context).draw();
|
m2Bass.setContext(context).draw();
|
||||||
|
|
||||||
stavesMeasure2 = { treble: m2Treble, bass: m2Bass };
|
stavesMeasure2 = { treble: m2Treble, bass: m2Bass };
|
||||||
@@ -126,6 +159,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
// Single Stave
|
// Single Stave
|
||||||
const staveY = isCompact ? 10 : 30;
|
const staveY = isCompact ? 10 : 30;
|
||||||
const m1Stave = new Stave(startX, staveY, measureWidth);
|
const m1Stave = new Stave(startX, staveY, measureWidth);
|
||||||
|
applyLedgerStyle(m1Stave);
|
||||||
m1Stave.addClef(clef).addKeySignature(keySignature);
|
m1Stave.addClef(clef).addKeySignature(keySignature);
|
||||||
m1Stave.setContext(context).draw();
|
m1Stave.setContext(context).draw();
|
||||||
|
|
||||||
@@ -133,6 +167,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
|
|
||||||
if (numMeasures === 2) {
|
if (numMeasures === 2) {
|
||||||
const m2Stave = new Stave(startX + measureWidth, staveY, measureWidth);
|
const m2Stave = new Stave(startX + measureWidth, staveY, measureWidth);
|
||||||
|
applyLedgerStyle(m2Stave);
|
||||||
m2Stave.setContext(context).draw();
|
m2Stave.setContext(context).draw();
|
||||||
stavesMeasure2 = { [clef]: m2Stave };
|
stavesMeasure2 = { [clef]: m2Stave };
|
||||||
}
|
}
|
||||||
@@ -157,7 +192,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set styles
|
// Set styles
|
||||||
staveNote.setStyle({ fillStyle: "var(--color-text-main)", strokeStyle: "var(--color-text-main)" });
|
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
|
||||||
|
|
||||||
if (data.accidental) {
|
if (data.accidental) {
|
||||||
const accidental = new Accidental(data.accidental);
|
const accidental = new Accidental(data.accidental);
|
||||||
@@ -166,7 +201,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
|
|
||||||
if (type === 'played') {
|
if (type === 'played') {
|
||||||
// Always use default style (text color), no green/red distinction
|
// Always use default style (text color), no green/red distinction
|
||||||
staveNote.setStyle({ fillStyle: "var(--color-text-main)", strokeStyle: "var(--color-text-main)" });
|
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { note: staveNote, clef: noteClef };
|
return { note: staveNote, clef: noteClef };
|
||||||
@@ -235,7 +270,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
|
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote, theme, schemeVersion]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="sheet-music-container" />;
|
return <div ref={containerRef} className="sheet-music-container" />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
|
/* Theme-aware colors: the tuner renders on a translucent surface in both
|
||||||
|
themes, so all tints derive from --color-text-main instead of hardcoding
|
||||||
|
white (unreadable in light mode) or black (unreadable in dark mode). */
|
||||||
|
|
||||||
.tuning-meter-container {
|
.tuning-meter-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 260px;
|
width: 260px;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: color-mix(in srgb, var(--color-text-main) 6%, transparent);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent);
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No-signal state: widget stays visible but clearly idle */
|
||||||
|
.tuning-meter-container.inactive {
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-scale {
|
.meter-scale {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: color-mix(in srgb, var(--color-text-main) 10%, transparent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -25,7 +35,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--color-text-muted);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +57,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: var(--color-text-muted);
|
||||||
|
opacity: 0.35;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -57,35 +68,55 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
background: white;
|
background: var(--color-text-main);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
transition: left 0.1s ease-out;
|
/* No CSS transition: `left` is updated directly on every detection
|
||||||
|
frame (~60 fps). Interrupting/restarting a transition 60x per second
|
||||||
|
is handled poorly by some engines (Safari leaves the needle stuck). */
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-text-main) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status colors — the theme's mid-luminance success/warning/error vars are
|
||||||
|
readable on light AND dark backgrounds */
|
||||||
.meter-needle.perfect {
|
.meter-needle.perfect {
|
||||||
background-color: #4ade80;
|
background-color: var(--color-success);
|
||||||
box-shadow: 0 0 10px #4ade80;
|
box-shadow: 0 0 10px var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind Green-400 */
|
|
||||||
.meter-needle.good {
|
.meter-needle.good {
|
||||||
background-color: #86efac;
|
background-color: color-mix(in srgb, var(--color-success) 60%, var(--color-text-main));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind Green-300 */
|
|
||||||
.meter-needle.okay {
|
.meter-needle.okay {
|
||||||
background-color: #facc15;
|
background-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind Yellow-400 */
|
|
||||||
.meter-needle.poor {
|
.meter-needle.poor {
|
||||||
background-color: #f87171;
|
background-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind Red-400 */
|
.tuning-readout .cents-value.perfect {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tuning-readout .cents-value.good {
|
||||||
|
color: color-mix(in srgb, var(--color-success) 60%, var(--color-text-main));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tuning-readout .cents-value.okay {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tuning-readout .cents-value.poor {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tuning-meter-container.inactive .meter-needle {
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.tuning-readout {
|
.tuning-readout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -97,22 +128,10 @@
|
|||||||
.tuning-readout .note-name {
|
.tuning-readout .note-name {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: white;
|
color: var(--color-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tuning-readout .cents-value {
|
.tuning-readout .cents-value {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tuning-readout .cents-value.perfect {
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-readout .cents-value.okay {
|
|
||||||
color: #facc15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-readout .cents-value.poor {
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,40 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './TuningMeter.css';
|
import './TuningMeter.css';
|
||||||
|
|
||||||
interface TuningMeterProps {
|
interface TuningMeterProps {
|
||||||
cents: number; // Deviation in cents from nearest note (-50 to +50 usually)
|
/** Deviation in cents from nearest note (-50 to +50 usually), already
|
||||||
noteName: string; // The detected note name (e.g. "A4")
|
* smoothed by the detection loop (display-only value). Null = no signal. */
|
||||||
|
cents: number | null;
|
||||||
|
/** The detected note name (e.g. "A4"). Null = no signal. */
|
||||||
|
noteName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) => {
|
export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) => {
|
||||||
// Clamp cents for display (-50 to 50)
|
const hasSignal = cents !== null && noteName !== null;
|
||||||
const clampedCents = Math.max(-50, Math.min(50, cents));
|
|
||||||
|
|
||||||
// Map -50..50 to 0..100% for position
|
// Clamp for display (-50 to 50), then map to 0..100%.
|
||||||
// -50 cents -> 0%
|
// No CSS transition: cents updates every detection frame (~60 fps) and is
|
||||||
// 0 cents -> 50%
|
// EMA-smoothed upstream — see usePitchDetector / AppConfig. When there is
|
||||||
// 50 cents -> 100%
|
// no signal the needle parks in the middle and the widget dims.
|
||||||
|
const clampedCents = Math.max(-50, Math.min(50, cents ?? 0));
|
||||||
const positionPercent = ((clampedCents + 50) / 100) * 100;
|
const positionPercent = ((clampedCents + 50) / 100) * 100;
|
||||||
|
|
||||||
// Color determination
|
// Status color reacts instantly to the raw (unsmoothed) value
|
||||||
let statusClass = 'neutral';
|
let statusClass = 'neutral';
|
||||||
if (Math.abs(cents) < 5) {
|
if (hasSignal) {
|
||||||
statusClass = 'perfect'; // Green
|
if (Math.abs(cents!) < 5) {
|
||||||
} else if (Math.abs(cents) < 15) {
|
statusClass = 'perfect'; // Green
|
||||||
statusClass = 'good'; // Green-ish
|
} else if (Math.abs(cents!) < 15) {
|
||||||
} else if (Math.abs(cents) < 30) {
|
statusClass = 'good'; // Green-ish
|
||||||
statusClass = 'okay'; // Yellow
|
} else if (Math.abs(cents!) < 30) {
|
||||||
} else {
|
statusClass = 'okay'; // Yellow
|
||||||
statusClass = 'poor'; // Red
|
} else {
|
||||||
|
statusClass = 'poor'; // Red
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tuning-meter-container">
|
<div className={`tuning-meter-container ${hasSignal ? '' : 'inactive'}`}>
|
||||||
<div className="meter-scale">
|
<div className="meter-scale">
|
||||||
<div className="tick tick-left">-50</div>
|
<div className="tick tick-left">-50</div>
|
||||||
<div className="tick tick-center">0</div>
|
<div className="tick tick-center">0</div>
|
||||||
@@ -39,7 +43,7 @@ export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) =>
|
|||||||
{/* Center Marker Line */}
|
{/* Center Marker Line */}
|
||||||
<div className="center-line"></div>
|
<div className="center-line"></div>
|
||||||
|
|
||||||
{/* The Needle/Indicator */}
|
{/* The Needle/Indicator — position updated directly per frame */}
|
||||||
<div
|
<div
|
||||||
className={`meter-needle ${statusClass}`}
|
className={`meter-needle ${statusClass}`}
|
||||||
style={{ left: `${positionPercent}%` }}
|
style={{ left: `${positionPercent}%` }}
|
||||||
@@ -48,9 +52,9 @@ export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tuning-readout">
|
<div className="tuning-readout">
|
||||||
<span className="note-name">{noteName}</span>
|
<span className="note-name">{hasSignal ? noteName : '—'}</span>
|
||||||
<span className={`cents-value ${statusClass}`}>
|
<span className={`cents-value ${statusClass}`}>
|
||||||
{cents > 0 ? '+' : ''}{Math.round(cents)} ct
|
{hasSignal ? `${cents! > 0 ? '+' : ''}${Math.round(cents!)} ct` : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, type ReactNode } from 'react';
|
|
||||||
import { type AppSettings, DEFAULT_SETTINGS } from '../types/SettingsTypes';
|
|
||||||
|
|
||||||
interface SettingsContextType {
|
|
||||||
settings: AppSettings;
|
|
||||||
updateSettings: (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => void;
|
|
||||||
updateSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
||||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
|
||||||
|
|
||||||
const updateSettings = (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => {
|
|
||||||
setSettings(newSettings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
|
||||||
setSettings(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContext.Provider value={{ settings, updateSettings, updateSetting }}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSettings = () => {
|
|
||||||
const context = useContext(SettingsContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useSettings must be used within a SettingsProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
27
src/context/SettingsProvider.tsx
Normal file
27
src/context/SettingsProvider.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { type AppSettings, DEFAULT_SETTINGS } from '../types/SettingsTypes';
|
||||||
|
import { SettingsContext, type SettingsContextType } from './settings-context';
|
||||||
|
|
||||||
|
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
// Stable identities so consumers can safely use these in effect deps
|
||||||
|
const updateSettings = useCallback<SettingsContextType['updateSettings']>((newSettings) => {
|
||||||
|
setSettings(newSettings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSetting = useCallback<SettingsContextType['updateSetting']>((key, value) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<SettingsContextType>(
|
||||||
|
() => ({ settings, updateSettings, updateSetting }),
|
||||||
|
[settings, updateSettings, updateSetting]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
src/context/settings-context.ts
Normal file
10
src/context/settings-context.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import { type AppSettings } from '../types/SettingsTypes';
|
||||||
|
|
||||||
|
export interface SettingsContextType {
|
||||||
|
settings: AppSettings;
|
||||||
|
updateSettings: (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => void;
|
||||||
|
updateSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||||
10
src/context/useSettings.ts
Normal file
10
src/context/useSettings.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SettingsContext } from './settings-context';
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSettings must be used within a SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
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 { useCallback } from 'react';
|
||||||
import { midiToFrequency } from '../music/NoteUtils';
|
import { audioEngine } from '../audio/AudioEngine';
|
||||||
|
|
||||||
interface AudioPlayerHandle {
|
interface AudioPlayerHandle {
|
||||||
playNote: (midi: number, duration?: number, volume?: number) => void;
|
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 {
|
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) => {
|
const playNote = useCallback((midi: number, duration: number = 0.5, volume: number = 0.3) => {
|
||||||
if (!audioContext.current) {
|
audioEngine.playNote(midi, duration, volume);
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { playNote };
|
return { playNote };
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useSettings } from '../context/SettingsContext';
|
import { useSettings } from '../context/useSettings';
|
||||||
import { useAudioPlayer } from './useAudioPlayer';
|
import { useAudioPlayer } from './useAudioPlayer';
|
||||||
import { useMetronome } from './useMetronome';
|
import { useMetronome } from './useMetronome';
|
||||||
|
import { audioEngine } from '../audio/AudioEngine';
|
||||||
|
import { MatchTracker } from '../game/MatchTracker';
|
||||||
import {
|
import {
|
||||||
getRandomNote,
|
getRandomNote,
|
||||||
getNoteDetails
|
getNoteDetails
|
||||||
@@ -12,7 +14,11 @@ import {
|
|||||||
getFirstPositionNotes,
|
getFirstPositionNotes,
|
||||||
} from '../music/Tunings';
|
} from '../music/Tunings';
|
||||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
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 = (
|
export const useGameLogic = (
|
||||||
pitchData: { midi: number; note: string; cents: number; frequency: number } | null
|
pitchData: { midi: number; note: string; cents: number; frequency: number } | null
|
||||||
@@ -21,12 +27,14 @@ export const useGameLogic = (
|
|||||||
const { playNote } = useAudioPlayer();
|
const { playNote } = useAudioPlayer();
|
||||||
|
|
||||||
const [targetMidi, setTargetMidi] = useState<number>(60);
|
const [targetMidi, setTargetMidi] = useState<number>(60);
|
||||||
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
|
|
||||||
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [revealed, setRevealed] = useState(false);
|
||||||
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
||||||
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ---
|
// --- Valid Notes Calculation ---
|
||||||
const currentTuning = TUNINGS[settings.tuningId];
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||||
@@ -94,19 +102,18 @@ export const useGameLogic = (
|
|||||||
const min = validNotes[0];
|
const min = validNotes[0];
|
||||||
const max = validNotes[validNotes.length - 1];
|
const max = validNotes[validNotes.length - 1];
|
||||||
|
|
||||||
const newNote = getRandomNote(min, max, validNotes);
|
// Avoid repeating the previous note (a single retry could still land on it)
|
||||||
if (newNote === targetMidi && validNotes.length > 1) {
|
const candidates = validNotes.length > 1
|
||||||
const retry = getRandomNote(min, max, validNotes);
|
? validNotes.filter(n => n !== targetMidi)
|
||||||
setTargetMidi(retry);
|
: validNotes;
|
||||||
} else {
|
setTargetMidi(getRandomNote(min, max, candidates));
|
||||||
setTargetMidi(newNote);
|
|
||||||
}
|
tracker.reset();
|
||||||
setMatchStartTime(null);
|
|
||||||
if (!keepFeedback) {
|
if (!keepFeedback) {
|
||||||
setFeedbackMessage("");
|
setFeedbackMessage("");
|
||||||
}
|
}
|
||||||
setRevealed(false);
|
setRevealed(false);
|
||||||
}, [validNotes, targetMidi]);
|
}, [validNotes, targetMidi, tracker]);
|
||||||
|
|
||||||
// --- Metronome Logic ---
|
// --- Metronome Logic ---
|
||||||
const effectiveBpm = useMemo(() => {
|
const effectiveBpm = useMemo(() => {
|
||||||
@@ -129,16 +136,54 @@ export const useGameLogic = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Audio Auto-Play ---
|
// --- Audio Auto-Play ---
|
||||||
useEffect(() => {
|
// Boolean summary of "is the mic currently detecting a sounding note".
|
||||||
const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
|
// 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) {
|
// Remembers which target the reference note was already played for,
|
||||||
const timer = setTimeout(() => {
|
// so re-running the effect (e.g. the quiet-state flipping) can't play it twice.
|
||||||
playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5);
|
const autoPlayedForRef = useRef<number | null>(null);
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
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 ---
|
// --- Init / Reset ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,7 +201,6 @@ export const useGameLogic = (
|
|||||||
const noteDetails = getNoteDetails(targetMidi);
|
const noteDetails = getNoteDetails(targetMidi);
|
||||||
setFeedbackMessage(`Good! ${noteDetails.name}`);
|
setFeedbackMessage(`Good! ${noteDetails.name}`);
|
||||||
setRevealed(true);
|
setRevealed(true);
|
||||||
setMatchStartTime(null);
|
|
||||||
|
|
||||||
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
|
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
|
||||||
const isTimerMode = settings.rhythm.mode === 'seconds';
|
const isTimerMode = settings.rhythm.mode === 'seconds';
|
||||||
@@ -178,7 +222,6 @@ export const useGameLogic = (
|
|||||||
feedbackTimeoutRef.current = setTimeout(() => {
|
feedbackTimeoutRef.current = setTimeout(() => {
|
||||||
setFeedbackMessage("");
|
setFeedbackMessage("");
|
||||||
}, 1500);
|
}, 1500);
|
||||||
setMatchStartTime(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
|
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
|
||||||
@@ -201,25 +244,22 @@ export const useGameLogic = (
|
|||||||
|
|
||||||
|
|
||||||
// --- Match Checking Loop ---
|
// --- Match Checking Loop ---
|
||||||
|
// Tolerant of flicker: MatchTracker keeps accumulating hold time across
|
||||||
|
// brief dropouts instead of resetting on every single bad frame.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pitchData) {
|
// Ignore the microphone while the app itself is making sound — the
|
||||||
setMatchStartTime(null);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pitchData.midi === targetMidi) {
|
const matching = pitchData !== null && pitchData.midi === targetMidi;
|
||||||
if (matchStartTime === null) {
|
if (tracker.update(matching, Date.now())) {
|
||||||
setMatchStartTime(Date.now());
|
handleMatchSuccess();
|
||||||
} else {
|
|
||||||
const duration = Date.now() - matchStartTime;
|
|
||||||
if (duration > NOTE_MATCH_THRESHOLD_MS) {
|
|
||||||
handleMatchSuccess();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setMatchStartTime(null);
|
|
||||||
}
|
}
|
||||||
}, [pitchData, targetMidi, matchStartTime, handleMatchSuccess]);
|
}, [pitchData, targetMidi, handleMatchSuccess, tracker]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetMidi,
|
targetMidi,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useEffect, useCallback } from 'react';
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { createAudioContext } from '../audio/AudioEngine';
|
||||||
|
|
||||||
interface MetronomeOptions {
|
interface MetronomeOptions {
|
||||||
bpm: number;
|
bpm: number;
|
||||||
@@ -53,6 +54,10 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
|
|||||||
onTickRef.current(currentBeat.current);
|
onTickRef.current(currentBeat.current);
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
|
// Indirection so the scheduler can reschedule itself without referencing
|
||||||
|
// its own variable during initialization
|
||||||
|
const schedulerRef = useRef<() => void>(() => { });
|
||||||
|
|
||||||
const scheduler = useCallback(() => {
|
const scheduler = useCallback(() => {
|
||||||
if (!audioContext.current) return;
|
if (!audioContext.current) return;
|
||||||
|
|
||||||
@@ -62,9 +67,13 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
|
|||||||
playClick(nextNoteTime.current);
|
playClick(nextNoteTime.current);
|
||||||
nextNote();
|
nextNote();
|
||||||
}
|
}
|
||||||
timerID.current = window.setTimeout(scheduler, lookahead);
|
timerID.current = window.setTimeout(() => schedulerRef.current(), lookahead);
|
||||||
}, [nextNote, playClick]);
|
}, [nextNote, playClick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
schedulerRef.current = scheduler;
|
||||||
|
}, [scheduler]);
|
||||||
|
|
||||||
const restart = useCallback(() => {
|
const restart = useCallback(() => {
|
||||||
if (audioContext.current) {
|
if (audioContext.current) {
|
||||||
// Reset to play one beat interval from NOW
|
// Reset to play one beat interval from NOW
|
||||||
@@ -78,7 +87,7 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playing) {
|
if (playing) {
|
||||||
if (!audioContext.current) {
|
if (!audioContext.current) {
|
||||||
audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
audioContext.current = createAudioContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume if suspended (browser autoplay policy)
|
// 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 { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
|
||||||
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
|
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 {
|
interface PitchData {
|
||||||
frequency: number;
|
frequency: number;
|
||||||
@@ -10,98 +16,186 @@ interface PitchData {
|
|||||||
clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it
|
clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it
|
||||||
}
|
}
|
||||||
|
|
||||||
import { MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
|
|
||||||
|
|
||||||
export type { MicrophoneDebugInfo };
|
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 analyzerRef = useRef<PitchAnalyzer | null>(null);
|
||||||
const [pitchData, setPitchData] = useState<PitchData | null>(null);
|
const [pitchData, setPitchData] = useState<PitchData | null>(null);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [audioLevel, setAudioLevel] = useState<number>(0);
|
const [audioLevel, setAudioLevel] = useState<number>(0);
|
||||||
const [debugInfo, setDebugInfo] = useState<MicrophoneDebugInfo | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (analyzerRef.current) {
|
sensitivityRef.current = sensitivity;
|
||||||
analyzerRef.current.setSensitivity(sensitivity);
|
analyzerRef.current?.setSensitivity(sensitivity);
|
||||||
}
|
|
||||||
}, [sensitivity]);
|
}, [sensitivity]);
|
||||||
|
|
||||||
const updatePitch = useCallback(() => {
|
// Adapt the analysis window to the instrument's lowest note, live
|
||||||
if (!analyzerRef.current) return;
|
useEffect(() => {
|
||||||
|
minFrequencyRef.current = minFrequencyHz;
|
||||||
const freq = analyzerRef.current.getPitch();
|
if (minFrequencyHz) {
|
||||||
if (freq) {
|
analyzerRef.current?.setLowestFrequency(minFrequencyHz);
|
||||||
const midi = frequencyToMidi(freq);
|
|
||||||
const { scientific } = getNoteDetails(midi);
|
|
||||||
|
|
||||||
const cents = getCentDifference(freq, midi);
|
|
||||||
|
|
||||||
setPitchData({
|
|
||||||
frequency: freq,
|
|
||||||
midi,
|
|
||||||
note: scientific,
|
|
||||||
cents,
|
|
||||||
clarity: 1
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}, [minFrequencyHz]);
|
||||||
// Always update level and debug info
|
|
||||||
setAudioLevel(analyzerRef.current.getCurrentLevel());
|
|
||||||
setDebugInfo(analyzerRef.current.getDebugInfo());
|
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(updatePitch);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (!active) {
|
||||||
if (!analyzerRef.current) {
|
setIsListening(false);
|
||||||
analyzerRef.current = new PitchAnalyzer();
|
setPitchData(null);
|
||||||
analyzerRef.current.setSensitivity(sensitivity);
|
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()
|
// Always update level (smoothed)
|
||||||
.then(() => {
|
smoothedLevel += (analyzer.getCurrentLevel() - smoothedLevel) * DISPLAY_EMA_FACTOR;
|
||||||
setIsListening(true);
|
setAudioLevel(smoothedLevel);
|
||||||
setError(null);
|
|
||||||
updatePitch();
|
// Debug info is only shown in the settings modal; ~4 Hz is plenty
|
||||||
})
|
// and avoids a third state update (full re-render) every frame.
|
||||||
.catch(err => {
|
if (++frameCount % 15 === 0) {
|
||||||
console.error(err);
|
setDebugInfo(analyzer.getDebugInfo());
|
||||||
// Provide more specific error messages
|
}
|
||||||
if (err.name === 'NotAllowedError') {
|
|
||||||
setError("Microphone access denied. Please allow microphone access.");
|
rafId = requestAnimationFrame(tick);
|
||||||
} else if (err.name === 'NotFoundError') {
|
}
|
||||||
setError("No microphone found. Please connect a microphone.");
|
|
||||||
} else if (err.name === 'NotReadableError') {
|
analyzer.start()
|
||||||
setError("Microphone is in use by another app.");
|
.then(() => {
|
||||||
} else {
|
if (cancelled) {
|
||||||
setError("Could not access microphone.");
|
// StrictMode double-mount or quick toggle-off while
|
||||||
}
|
// getUserMedia was pending: don't leave the mic running.
|
||||||
setIsListening(false);
|
analyzer.stop();
|
||||||
});
|
return;
|
||||||
} else {
|
}
|
||||||
if (analyzerRef.current) {
|
setIsListening(true);
|
||||||
analyzerRef.current.stop();
|
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;
|
analyzerRef.current = null;
|
||||||
}
|
}
|
||||||
setIsListening(false);
|
setIsListening(false);
|
||||||
setPitchData(null);
|
setPitchData(null);
|
||||||
setAudioLevel(0);
|
setAudioLevel(0);
|
||||||
setDebugInfo(null);
|
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 };
|
return { pitchData, isListening, error, audioLevel, debugInfo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './styles/variables.css'
|
import './styles/variables.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { SettingsProvider } from './context/SettingsContext.tsx'
|
import { SettingsProvider } from './context/SettingsProvider.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ export function getNoteInKey(midi: number, keySignature: string): VexFlowNoteSpe
|
|||||||
// In start keys (C, Am), no accidentals in key.
|
// In start keys (C, Am), no accidentals in key.
|
||||||
|
|
||||||
// Parse the rawName: "F#" -> step "F", mod "#"
|
// Parse the rawName: "F#" -> step "F", mod "#"
|
||||||
let step = rawName.charAt(0);
|
const step = rawName.charAt(0);
|
||||||
let mod = rawName.length > 1 ? rawName.charAt(1) : "";
|
const mod = rawName.length > 1 ? rawName.charAt(1) : "";
|
||||||
|
|
||||||
// VexFlow Key Format: "c/4", "f/4", "fb/4"
|
// VexFlow Key Format: "c/4", "f/4", "fb/4"
|
||||||
// We need to return the accidental to *display*.
|
// We need to return the accidental to *display*.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Native form controls (select dropdowns, checkboxes, number spinners,
|
||||||
|
range sliders) follow this instead of keeping black-on-black in dark mode */
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
/* HSL Colors for easy manipulation */
|
/* HSL Colors for easy manipulation */
|
||||||
/* Dark Red / Brownish Tone */
|
/* Dark Red / Brownish Tone */
|
||||||
--hue-primary: 0;
|
--hue-primary: 0;
|
||||||
@@ -54,6 +58,7 @@
|
|||||||
/* Auto Dark Mode (System Preference) */
|
/* Auto Dark Mode (System Preference) */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
--color-bg: hsl(30, 20%, 10%);
|
--color-bg: hsl(30, 20%, 10%);
|
||||||
--color-surface: hsl(30, 20%, 15%);
|
--color-surface: hsl(30, 20%, 15%);
|
||||||
--color-text-main: hsl(30, 10%, 90%);
|
--color-text-main: hsl(30, 10%, 90%);
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
|
|
||||||
/* Manual Dark Mode Override */
|
/* Manual Dark Mode Override */
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
--color-bg: hsl(30, 20%, 10%);
|
--color-bg: hsl(30, 20%, 10%);
|
||||||
--color-surface: hsl(30, 20%, 15%);
|
--color-surface: hsl(30, 20%, 15%);
|
||||||
--color-text-main: hsl(30, 10%, 90%);
|
--color-text-main: hsl(30, 10%, 90%);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface AppSettings {
|
|||||||
customMaxFret?: number;
|
customMaxFret?: number;
|
||||||
autoPlaySightReading?: boolean;
|
autoPlaySightReading?: boolean;
|
||||||
autoPlayVolume?: number;
|
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;
|
virtualGuitarVolume?: number;
|
||||||
virtualGuitarMute?: boolean;
|
virtualGuitarMute?: boolean;
|
||||||
micSensitivity?: number;
|
micSensitivity?: number;
|
||||||
@@ -55,6 +60,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
customMaxFret: 12,
|
customMaxFret: 12,
|
||||||
autoPlaySightReading: false,
|
autoPlaySightReading: false,
|
||||||
autoPlayVolume: 0.5,
|
autoPlayVolume: 0.5,
|
||||||
|
referenceNoteDuration: 1.5,
|
||||||
|
waitForQuiet: false,
|
||||||
virtualGuitarVolume: 0.5,
|
virtualGuitarVolume: 0.5,
|
||||||
virtualGuitarMute: false,
|
virtualGuitarMute: false,
|
||||||
micSensitivity: 0.5,
|
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;
|
frequency?: number;
|
||||||
minFrequency?: number;
|
minFrequency?: number;
|
||||||
maxFrequency?: number;
|
maxFrequency?: number;
|
||||||
|
threshold?: number;
|
||||||
|
probabilityThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Detector = (float32Array: Float32Array) => number | null;
|
type Detector = (float32Array: Float32Array) => number | null;
|
||||||
|
|
||||||
export function YIN(config?: PitchFinderConfig): Detector;
|
export function YIN(config?: PitchFinderConfig): Detector;
|
||||||
export function AMDF(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