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:
@@ -1098,3 +1098,8 @@ button {
|
||||
display: flex;
|
||||
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 { OpenSourceModal } from './components/OpenSourceModal';
|
||||
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 { usePitchDetector } from './hooks/usePitchDetector';
|
||||
import { useSettings } from './context/useSettings';
|
||||
@@ -326,6 +328,16 @@ function App() {
|
||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||
</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' : ''}`}>
|
||||
{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 { useSettings } from '../context/useSettings';
|
||||
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 { rawFrameCount } from '../hooks/rawFrameBus';
|
||||
import { audioEngine } from '../audio/AudioEngine';
|
||||
@@ -29,6 +29,8 @@ import { Fretboard } from './Fretboard';
|
||||
import { PianoKeys } from './PianoKeys';
|
||||
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
||||
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';
|
||||
|
||||
export interface PhraseHandle {
|
||||
@@ -95,6 +97,21 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const [octaveShift, setOctaveShift] = useState(0);
|
||||
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
|
||||
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) => {
|
||||
setImportError(null);
|
||||
@@ -160,6 +177,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
}, [baseScore, octaveShift, phrase.material]);
|
||||
|
||||
const fixedMaterial = phrase.material === 'library' || phrase.material === 'import';
|
||||
const scoreOk = !!fullScore && fullScore.ok;
|
||||
const selection = useMemo(() => ({
|
||||
startBar: fixedMaterial ? phrase.startBar : 1,
|
||||
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,
|
||||
bpm: phrase.bpm,
|
||||
clickSound: phrase.clickSound,
|
||||
repeat: phrase.repeat,
|
||||
inputMode: phrase.inputMode,
|
||||
previewVolume: settings.autoPlayVolume ?? 0.4,
|
||||
},
|
||||
@@ -180,8 +197,47 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
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 scoreOk = !!fullScore && fullScore.ok;
|
||||
|
||||
// Range-conflict warning for fixed material.
|
||||
const rangeWarning = useMemo(() => {
|
||||
@@ -264,7 +320,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
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.';
|
||||
}, [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 p = trainer.phase;
|
||||
@@ -576,12 +632,27 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</label>
|
||||
{phrase.pace === 'tempo' && (
|
||||
<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
|
||||
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="number"
|
||||
type="range"
|
||||
min={30}
|
||||
max={180}
|
||||
step={1}
|
||||
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>
|
||||
)}
|
||||
@@ -603,16 +674,14 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
Click sound
|
||||
</label>
|
||||
)}
|
||||
{phrase.pace === 'tempo' && (
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.repeat}
|
||||
onChange={e => setPhrase({ repeat: e.target.checked })}
|
||||
/>
|
||||
Repeat automatically
|
||||
</label>
|
||||
)}
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.autoContinue}
|
||||
onChange={e => setPhrase({ autoContinue: e.target.checked })}
|
||||
/>
|
||||
Auto-continue
|
||||
</label>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -18,6 +18,19 @@ import firstPositionRaw from './library/first-position-study.abc?raw';
|
||||
import eMinorRaw from './library/e-minor-study.abc?raw';
|
||||
import dottedRaw from './library/dotted-rhythm-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 {
|
||||
readonly id: string;
|
||||
@@ -30,9 +43,22 @@ export interface ExerciseInfo {
|
||||
export const EXERCISES: ExerciseInfo[] = [
|
||||
{ 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: '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: '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-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-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 },
|
||||
];
|
||||
|
||||
|
||||
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 bpm: number;
|
||||
readonly clickSound: boolean;
|
||||
readonly repeat: boolean;
|
||||
readonly inputMode: 'mic' | 'virtual';
|
||||
readonly previewVolume?: number;
|
||||
}
|
||||
@@ -47,7 +46,6 @@ const LOOKAHEAD_S = 0.1;
|
||||
const CLICK_VOLUME = 0.4;
|
||||
const PREVIEW_GROUP = 'phrase-preview';
|
||||
const CLICK_GROUP = 'phrase-clicks';
|
||||
const REPEAT_DELAY_MS = 1400;
|
||||
/** Longest a sustained episode may block a repeated note in at-your-pace mode. */
|
||||
const REARM_AFTER_SUSTAINED_MS = 2500;
|
||||
|
||||
@@ -106,6 +104,7 @@ export function usePhraseTrainer(
|
||||
const windowIdxRef = useRef(0);
|
||||
const scoreStartRef = useRef(0);
|
||||
const spbRef = useRef(1);
|
||||
const countInBeatsRef = useRef(0);
|
||||
const runTokenRef = useRef(0);
|
||||
const repeatTimerRef = 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());
|
||||
setCurrentIdx(-1);
|
||||
setPhaseBoth('done');
|
||||
if (configRef.current.repeat) {
|
||||
const token = runTokenRef.current;
|
||||
repeatTimerRef.current = setTimeout(() => {
|
||||
if (runTokenRef.current === token) startRef.current?.();
|
||||
}, REPEAT_DELAY_MS);
|
||||
}
|
||||
|
||||
}, [setPhaseBoth]);
|
||||
|
||||
const processBoundaries = useCallback((now: number) => {
|
||||
@@ -191,7 +185,14 @@ export function usePhraseTrainer(
|
||||
schedule[clickIdxRef.current].at < now + LOOKAHEAD_S
|
||||
) {
|
||||
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);
|
||||
}
|
||||
clickIdxRef.current++;
|
||||
@@ -329,6 +330,7 @@ export function usePhraseTrainer(
|
||||
}
|
||||
const now = audioEngine.now();
|
||||
const countIn = score.meter.numerator;
|
||||
countInBeatsRef.current = countIn;
|
||||
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
||||
scheduleRef.current = buildSchedule(score, bpm, now + 0.15, countIn);
|
||||
boundaryIdxRef.current = 0;
|
||||
@@ -408,6 +410,7 @@ export function usePhraseTrainer(
|
||||
if (runTokenRef.current !== token) return;
|
||||
const now = audioEngine.now();
|
||||
const countIn = tail.meter.numerator;
|
||||
countInBeatsRef.current = countIn;
|
||||
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
|
||||
scheduleRef.current = buildSchedule(tail, bpm, now + 0.15, countIn);
|
||||
boundaryIdxRef.current = 0;
|
||||
|
||||
@@ -34,7 +34,9 @@ export interface PhraseSettings {
|
||||
pace: PhrasePace;
|
||||
bpm: number;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
|
||||
pace: 'step',
|
||||
bpm: 60,
|
||||
clickSound: false,
|
||||
repeat: false,
|
||||
autoContinue: true,
|
||||
inputMode: 'mic',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user