diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx new file mode 100644 index 0000000..e131e3a --- /dev/null +++ b/src/components/PhraseSheetMusic.tsx @@ -0,0 +1,344 @@ +/** + * Multi-bar phrase renderer (contract F). + * + * Renders a Score (ties pre-merged) as one or more systems of 1–4 bars. + * - Preserves source spelling; accidental display is measure-aware per + * (step, octave), seeded from the key signature and reset at barlines. + * - Splits tie-merged events into tied glyphs at barlines (rendering only — + * statuses stay per logical event). + * - Colors: current note = --color-primary (same accent as the existing + * fretboard hints) + a caret annotation (non-color cue), matched = + * --color-success, missed = --color-error, skipped = muted, rest = text. + * - Re-renders on note transitions and structural changes only, never at + * microphone frame rate. + */ +import React, { useEffect, useRef, useState } from 'react'; +import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, StaveConnector, Beam, StaveTie, Annotation } from 'vexflow'; +import { scoreEvents, type Score } from '../score/model'; +import { STEP_LETTERS, type StepLetter } from '../score/model'; +import type { NoteStatus } from '../game/PhraseMatcher'; +import { keyFor, isMode } from '../music/scales'; + +interface PhraseSheetMusicProps { + score: Score; + /** Logical event index currently up (-1 = none). */ + currentIdx: number; + statuses: NoteStatus[]; + clef: 'treble' | 'bass' | 'grand'; + /** Display transpose applied ONCE, at render time (guitar +12 etc.). */ + transpose: number; + width: number; + theme?: 'light' | 'dark' | 'auto'; +} + +interface Fragment { + note: StaveNote; + eventIdx: number; + /** Absolute tick of this fragment's start. */ + start: number; + /** Fragment duration in ticks. */ + ticks: number; + isAttack: boolean; + isEighth: boolean; + staveKey: 'treble' | 'bass'; + rest: boolean; +} + +// Duration ticks → VexFlow duration code (subset the model guarantees). +function durationCode(ticks: number): string | null { + switch (ticks) { + case 240: return '8'; + case 480: return '4'; + case 720: return '4d'; + case 960: return '2'; + case 1440: return '2d'; + case 1920: return 'w'; + default: return null; + } +} + +function accidentalGlyph(alter: number): string | undefined { + switch (alter) { + case 1: return '#'; + case -1: return 'b'; + case 2: return '##'; + case -2: return 'bb'; + default: return undefined; + } +} + +function keyString(step: StepLetter, alter: number, octave: number): string { + const alterStr = alter === 1 ? '#' : alter === -1 ? 'b' : alter === 2 ? '##' : alter === -2 ? 'bb' : ''; + return `${step.toLowerCase()}${alterStr}/${octave}`; +} + +function signatureAlterMap(score: Score): Record { + const map: Record = {}; + for (const s of STEP_LETTERS) map[s] = 0; + const mode = isMode(score.key.mode) ? score.key.mode : 'major'; + const key = keyFor(score.key.tonic, mode); + for (const [step, alter] of Object.entries(key.signatureAlter)) map[step] = alter; + return map; +} + +export const PhraseSheetMusic: React.FC = ({ + score, + currentIdx, + statuses, + clef, + transpose, + width, + 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); + }, []); + + const events = scoreEvents(score); + const totalBars = score.measures.length; + const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / 210))); + const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow)); + const isGrand = clef === 'grand'; + const rowHeight = isGrand ? 200 : 105; + const height = rowCount * rowHeight + 14; + + useEffect(() => { + const container = containerRef.current; + if (!container || events.length === 0) return; + + container.innerHTML = ''; + const renderer = new Renderer(container, Renderer.Backends.SVG); + renderer.resize(width, height); + const context = renderer.getContext(); + + const css = getComputedStyle(document.documentElement); + const resolve = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback; + const textColor = resolve('--color-text-main', '#333333'); + const currentColor = resolve('--color-primary', '#e63946'); + const successColor = resolve('--color-success', '#22c55e'); + const errorColor = resolve('--color-error', '#ef4444'); + const mutedColor = resolve('--color-text-muted', '#888888'); + + context.setFillStyle(textColor); + context.setStrokeStyle(textColor); + const ledgerStyle = { strokeStyle: textColor, lineWidth: 2 }; + const sigAlter = signatureAlterMap(score); + const mode = isMode(score.key.mode) ? score.key.mode : 'major'; + let vexKeySpec = 'C'; + try { vexKeySpec = keyFor(score.key.tonic, mode).signature; } catch { /* default */ } + + const pad = 10; + const usable = width - 2 * pad; + const barWidth = usable / Math.min(barsPerRow, Math.max(1, totalBars)); + + // Per-row drawing data, built first so ties can connect across bars. + const rowFragmentList: Fragment[][] = []; + + for (let m = 0; m < totalBars; m++) { + const rowIdx = Math.floor(m / barsPerRow); + while (rowFragmentList.length <= rowIdx) rowFragmentList.push([]); + const measure = score.measures[m]; + const measureStart = measure.startTick; + const measureEnd = measureStart + measure.durationTicks; + const measureState: Record = {}; + + for (let e = 0; e < events.length; e++) { + const ev = events[e]; + const evStart = ev.startTick; + const evEnd = evStart + ev.durationTicks; + if (evEnd <= measureStart || evStart >= measureEnd) continue; + + let cursor = Math.max(evStart, measureStart); + while (cursor < Math.min(evEnd, measureEnd)) { + const fragEnd = Math.min(evEnd, measureEnd); + const ticks = fragEnd - cursor; + const code = durationCode(ticks); + if (!code) { cursor = fragEnd; continue; } + + const rest = ev.pitch === null; + const staveKey: 'treble' | 'bass' = rest + ? (isGrand ? 'treble' : (clef as 'treble' | 'bass')) + : (isGrand ? (ev.pitch!.midi + transpose >= 60 ? 'treble' : 'bass') : (clef as 'treble' | 'bass')); + const isAttack = cursor === evStart; + + let note: StaveNote; + if (rest) { + note = new StaveNote({ keys: ['b/4'], duration: `${code}r`, clef: staveKey }); + } else { + const p = ev.pitch!; + const stateKey = `${p.step}${p.octave}`; + const expected = measureState[stateKey] ?? sigAlter[p.step]; + const glyph = p.alter !== expected ? accidentalGlyph(p.alter) : undefined; + measureState[stateKey] = p.alter; + note = new StaveNote({ + keys: [keyString(p.step, p.alter, p.octave)], + duration: code, + clef: staveKey, + }); + if (glyph) note.addModifier(new Accidental(glyph)); + } + + const status = statuses[e] ?? 'pending'; + const isCurrent = e === currentIdx; + let color = textColor; + if (isCurrent) color = currentColor; + else if (status === 'matched') color = successColor; + else if (status === 'missed') color = errorColor; + else if (status === 'skipped') color = mutedColor; + + note.setStyle({ fillStyle: color, strokeStyle: color }); + if (isCurrent && !rest) { + try { + note.addModifier( + new Annotation('▲') + .setVerticalJustification(Annotation.VerticalJustify.TOP) + .setStyle({ fillStyle: color, strokeStyle: color }), + ); + } catch { + // Caret is a courtesy cue; color still marks the note. + } + } + + rowFragmentList[rowIdx].push({ + note, + eventIdx: e, + start: cursor, + ticks, + isAttack, + isEighth: code === '8' && !rest, + staveKey, + rest, + }); + cursor = fragEnd; + } + } + } + + // ---- Staves, voices, formatting, drawing ----------------------------- + for (let r = 0; r < rowFragmentList.length; r++) { + const y = r * rowHeight + 10; + const rowMeasureNumbers: number[] = []; + for (let b = 0; b < barsPerRow; b++) { + const m = r * barsPerRow + b; + if (m < totalBars) rowMeasureNumbers.push(m); + } + + for (const m of rowMeasureNumbers) { + const b = m % barsPerRow; + const measure = score.measures[m]; + const measureStart = measure.startTick; + const measureEnd = measureStart + measure.durationTicks; + const x = pad + b * barWidth; + const isFirstOfRow = b === 0; + const frags = rowFragmentList[r].filter(f => f.start >= measureStart && f.start < measureEnd); + + // --- Staves --- + const treble = new Stave(x, y, barWidth); + treble.setDefaultLedgerLineStyle(ledgerStyle); + if (isFirstOfRow) { + treble.addClef('treble').addKeySignature(vexKeySpec); + if (r === 0) treble.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`); + treble.setMeasure(measure.number); + } + let bass: Stave | null = null; + if (isGrand) { + bass = new Stave(x, y + 110, barWidth); + bass.setDefaultLedgerLineStyle(ledgerStyle); + if (isFirstOfRow) { + bass.addClef('bass').addKeySignature(vexKeySpec); + if (r === 0) bass.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`); + } + } + + // --- Voices (grand: split per stave with ghost placeholders) --- + const buildVoice = (stave: Stave, sel: (f: Fragment) => boolean): { voice: Voice; stave: Stave } => { + const voice = new Voice({ numBeats: score.meter.numerator, beatValue: 4 }); + voice.setMode(Voice.Mode.SOFT); // pickups/closers are validated in the model + const all = frags.filter(sel).sort((a, b2) => a.start - b2.start); + let cursor = measureStart; + for (const f of all) { + if (f.start > cursor) { + const gapCode = durationCode(f.start - cursor); + if (gapCode) voice.addTickable(new GhostNote({ duration: gapCode })); + } + voice.addTickable(f.note); + cursor = f.start + f.ticks; + } + if (measureEnd > cursor) { + const tailCode = durationCode(measureEnd - cursor); + if (tailCode) voice.addTickable(new GhostNote({ duration: tailCode })); + } + return { voice, stave }; + }; + + const voices: { voice: Voice; stave: Stave }[] = []; + if (isGrand) { + voices.push(buildVoice(treble, f => f.staveKey === 'treble')); + voices.push(buildVoice(bass!, f => f.staveKey === 'bass')); + } else { + voices.push(buildVoice(treble, () => true)); + } + + const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 92 : 16)); + const formatter = new Formatter(); + formatter.joinVoices(voices.map(v => v.voice)); + formatter.format(voices.map(v => v.voice), formatWidth); + + treble.setContext(context).draw(); + if (bass) bass.setContext(context).draw(); + for (const { voice, stave } of voices) voice.draw(context, stave); + + if (isGrand && isFirstOfRow) { + const brace = new StaveConnector(treble, bass!); + brace.setType(StaveConnector.type.BRACE); + brace.setContext(context).draw(); + const line = new StaveConnector(treble, bass!); + line.setType(StaveConnector.type.SINGLE_LEFT); + line.setContext(context).draw(); + } + + // --- Beams: consecutive eighth pairs within one beat --- + const eighths = frags.filter(f => f.isEighth && f.note instanceof StaveNote); + for (let i = 0; i + 1 < eighths.length; i++) { + const a = eighths[i], b2 = eighths[i + 1]; + if (a.staveKey !== b2.staveKey) continue; + if (b2.start - a.start !== 240) continue; + const beatA = Math.floor((a.start - measureStart) / 480); + const beatB = Math.floor((b2.start - measureStart) / 480); + if (beatA === beatB) { + const beam = new Beam([a.note, b2.note], false); + beam.setContext(context).draw(); + } + } + } + + // --- Ties between consecutive fragments of one event (this row) --- + const rowFrags = [...rowFragmentList[r]].sort((a, b) => a.start - b.start); + for (let i = 0; i + 1 < rowFrags.length; i++) { + const a = rowFrags[i], b = rowFrags[i + 1]; + if (a.eventIdx === b.eventIdx && a.staveKey === b.staveKey && !a.rest && !b.rest) { + const tie = new StaveTie({ firstNote: a.note, lastNote: b.note }); + tie.setContext(context).draw(); + } + } + } + }, [score, currentIdx, statuses, clef, transpose, width, theme, schemeVersion, events, barsPerRow, rowCount, rowHeight, height, isGrand, totalBars]); + + return ( +
+ ); +}; + +export default PhraseSheetMusic; diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx new file mode 100644 index 0000000..4b85328 --- /dev/null +++ b/src/components/PhraseTrainer.tsx @@ -0,0 +1,618 @@ +/** + * 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, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useSettings } from '../context/useSettings'; +import { useAudioPlayer } from '../hooks/useAudioPlayer'; +import { usePhraseTrainer, type PhraseTrainerApi } from '../hooks/usePhraseTrainer'; +import { getPhraseSettings, type PhraseSettings } from '../types/SettingsTypes'; +import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange'; +import { generateMelody } from '../music/melodyGenerator'; +import { generateScaleDrill } from '../music/scaleDrills'; +import { isMode, MODE_LABELS, TONICS, type ModeId } from '../music/scales'; +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 } from '../music/InstrumentConfigs'; +import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload } 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 { playNote } = useAudioPlayer(); + const phrase = getPhraseSettings(settings); + const setPhrase = useCallback((updates: Partial) => { + updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); + }, [updateSettings]); + + const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; + const currentTuning = TUNINGS[settings.tuningId]; + const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning; + + // ---- Playable pool ---------------------------------------------------- + const pool = useMemo(() => { + 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, + }); + }, [phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, fretted, currentTuning, + 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 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 === '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, + 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 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, + repeat: phrase.repeat, + inputMode: phrase.inputMode, + previewVolume: settings.autoPlayVolume ?? 0.4, + }, + listening, + micError, + ); + + const materialError = fullScore && !fullScore.ok ? fullScore.error : null; + const scoreOk = !!fullScore && fullScore.ok; + + // 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') { + 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; + + // Current range def → clef/transpose (same resolution as single-note mode). + const currentRangeDef = useMemo(() => + currentInstrumentDef.ranges.find(r => r.id === settings.difficulty), + [currentInstrumentDef, settings.difficulty]); + const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode; + const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose; + + // ---- 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`; + 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]); + + 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); + + // ---- 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}
} + +
+
{statusText}
+
+ + {/* Transport */} +
+ + + + {(phrase.material === 'melody' || fixedMaterial) && ( + + )} + {phrase.pace === 'step' && ( + + )} +
+ + {/* Setup panel */} + {!settings.zenMode && ( +
+ Setup +
+ + + {(phrase.material === 'melody' || phrase.material === 'scale') && ( + <> + + + + )} + + {phrase.material === 'melody' && ( + <> + + + + + )} + + {phrase.material === 'scale' && ( + <> + + + + + )} + + {phrase.material === 'library' && ( + + )} + + {fixedMaterial && scoreOk && ( + <> + + + + + )} + + {phrase.material === 'import' && ( + + )} + + {fretted && ( + <> + + {phrase.fretWindowEnabled && ( + <> + + + + )} + + )} + + + {phrase.pace === 'tempo' && ( + + )} + + {phrase.pace === 'tempo' && ( + + )} + {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.

+ )} +

+ 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; diff --git a/src/components/SingleNoteTrainer.tsx b/src/components/SingleNoteTrainer.tsx new file mode 100644 index 0000000..5e6696d --- /dev/null +++ b/src/components/SingleNoteTrainer.tsx @@ -0,0 +1,192 @@ +/** + * The original single-note experience (Sight Reading / Ear Training), + * extracted from App so its game-logic effects unmount entirely while + * Phrase Mode is active (contract: "no background scoring after leaving + * the mode" — hiding the UI alone would keep useGameLogic running). + */ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import { SheetMusic } from './SheetMusic'; +import { useSettings } from '../context/useSettings'; +import { useAudioPlayer } from '../hooks/useAudioPlayer'; +import { useGameLogic } from '../hooks/useGameLogic'; +import { getNoteDetails } from '../music/NoteUtils'; +import { TUNINGS, getFretboardPositions } from '../music/Tunings'; +import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; +import { Fretboard } from './Fretboard'; +import { PianoKeys } from './PianoKeys'; +import { audioEngine } from '../audio/AudioEngine'; +import { Guitar, HelpCircle, Volume2, SkipForward } from 'lucide-react'; + +export interface SingleNoteHandle { + skipNote: () => void; + playCurrent: () => void; +} + +interface SingleNoteTrainerProps { + /** Raw detection (gating for display happens inside). */ + pitchData: { midi: number; note: string; cents: number; frequency: number } | null; + windowWidth: number; + windowHeight: number; + onSetHoveredMidi?: (midi: number | null) => void; +} + +export const SingleNoteTrainer = forwardRef( + function SingleNoteTrainer({ pitchData, windowWidth, windowHeight, onSetHoveredMidi }, ref) { + const { settings, updateSettings } = useSettings(); + const setSettings = updateSettings; + const { playNote } = useAudioPlayer(); + + const currentTuning = TUNINGS[settings.tuningId]; + const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; + + const { + targetMidi, + feedbackMessage, + revealed, + virtualNote, + generateNewNote, + handleVirtualInstrumentPlay + } = useGameLogic(pitchData); + + useImperativeHandle(ref, () => ({ + skipNote: () => generateNewNote(), + playCurrent: () => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5), + }), [generateNewNote, playNote, targetMidi, settings.referenceNoteDuration, settings.autoPlayVolume]); + + const [hoveredMidi, setHoveredMidi] = useState(null); + useEffect(() => { onSetHoveredMidi?.(hoveredMidi); }, [hoveredMidi, onSetHoveredMidi]); + + // Mic feedback is gated while the app's speaker output is audible. + const displayedPitch = audioEngine.isAudible() ? null : pitchData; + + const currentRangeDef = useMemo(() => + currentInstrumentDef.ranges.find(r => r.id === settings.difficulty), + [currentInstrumentDef, settings.difficulty]); + const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode; + const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose; + + const hintPositions = useMemo(() => { + if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning) return []; + return getFretboardPositions(targetMidi, currentTuning); + }, [settings.showHint, targetMidi, currentInstrumentDef, currentTuning]); + + return ( +
+
+ +
+ + {!settings.zenMode && ( +
+ {feedbackMessage ? ( +
+ {feedbackMessage.startsWith("Good! ") ? ( + <> +
Good!
+
{feedbackMessage.replace("Good! ", "")}
+ + ) : ( + feedbackMessage + )} +
+ ) : ( +
+ {settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"} +
+ )} +
+ )} + + {(settings.showHint || settings.showFretboard) && ( +
+ {settings.showHint && ( +
+ {getNoteDetails(targetMidi + activeTranspose).scientific} +
+ )} + {currentInstrumentDef.showTuning && ( + currentInstrumentDef.id === 'piano' ? ( + + ) : ( + currentTuning && ( + + ) + ) + )} +
+ )} + + {!settings.zenMode && ( +
+ {currentInstrumentDef.showTuning && ( + + )} + + + +
+ )} +
+ ); + } +); + +export default SingleNoteTrainer; diff --git a/src/exercises/abcParser.test.ts b/src/exercises/abcParser.test.ts new file mode 100644 index 0000000..1fa4e3b --- /dev/null +++ b/src/exercises/abcParser.test.ts @@ -0,0 +1,427 @@ +import { describe, it, expect } from 'vitest'; +import { parseAbc, type AbcParseOptions } from './abcParser'; +import { PPQ, validateScore, scoreEvents, scoreFitsPool, type Score } from '../score/model'; + +/** The §G concert-pitch fixture. */ +const FIRST_STEPS = [ + 'X:1', + 'T:First steps', + 'C:Original exercise', + 'M:4/4', + 'L:1/4', + 'Q:1/4=60', + 'K:C', + 'C D E F | G2 E C |]', +].join('\n'); + +/** Full header block (M: 4/4, L: 1/4, K: C by default); body starts at line 3. */ +const HEADERS = 'M:4/4\nL:1/4\nK:C'; +const tune = (body: string, headers: string = HEADERS) => `X:1\n${headers}\n${body}`; +const tuneWithKey = (key: string) => tune('C D E F |', `M:4/4\nL:1/4\nK:${key}`); + +function mustParse(text: string, options?: AbcParseOptions): Score { + const result = parseAbc(text, options); + if (!result.ok) throw new Error(`expected successful parse, got: ${result.error}`); + return result.value; +} + +function mustFail(text: string, pattern: RegExp, options?: AbcParseOptions): void { + const result = parseAbc(text, options); + if (result.ok) { + throw new Error(`expected parse failure matching ${pattern}, but parsing succeeded`); + } + expect(result.error).toMatch(pattern); +} + +const midis = (score: Score) => scoreEvents(score).map(e => e.pitch?.midi ?? null); +const durations = (score: Score) => scoreEvents(score).map(e => e.durationTicks); + +describe('parseAbc — §G concert-pitch fixture', () => { + it('parses the fixture with the exact expected pitches and ticks', () => { + const score = mustParse(FIRST_STEPS); + expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]); + expect(durations(score)).toEqual([480, 480, 480, 480, 960, 480, 480]); + expect(score.measures).toHaveLength(2); + expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]); + expect(validateScore(score)).toEqual([]); + }); + + it('carries full metadata from the headers', () => { + const score = mustParse(FIRST_STEPS); + expect(score.version).toBe(1); + expect(score.id).toBe('1'); + expect(score.title).toBe('First steps'); + expect(score.meter).toEqual({ numerator: 4, denominator: 4 }); + expect(score.key).toEqual({ tonic: 'C', mode: 'major', signature: 'C' }); + expect(score.voices).toHaveLength(1); + expect(score.voices[0].id).toBe('melody'); + expect(score.source?.composer).toBe('Original exercise'); + expect(score.source?.rights).toContain('CC0'); + expect(score.source?.rights).toContain('Original exercise'); + expect(score.source?.writtenToSoundingSemitones).toBe(0); + }); + + it('fits the pitch pool of its own notes', () => { + const score = mustParse(FIRST_STEPS); + expect(scoreFitsPool(score, [60, 62, 64, 65, 67])).toBe(true); + expect(scoreFitsPool(score, [60, 62, 64])).toBe(false); + }); +}); + +describe('parseAbc — headers', () => { + it('defaults L to 1/8 when absent', () => { + const score = mustParse('X:1\nM:4/4\nK:C\nC D E F G A B c | C D E F G A B c |'); + expect(durations(score)).toEqual(Array(16).fill(240)); + expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]); + }); + + it('ignores unknown headers, comments and blank lines silently', () => { + const text = [ + 'X:1', + '% a full-line comment', + 'Y:unknown header ignored', + 'w:lyrics line ignored', + '', + 'M:4/4', + 'L:1/4', + 'K:C', + 'C D E F | G2 E C | % trailing comment', + ].join('\n'); + const score = mustParse(text); + expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]); + }); + + it('uses the Q: header as metadata only (ignored)', () => { + const score = mustParse(`${FIRST_STEPS}\n`); + expect(score).toBeDefined(); // Q: did not break parsing (covered by fixture) + }); + + it('requires M: and K: before the body', () => { + mustFail('X:1\nK:C\nC D E F |', /M:/); + mustFail('X:1\nM:4/4\nC D E F |', /K:/); + }); + + it('rejects unsupported meters and unit lengths', () => { + mustFail(tune('C D E F |', 'M:6/8\nL:1/4\nK:C'), /6\/8|meter/i); + mustFail(tune('C D E F |', 'M:C\nL:1/4\nK:C'), /meter/i); + mustFail(tune('C D E F |', 'M:4/4\nL:1/16\nK:C'), /unit|1\/16/i); + }); + + it('rejects duplicate M:/K: (no changes mid-tune) and a second X:', () => { + mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nM:3/4\nC D E |', /M:/); + mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nK:G\nC D E F |', /K:/); + mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nX:2', /multiple tunes|X:/i); + }); +}); + +describe('parseAbc — key signatures', () => { + it('resolves tonic, mode and parent signature', () => { + expect(mustParse(tuneWithKey('Am')).key).toEqual({ tonic: 'A', mode: 'minor', signature: 'C' }); + expect(mustParse(tuneWithKey('Ddor')).key).toEqual({ tonic: 'D', mode: 'dorian', signature: 'C' }); + expect(mustParse(tuneWithKey('Gmix')).key).toEqual({ tonic: 'G', mode: 'mixolydian', signature: 'C' }); + expect(mustParse(tuneWithKey('Eb')).key).toEqual({ tonic: 'Eb', mode: 'major', signature: 'Eb' }); + expect(mustParse(tuneWithKey('F#')).key).toEqual({ tonic: 'F#', mode: 'major', signature: 'F#' }); + expect(mustParse(tuneWithKey('Bb')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' }); + expect(mustParse(tuneWithKey('_B')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' }); + expect(mustParse(tuneWithKey('Gmaj')).key.mode).toBe('major'); + expect(mustParse(tuneWithKey('Amin')).key.mode).toBe('minor'); + expect(mustParse(tuneWithKey('Eaeo')).key.mode).toBe('minor'); + }); + + it('rejects unknown keys and never falls back to C', () => { + mustFail(tuneWithKey('H'), /unrecognized key/i); + mustFail(tuneWithKey('Fb'), /unrecognized key/i); + mustFail(tuneWithKey(''), /unrecognized key/i); + mustFail(tuneWithKey('Gbmin7'), /unrecognized key/i); + mustFail(tuneWithKey('C##'), /unrecognized key/i); + }); +}); + +describe('parseAbc — notes, octaves, accidentals', () => { + it('applies ABC octave conventions (C = C4 = 60)', () => { + const score = mustParse(tune('C,, C, C c | c\' c\'\' C c |')); + expect(midis(score)).toEqual([36, 48, 60, 72, 84, 96, 60, 72]); + expect(scoreEvents(score)[2].pitch?.step).toBe('C'); + expect(scoreEvents(score)[2].pitch?.octave).toBe(4); + }); + + it('carries accidentals per (letter, octave) within a measure, seeded from the key', () => { + // §F example: K:G, L:1/4 — signature sharpens F; "=F" naturals it, carry + // keeps F natural, "^F" sharpens it again; next bar starts sharp. + const score = mustParse('X:1\nM:4/4\nL:1/4\nK:G\n=F F ^F F | F4 |'); + expect(midis(score)).toEqual([65, 65, 66, 66, 66]); + expect(validateScore(score)).toEqual([]); + }); + + it('keeps accidentals letter- and octave-specific within the measure', () => { + // ^f is F5 (78); c is C5 (72) — the sharp must not affect the C + const score = mustParse(tune('^f c ^f c |')); + expect(midis(score)).toEqual([78, 72, 78, 72]); + }); + + it('supports doubled accidentals (^^ / __) per §G', () => { + // Note: the task's error-test bullet lists "^^F" as an error, but §G + // explicitly requires doubled accidentals; spec wins, "^^F" is alter +2. + const sharp = mustParse(tune('^^F F F F |')); + expect(sharp.voices[0].events[0].pitch?.alter).toBe(2); + expect(midis(sharp)).toEqual([67, 67, 67, 67]); + const flat = mustParse(tune('__B B B B |')); + expect(flat.voices[0].events[0].pitch?.alter).toBe(-2); + expect(midis(flat)).toEqual([69, 69, 69, 69]); + }); + + it('rejects triple and conflicting accidental runs', () => { + mustFail(tune('^^^F C D E |'), /unsupported accidental/i); + mustFail(tune('^_F C D E |'), /conflicting accidentals/i); + mustFail(tune('=^F C D E |'), /conflicting accidentals/i); + }); + + it('rejects stray accidentals and accidentals on rests', () => { + mustFail(tune('^ C D E F |'), /stray accidental/i); + mustFail(tune('C D E ^z |'), /rest/i); + }); +}); + +describe('parseAbc — durations', () => { + it('supports the exact allowed resulting durations with L:1/4', () => { + // eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440, + // whole 1920 — every measure exactly full (4/4 = 1920 ticks) + const score = mustParse(tune('A/2 A A3/2 A | A2 A A | A3 A | A4 |')); + expect(durations(score)).toEqual([240, 480, 720, 480, 960, 480, 480, 1440, 480, 1920]); + expect(validateScore(score)).toEqual([]); + }); + + it('writes dotted quarters with L:1/8 via multiplier 3 ("A3" = 720)', () => { + const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nA3 A A A A A |'); + expect(durations(score)).toEqual([720, 240, 240, 240, 240, 240]); + }); + + it('rejects resulting durations outside the supported table', () => { + // "A3/2" with L:1/8 = 360 ticks (dotted eighth) — not in the supported + // table; the §G example calling it a "dotted quarter" is inconsistent + // with the "multiplier × L ticks" rule, which is authoritative here. + mustFail('X:1\nM:4/4\nL:1/8\nK:C\nA3/2 A A A A A A |', /360|unsupported duration/i); + mustFail(tune('C/4 C D E F |'), /120|unsupported duration/i); + mustFail(tune('A6 A B C D |'), /2880|unsupported duration/i); + mustFail(tune('A0 C D E F |'), /zero duration/i); + }); + + it('supports rests z with the same duration rules', () => { + const score = mustParse(tune('C z D z |')); + expect(midis(score)).toEqual([60, null, 62, null]); + expect(durations(score)).toEqual([480, 480, 480, 480]); + expect(validateScore(score)).toEqual([]); + }); +}); + +describe('parseAbc — ties', () => { + it('merges a tie across a barline into ONE logical event', () => { + // §G: "one eight-quarter-note logical event" (each C4 = 4 quarters, + // sum = 8 quarters = 3840 ticks; the task bullet's "1920" contradicts + // the merge-sum rule and the contract text). + const score = mustParse(tune('C4- | C4 |')); + expect(scoreEvents(score)).toHaveLength(1); + expect(scoreEvents(score)[0].durationTicks).toBe(8 * PPQ); + expect(scoreEvents(score)[0].startTick).toBe(0); + expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]); + expect(validateScore(score)).toEqual([]); + }); + + it('merges a tie chain inside one measure', () => { + const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nC2- C2- C4 |'); + expect(scoreEvents(score)).toHaveLength(1); + expect(scoreEvents(score)[0].durationTicks).toBe(4 * PPQ); + expect(validateScore(score)).toEqual([]); + }); + + it('rejects dangling ties and ties to rests', () => { + mustFail(tune('C D E F- |'), /dangling tie|no note follows/i); + mustFail(tune('C4-'), /dangling tie|no note follows/i); + mustFail(tune('C2 D2- | z2 F2 |'), /tie must connect two notes|rest/i); + }); + + it('rejects ties between different sounding pitches', () => { + mustFail(tune('C2 D2- | E2 F2 |'), /different sounding pitches/i); + mustFail(tune('F4- | ^F4 |'), /different sounding pitches/i); + }); + + it('rejects a doubled tie marker', () => { + mustFail(tune('C2- -C2 D2 E2 |'), /already tied|unexpected "-"/i); + }); +}); + +describe('parseAbc — repeats', () => { + it('expands |: ... :| so the segment sounds twice', () => { + const score = mustParse(tune('C D E F |: G A B c :| C D E F |')); + expect(midis(score)).toEqual([ + 60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72, 60, 62, 64, 65, + ]); + expect(score.measures).toHaveLength(4); + expect(validateScore(score)).toEqual([]); + }); + + it('repeats from the beginning of the body for an unmatched ":|"', () => { + const score = mustParse(tune('C D E F :|')); + expect(midis(score)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]); + expect(score.measures).toHaveLength(2); + }); + + it('treats an unclosed "|:" as repeating to the end', () => { + const score = mustParse(tune('C D E F |: G A B c |')); + expect(midis(score)).toEqual([60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72]); + expect(score.measures).toHaveLength(3); + }); + + it('rejects nested repeats and alternate endings', () => { + mustFail(tune('C D E F |: G A B c |: C D E F :|'), /nested repeats/i); + mustFail(tune('C D E F |1 G A B c :|2 C D E F |'), /alternate endings/i); + }); + + it('supports ||, |] and :|]', () => { + const a = mustParse(tune('C D E F || G A B c |]')); + expect(a.measures).toHaveLength(2); + // ":|]": repeat close + final barline; unmatched close repeats from start + const b = mustParse(tune('C D E F :|]')); + expect(b.measures).toHaveLength(2); + expect(midis(b)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]); + }); +}); + +describe('parseAbc — pickups and measure validation', () => { + it('accepts a pickup complemented by the final measure', () => { + const score = mustParse(tune('G | C D E F | G3 |')); + expect(score.measures).toHaveLength(3); + expect(score.measures.map(m => m.durationTicks)).toEqual([480, 4 * PPQ, 3 * PPQ]); + expect(midis(score)).toEqual([67, 60, 62, 64, 65, 67]); // "G3" = dotted half G4 + expect(validateScore(score)).toEqual([]); + }); + + it('rejects short middle measures with the measure number', () => { + mustFail(tune('C D E F | C D E | C D E F |'), /measure 2/); + }); + + it('rejects overfull measures', () => { + mustFail(tune('C D E F G | C D E F |'), /overfull|measure 1/); + }); + + it('rejects a short final measure without a pickup', () => { + mustFail(tune('C D E F | G3 |'), /short|measure 2/); + }); + + it('rejects a single short measure and a pickup without complement', () => { + mustFail(tune('C D E |'), /measure 1 is short/); + mustFail(tune('G | C D E F | G A B C |'), /not complemented/i); + }); + + it('supports 3/4 meter end to end', () => { + const score = mustParse('X:1\nM:3/4\nL:1/4\nK:C\nC D E | C D E |'); + expect(score.meter.numerator).toBe(3); + expect(score.measures.map(m => m.durationTicks)).toEqual([3 * PPQ, 3 * PPQ]); + expect(validateScore(score)).toEqual([]); + }); +}); + +describe('parseAbc — rejections of unsupported syntax', () => { + it('rejects chords, inline fields, tuplets, grace notes, decorations, lyrics, overlays', () => { + mustFail(tune('[CEG] C D E F |'), /chord|inline field/i); + mustFail(tune('[K:G] C D E F |'), /chord|inline field/i); + mustFail(tune('(3 C D E F |'), /tuplet/i); + mustFail(tune('{g} C D E F |'), /grace/i); + mustFail(tune('!trill! C D E F |'), /decoration/i); + mustFail(tune('+accent+ C D E F |'), /decoration/i); + mustFail(tune('C D "words" E F |'), /lyrics/i); + mustFail(tune('C & D E F G |'), /overlay|unsupported/i); + }); + + it('rejects unsupported characters and stray tokens', () => { + mustFail(tune('C D E H |'), /unsupported note letter/i); + mustFail(tune('C D E Z |'), /unsupported note letter/i); + mustFail(tune('| 3 C D E F |'), /unsupported/i); + mustFail(tune('C2, D E F |'), /unsupported/i); + }); + + it('reports line and column for body errors', () => { + const result = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\n[CEG] C D E F |'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/\(line 5, column 1\)/); + expect(result.error).toMatch(/chord|inline field/i); + } + const result2 = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\nC D E F | G [CEG] |\n'); + expect(result2.ok).toBe(false); + if (!result2.ok) { + // '[' at column 13 of line 5 + expect(result2.error).toMatch(/\(line 5, column 13\)/); + } + }); +}); + +describe('parseAbc — bounds', () => { + it('rejects oversized input before parsing', () => { + mustFail('C '.repeat(200 * 1024), /too large|256/i); + }); + + it('rejects more than 256 measures', () => { + mustFail(`X:1\nM:4/4\nL:1/4\nK:C\n${'C D E F | '.repeat(300)}`, /too many measures/i); + }); +}); + +describe('parseAbc — writtenToSoundingSemitones', () => { + it('applies an octave transposition while preserving the spelling (§B.6)', () => { + const score = mustParse(tune('E, A, C E |'), { writtenToSoundingSemitones: -12 }); + // written E3/A3/C4/E4 (52, 57, 60, 64) sounds an octave lower + expect(midis(score)).toEqual([40, 45, 48, 52]); + expect(scoreEvents(score).map(e => [e.pitch?.step, e.pitch?.octave])).toEqual([ + ['E', 2], ['A', 2], ['C', 3], ['E', 3], + ]); + expect(score.source?.writtenToSoundingSemitones).toBe(-12); + expect(validateScore(score)).toEqual([]); + }); + + it('rejects non-octave transpositions (spelling cannot be preserved)', () => { + mustFail(tune('C D E F |'), /multiple of 12/i, { writtenToSoundingSemitones: -1 }); + }); +}); + +describe('parseAbc — source metadata', () => { + it('collects C, S, Z into the source record', () => { + const text = [ + 'X:5', + 'T:Test tune', + 'C:Jane Composer', + 'S:Public domain collection', + 'Z:Simplified from the original', + 'M:3/4', + 'L:1/4', + 'K:G', + 'G B d | G B d |', + ].join('\n'); + const score = mustParse(text); + expect(score.id).toBe('5'); + expect(score.title).toBe('Test tune'); + expect(score.source?.composer).toBe('Jane Composer'); + expect(score.source?.rights).toContain('Public domain collection'); + expect(score.source?.adaptation).toBe('Simplified from the original'); + }); + + it('falls back to a placeholder source when neither S: nor C: is given', () => { + const score = mustParse('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |'); + expect(score.source?.rights).toContain('unspecified'); + expect(score.id).toBe('1'); + }); + + it('uses the abc-untitled fallback id when X: is absent', () => { + const score = mustParse('M:4/4\nL:1/4\nK:C\nC D E F |'); + expect(score.id).toBe('abc-untitled'); + expect(score.title).toBe('Untitled'); + }); + + it('rejects an empty body (headers only) and rest-only tunes', () => { + mustFail('X:1\nM:4/4\nL:1/4\nK:C\n', /no notes|empty body/i); + mustFail(tune('z z z z |'), /only rests|no notes/i); + }); + + it('rejects a repeat close before any music', () => { + mustFail(tune(':| C D E F |'), /no music before it/i); + }); +}); diff --git a/src/exercises/abcParser.ts b/src/exercises/abcParser.ts new file mode 100644 index 0000000..ec1c96f --- /dev/null +++ b/src/exercises/abcParser.ts @@ -0,0 +1,671 @@ +/** + * ABC-notation subset parser for the Sheet Music Trainer's Phrase Mode. + * + * Implements the subset specified in docs/PHRASE_MODE_IMPLEMENTATION.md §G and + * produces the normalized Score model from src/score/model.ts (§B). The parser + * is pure: no globals, no network, no eval, no I/O. + * + * Supported subset: + * - Headers X, T, C, S, Z, M, L, Q, K; blank lines and %-comments are ignored; + * unknown `word:` headers are ignored silently. M: and K: are required + * before the tune body; L: defaults to 1/8; X is optional. + * - M: exactly 3/4 or 4/4; L: exactly 1/4 or 1/8. + * - K: tonic letter A-G with an optional accidental (prefix ^/_/^^/__, or + * suffix #/b) and an optional mode suffix (none/maj = major, m/min = minor, + * or ion/dor/phr/lyd/mix/aeo/loc). Unknown keys are rejected — never a + * silent fallback to C. + * - Notes a-g/A-G with octave marks (',' down, "'" up), accidentals, and + * duration multipliers (integer, /n, n/m — dots are written as multipliers) + * relative to L. Supported resulting durations ONLY: 240 (eighth), 480 + * (quarter), 720 (dotted quarter), 960 (half), 1440 (dotted half), 1920 + * (whole) ticks at PPQ=480. + * - Rests z with the same duration rules -> events with pitch null. + * - Ties '-' between two notes of the SAME sounding pitch (also across + * barlines), merged into ONE ScoreEvent whose duration is the sum. + * - Barlines '|', '||', '|]'; simple non-nested '|: ... :|' repeats play the + * segment twice (expanded); an unmatched ':|' repeats from the beginning of + * the body; an unclosed '|:' repeats to the end (standard ABC behaviour). + * - Accidental carry per (letter, octave) within each measure, seeded at each + * new measure from the key signature (all octaves), reset at every barline. + * - Measure validation: every measure full (numerator × 480), EXCEPT a short + * first pickup measure which must be complemented by the final measure + * (pickup + final = full). Short middle / overfull measures are errors. + * + * Deliberate readings of spec ambiguities (see the task handoff notes): + * - Durations are standard ABC: multiplier × L ticks. The §G example + * "A3/2 with L:1/8 = dotted quarter" is arithmetically inconsistent with + * that rule (1.5 × 240 = 360 ticks, a dotted eighth, which is NOT in the + * supported table); with L:1/4, A3/2 IS the dotted quarter (720), and with + * L:1/8, A3 is the dotted quarter. The absolute supported-ticks table is + * authoritative. + * - Doubled accidentals ^^/__ are SUPPORTED (§G explicitly requires "doubled + * accidentals"); "^^F" is therefore valid despite one bullet in the task's + * error-test list. Triple or conflicting accidental runs are rejected. + * - writtenToSoundingSemitones is ADDED to the written midi (§B.6: -12 turns + * written E3/52 into sounding E2/40 while keeping the spelling); the task + * text's word "subtract" would flip the sign for the same example value. + * - "C4- | C4 |" (L:1/4) merges into ONE event of 3840 ticks — eight quarter + * notes, per §G ("one eight-quarter-note logical event") and the merge-sum + * rule; 1920 ticks would contradict both. + */ + +import { PPQ, LETTER_PC, STEP_LETTERS, validateScore } from '../score/model'; +import type { + Result, + Score, + ScoreMeasure, + SpelledPitch, + StepLetter, +} from '../score/model'; +import { TONICS, keyFor, spelledPitch } from '../music/scales'; +import type { KeyContext, ModeId } from '../music/scales'; + +export interface AbcParseOptions { + /** + * Semitones from WRITTEN source pitch to sounding pitch (§B.6), e.g. -12 + * for written guitar/bass material. The parser adds this value to the + * written midi while keeping the spelling (recomputing the octave). + * Must be an integer multiple of 12 in this subset. Default 0 (concert + * pitch). + */ + writtenToSoundingSemitones?: number; +} + +/** Input/measure/event bounds (§G), enforced during parsing, not after. */ +const MAX_INPUT_CHARS = 256 * 1024; +const MAX_MEASURES = 256; +const MAX_EVENTS = 4096; + +/** The only note lengths the subset supports (ticks at PPQ = 480). */ +const SUPPORTED_DURATION_TICKS: readonly number[] = [240, 480, 720, 960, 1440, 1920]; + +interface Pos { + readonly line: number; + readonly col: number; +} + +/** A parse failure; when no position applies the message stands alone. */ +const fail = (message: string, pos?: Pos): { ok: false; error: string } => ({ + ok: false, + error: pos ? `${message} (line ${pos.line}, column ${pos.col})` : message, +}); + +/** One note or rest as written in the body (pitch already resolved). */ +interface NoteSpec { + readonly isRest: boolean; + readonly pitch: SpelledPitch | null; + readonly durationTicks: number; + /** True when a '-' tie follows this note (checked/merged at build time). */ + tieToNext: boolean; + readonly pos: Pos; +} + +/** Names for rejected syntax, used in error messages. */ +const UNSUPPORTED_NOTATION: Record = { + '[': 'chords and inline fields (e.g. [CEG] or [K:...])', + '(': 'tuplets and slurs (e.g. (3)', + '{': 'grace notes', + '"': 'lyrics and annotations', + '!': 'decorations (e.g. !trill!)', + '+': 'decorations (e.g. +accent+)', + '&': 'voice overlays', +}; + +const describePitch = (p: SpelledPitch): string => { + const acc = p.alter === 2 ? '##' : p.alter === 1 ? '#' : p.alter === -2 ? 'bb' : p.alter === -1 ? 'b' : ''; + return `${p.step}${acc}${p.octave}`; +}; + +export function parseAbc(text: string, options: AbcParseOptions = {}): Result { + const shift = options.writtenToSoundingSemitones ?? 0; + if (!Number.isInteger(shift) || ((shift % 12) + 12) % 12 !== 0) { + return fail( + `writtenToSoundingSemitones must be an integer multiple of 12 (spelling-preserving octave shift), got ${shift}`, + ); + } + if (text.length > MAX_INPUT_CHARS) { + return fail(`ABC input is too large (${text.length} characters; the limit is 256 KiB)`); + } + + // ---------------------------------------------------------------- headers + const h = { + x: null as string | null, + t: null as string | null, + c: null as string | null, + s: null as string | null, + z: null as string | null, + q: null as string | null, + meterNumerator: 0 as 0 | 3 | 4, + unitTicks: 240, // default L:1/8 + key: null as KeyContext | null, + seen: new Set(), + }; + + const parseKeyHeader = (value: string, pos: Pos): { ok: false; error: string } | null => { + const spec = value.trim(); + let i = 0; + let prefixAlter = 0; + while (i < spec.length && (spec[i] === '^' || spec[i] === '_')) { + prefixAlter += spec[i] === '^' ? 1 : -1; + i++; + } + if (i >= spec.length || !/^[A-G]$/.test(spec[i])) { + return fail( + `unrecognized key "${value}" — expected a tonic letter A-G with an optional accidental and mode suffix (e.g. K:G, K:F#, K:Am, K:Ddor, K:Eb)`, + pos, + ); + } + const letter = spec[i].toUpperCase(); + i++; + let suffixAlter = 0; + while (i < spec.length && (spec[i] === '#' || spec[i] === 'b')) { + suffixAlter += spec[i] === '#' ? 1 : -1; + i++; + } + if (prefixAlter !== 0 && suffixAlter !== 0) { + return fail(`unrecognized key "${value}" — mixing prefix and suffix accidentals`, pos); + } + const totalAlter = prefixAlter + suffixAlter; + if (Math.abs(totalAlter) > 1) { + return fail(`unrecognized key "${value}" — double accidentals are not supported in key signatures`, pos); + } + const modeWord = spec.slice(i).trim().toLowerCase(); + const MODE_MAP: Record = { + '': 'major', maj: 'major', ion: 'major', + m: 'minor', min: 'minor', aeo: 'minor', + dor: 'dorian', phr: 'phrygian', lyd: 'lydian', + mix: 'mixolydian', loc: 'locrian', + }; + const mode = MODE_MAP[modeWord]; + if (!mode) { + return fail( + `unrecognized key "${value}" — unknown mode suffix "${modeWord}" (supported: none, maj, m, min, ion, dor, phr, lyd, mix, aeo, loc)`, + pos, + ); + } + const tonicName = totalAlter === 1 ? `${letter}#` : totalAlter === -1 ? `${letter}b` : letter; + if (!(tonicName in TONICS)) { + return fail(`unrecognized key "${value}" — no conventional signature for tonic "${tonicName}"`, pos); + } + h.key = keyFor(tonicName, mode); + return null; + }; + + const handleHeader = (letter: string, value: string, pos: Pos): { ok: false; error: string } | null => { + switch (letter) { + case 'x': + if (h.seen.has('x')) { + return fail('multiple tunes are not supported (second "X:" header)', pos); + } + h.seen.add('x'); + h.x = value; + return null; + case 'm': { + if (h.seen.has('m')) { + return fail('duplicate "M:" header (meter changes mid-tune are not supported)', pos); + } + h.seen.add('m'); + const match = /^([34])\/4$/.exec(value); + if (!match) { + return fail(`unsupported meter "${value || '(empty)'}" — only M:3/4 and M:4/4 are supported`, pos); + } + h.meterNumerator = Number(match[1]) as 3 | 4; + return null; + } + case 'l': { + if (h.seen.has('l')) return fail('duplicate "L:" header', pos); + h.seen.add('l'); + const match = /^1\/([48])$/.exec(value); + if (!match) { + return fail(`unsupported unit length "${value || '(empty)'}" — only L:1/4 and L:1/8 are supported`, pos); + } + h.unitTicks = Number(match[1]) === 4 ? PPQ : PPQ / 2; + return null; + } + case 'k': { + if (h.seen.has('k')) { + return fail('duplicate "K:" header (key changes mid-tune are not supported)', pos); + } + h.seen.add('k'); + return parseKeyHeader(value, pos); + } + case 't': + case 'c': + case 's': + case 'z': + case 'q': { + // Metadata: the first occurrence wins, later ones are ignored. + if (!h.seen.has(letter)) { + h.seen.add(letter); + h[letter] = value; + } + return null; + } + default: + return null; // unknown header: ignored silently (§G) + } + }; + + // ------------------------------------------------------------------ body + /** Accidental carry per (letter, octave) within the current measure. */ + let carry = new Map(); + let cur: NoteSpec[] = []; + const measures: NoteSpec[][] = []; + let repeatStart: number | null = null; + let bodySeen = false; + + const pushMeasure = (pos: Pos): { ok: false; error: string } | null => { + if (cur.length === 0) return null; // redundant/leading barline: no measure + if (measures.length >= MAX_MEASURES) { + return fail(`too many measures (limit ${MAX_MEASURES})`, pos); + } + measures.push(cur); + cur = []; + carry = new Map(); // accidental carry resets at every barline + return null; + }; + + const closeRepeat = (pos: Pos): { ok: false; error: string } | null => { + const pushed = pushMeasure(pos); + if (pushed) return pushed; + const start = repeatStart ?? 0; // unmatched ":|" repeats from the body start + const segment = measures.slice(start); + if (segment.length === 0) return fail('empty repeat section', pos); + for (const segmentMeasure of segment) { + if (measures.length >= MAX_MEASURES) { + return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, pos); + } + measures.push(segmentMeasure); + } + repeatStart = null; + return null; + }; + + const parseDuration = ( + raw: string, + start: number, + pos: Pos, + ): { ok: true; nextIndex: number; ticks: number } | { ok: false; error: string } => { + let i = start; + const n = raw.length; + let numStr = ''; + while (i < n && raw[i] >= '0' && raw[i] <= '9') { + numStr += raw[i]; + i++; + } + let hasSlash = false; + let denStr = ''; + if (i < n && raw[i] === '/') { + hasSlash = true; + i++; + while (i < n && raw[i] >= '0' && raw[i] <= '9') { + denStr += raw[i]; + i++; + } + } + if (numStr.length === 0 && !hasSlash) { + return { ok: true, nextIndex: start, ticks: h.unitTicks }; + } + if (numStr.length > 6 || denStr.length > 6) { + return fail(`duration "${raw.slice(start, i)}" is out of range`, pos); + } + const num = numStr.length > 0 ? Number(numStr) : 1; + const den = hasSlash ? (denStr.length > 0 ? Number(denStr) : 2) : 1; + if (num === 0) return fail(`zero duration "${raw.slice(start, i)}"`, pos); + const product = num * h.unitTicks; + if (product % den !== 0) { + return fail(`unsupported duration "${raw.slice(start, i)}" — it does not resolve to whole ticks`, pos); + } + const ticks = product / den; + if (!SUPPORTED_DURATION_TICKS.includes(ticks)) { + return fail( + `unsupported duration "${raw.slice(start, i)}" = ${ticks} ticks — supported: eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440, whole 1920`, + pos, + ); + } + return { ok: true, nextIndex: i, ticks }; + }; + + const parseNoteToken = ( + raw: string, + start: number, + line: number, + key: KeyContext, + ): { ok: true; nextIndex: number } | { ok: false; error: string } => { + let i = start; + const n = raw.length; + const pos = (): Pos => ({ line, col: start + 1 }); + + // Optional accidental prefix: ^ ^^ __ _ = (no mixing of kinds). + let explicitAlter: number | null = null; + if (raw[i] === '^' || raw[i] === '_' || raw[i] === '=') { + let sharps = 0; + let flats = 0; + let naturals = 0; + while (i < n && (raw[i] === '^' || raw[i] === '_' || raw[i] === '=')) { + if (raw[i] === '^') sharps++; + else if (raw[i] === '_') flats++; + else naturals++; + i++; + } + if (naturals > 0 && (sharps > 0 || flats > 0)) { + return fail('conflicting accidentals — "=" cannot be combined with "^" or "_"', pos()); + } + if (sharps > 0 && flats > 0) { + return fail('conflicting accidentals — "^" and "_" cannot be combined', pos()); + } + if (naturals > 1) return fail('repeated "=" accidental', pos()); + if (sharps > 2 || flats > 2) { + return fail('unsupported accidental — at most doubled ("^^" or "__")', pos()); + } + explicitAlter = sharps - flats; + } + + if (i >= n || !/[a-zA-Z]/.test(raw[i])) { + if (explicitAlter !== null) { + return fail('stray accidental — expected a note letter after the accidental', pos()); + } + return fail(`unexpected character "${raw[i] ?? ''}"`, pos()); + } + const ch = raw[i]; + + if (ch === 'z') { + if (explicitAlter !== null) { + return fail('accidentals apply to notes, not rests', pos()); + } + const dur = parseDuration(raw, i + 1, pos()); + if (!dur.ok) return dur; + cur.push({ isRest: true, pitch: null, durationTicks: dur.ticks, tieToNext: false, pos: pos() }); + return { ok: true, nextIndex: dur.nextIndex }; + } + + if (!/^[a-gA-G]$/.test(ch)) { + return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, pos()); + } + + const isLower = ch >= 'a' && ch <= 'g'; + const letter = (isLower ? ch.toUpperCase() : ch) as StepLetter; + let octave = isLower ? 5 : 4; // ABC: C = C4 (midi 60), c = C5 (72) + i++; + while (i < n && (raw[i] === ',' || raw[i] === "'")) { + octave += raw[i] === ',' ? -1 : 1; + i++; + } + const dur = parseDuration(raw, i, { line, col: i + 1 }); + if (!dur.ok) return dur; + + // Accidental carry: explicit accidental wins, else this measure's + // carried value for (letter, octave), else the key signature. + const carryKey = `${letter}${octave}`; + const alter = explicitAlter ?? carry.get(carryKey) ?? key.signatureAlter[letter]; + const writtenMidi = 12 * (octave + 1) + LETTER_PC[STEP_LETTERS.indexOf(letter)] + alter; + const soundingMidi = writtenMidi + shift; + if (soundingMidi < 0 || soundingMidi > 127) { + return fail(`note "${raw.slice(start, i)}" resolves to MIDI ${soundingMidi}, outside the range 0-127`, pos()); + } + let pitch: SpelledPitch; + try { + pitch = spelledPitch(soundingMidi, letter, alter); + } catch { + return fail(`internal spelling error for "${raw.slice(start, i)}"`, pos()); + } + if (explicitAlter !== null) carry.set(carryKey, explicitAlter); + cur.push({ isRest: false, pitch, durationTicks: dur.ticks, tieToNext: false, pos: pos() }); + return { ok: true, nextIndex: dur.nextIndex }; + }; + + const tokenizeLine = (raw: string, line: number): { ok: false; error: string } | null => { + let i = 0; + const n = raw.length; + while (i < n) { + const ch = raw[i]; + const col = i + 1; + if (ch === ' ' || ch === '\t') { + i++; + continue; + } + if (ch === '%') break; // comment to end of line + if (!bodySeen) { + if (h.meterNumerator === 0) { + return fail('missing "M:" header before the tune body', { line, col }); + } + if (!h.key) { + return fail('missing "K:" header before the tune body', { line, col }); + } + bodySeen = true; + } + const here: Pos = { line, col }; + + if (ch === '|') { + const next = raw[i + 1] ?? ''; + if (next === '|' || next === ']') { + const pushed = pushMeasure(here); + if (pushed) return pushed; + i += 2; + continue; + } + if (next === ':') { + if (repeatStart !== null) { + return fail('nested repeats are not supported', here); + } + const pushed = pushMeasure(here); + if (pushed) return pushed; + repeatStart = measures.length; + i += 2; + continue; + } + if (next >= '0' && next <= '9') { + return fail('alternate endings (|1, |2) are not supported', here); + } + const pushed = pushMeasure(here); + if (pushed) return pushed; + i++; + continue; + } + if (ch === ':') { + if (raw[i + 1] === '|') { + let j = i + 2; + if (raw[j] === ']') j++; // ":|]" = repeat close + final barline + if (repeatStart === null && measures.length === 0 && cur.length === 0) { + return fail('repeat close ":|" with no music before it', here); + } + const closed = closeRepeat(here); + if (closed) return closed; + i = j; + continue; + } + return fail('unexpected ":" — only ":|" repeat closes are supported', here); + } + if (ch === '-') { + const last = cur.length > 0 ? cur[cur.length - 1] : null; + if (!last || last.isRest) { + return fail('unexpected "-" — ties connect two notes', here); + } + if (last.tieToNext) { + return fail('unexpected "-" — this note is already tied', here); + } + last.tieToNext = true; + i++; + continue; + } + if (/[a-gA-Gz^_=]/.test(ch)) { + const key = h.key; // non-null: bodySeen was checked above + if (!key) return fail('missing "K:" header before the tune body', here); + const parsed = parseNoteToken(raw, i, line, key); + if (!parsed.ok) return parsed; + i = parsed.nextIndex; + continue; + } + const named = UNSUPPORTED_NOTATION[ch]; + if (named) { + return fail(`unsupported syntax "${ch}" — ${named} are not supported in this subset`, here); + } + if (/^[a-zA-Z]$/.test(ch)) { + return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, here); + } + return fail(`unsupported syntax "${ch}" — this character is not supported in this subset`, here); + } + return null; + }; + + const lines = text.split(/\r?\n/); + for (let li = 0; li < lines.length; li++) { + const raw = lines[li]; + const line = li + 1; + const trimmed = raw.trim(); + if (trimmed.length === 0 || trimmed.startsWith('%')) continue; // blank/comment + + const headerMatch = /^\s*([A-Za-z]+):(.*)$/.exec(raw); + if (headerMatch) { + const letter = headerMatch[1].toLowerCase(); + const value = headerMatch[2].trim(); + const pos: Pos = { line, col: raw.indexOf(':') + 2 }; + const err = handleHeader(letter, value, pos); + if (err) return err; + continue; + } + const err = tokenizeLine(raw, line); + if (err) return err; + } + + // ------------------------------------------------- finalize / expansion + const endPos: Pos = { line: lines.length, col: 1 }; + if (repeatStart !== null) { + // Unclosed "|:" repeats to the end of the tune (standard ABC). + const pushed = pushMeasure(endPos); + if (pushed) return pushed; + const segment = measures.slice(repeatStart); + if (segment.length === 0) return fail('empty repeat section'); + for (const segmentMeasure of segment) { + if (measures.length >= MAX_MEASURES) { + return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, endPos); + } + measures.push(segmentMeasure); + } + repeatStart = null; + } else { + const pushed = pushMeasure(endPos); + if (pushed) return pushed; + } + + if (measures.length === 0) { + return fail('the tune contains no notes (empty body)'); + } + + // ------------------------------------------------------ measure checks + const numMeasures = measures.length; + const full = h.meterNumerator * PPQ; + const sums = measures.map(ms => ms.reduce((total, note) => total + note.durationTicks, 0)); + + for (let m = 0; m < numMeasures; m++) { + if (sums[m] > full) { + return fail( + `measure ${m + 1} is overfull: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`, + ); + } + } + if (numMeasures === 1) { + if (sums[0] !== full) { + return fail(`measure 1 is short: ${sums[0]} ticks (expected ${full} for ${h.meterNumerator}/4)`); + } + } else { + for (let m = 1; m < numMeasures; m++) { + const mayComplement = m === numMeasures - 1 && sums[0] < full; + if (sums[m] < full && !mayComplement) { + return fail( + `measure ${m + 1} is short: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`, + ); + } + } + if (sums[0] < full && sums[0] + sums[numMeasures - 1] !== full) { + return fail( + `pickup measure is not complemented: measure 1 (${sums[0]}) + final measure (${sums[numMeasures - 1]}) != ${full} ticks`, + ); + } + } + + // ------------------------------------------------------- event building + interface MutableEvent { + id: string; + startTick: number; + durationTicks: number; + pitch: SpelledPitch | null; + } + const events: MutableEvent[] = []; + let tick = 0; + let eventSeq = 0; + let tieOpen: NoteSpec | null = null; + + for (let m = 0; m < numMeasures; m++) { + for (const note of measures[m]) { + if (tieOpen) { + if (!note.pitch || !tieOpen.pitch) { + return fail('a tie must connect two notes, but a rest follows the tied note', note.pos); + } + if (tieOpen.pitch.midi !== note.pitch.midi) { + return fail( + `tie connects different sounding pitches (${describePitch(tieOpen.pitch)} != ${describePitch(note.pitch)})`, + note.pos, + ); + } + const merged = events[events.length - 1]; + merged.durationTicks += note.durationTicks; + tieOpen = note.tieToNext ? note : null; + } else { + if (events.length >= MAX_EVENTS) { + return fail(`too many events (limit ${MAX_EVENTS})`, note.pos); + } + eventSeq++; + events.push({ + id: `ev${eventSeq}`, + startTick: tick, + durationTicks: note.durationTicks, + pitch: note.pitch, + }); + if (note.tieToNext) tieOpen = note; + } + tick += note.durationTicks; + } + } + if (tieOpen) { + return fail('dangling tie: no note follows the tied note', tieOpen.pos); + } + if (events.every(e => e.pitch === null)) { + return fail('the tune contains no notes (only rests)'); + } + + const outMeasures: ScoreMeasure[] = []; + let measureStart = 0; + for (let m = 0; m < numMeasures; m++) { + outMeasures.push({ number: m + 1, startTick: measureStart, durationTicks: sums[m] }); + measureStart += sums[m]; + } + + if (!h.key) { + // Unreachable when measures exist (the body cannot start without K:), + // but keeps the type checker honest. + return fail('missing "K:" header before the tune body'); + } + + const score: Score = { + version: 1, + id: h.x !== null && h.x.length > 0 ? h.x : 'abc-untitled', + title: h.t !== null && h.t.length > 0 ? h.t : 'Untitled', + meter: { numerator: h.meterNumerator as 3 | 4, denominator: 4 }, + key: { tonic: h.key.tonic, mode: h.key.mode, signature: h.key.signature }, + measures: outMeasures, + voices: [{ id: 'melody', events }], + source: { + rights: `Transcription by project contributor (CC0); source: ${h.s || h.c || 'unspecified'}`, + writtenToSoundingSemitones: shift, + ...(h.c ? { composer: h.c } : {}), + ...(h.z ? { adaptation: h.z } : {}), + }, + }; + + const problems = validateScore(score); + if (problems.length > 0) { + return fail(`parsed tune failed score validation: ${problems.join('; ')}`); + } + return { ok: true, value: score }; +} diff --git a/src/exercises/library.test.ts b/src/exercises/library.test.ts new file mode 100644 index 0000000..83ff2bc --- /dev/null +++ b/src/exercises/library.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { EXERCISES, findExercise, loadExercise } from './library'; +import { validateScore, scoreEvents } from '../score/model'; + +describe('bundled exercise library', () => { + it('has a non-empty, uniquely-id\'d catalog', () => { + expect(EXERCISES.length).toBeGreaterThanOrEqual(4); + const ids = new Set(EXERCISES.map(e => e.id)); + expect(ids.size).toBe(EXERCISES.length); + for (const ex of EXERCISES) { + expect(ex.label.length).toBeGreaterThan(0); + expect(ex.abc.length).toBeGreaterThan(0); + } + }); + + it('every bundled file parses and validates', () => { + for (const ex of EXERCISES) { + const res = loadExercise(ex.id); + expect(res.ok, `${ex.id}: ${res.ok ? '' : res.error}`).toBe(true); + if (!res.ok) continue; + const problems = validateScore(res.value); + expect(problems, `${ex.id}: ${problems.join('; ')}`).toEqual([]); + expect(scoreEvents(res.value).length).toBeGreaterThan(0); + expect(res.value.title.length).toBeGreaterThan(0); + expect(res.value.source).toBeDefined(); + expect(res.value.source!.rights.length).toBeGreaterThan(0); + } + }); + + it('exercise sources declare provenance (composer or original)', () => { + for (const ex of EXERCISES) { + const res = loadExercise(ex.id); + if (!res.ok) continue; + const src = res.value.source!; + expect(src.composer || '').toBeTruthy(); + expect(src.rights).toBeTruthy(); + } + }); + + it('Ode to Joy theme has the expected note count and midis', () => { + const res = loadExercise('ode-to-joy'); + expect(res.ok).toBe(true); + if (!res.ok) return; + const evts = scoreEvents(res.value); + expect(evts).toHaveLength(30); // two 4-bar phrases + expect(res.value.measures).toHaveLength(8); + // First phrase: E E F G G F E D C C D E + expect(evts.slice(0, 12).map(e => e.pitch!.midi)).toEqual([64, 64, 65, 67, 67, 65, 64, 62, 60, 60, 62, 64]); + // Bar 4 dotted rhythm: E3/2 D/2 D2 → 720, 240, 960 + expect(evts[12].durationTicks).toBe(720); + expect(evts[13].durationTicks).toBe(240); + expect(evts[14].durationTicks).toBe(960); + }); + + it('unknown exercise ids fail cleanly', () => { + expect(loadExercise('nope').ok).toBe(false); + expect(findExercise('nope')).toBeUndefined(); + expect(findExercise('ode-to-joy')?.id).toBe('ode-to-joy'); + }); +}); diff --git a/src/exercises/library.ts b/src/exercises/library.ts new file mode 100644 index 0000000..207f663 --- /dev/null +++ b/src/exercises/library.ts @@ -0,0 +1,54 @@ +/** + * Bundled exercise library (contract G). + * + * Files live in src/exercises/library/*.abc and are parsed at runtime by the + * ABC subset parser. Every file is validated by a unit test. Provenance and + * adaptation notes are embedded in the files themselves and mirrored in + * SOURCES.md. Content policy: verified public-domain melodies + original + * technical exercises only — nothing is attributed to a historical composer + * without a verifiable source (see docs/PHRASE_MODE_IMPLEMENTATION.md). + */ +import { parseAbc, type AbcParseOptions } from './abcParser'; +import type { Result } from '../score/model'; +import type { Score } from '../score/model'; + +import odeRaw from './library/ode-to-joy.abc?raw'; +import twinkleRaw from './library/twinkle.abc?raw'; +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'; + +export interface ExerciseInfo { + readonly id: string; + readonly label: string; + /** Difficulty hint shown in the picker. */ + readonly level: 'beginner' | 'intermediate'; + readonly abc: string; +} + +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: 'first-position-study', label: 'First Position Study', level: 'beginner', abc: firstPositionRaw }, + { id: 'e-minor-study', label: 'E Minor Study', level: 'intermediate', abc: eMinorRaw }, + { id: 'dotted-rhythm-study', label: 'Dotted Rhythm Study', level: 'intermediate', abc: dottedRaw }, + { id: 'broken-chord-study', label: 'Broken Chord Study', level: 'intermediate', abc: brokenChordRaw }, +]; + +export function findExercise(id: string): ExerciseInfo | undefined { + return EXERCISES.find(e => e.id === id); +} + +const cache = new Map>(); + +/** Parse a bundled exercise (memoized; failures are cached too). */ +export function loadExercise(id: string, options?: AbcParseOptions): Result { + const key = id + (options?.writtenToSoundingSemitones ?? 0); + const hit = cache.get(key); + if (hit) return hit; + const info = findExercise(id); + const result = info ? parseAbc(info.abc, options) : { ok: false as const, error: `Unknown exercise: ${id}` }; + cache.set(key, result); + return result; +} diff --git a/src/exercises/library/broken-chord-study.abc b/src/exercises/library/broken-chord-study.abc new file mode 100644 index 0000000..eacffb9 --- /dev/null +++ b/src/exercises/library/broken-chord-study.abc @@ -0,0 +1,11 @@ +% Original technical exercise: broken C-G-Am-F chords as a single line. +% Written for this project, 2026, CC0. +X:6 +T:Broken Chord 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 G E | C E G c | G, B, D B, | G, B, D G | A, C E C | A, C E A | F, A, C A, | F, A, C F |] diff --git a/src/exercises/library/dotted-rhythm-study.abc b/src/exercises/library/dotted-rhythm-study.abc new file mode 100644 index 0000000..7b70e0f --- /dev/null +++ b/src/exercises/library/dotted-rhythm-study.abc @@ -0,0 +1,11 @@ +% Original technical exercise: dotted quarter / eighth patterns. +% Written for this project, 2026, CC0. +X:5 +T:Dotted Rhythm Study +C:Original exercise (project contributor) +Z:Project contributor, 2026 (CC0) +M:4/4 +L:1/4 +Q:1/4=70 +K:C +C3/2 E/2 E F | G3/2 E/2 C2 | D3/2 E/2 F G | E3/2 D/2 C2 | C3/2 E/2 E F | G3/2 E/2 G2 | A3/2 G/2 F E | D3/2 C/2 C2 |] diff --git a/src/exercises/library/e-minor-study.abc b/src/exercises/library/e-minor-study.abc new file mode 100644 index 0000000..3ebb1be --- /dev/null +++ b/src/exercises/library/e-minor-study.abc @@ -0,0 +1,11 @@ +% Original technical exercise (not attributed to any historical composer). +% Written for this project, 2026, CC0. E minor + D major arpeggios. +X:4 +T:E Minor Study +C:Original exercise (project contributor) +Z:Project contributor, 2026 (CC0) +M:4/4 +L:1/4 +Q:1/4=80 +K:Em +E, G, B, E | G B E2 | E, G, B, E | G B E2 | D F A D | F A D2 | E, G, B, E | E, B, E2 |] diff --git a/src/exercises/library/first-position-study.abc b/src/exercises/library/first-position-study.abc new file mode 100644 index 0000000..07e1574 --- /dev/null +++ b/src/exercises/library/first-position-study.abc @@ -0,0 +1,11 @@ +% Original technical exercise (not attributed to any historical composer). +% Written for this project, 2026, CC0. G major, first-position friendly. +X:3 +T:First Position Study +C:Original exercise (project contributor) +Z:Project contributor, 2026 (CC0) +M:4/4 +L:1/4 +Q:1/4=80 +K:G +G A B D | D B A G | E G A B | A G G2 | D E G A | B A G E | D E G A | G D G2 |] diff --git a/src/exercises/library/ode-to-joy.abc b/src/exercises/library/ode-to-joy.abc new file mode 100644 index 0000000..88b00f9 --- /dev/null +++ b/src/exercises/library/ode-to-joy.abc @@ -0,0 +1,15 @@ +% Ode to Joy — main theme of Beethoven's Symphony No. 9 (1824). +% Composer died 1827; the work itself is public domain worldwide. +% Transcription: project contributor, 2026 (CC0). Single-voice adaptation: +% only the lead melody line; the accompanying harmonies are omitted. +% Pitch convention: concert pitch. +X:1 +T:Ode to Joy (Theme) +C:Ludwig van Beethoven (1770-1827) +S:Symphony No. 9 in D minor, Op. 125 (1824), fourth movement main theme +Z:Project contributor, 2026 (CC0 transcription); adaptation: melody line only +M:4/4 +L:1/4 +Q:1/4=100 +K:C +E E F G | G F E D | C C D E | E3/2 D/2 D2 | E E F G | G F E D | C C D E | D3/2 C/2 C2 |] diff --git a/src/exercises/library/twinkle.abc b/src/exercises/library/twinkle.abc new file mode 100644 index 0000000..ac08b7c --- /dev/null +++ b/src/exercises/library/twinkle.abc @@ -0,0 +1,13 @@ +% Ah! vous dirai-je, maman — traditional French melody (published 1761). +% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0). +% Pitch convention: concert pitch. +X:2 +T:Ah! vous dirai-je, maman (Twinkle, Twinkle) +C:Traditional (French melody, published 1761) +S:Common version of the tune; no known composer +Z:Project contributor, 2026 (CC0 transcription) +M:4/4 +L:1/4 +Q:1/4=100 +K:C +C C G G | A A G2 | F F E E | D D C2 | G G F F | E E D2 | G G F F | E E D2 | C C G G | A A G2 | F F E E | D D C2 |] diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index 22ba400..20f682c 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -10,7 +10,62 @@ export interface RhythmSettings { volume: number; } +export type PhraseMaterial = 'melody' | 'scale' | 'library' | 'import'; +export type PhrasePace = 'step' | 'tempo'; + +export interface PhraseSettings { + material: PhraseMaterial; + libraryId: string; + /** Imported score id (the parsed score lives in component state, session-only). */ + keyTonic: string; + keyMode: string; // ModeId + bars: number; // 1–8 (melodies) + meterNumerator: 3 | 4; + rhythmLevel: 1 | 2; + scaleCoverage: 'one-octave' | 'two-octave' | 'position'; + scaleDirection: 'up' | 'down' | 'updown'; + /** Phrase-local fret window (guitar/bass) — overrides the note set. */ + fretWindowEnabled: boolean; + fretMin: number; + fretMax: number; + /** Practice range for library/import material (1-based, inclusive). */ + startBar: number; + barCount: number; + pace: PhrasePace; + bpm: number; + clickSound: boolean; + repeat: boolean; + inputMode: 'mic' | 'virtual'; +} + +export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { + material: 'melody', + libraryId: '', + keyTonic: 'C', + keyMode: 'major', + bars: 2, + meterNumerator: 4, + rhythmLevel: 1, + scaleCoverage: 'one-octave', + scaleDirection: 'updown', + fretWindowEnabled: false, + fretMin: 0, + fretMax: 4, + startBar: 1, + barCount: 2, + pace: 'step', + bpm: 60, + clickSound: false, + repeat: false, + inputMode: 'mic', +}; + +export function getPhraseSettings(s: AppSettings): PhraseSettings { + return { ...DEFAULT_PHRASE_SETTINGS, ...(s.phrase ?? {}) }; +} + export interface AppSettings { + phrase?: PhraseSettings; difficulty: Difficulty; showHint: boolean; showFretboard: boolean; @@ -20,7 +75,7 @@ export interface AppSettings { instrument: string; rhythm: RhythmSettings; zenMode: boolean; - gameMode: 'sight_reading' | 'ear_training'; + gameMode: 'sight_reading' | 'ear_training' | 'phrase'; customMinFret?: number; customMaxFret?: number; autoPlaySightReading?: boolean; @@ -56,6 +111,7 @@ export const DEFAULT_SETTINGS: AppSettings = { }, zenMode: false, gameMode: 'sight_reading', + phrase: DEFAULT_PHRASE_SETTINGS, customMinFret: 0, customMaxFret: 12, autoPlaySightReading: false,