Files
sheetmusictrainer/src/components/PhraseTrainer.tsx
OpenClaw ffb2776c8c Giuliani studies as own material category; beam-before-draw fix
- NEW material 'giuliani': figures require a real voicing — the generator
  assigns cycle positions (bass root + upper chord tones) to DISTINCT
  strings with valid frets per bar; bars that cannot be voiced are skipped
  (deterministic), clear error when nothing fits. Patterns: p-i-m, p-i-m-a,
  p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m. Honors the chord pool, string
  restrictions and the tuning; one figure per beat.
- 6/8 meter added (numerator 6) across score model, settings, generators;
  meter selects now offer 4/4, 3/4, 6/8.
- Arpeggio category: Giuliani entries replaced with note-sequence
  broken-chord figures (1-3-2-1, 1-3-2-5(8), 1-5-3-5(8), 1-2-3-5-3) —
  pure pitch sequences, no voicing claims.
- Beam fix (root cause): setBeam now happens BEFORE voice.draw — VexFlow
  decides flag vs beam at draw time; registering after drawing left both
  flag and beam. Beams drawn after notes, flags suppressed correctly.
2026-09-15 02:44:52 +00:00

898 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from '../music/playableRange';
import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills';
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
import { generateGiulianiStudy, GIULIANI_PATTERN_LABELS, type GiulianiPattern } from '../music/giulianiGenerator';
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<PhraseHandle, PhraseTrainerProps>(
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;
// Pace is now DERIVED from the metronome widget's sync checkbox:
// checked = tempo (sync), unchecked = step (at your pace).
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
phrase.pace = pace;
// Click decision — ONE source: the metronome widget's sound checkbox.
// - Sync (tempo): run clicks when armed && sound (count-in incl.);
// widget silent while idle.
// - Free metronome: widget clicks freely; the run stays silent to
// avoid doubling (sync off in tempo = free widget during runs).
phrase.clickSound = pace === 'tempo'
? settings.rhythm.active && settings.rhythm.sound
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => {
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
// Mirror shared filter fields into the unified practice filter.
const shared: Partial<PracticeFilter> = {};
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, computed identically to
// the single-note modes (full 0–12 fretboard when no filter is
// active — the legacy difficulty ranges no longer apply).
if (fretted && currentTuning) {
const all = currentTuning.strings;
const selected = pf.strings.length > 0
? all.filter((_, i) => pf.strings.includes(i))
: all;
if (selected.length === 0) return [];
const lo = pf.fretWindowEnabled ? Math.max(0, Math.min(24, pf.fretMin)) : 0;
const hi = pf.fretWindowEnabled ? Math.max(lo, Math.min(24, pf.fretMax)) : 12;
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);
}
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<Score | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [octaveShift, setOctaveShift] = useState(0);
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
const fileInputRef = useRef<HTMLInputElement>(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<Score> | 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 },
rhythm: phrase.scaleRhythm,
}, pool);
}
if (phrase.material === 'giuliani') {
return generateGiulianiStudy({
keyTonic: phrase.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId,
pattern: phrase.giulianiPattern,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
tuningId: settings.tuningId,
bars: phrase.giulianiBars,
chordSelection: phrase.arpeggioChordSelection as string[],
seed: melodySeed,
allowedStrings: pf.strings,
}, 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,
chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[],
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.scaleRhythm, phrase.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm,
phrase.arpeggioChordSelection, melodySeed,
phrase.giulianiPattern, phrase.giulianiBars,
melodySeed, pool, importedScore]);
const fullScore: Result<Score> | 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<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;
// 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<number | null>(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 (
<div className={`card sheet-music-card phrase-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''}`}>
<div className="phrase-title-row">
<Music2 size={18} />
<span className="phrase-title">
{scoreOk ? fullScore!.value.title : 'Phrase Mode'}
{fixedMaterial && scoreOk && (
<span className="phrase-range">
{' '}— bars {selection.startBar}–{Math.min(selection.startBar + selection.barCount - 1, fullScore!.value.measures.length)}
</span>
)}
</span>
</div>
<div className="sheet-music-container">
{scoreOk ? (
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
</>
)}
{!settings.zenMode && currentInstrumentDef.showTuning && (
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
>
<Guitar size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button>
)}
{!settings.zenMode && (
<button
className={`hint-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
)}
</div>
{/* Setup panel */}
{!settings.zenMode && (
<details className="phrase-setup">
<summary><ChevronDown size={16} /> Setup</summary>
<div className="phrase-setup-grid">
<label>Material
<select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}>
<option value="melody">Melodies (generated)</option>
<option value="scale">Scale drills</option>
<option value="arpeggio">Arpeggios (generated)</option>
<option value="giuliani">Giuliani studies</option>
<option value="library">Exercises</option>
<option value="import">Imported file</option>
</select>
</label>
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Key/mode: use the "Target key" controls below the sheet — shared across modes.
</span>
)}
{phrase.material === 'melody' && (
<>
<label>Bars
<select value={phrase.bars} onChange={e => setPhrase({ bars: Number(e.target.value) })}>
{[1, 2, 3, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
<label>Rhythm
<select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Math.max(1, Math.min(3, Number(e.target.value))) as 1 | 2 | 3 })}>
<option value={1}>Simple</option>
<option value={2}>Mixed</option>
<option value={3}>Elaborate (16ths)</option>
</select>
</label>
</>
)}
{phrase.material === 'scale' && (
<>
<label>Coverage
<select value={phrase.scaleCoverage} onChange={e => setPhrase({ scaleCoverage: e.target.value as PhraseSettings['scaleCoverage'] })}>
<option value="one-octave">One octave</option>
<option value="two-octave">Two octaves</option>
<option value="position">Within position</option>
</select>
</label>
<label>Direction
<select value={phrase.scaleDirection} onChange={e => setPhrase({ scaleDirection: e.target.value as PhraseSettings['scaleDirection'] })}>
<option value="up">Up</option>
<option value="down">Down</option>
<option value="updown">Up &amp; down</option>
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
</>
)}
{phrase.material === 'arpeggio' && (
<>
<label>Pattern
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
<option value="up">Up</option>
<option value="down">Down</option>
<option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</option>
<optgroup label="Broken-chord figures">
<option value="1321">1-3-2-1</option>
<option value="1325">1-3-2-5(8)</option>
<option value="1535">1-5-3-5(8)</option>
<option value="12353">1-2-3-5-3</option>
</optgroup>
</select>
</label>
<label>Coverage
<select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}>
<option value="one-octave">One octave</option>
<option value="two-octave">Two octaves</option>
</select>
</label>
<label>Length
<select value={phrase.arpeggioBars} onChange={e => setPhrase({ arpeggioBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b === 1 ? 'Single chord' : `${b} bars`}</option>)}
</select>
</label>
{phrase.arpeggioBars > 1 && (
<label>Chords
<select value={phrase.arpeggioProgression} onChange={e => setPhrase({ arpeggioProgression: e.target.value as PhraseSettings['arpeggioProgression'] })}>
<option value="functional">Functional (I-IV-V...)</option>
<option value="diatonic-cycle">Diatonic cycle</option>
<option value="random">Random</option>
</select>
</label>
)}
<div style={{ gridColumn: '1 / -1' }}>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
Chords ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d);
return (
<button
key={d}
className={`control-button small ${active ? 'active' : ''}`}
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
onClick={() => {
const cur = phrase.arpeggioChordSelection.length === 0
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d];
setPhrase({ arpeggioChordSelection: next });
}}
>
{ARPEGGIO_DEGREE_LABELS[d]}
</button>
);
})}
</div>
</div>
<label>Rhythm
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}>
<option value="quarters">Quarter notes</option>
<option value="eighths">Eighth notes</option>
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
</>
)}
{phrase.material === 'giuliani' && (
<>
<label>Pattern
<select value={phrase.giulianiPattern} onChange={e => setPhrase({ giulianiPattern: e.target.value as PhraseSettings['giulianiPattern'] })}>
{(Object.keys(GIULIANI_PATTERN_LABELS) as GiulianiPattern[]).map(p => (
<option key={p} value={p}>{GIULIANI_PATTERN_LABELS[p]}</option>
))}
</select>
</label>
<label>Length
<select value={phrase.giulianiBars} onChange={e => setPhrase({ giulianiBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)}
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Chords: from the arpeggio chord pool; voicing enforced (bass + upper strings).
</span>
</>
)}
{phrase.material === 'library' && (
<label>Exercise
<select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}>
{EXERCISES.map(ex => (
<option key={ex.id} value={ex.id}>{ex.label} ({ex.level})</option>
))}
</select>
</label>
)}
{fixedMaterial && scoreOk && (
<>
<label>Start bar
<select value={phrase.startBar} onChange={e => setPhrase({ startBar: Number(e.target.value) })}>
{Array.from({ length: fullScore!.value.measures.length }, (_, i) => i + 1).map(b => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
<label>Bars at a time
<select value={phrase.barCount} onChange={e => setPhrase({ barCount: Number(e.target.value) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select>
</label>
<label>Transpose
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
<option value={-2}>-2 oct</option>
<option value={-1}>-1 oct</option>
<option value={0}>0</option>
<option value={1}>+1 oct</option>
<option value={2}>+2 oct</option>
</select>
</label>
</>
)}
{phrase.material === 'import' && (
<label>File
<button className="hint-button" onClick={() => fileInputRef.current?.click()}>
<Upload size={16} /> Choose .abc file
</button>
<input
ref={fileInputRef}
type="file"
accept=".abc,.txt"
style={{ display: 'none' }}
onChange={e => { const f = e.target.files?.[0]; if (f) handleImportFile(f); e.target.value = ''; }}
/>
</label>
)}
{fretted && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Fret window: use the "Fret window" controls below the sheet — shared across modes.
</span>
)}
{/* Pace lives in the metronome widget now:
'Sync to exercise' checked = tempo pace,
unchecked = at-your-pace (step). */}
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.inputMode === 'virtual'}
onChange={e => setPhrase({ inputMode: e.target.checked ? 'virtual' : 'mic' })}
/>
Virtual input only
</label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
BPM + click: on the metronome widget below.
</span>
)}
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.autoContinue}
onChange={e => setPhrase({ autoContinue: e.target.checked })}
/>
Auto-continue
</label>
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.autoStartOnNote}
onChange={e => setPhrase({ autoStartOnNote: e.target.checked })}
/>
Start on first note (no count-in)
</label>
</div>
{phrase.pace === 'tempo' && (
<p className="phrase-note">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.</p>
)}
<p className="phrase-note">
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.
</p>
</details>
)}
{/* Hint instruments */}
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
<div className="hint-card">
{settings.showHint && currentMidi !== null && (
<div className="hint-note landscape-hint-note">
{getNoteDetails(currentMidi + activeTranspose).scientific}
</div>
)}
{currentInstrumentDef.id === 'piano' ? (
<PianoKeys
minMidi={36}
maxMidi={84}
markedNotes={settings.showHint && currentMidi !== null ? [currentMidi] : []}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi}
/>
) : (
currentTuning && (
<Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi}
showHints={settings.showHint}
maxFrets={Math.max(15, phrase.fretWindowEnabled ? phrase.fretMax + 1 : 0)}
/>
)
)}
</div>
)}
{phrase.material === 'import' && importedScore && !importError && (
<p className="phrase-note">Imported “{importedScore.title}” — this stays in this browser tab only.</p>
)}
</div>
);
}
);
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;