mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Add reference note duration and wait-for-silence options
- Settings: reference note duration slider (0.5-4s, default 1.5s) applies to ear-training auto-play, sight-reading auto-play and the manual Play Note button/keys alike. - Settings: 'Wait for Silence' toggle holds the next reference note until the mic detects the instrument has gone quiet (a still-ringing string masking the next note was easy to miss). Safety cap of 8s so continuously detectable room noise can't stall the app; without a mic it plays immediately. The quiet state is a boolean dep (not the per-frame pitch object) so the play timer isn't reset 60x/sec. - Gate all live feedback (staff preview, readout, tuner) during speaker playback: the mic inevitably hears the reference note and would briefly leak the target in ear training. Virtual fretboard taps (explicit user input) still display instantly.
This commit is contained in:
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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user