From 4f2ee8798ab7968efec5d7a1ebe8d06c86ab7bf2 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Mon, 14 Sep 2026 01:45:39 +0200 Subject: [PATCH] Phrase mode: count-in click fix, auto-continue sections, BPM widget, live-note staff, 13 new exercises --- eslint.config.js | 2 +- src/App.css | 5 + src/App.tsx | 12 +++ src/components/LiveNoteStaff.tsx | 90 ++++++++++++++++ src/components/PhraseTrainer.tsx | 101 +++++++++++++++--- src/exercises/library.ts | 26 +++++ .../library/a-minor-arpeggio-study.abc | 10 ++ src/exercises/library/bb-major-study.abc | 10 ++ src/exercises/library/c-major-run.abc | 11 ++ src/exercises/library/descending-study.abc | 10 ++ src/exercises/library/dotted-eighth-study.abc | 10 ++ .../library/e-minor-pentatonic-run.abc | 10 ++ src/exercises/library/f-major-study.abc | 10 ++ src/exercises/library/jingle-bells.abc | 13 +++ src/exercises/library/london-bridge.abc | 13 +++ .../library/mary-had-a-little-lamb.abc | 12 +++ src/exercises/library/mixed-rhythm-study.abc | 10 ++ src/exercises/library/thirds-study.abc | 10 ++ src/exercises/library/waltz-study.abc | 10 ++ src/hooks/usePhraseTrainer.ts | 21 ++-- src/types/SettingsTypes.ts | 6 +- 21 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 src/components/LiveNoteStaff.tsx create mode 100644 src/exercises/library/a-minor-arpeggio-study.abc create mode 100644 src/exercises/library/bb-major-study.abc create mode 100644 src/exercises/library/c-major-run.abc create mode 100644 src/exercises/library/descending-study.abc create mode 100644 src/exercises/library/dotted-eighth-study.abc create mode 100644 src/exercises/library/e-minor-pentatonic-run.abc create mode 100644 src/exercises/library/f-major-study.abc create mode 100644 src/exercises/library/jingle-bells.abc create mode 100644 src/exercises/library/london-bridge.abc create mode 100644 src/exercises/library/mary-had-a-little-lamb.abc create mode 100644 src/exercises/library/mixed-rhythm-study.abc create mode 100644 src/exercises/library/thirds-study.abc create mode 100644 src/exercises/library/waltz-study.abc diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..2a81dd7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'scripts']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/src/App.css b/src/App.css index 1a3fd90..1b0e360 100644 --- a/src/App.css +++ b/src/App.css @@ -1098,3 +1098,8 @@ button { display: flex; justify-content: center; } + +.live-note-staff { + flex: none; + opacity: 0.9; +} diff --git a/src/App.tsx b/src/App.tsx index 9c36123..b4a7d52 100644 --- a/src/App.tsx +++ b/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 ? : } + { + const { clef } = resolveClefTranspose(currentInstrumentDef, settings.difficulty); + return clef === 'grand' ? 'treble' : clef; + })()} + transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} + keySignature={settings.keySignature} + theme={settings.theme} + />
{displayedPitch ? ( <> diff --git a/src/components/LiveNoteStaff.tsx b/src/components/LiveNoteStaff.tsx new file mode 100644 index 0000000..a271dc9 --- /dev/null +++ b/src/components/LiveNoteStaff.tsx @@ -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 = ({ + midi, + clef = 'treble', + transpose = 0, + keySignature = 'C', + width = 130, + theme = 'auto', +}) => { + const containerRef = useRef(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 ( +
+ ); +}; + +export default LiveNoteStaff; diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index d9a06ad..9753ec1 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -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( const [octaveShift, setOctaveShift] = useState(0); const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000)); const fileInputRef = useRef(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( }, [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( 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( 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('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( 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( {phrase.pace === 'tempo' && ( )} @@ -603,16 +674,14 @@ export const PhraseTrainer = forwardRef( Click sound )} - {phrase.pace === 'tempo' && ( - - )} +
{phrase.pace === 'tempo' && phrase.clickSound && (

Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.

diff --git a/src/exercises/library.ts b/src/exercises/library.ts index 207f663..55bfdb6 100644 --- a/src/exercises/library.ts +++ b/src/exercises/library.ts @@ -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 }, ]; diff --git a/src/exercises/library/a-minor-arpeggio-study.abc b/src/exercises/library/a-minor-arpeggio-study.abc new file mode 100644 index 0000000..c0aaed3 --- /dev/null +++ b/src/exercises/library/a-minor-arpeggio-study.abc @@ -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 |] diff --git a/src/exercises/library/bb-major-study.abc b/src/exercises/library/bb-major-study.abc new file mode 100644 index 0000000..4e35ff2 --- /dev/null +++ b/src/exercises/library/bb-major-study.abc @@ -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 |] diff --git a/src/exercises/library/c-major-run.abc b/src/exercises/library/c-major-run.abc new file mode 100644 index 0000000..7a18f0e --- /dev/null +++ b/src/exercises/library/c-major-run.abc @@ -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 |] diff --git a/src/exercises/library/descending-study.abc b/src/exercises/library/descending-study.abc new file mode 100644 index 0000000..ed5a7a2 --- /dev/null +++ b/src/exercises/library/descending-study.abc @@ -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 |] diff --git a/src/exercises/library/dotted-eighth-study.abc b/src/exercises/library/dotted-eighth-study.abc new file mode 100644 index 0000000..e5dee47 --- /dev/null +++ b/src/exercises/library/dotted-eighth-study.abc @@ -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 |] diff --git a/src/exercises/library/e-minor-pentatonic-run.abc b/src/exercises/library/e-minor-pentatonic-run.abc new file mode 100644 index 0000000..52d733e --- /dev/null +++ b/src/exercises/library/e-minor-pentatonic-run.abc @@ -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 |] diff --git a/src/exercises/library/f-major-study.abc b/src/exercises/library/f-major-study.abc new file mode 100644 index 0000000..7443050 --- /dev/null +++ b/src/exercises/library/f-major-study.abc @@ -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 |] diff --git a/src/exercises/library/jingle-bells.abc b/src/exercises/library/jingle-bells.abc new file mode 100644 index 0000000..37dc6be --- /dev/null +++ b/src/exercises/library/jingle-bells.abc @@ -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 |] diff --git a/src/exercises/library/london-bridge.abc b/src/exercises/library/london-bridge.abc new file mode 100644 index 0000000..da8f788 --- /dev/null +++ b/src/exercises/library/london-bridge.abc @@ -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 |] diff --git a/src/exercises/library/mary-had-a-little-lamb.abc b/src/exercises/library/mary-had-a-little-lamb.abc new file mode 100644 index 0000000..6ab81dc --- /dev/null +++ b/src/exercises/library/mary-had-a-little-lamb.abc @@ -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 |] diff --git a/src/exercises/library/mixed-rhythm-study.abc b/src/exercises/library/mixed-rhythm-study.abc new file mode 100644 index 0000000..d1e3b9e --- /dev/null +++ b/src/exercises/library/mixed-rhythm-study.abc @@ -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 |] diff --git a/src/exercises/library/thirds-study.abc b/src/exercises/library/thirds-study.abc new file mode 100644 index 0000000..0590cf2 --- /dev/null +++ b/src/exercises/library/thirds-study.abc @@ -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 |] diff --git a/src/exercises/library/waltz-study.abc b/src/exercises/library/waltz-study.abc new file mode 100644 index 0000000..26ab4ef --- /dev/null +++ b/src/exercises/library/waltz-study.abc @@ -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 |] diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index 59ddf18..79939cb 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -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 | null>(null); const previewTimerRef = useRef | 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; diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index 20f682c..8c993a3 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -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', };