Phrase mode: count-in click fix, auto-continue sections, BPM widget, live-note staff, 13 new exercises

This commit is contained in:
2026-09-14 01:45:39 +02:00
parent 82c105bcbf
commit 4f2ee8798a
21 changed files with 374 additions and 28 deletions

View File

@@ -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: [

View File

@@ -1098,3 +1098,8 @@ button {
display: flex;
justify-content: center;
}
.live-note-staff {
flex: none;
opacity: 0.9;
}

View File

@@ -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 ? (
<>

View 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;

View File

@@ -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="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}
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 })}
checked={phrase.autoContinue}
onChange={e => setPhrase({ autoContinue: e.target.checked })}
/>
Repeat automatically
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>

View File

@@ -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 },
];

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View 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 |]

View File

@@ -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;

View File

@@ -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',
};