/** * Phrase Mode main component (contracts D–F, H). * * Owns: material selection (generated melodies / scale drills / library / * local import), the practice-range selection, the setup panel, transport * controls, and the hint instruments. Playback state lives in usePhraseTrainer. * * Deviation from the contract (recorded): range conflicts for fixed material * show a warning and an explicit octave-shift control rather than blocking * the run; notes outside the playable range simply cannot be matched by mic. */ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { useSettings } from '../context/useSettings'; import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; import { rawFrameCount } from '../hooks/rawFrameBus'; import { audioEngine } from '../audio/AudioEngine'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange'; import { generateMelody } from '../music/melodyGenerator'; import { generateScaleDrill } from '../music/scaleDrills'; import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator'; import { isMode, type ModeId } from '../music/scales'; import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator'; import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model'; import { getNoteDetails } from '../music/NoteUtils'; import { EXERCISES, loadExercise } from '../exercises/library'; import { parseAbc } from '../exercises/abcParser'; import { PhraseSheetMusic } from './PhraseSheetMusic'; 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 { pauseToggle: () => void; previewToggle: () => void; retry: () => void; skip: () => void; next: () => void; pause: () => void; } interface PhraseTrainerProps { listening: boolean; micError: string | null; windowWidth: number; } /** Shift a fixed score by whole octaves (explicit user action only). */ function shiftScoreOctaves(score: Score, octaves: number): Score { if (octaves === 0) return score; const events: ScoreEvent[] = scoreEvents(score).map((e, i) => { if (!e.pitch) return e; return { ...e, id: `os${i}`, pitch: { ...e.pitch, midi: e.pitch.midi + 12 * octaves, octave: e.pitch.octave + octaves }, }; }); return { ...score, voices: [{ id: 'melody', events }] }; } export const PhraseTrainer = forwardRef( function PhraseTrainer({ listening, micError, windowWidth }, ref) { const { settings, updateSettings } = useSettings(); const setSettings = updateSettings; const { playNote } = useAudioPlayer(); const phrase = getPhraseSettings(settings); // Unified target-note controls: Phrase Mode reads/writes the shared // practice filter so all modes expose identical key/fret-window state. const pf = getPracticeFilter(settings); phrase.keyTonic = pf.keyTonic; phrase.keyMode = pf.keyMode; phrase.fretWindowEnabled = pf.fretWindowEnabled; phrase.fretMin = pf.fretMin; phrase.fretMax = pf.fretMax; // Unified metronome: phrase tempo reads from the shared rhythm BPM // (single source of truth across modes; the phrase-local BPM control // was replaced by the common metronome widget in Controls). phrase.bpm = settings.rhythm.bpm; // "Sync to metronome": the phrase run IS the click source while it // plays (scheduler-driven, with count-in) — the metronome widget only // mirrors the beats visually and is muted in Phrase Mode, so clicks // can never double. The separate-click checkbox stays as an explicit // opt-in for a click even in "at your pace" mode. const metronomeActive = settings.rhythm.active; phrase.clickSound = phrase.pace === 'tempo' ? true : phrase.clickSound && metronomeActive; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; // Mirror shared filter fields into the unified practice filter. const shared: Partial = {}; if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic; if ('keyMode' in updates) shared.keyMode = updates.keyMode; if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled; if ('fretMin' in updates) shared.fretMin = updates.fretMin; if ('fretMax' in updates) shared.fretMax = updates.fretMax; if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared }; return next; }); }, [updateSettings]); const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentTuning = TUNINGS[settings.tuningId]; const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning; // ---- Playable pool ---------------------------------------------------- const pool = useMemo(() => { // Unified filter: strings ∩ fret window take precedence for fretted // instruments (same semantics as the single-note modes). if (fretted && currentTuning && (pf.strings.length > 0 || pf.fretWindowEnabled)) { const all = currentTuning.strings; const selected = pf.strings.length > 0 ? all.filter((_, i) => pf.strings.includes(i)) : all; if (selected.length > 0) { if (pf.fretWindowEnabled) { const lo = Math.max(0, Math.min(24, pf.fretMin)); const hi = Math.max(lo, Math.min(24, pf.fretMax)); return selected.flatMap(open => { const notes: number[] = []; for (let fret = lo; fret <= hi; fret++) notes.push(open + fret); return notes; }).sort((a, b) => a - b); } // Strings only: use their full range const maxFret = Math.max(...all.map(open => { let f = 0; while (open + f <= 127) f++; return Math.min(f - 1, 24); })); return selected.flatMap(open => { const notes: number[] = []; for (let fret = 0; fret <= maxFret; fret++) notes.push(open + fret); return notes; }).sort((a, b) => a - b); } return []; } if (phrase.fretWindowEnabled && fretted && currentTuning) { return fretWindowNotes(currentTuning, phrase.fretMin, phrase.fretMax); } return computePlayableNotes({ instrumentId: settings.instrument, difficulty: settings.difficulty, tuningId: settings.tuningId, customMinFret: settings.customMinFret, customMaxFret: settings.customMaxFret, }); }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]); // ---- Imported score (session-only) -------------------------------------- const [importedScore, setImportedScore] = useState(null); const [importError, setImportError] = useState(null); const [octaveShift, setOctaveShift] = useState(0); const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000)); const fileInputRef = useRef(null); const pendingAutoStart = useRef(false); const handleImportFile = useCallback((file: File) => { setImportError(null); if (file.size > 256 * 1024) { setImportError('File is larger than 256 KiB.'); return; } file.text().then(text => { const result = parseAbc(text); if (result.ok) { setImportedScore(result.value); setOctaveShift(0); setPhrase({ material: 'import', startBar: 1 }); } else { setImportError(result.error); } }).catch(() => setImportError('Could not read the file.')); }, [setPhrase]); // ---- Material score ---------------------------------------------------- const baseScore: Result | null = useMemo(() => { if (phrase.material === 'melody') { const result = generateMelody({ keyTonic: phrase.keyTonic, keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, bars: phrase.bars, meter: { numerator: phrase.meterNumerator, denominator: 4 }, rhythmLevel: phrase.rhythmLevel, seed: melodySeed, }, pool); return result.ok ? { ok: true as const, value: result.value.score } : result; } if (phrase.material === 'scale') { return generateScaleDrill({ keyTonic: phrase.keyTonic, keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, direction: phrase.scaleDirection, coverage: phrase.scaleCoverage, meter: { numerator: phrase.meterNumerator, denominator: 4 }, }, pool); } if (phrase.material === 'arpeggio') { return generateArpeggio({ keyTonic: phrase.keyTonic, keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', pattern: phrase.arpeggioPattern, coverage: phrase.arpeggioCoverage, meter: { numerator: phrase.meterNumerator, denominator: 4 }, bars: phrase.arpeggioBars, progression: phrase.arpeggioProgression, rhythm: phrase.arpeggioRhythm, seed: melodySeed, }, pool); } if (phrase.material === 'library') { const id = phrase.libraryId || EXERCISES[0]?.id; if (!id) return { ok: false, error: 'No exercises available.' }; return loadExercise(id); } if (phrase.material === 'import') { if (!importedScore) return { ok: false, error: 'No file imported yet — pick an .abc file in the setup panel.' }; return { ok: true, value: importedScore }; } return null; }, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.libraryId, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, melodySeed, melodySeed, pool, importedScore]); const fullScore: Result | null = useMemo(() => { if (!baseScore) return null; if (!baseScore.ok) return baseScore; if (octaveShift !== 0 && (phrase.material === 'library' || phrase.material === 'import')) { return { ok: true, value: shiftScoreOctaves(baseScore.value, octaveShift) }; } return baseScore; }, [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), }), [fixedMaterial, phrase.startBar, phrase.barCount, fullScore]); const trainer: PhraseTrainerApi | null = usePhraseTrainer( fullScore && fullScore.ok ? fullScore.value : EMPTY_SCORE, selection, { pace: phrase.pace, bpm: phrase.bpm, clickSound: phrase.clickSound, inputMode: phrase.inputMode, previewVolume: settings.autoPlayVolume ?? 0.4, autoStartOnNote: phrase.autoStartOnNote, }, listening, 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; // Range-conflict warning for fixed material. const rangeWarning = useMemo(() => { if (!scoreOk || !fixedMaterial) return null; const set = new Set(pool); const out = scoreEvents(fullScore!.value).filter(e => e.pitch && !set.has(e.pitch.midi)); return out.length > 0 ? `${out.length} of ${scoreEvents(fullScore!.value).length} notes are outside the current playable range — change the range, or use the octave control.` : null; }, [scoreOk, fixedMaterial, pool, fullScore]); const nextAction = useCallback(() => { if (phrase.material === 'melody' || (phrase.material === 'arpeggio' && phrase.arpeggioBars > 1)) { setMelodySeed(s => (s + 1) % 999983); } else if (fixedMaterial && scoreOk) { const total = fullScore!.value.measures.length; const nextStart = phrase.startBar + phrase.barCount; if (nextStart > total) { setPhrase({ startBar: 1 }); // back to beginning } else { setPhrase({ startBar: nextStart }); } } }, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]); // ---- Ref API ----------------------------------------------------------- useImperativeHandle(ref, () => ({ pauseToggle: () => trainer?.pauseToggle(), previewToggle: () => trainer?.previewToggle(), retry: () => trainer?.retry(), skip: () => trainer?.skip(), pause: () => trainer?.pause(), next: () => nextAction(), })); // ---- Hints -------------------------------------------------------------- const activeScore = trainer.activeScore; const events = scoreEvents(activeScore); const currentMidi = trainer.currentIdx >= 0 && events[trainer.currentIdx]?.pitch ? events[trainer.currentIdx]!.pitch!.midi : null; const hintPositions = useMemo(() => { if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return []; if (phrase.fretWindowEnabled) { return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax); } return getFretboardPositions(currentMidi, currentTuning); }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax]); const handleVirtualPlay = useCallback((midi: number) => { if (!settings.virtualGuitarMute) { playNote(midi, 0.5, settings.virtualGuitarVolume ?? 0.5); } trainer?.virtualTap(midi); }, [settings.virtualGuitarMute, settings.virtualGuitarVolume, playNote, trainer]); const [hoveredMidi, setHoveredMidi] = useState(null); // accepted by Fretboard/PianoKeys; phrase renderer has no hover preview void hoveredMidi; // Shared clef/transpose resolution (single-note mode uses the same). const { clef: activeClef, transpose: activeTranspose } = useMemo(() => resolveClefTranspose(currentInstrumentDef, settings.difficulty), [currentInstrumentDef, settings.difficulty]); // ---- Status line ---------------------------------------------------------- const statusText = useMemo(() => { if (!scoreOk) return materialError ?? 'No material.'; const p = trainer.phase; if (p === 'preview') return 'Listen …'; if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`; if (p === 'paused') return 'Paused'; if (p === 'done') { if (phrase.pace === 'tempo') return `Matched ${trainer.summary.matched} of ${trainer.summary.total} notes`; return `Completed ${trainer.summary.total} notes, skipped ${trainer.summary.skipped}`; } if (p === 'playing') { if (phrase.pace === 'tempo') return `${trainer.summary.matched} matched · ${trainer.summary.missed + trainer.summary.skipped} missed`; if (trainer.repeatedNoteBlocked) return 'Same note again — release, then strike it once more'; 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, trainer.repeatedNoteBlocked]); const startLabel = useMemo(() => { const p = trainer.phase; if (p === 'playing' || p === 'countIn') return 'Pause'; if (p === 'paused') return 'Resume'; if (p === 'preview') return 'Stop preview'; return 'Start'; }, [trainer.phase]); const startAction = useMemo(() => { if (trainer.phase === 'preview') return trainer.previewToggle; return trainer.pauseToggle; }, [trainer]); const staffWidth = Math.min(windowWidth - 40, 860); // Debug hook for real-browser QA (enable with localStorage.phraseDebug = '1') useEffect(() => { if (typeof window === 'undefined') return; const w = window as unknown as { __phraseDebug?: unknown }; if (localStorage.getItem('phraseDebug') === '1') { w.__phraseDebug = () => ({ frames: rawFrameCount(), audioNow: audioEngine.now(), scoreStart: trainer.scoreStart, spb: trainer.spb, micGate: audioEngine.isAudible(), micBlanked: audioEngine.isMicBlanked(), phase: trainer.phase, currentIdx: trainer.currentIdx, statuses: [...trainer.statuses], summary: trainer.summary, pool: [...pool], firstNote: events[0]?.pitch?.midi ?? null, scoreOk, material: phrase.material, midis: events.map(e => (e.pitch ? e.pitch.midi : null)), }); } else { delete w.__phraseDebug; } }); // ---- Render --------------------------------------------------------------- return (
{scoreOk ? fullScore!.value.title : 'Phrase Mode'} {fixedMaterial && scoreOk && ( {' '}— bars {selection.startBar}–{Math.min(selection.startBar + selection.barCount - 1, fullScore!.value.measures.length)} )}
{scoreOk ? ( ) : (
{materialError}
)}
{trainer.error &&
{trainer.error}
} {importError &&
{importError}
} {rangeWarning &&
{rangeWarning}
}
{trainer.phase === 'countIn' && ( {trainer.countInLeft} )}
{statusText}
{/* Transport — reuses the single-note button styles/behavior */}
{!settings.zenMode && ( <> {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( )} {phrase.pace === 'step' && ( )} )} {/* Same virtual-instrument / hint toggles as single-note mode */} {trainer.phase === 'done' && settings.zenMode && ( <> {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( )} )} {!settings.zenMode && currentInstrumentDef.showTuning && ( )} {!settings.zenMode && ( )}
{/* Setup panel */} {!settings.zenMode && (
Setup
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && ( Key/mode: use the "Target key" controls below the sheet — shared across modes. )} {phrase.material === 'melody' && ( <> )} {phrase.material === 'scale' && ( <> )} {phrase.material === 'arpeggio' && ( <> {phrase.arpeggioBars > 1 && ( )} )} {phrase.material === 'library' && ( )} {fixedMaterial && scoreOk && ( <> )} {phrase.material === 'import' && ( )} {fretted && ( Fret window: use the "Fret window" controls below the sheet — shared across modes. )} {phrase.pace === 'tempo' && ( Tempo (BPM): set it on the metronome in the tools below — shared across modes. )} {phrase.pace === 'tempo' && ( Clicks come from the run (with count-in); the metronome widget mirrors the beats. )} {phrase.pace === 'step' && ( )}
{phrase.pace === 'tempo' && (

The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.

)}

Matching checks pitch, not fingering — several fretboard positions produce the same pitch. For repeated identical notes, briefly release between notes so each attack is detected.

)} {/* Hint instruments */} {(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
{settings.showHint && currentMidi !== null && (
{getNoteDetails(currentMidi + activeTranspose).scientific}
)} {currentInstrumentDef.id === 'piano' ? ( ) : ( currentTuning && ( ) )}
)} {phrase.material === 'import' && importedScore && !importError && (

Imported “{importedScore.title}” — this stays in this browser tab only.

)}
); } ); const EMPTY_SCORE: Score = { version: 1, id: 'empty', title: '', meter: { numerator: 4, denominator: 4 }, key: { tonic: 'C', mode: 'major', signature: 'C' }, measures: [], voices: [{ id: 'melody', events: [] }], }; export default PhraseTrainer;