mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: count-in click fix, auto-continue sections, BPM widget, live-note staff, 13 new exercises
This commit is contained in:
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'scripts']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -1098,3 +1098,8 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-note-staff {
|
||||||
|
flex: none;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -5,6 +5,8 @@ import { Controls } from './components/Controls';
|
|||||||
import { SettingsModal } from './components/SettingsModal';
|
import { SettingsModal } from './components/SettingsModal';
|
||||||
import { OpenSourceModal } from './components/OpenSourceModal';
|
import { OpenSourceModal } from './components/OpenSourceModal';
|
||||||
import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer';
|
import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer';
|
||||||
|
import { LiveNoteStaff } from './components/LiveNoteStaff';
|
||||||
|
import { resolveClefTranspose } from './music/InstrumentConfigs';
|
||||||
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
|
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
|
||||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||||
import { useSettings } from './context/useSettings';
|
import { useSettings } from './context/useSettings';
|
||||||
@@ -326,6 +328,16 @@ function App() {
|
|||||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<LiveNoteStaff
|
||||||
|
midi={displayedPitch ? displayedPitch.midi : null}
|
||||||
|
clef={(() => {
|
||||||
|
const { clef } = resolveClefTranspose(currentInstrumentDef, settings.difficulty);
|
||||||
|
return clef === 'grand' ? 'treble' : clef;
|
||||||
|
})()}
|
||||||
|
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
||||||
|
keySignature={settings.keySignature}
|
||||||
|
theme={settings.theme}
|
||||||
|
/>
|
||||||
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
||||||
{displayedPitch ? (
|
{displayedPitch ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
90
src/components/LiveNoteStaff.tsx
Normal file
90
src/components/LiveNoteStaff.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Tiny always-visible staff showing the currently DETECTED note (the same
|
||||||
|
* display pipeline as the single-note "played note" measure): written pitch
|
||||||
|
* (sounding + display transpose), active key signature, theme-aware.
|
||||||
|
* Renders only when the detected midi changes — not per frame.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter } from 'vexflow';
|
||||||
|
import { getNoteInKey } from '../music/NoteUtils';
|
||||||
|
|
||||||
|
interface LiveNoteStaffProps {
|
||||||
|
/** Sounding midi of the live detected pitch, or null when silent. */
|
||||||
|
midi: number | null;
|
||||||
|
clef: 'treble' | 'bass';
|
||||||
|
/** Display transpose applied once (guitar +12 …), as in SheetMusic. */
|
||||||
|
transpose: number;
|
||||||
|
keySignature: string;
|
||||||
|
width?: number;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||||
|
midi,
|
||||||
|
clef = 'treble',
|
||||||
|
transpose = 0,
|
||||||
|
keySignature = 'C',
|
||||||
|
width = 130,
|
||||||
|
theme = 'auto',
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
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(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
||||||
|
renderer.resize(width, 74);
|
||||||
|
const context = renderer.getContext();
|
||||||
|
|
||||||
|
const resolvedColor =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--color-text-main')
|
||||||
|
.trim() || '#333333';
|
||||||
|
context.setFillStyle(resolvedColor);
|
||||||
|
context.setStrokeStyle(resolvedColor);
|
||||||
|
|
||||||
|
const stave = new Stave(0, 10, width - 2);
|
||||||
|
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
|
||||||
|
stave.addClef(clef);
|
||||||
|
if (keySignature) stave.addKeySignature(keySignature);
|
||||||
|
stave.setContext(context).draw();
|
||||||
|
|
||||||
|
if (midi !== null) {
|
||||||
|
const written = midi + transpose;
|
||||||
|
const spec = getNoteInKey(written, keySignature);
|
||||||
|
const note = new StaveNote({
|
||||||
|
keys: spec.keys,
|
||||||
|
duration: 'w',
|
||||||
|
clef,
|
||||||
|
});
|
||||||
|
note.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
|
||||||
|
if (spec.accidental) note.addModifier(new Accidental(spec.accidental));
|
||||||
|
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
|
voice.setMode(Voice.Mode.SOFT);
|
||||||
|
voice.addTickable(note);
|
||||||
|
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
||||||
|
voice.draw(context, stave);
|
||||||
|
}
|
||||||
|
}, [midi, clef, transpose, keySignature, width, theme, schemeVersion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="live-note-staff"
|
||||||
|
style={{ width }}
|
||||||
|
role="img"
|
||||||
|
aria-label={midi !== null ? `Currently played: note` : 'No note currently detected'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LiveNoteStaff;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
import { useSettings } from '../context/useSettings';
|
import { useSettings } from '../context/useSettings';
|
||||||
import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
||||||
import { usePhraseTrainer, type PhraseTrainerApi } from '../hooks/usePhraseTrainer';
|
import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer';
|
||||||
import { getPhraseSettings, type PhraseSettings } from '../types/SettingsTypes';
|
import { getPhraseSettings, type PhraseSettings } from '../types/SettingsTypes';
|
||||||
import { rawFrameCount } from '../hooks/rawFrameBus';
|
import { rawFrameCount } from '../hooks/rawFrameBus';
|
||||||
import { audioEngine } from '../audio/AudioEngine';
|
import { audioEngine } from '../audio/AudioEngine';
|
||||||
@@ -29,6 +29,8 @@ import { Fretboard } from './Fretboard';
|
|||||||
import { PianoKeys } from './PianoKeys';
|
import { PianoKeys } from './PianoKeys';
|
||||||
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
||||||
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
||||||
|
const AUTO_CONTINUE_DELAY_MS = 1500;
|
||||||
|
|
||||||
import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
|
import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
|
||||||
|
|
||||||
export interface PhraseHandle {
|
export interface PhraseHandle {
|
||||||
@@ -95,6 +97,21 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
const [octaveShift, setOctaveShift] = useState(0);
|
const [octaveShift, setOctaveShift] = useState(0);
|
||||||
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
|
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pendingAutoStart = useRef(false);
|
||||||
|
// BPM text field: editing stays free-form while typing; commits on
|
||||||
|
// blur/Enter (single-note widget behavior).
|
||||||
|
const [bpmText, setBpmText] = useState(String(phrase.bpm));
|
||||||
|
const [lastBpm, setLastBpm] = useState(phrase.bpm);
|
||||||
|
if (phrase.bpm !== lastBpm) {
|
||||||
|
// React's "adjust state when a prop changes" pattern (no effect).
|
||||||
|
setLastBpm(phrase.bpm);
|
||||||
|
setBpmText(String(phrase.bpm));
|
||||||
|
}
|
||||||
|
const commitBpm = useCallback(() => {
|
||||||
|
const v = parseInt(bpmText, 10);
|
||||||
|
if (Number.isNaN(v)) { setBpmText(String(phrase.bpm)); return; }
|
||||||
|
setPhrase({ bpm: Math.max(30, Math.min(180, v)) });
|
||||||
|
}, [bpmText, phrase.bpm, setPhrase]);
|
||||||
|
|
||||||
const handleImportFile = useCallback((file: File) => {
|
const handleImportFile = useCallback((file: File) => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
@@ -160,6 +177,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
}, [baseScore, octaveShift, phrase.material]);
|
}, [baseScore, octaveShift, phrase.material]);
|
||||||
|
|
||||||
const fixedMaterial = phrase.material === 'library' || phrase.material === 'import';
|
const fixedMaterial = phrase.material === 'library' || phrase.material === 'import';
|
||||||
|
const scoreOk = !!fullScore && fullScore.ok;
|
||||||
const selection = useMemo(() => ({
|
const selection = useMemo(() => ({
|
||||||
startBar: fixedMaterial ? phrase.startBar : 1,
|
startBar: fixedMaterial ? phrase.startBar : 1,
|
||||||
barCount: fixedMaterial ? phrase.barCount : (fullScore && fullScore.ok ? fullScore.value.measures.length : 8),
|
barCount: fixedMaterial ? phrase.barCount : (fullScore && fullScore.ok ? fullScore.value.measures.length : 8),
|
||||||
@@ -172,7 +190,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
pace: phrase.pace,
|
pace: phrase.pace,
|
||||||
bpm: phrase.bpm,
|
bpm: phrase.bpm,
|
||||||
clickSound: phrase.clickSound,
|
clickSound: phrase.clickSound,
|
||||||
repeat: phrase.repeat,
|
|
||||||
inputMode: phrase.inputMode,
|
inputMode: phrase.inputMode,
|
||||||
previewVolume: settings.autoPlayVolume ?? 0.4,
|
previewVolume: settings.autoPlayVolume ?? 0.4,
|
||||||
},
|
},
|
||||||
@@ -180,8 +197,47 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
micError,
|
micError,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-continue: when a run finishes, keep practice flowing —
|
||||||
|
// fixed material advances to the next section (wrapping), generated
|
||||||
|
// melodies roll a new seed, scale drills restart. Tempo mode only.
|
||||||
|
const prevPhaseRef = useRef<PhrasePhase>('ready');
|
||||||
|
useEffect(() => {
|
||||||
|
const was = prevPhaseRef.current;
|
||||||
|
prevPhaseRef.current = trainer.phase;
|
||||||
|
if (trainer.phase === 'done' && was !== 'done' && phrase.autoContinue && scoreOk && phrase.pace === 'tempo') {
|
||||||
|
const material = phrase.material;
|
||||||
|
const full = fullScore && fullScore.ok ? fullScore.value : null;
|
||||||
|
if (material === 'scale') {
|
||||||
|
const timer = setTimeout(() => trainer.start(), AUTO_CONTINUE_DELAY_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (material === 'library' || material === 'import') {
|
||||||
|
if (!full) return;
|
||||||
|
const total = full.measures.length;
|
||||||
|
const nextStart = phrase.startBar + phrase.barCount;
|
||||||
|
pendingAutoStart.current = true;
|
||||||
|
setPhrase(nextStart > total ? { startBar: 1 } : { startBar: nextStart });
|
||||||
|
} else {
|
||||||
|
pendingAutoStart.current = true;
|
||||||
|
setMelodySeed(s => (s + 1) % 999983);
|
||||||
|
}
|
||||||
|
}, AUTO_CONTINUE_DELAY_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [trainer.phase, phrase.pace, phrase.autoContinue, phrase.material, phrase.startBar, phrase.barCount, scoreOk, fullScore, setPhrase]);
|
||||||
|
|
||||||
|
// Auto-start after a structural change triggered by auto-continue.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingAutoStart.current && trainer.phase === 'ready' && scoreOk) {
|
||||||
|
pendingAutoStart.current = false;
|
||||||
|
trainer.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const materialError = fullScore && !fullScore.ok ? fullScore.error : null;
|
const materialError = fullScore && !fullScore.ok ? fullScore.error : null;
|
||||||
const scoreOk = !!fullScore && fullScore.ok;
|
|
||||||
|
|
||||||
// Range-conflict warning for fixed material.
|
// Range-conflict warning for fixed material.
|
||||||
const rangeWarning = useMemo(() => {
|
const rangeWarning = useMemo(() => {
|
||||||
@@ -264,7 +320,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
return 'Play the highlighted note';
|
return 'Play the highlighted note';
|
||||||
}
|
}
|
||||||
return phrase.pace === 'tempo' ? 'Press start — one bar of count-in, then play in time.' : 'Press start, then play the highlighted notes.';
|
return phrase.pace === 'tempo' ? 'Press start — one bar of count-in, then play in time.' : 'Press start, then play the highlighted notes.';
|
||||||
}, [scoreOk, materialError, trainer.phase, trainer.countInLeft, trainer.summary, phrase.pace]);
|
}, [scoreOk, materialError, trainer.phase, trainer.countInLeft, trainer.summary, phrase.pace, trainer.repeatedNoteBlocked]);
|
||||||
|
|
||||||
const startLabel = useMemo(() => {
|
const startLabel = useMemo(() => {
|
||||||
const p = trainer.phase;
|
const p = trainer.phase;
|
||||||
@@ -576,12 +632,27 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
</label>
|
</label>
|
||||||
{phrase.pace === 'tempo' && (
|
{phrase.pace === 'tempo' && (
|
||||||
<label>BPM
|
<label>BPM
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.max(30, phrase.bpm - 1) })}>−</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={bpmText}
|
||||||
|
onChange={e => { if (/^\d{0,3}$/.test(e.target.value)) setBpmText(e.target.value); }}
|
||||||
|
onBlur={commitBpm}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||||
|
style={{ width: '46px', textAlign: 'center' }}
|
||||||
|
/>
|
||||||
|
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.min(180, phrase.bpm + 1) })}>+</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
min={30}
|
min={30}
|
||||||
max={180}
|
max={180}
|
||||||
|
step={1}
|
||||||
value={phrase.bpm}
|
value={phrase.bpm}
|
||||||
onChange={e => setPhrase({ bpm: Math.max(30, Math.min(180, parseInt(e.target.value) || 60)) })}
|
onChange={e => setPhrase({ bpm: Number(e.target.value) })}
|
||||||
|
aria-label="Phrase tempo"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -603,16 +674,14 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
Click sound
|
Click sound
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{phrase.pace === 'tempo' && (
|
|
||||||
<label className="phrase-check">
|
<label className="phrase-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={phrase.repeat}
|
checked={phrase.autoContinue}
|
||||||
onChange={e => setPhrase({ repeat: e.target.checked })}
|
onChange={e => setPhrase({ autoContinue: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Repeat automatically
|
Auto-continue
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{phrase.pace === 'tempo' && phrase.clickSound && (
|
{phrase.pace === 'tempo' && phrase.clickSound && (
|
||||||
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</p>
|
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</p>
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ import firstPositionRaw from './library/first-position-study.abc?raw';
|
|||||||
import eMinorRaw from './library/e-minor-study.abc?raw';
|
import eMinorRaw from './library/e-minor-study.abc?raw';
|
||||||
import dottedRaw from './library/dotted-rhythm-study.abc?raw';
|
import dottedRaw from './library/dotted-rhythm-study.abc?raw';
|
||||||
import brokenChordRaw from './library/broken-chord-study.abc?raw';
|
import brokenChordRaw from './library/broken-chord-study.abc?raw';
|
||||||
|
import jingleRaw from './library/jingle-bells.abc?raw';
|
||||||
|
import maryRaw from './library/mary-had-a-little-lamb.abc?raw';
|
||||||
|
import londonRaw from './library/london-bridge.abc?raw';
|
||||||
|
import cMajorRunRaw from './library/c-major-run.abc?raw';
|
||||||
|
import thirdsRaw from './library/thirds-study.abc?raw';
|
||||||
|
import aMinorArpRaw from './library/a-minor-arpeggio-study.abc?raw';
|
||||||
|
import waltzRaw from './library/waltz-study.abc?raw';
|
||||||
|
import eMinorPentRaw from './library/e-minor-pentatonic-run.abc?raw';
|
||||||
|
import dottedEighthRaw from './library/dotted-eighth-study.abc?raw';
|
||||||
|
import mixedRhythmRaw from './library/mixed-rhythm-study.abc?raw';
|
||||||
|
import fMajorRaw from './library/f-major-study.abc?raw';
|
||||||
|
import bbMajorRaw from './library/bb-major-study.abc?raw';
|
||||||
|
import descendingRaw from './library/descending-study.abc?raw';
|
||||||
|
|
||||||
export interface ExerciseInfo {
|
export interface ExerciseInfo {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -30,9 +43,22 @@ export interface ExerciseInfo {
|
|||||||
export const EXERCISES: ExerciseInfo[] = [
|
export const EXERCISES: ExerciseInfo[] = [
|
||||||
{ id: 'ode-to-joy', label: 'Ode to Joy (Theme)', level: 'beginner', abc: odeRaw },
|
{ id: 'ode-to-joy', label: 'Ode to Joy (Theme)', level: 'beginner', abc: odeRaw },
|
||||||
{ id: 'twinkle', label: 'Ah! vous dirai-je, maman', level: 'beginner', abc: twinkleRaw },
|
{ id: 'twinkle', label: 'Ah! vous dirai-je, maman', level: 'beginner', abc: twinkleRaw },
|
||||||
|
{ id: 'jingle-bells', label: 'Jingle Bells (Opening)', level: 'beginner', abc: jingleRaw },
|
||||||
|
{ id: 'mary-had-a-little-lamb', label: 'Mary Had a Little Lamb', level: 'beginner', abc: maryRaw },
|
||||||
|
{ id: 'london-bridge', label: 'London Bridge (First Phrase)', level: 'beginner', abc: londonRaw },
|
||||||
{ id: 'first-position-study', label: 'First Position Study', level: 'beginner', abc: firstPositionRaw },
|
{ id: 'first-position-study', label: 'First Position Study', level: 'beginner', abc: firstPositionRaw },
|
||||||
|
{ id: 'c-major-run', label: 'C Major Run', level: 'beginner', abc: cMajorRunRaw },
|
||||||
|
{ id: 'thirds-study', label: 'Thirds Study', level: 'beginner', abc: thirdsRaw },
|
||||||
|
{ id: 'waltz-study', label: 'Waltz Study (3/4)', level: 'beginner', abc: waltzRaw },
|
||||||
{ id: 'e-minor-study', label: 'E Minor Study', level: 'intermediate', abc: eMinorRaw },
|
{ id: 'e-minor-study', label: 'E Minor Study', level: 'intermediate', abc: eMinorRaw },
|
||||||
|
{ id: 'e-minor-pentatonic-run', label: 'E Minor Pentatonic Run', level: 'intermediate', abc: eMinorPentRaw },
|
||||||
{ id: 'dotted-rhythm-study', label: 'Dotted Rhythm Study', level: 'intermediate', abc: dottedRaw },
|
{ id: 'dotted-rhythm-study', label: 'Dotted Rhythm Study', level: 'intermediate', abc: dottedRaw },
|
||||||
|
{ id: 'dotted-eighth-study', label: 'Dotted Eighth Study', level: 'intermediate', abc: dottedEighthRaw },
|
||||||
|
{ id: 'mixed-rhythm-study', label: 'Mixed Rhythm Study', level: 'intermediate', abc: mixedRhythmRaw },
|
||||||
|
{ id: 'f-major-study', label: 'F Major Study', level: 'intermediate', abc: fMajorRaw },
|
||||||
|
{ id: 'bb-major-study', label: 'Bb Major Study', level: 'intermediate', abc: bbMajorRaw },
|
||||||
|
{ id: 'a-minor-arpeggio-study', label: 'A Minor Arpeggio Study', level: 'intermediate', abc: aMinorArpRaw },
|
||||||
|
{ id: 'descending-study', label: 'Descending Study', level: 'intermediate', abc: descendingRaw },
|
||||||
{ id: 'broken-chord-study', label: 'Broken Chord Study', level: 'intermediate', abc: brokenChordRaw },
|
{ id: 'broken-chord-study', label: 'Broken Chord Study', level: 'intermediate', abc: brokenChordRaw },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
10
src/exercises/library/a-minor-arpeggio-study.abc
Normal file
10
src/exercises/library/a-minor-arpeggio-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise: A minor broken chords (project contributor, 2026, CC0).
|
||||||
|
X:12
|
||||||
|
T:A Minor Arpeggio Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:Am
|
||||||
|
A, C E C | A, C E A | G, B, D B, | G, B, D G | F, A, C A, | F, A, C F | E, G, B, G, | E, G, B, E |]
|
||||||
10
src/exercises/library/bb-major-study.abc
Normal file
10
src/exercises/library/bb-major-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise in _B major (two flats) (project contributor, 2026, CC0).
|
||||||
|
X:18
|
||||||
|
T:_B Major Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:_B
|
||||||
|
_B, C D _E | F G F D | _B, C D F | G F D _B, | C D _E F | G F _E C | D _E F G | F2 D2 |]
|
||||||
11
src/exercises/library/c-major-run.abc
Normal file
11
src/exercises/library/c-major-run.abc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
% Original technical exercise (project contributor, 2026, CC0).
|
||||||
|
% C major, steady quarters — a gentle reading warm-up.
|
||||||
|
X:10
|
||||||
|
T:C Major Run
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:C
|
||||||
|
C D E F | G A B c | c B A G | F E D C |]
|
||||||
10
src/exercises/library/descending-study.abc
Normal file
10
src/exercises/library/descending-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise: descending lines in A minor (project contributor, 2026, CC0).
|
||||||
|
X:19
|
||||||
|
T:Descending Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:Am
|
||||||
|
c B A G | F E D C | B, C D E | F E D C | A G F E | D C B, A, | B, C D E | A2 z2 |]
|
||||||
10
src/exercises/library/dotted-eighth-study.abc
Normal file
10
src/exercises/library/dotted-eighth-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise: dotted quarter + eighth figures (project contributor, 2026, CC0).
|
||||||
|
X:15
|
||||||
|
T:Dotted Eighth Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=70
|
||||||
|
K:G
|
||||||
|
G3/2 A/2 B G | D3/2 E/2 G B | A3/2 B/2 c A | E3/2 ^F/2 A c | B3/2 A/2 G ^F | E3/2 D/2 B, A, | G,3/2 A,/2 B, D | G2 z2 |]
|
||||||
10
src/exercises/library/e-minor-pentatonic-run.abc
Normal file
10
src/exercises/library/e-minor-pentatonic-run.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise: E minor pentatonic (project contributor, 2026, CC0).
|
||||||
|
X:14
|
||||||
|
T:E Minor Pentatonic Run
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=90
|
||||||
|
K:Em
|
||||||
|
E G A B | d B A G | E D B, A, | B, D E2 | G A B d | B A G E | D B, A, B, | E2 z2 |]
|
||||||
10
src/exercises/library/f-major-study.abc
Normal file
10
src/exercises/library/f-major-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise in F major (one flat) (project contributor, 2026, CC0).
|
||||||
|
X:17
|
||||||
|
T:F Major Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:F
|
||||||
|
F G A _B | c d c A | F G A c | d c A F | G A _B c | d c _B G | A _B c d | c2 A2 |]
|
||||||
13
src/exercises/library/jingle-bells.abc
Normal file
13
src/exercises/library/jingle-bells.abc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
% Jingle Bells — opening phrase. James Lord Pierpont, 1857; public domain.
|
||||||
|
% Transcription: project contributor, 2026 (CC0). Opening 4 bars only.
|
||||||
|
% Pitch convention: concert pitch.
|
||||||
|
X:7
|
||||||
|
T:Jingle Bells (Opening)
|
||||||
|
C:James Lord Pierpont (1857)
|
||||||
|
S:Public domain since publication; opening phrase
|
||||||
|
Z:Project contributor, 2026 (CC0 transcription)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=120
|
||||||
|
K:C
|
||||||
|
E E E2 | E E E2 | E G C D | E2 z2 |]
|
||||||
13
src/exercises/library/london-bridge.abc
Normal file
13
src/exercises/library/london-bridge.abc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
% London Bridge Is Falling Down — traditional English nursery rhyme.
|
||||||
|
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
|
||||||
|
% Adaptation: the first two-measure phrase, repeated as the tune itself does.
|
||||||
|
X:9
|
||||||
|
T:London Bridge (First Phrase)
|
||||||
|
C:Traditional (English nursery rhyme)
|
||||||
|
S:Common version of the tune
|
||||||
|
Z:Project contributor, 2026 (CC0 transcription); adaptation: first phrase, repeated
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=100
|
||||||
|
K:C
|
||||||
|
G A G F | E F G2 | D E F2 | E F G2 | G A G F | E F G2 | D E F2 | E F G2 |]
|
||||||
12
src/exercises/library/mary-had-a-little-lamb.abc
Normal file
12
src/exercises/library/mary-had-a-little-lamb.abc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
% Mary Had a Little Lamb — traditional nursery melody (19th century).
|
||||||
|
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
|
||||||
|
X:8
|
||||||
|
T:Mary Had a Little Lamb
|
||||||
|
C:Traditional
|
||||||
|
S:Common nursery-rhyme version
|
||||||
|
Z:Project contributor, 2026 (CC0 transcription)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=100
|
||||||
|
K:C
|
||||||
|
E D C D | E E E2 | D D D2 | E G G2 | E D C D | E E E E | D D E D | C2 z2 |]
|
||||||
10
src/exercises/library/mixed-rhythm-study.abc
Normal file
10
src/exercises/library/mixed-rhythm-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise mixing paired eighths and quarters (project contributor, 2026, CC0).
|
||||||
|
X:16
|
||||||
|
T:Mixed Rhythm Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:C
|
||||||
|
C C D/2 E/2 F | G G A/2 B/2 c | c B A/2 G/2 F | E E D2 | F F G/2 A/2 B | c c B/2 A/2 G | A G F/2 E/2 D | C2 z2 |]
|
||||||
10
src/exercises/library/thirds-study.abc
Normal file
10
src/exercises/library/thirds-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise: melody in thirds (project contributor, 2026, CC0).
|
||||||
|
X:11
|
||||||
|
T:Thirds Study
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:4/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=80
|
||||||
|
K:C
|
||||||
|
C E D F | E G F A | G B A c | B c B2 |]
|
||||||
10
src/exercises/library/waltz-study.abc
Normal file
10
src/exercises/library/waltz-study.abc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
% Original technical exercise in 3/4 (project contributor, 2026, CC0).
|
||||||
|
X:13
|
||||||
|
T:Waltz Study (3/4)
|
||||||
|
C:Original exercise (project contributor)
|
||||||
|
Z:Project contributor, 2026 (CC0)
|
||||||
|
M:3/4
|
||||||
|
L:1/4
|
||||||
|
Q:1/4=90
|
||||||
|
K:C
|
||||||
|
C E G | c2 G | A G F | E2 C | D F A | c2 A | G F E | D2 C |]
|
||||||
@@ -30,7 +30,6 @@ export interface PhraseTrainerConfig {
|
|||||||
readonly pace: 'step' | 'tempo';
|
readonly pace: 'step' | 'tempo';
|
||||||
readonly bpm: number;
|
readonly bpm: number;
|
||||||
readonly clickSound: boolean;
|
readonly clickSound: boolean;
|
||||||
readonly repeat: boolean;
|
|
||||||
readonly inputMode: 'mic' | 'virtual';
|
readonly inputMode: 'mic' | 'virtual';
|
||||||
readonly previewVolume?: number;
|
readonly previewVolume?: number;
|
||||||
}
|
}
|
||||||
@@ -47,7 +46,6 @@ const LOOKAHEAD_S = 0.1;
|
|||||||
const CLICK_VOLUME = 0.4;
|
const CLICK_VOLUME = 0.4;
|
||||||
const PREVIEW_GROUP = 'phrase-preview';
|
const PREVIEW_GROUP = 'phrase-preview';
|
||||||
const CLICK_GROUP = 'phrase-clicks';
|
const CLICK_GROUP = 'phrase-clicks';
|
||||||
const REPEAT_DELAY_MS = 1400;
|
|
||||||
/** Longest a sustained episode may block a repeated note in at-your-pace mode. */
|
/** Longest a sustained episode may block a repeated note in at-your-pace mode. */
|
||||||
const REARM_AFTER_SUSTAINED_MS = 2500;
|
const REARM_AFTER_SUSTAINED_MS = 2500;
|
||||||
|
|
||||||
@@ -106,6 +104,7 @@ export function usePhraseTrainer(
|
|||||||
const windowIdxRef = useRef(0);
|
const windowIdxRef = useRef(0);
|
||||||
const scoreStartRef = useRef(0);
|
const scoreStartRef = useRef(0);
|
||||||
const spbRef = useRef(1);
|
const spbRef = useRef(1);
|
||||||
|
const countInBeatsRef = useRef(0);
|
||||||
const runTokenRef = useRef(0);
|
const runTokenRef = useRef(0);
|
||||||
const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -173,12 +172,7 @@ export function usePhraseTrainer(
|
|||||||
if (m) setStatuses(m.allStatuses());
|
if (m) setStatuses(m.allStatuses());
|
||||||
setCurrentIdx(-1);
|
setCurrentIdx(-1);
|
||||||
setPhaseBoth('done');
|
setPhaseBoth('done');
|
||||||
if (configRef.current.repeat) {
|
|
||||||
const token = runTokenRef.current;
|
|
||||||
repeatTimerRef.current = setTimeout(() => {
|
|
||||||
if (runTokenRef.current === token) startRef.current?.();
|
|
||||||
}, REPEAT_DELAY_MS);
|
|
||||||
}
|
|
||||||
}, [setPhaseBoth]);
|
}, [setPhaseBoth]);
|
||||||
|
|
||||||
const processBoundaries = useCallback((now: number) => {
|
const processBoundaries = useCallback((now: number) => {
|
||||||
@@ -191,7 +185,14 @@ export function usePhraseTrainer(
|
|||||||
schedule[clickIdxRef.current].at < now + LOOKAHEAD_S
|
schedule[clickIdxRef.current].at < now + LOOKAHEAD_S
|
||||||
) {
|
) {
|
||||||
const e = schedule[clickIdxRef.current];
|
const e = schedule[clickIdxRef.current];
|
||||||
if ((e.kind === 'count-in-beat' || e.kind === 'beat') && configRef.current.clickSound) {
|
// With a count-in, the downbeat of bar 1 is already the LAST
|
||||||
|
// count-in click — an extra click at scoreStart would make the
|
||||||
|
// player count "1 2 3 4 | 1" and start one beat early.
|
||||||
|
const isRedundantDownbeat =
|
||||||
|
e.kind === 'beat' &&
|
||||||
|
countInBeatsRef.current > 0 &&
|
||||||
|
e.at <= scoreStartRef.current + 0.02;
|
||||||
|
if ((e.kind === 'count-in-beat' || e.kind === 'beat') && !isRedundantDownbeat && configRef.current.clickSound) {
|
||||||
audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.beat !== undefined);
|
audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.beat !== undefined);
|
||||||
}
|
}
|
||||||
clickIdxRef.current++;
|
clickIdxRef.current++;
|
||||||
@@ -329,6 +330,7 @@ export function usePhraseTrainer(
|
|||||||
}
|
}
|
||||||
const now = audioEngine.now();
|
const now = audioEngine.now();
|
||||||
const countIn = score.meter.numerator;
|
const countIn = score.meter.numerator;
|
||||||
|
countInBeatsRef.current = countIn;
|
||||||
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
||||||
scheduleRef.current = buildSchedule(score, bpm, now + 0.15, countIn);
|
scheduleRef.current = buildSchedule(score, bpm, now + 0.15, countIn);
|
||||||
boundaryIdxRef.current = 0;
|
boundaryIdxRef.current = 0;
|
||||||
@@ -408,6 +410,7 @@ export function usePhraseTrainer(
|
|||||||
if (runTokenRef.current !== token) return;
|
if (runTokenRef.current !== token) return;
|
||||||
const now = audioEngine.now();
|
const now = audioEngine.now();
|
||||||
const countIn = tail.meter.numerator;
|
const countIn = tail.meter.numerator;
|
||||||
|
countInBeatsRef.current = countIn;
|
||||||
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
||||||
scheduleRef.current = buildSchedule(tail, bpm, now + 0.15, countIn);
|
scheduleRef.current = buildSchedule(tail, bpm, now + 0.15, countIn);
|
||||||
boundaryIdxRef.current = 0;
|
boundaryIdxRef.current = 0;
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export interface PhraseSettings {
|
|||||||
pace: PhrasePace;
|
pace: PhrasePace;
|
||||||
bpm: number;
|
bpm: number;
|
||||||
clickSound: boolean;
|
clickSound: boolean;
|
||||||
repeat: boolean;
|
/** When a run finishes, automatically continue (next exercise section /
|
||||||
|
* new melody) after a short pause — keeps practice flowing. */
|
||||||
|
autoContinue: boolean;
|
||||||
inputMode: 'mic' | 'virtual';
|
inputMode: 'mic' | 'virtual';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
|
|||||||
pace: 'step',
|
pace: 'step',
|
||||||
bpm: 60,
|
bpm: 60,
|
||||||
clickSound: false,
|
clickSound: false,
|
||||||
repeat: false,
|
autoContinue: true,
|
||||||
inputMode: 'mic',
|
inputMode: 'mic',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user