mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: ABC parser (subagent), library content, renderer, PhraseTrainer component
This commit is contained in:
344
src/components/PhraseSheetMusic.tsx
Normal file
344
src/components/PhraseSheetMusic.tsx
Normal file
@@ -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<string, number> {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
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<PhraseSheetMusicProps> = ({
|
||||||
|
score,
|
||||||
|
currentIdx,
|
||||||
|
statuses,
|
||||||
|
clef,
|
||||||
|
transpose,
|
||||||
|
width,
|
||||||
|
theme = 'auto',
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [schemeVersion, setSchemeVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onChange = () => setSchemeVersion(v => v + 1);
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="sheet-music-container phrase-sheet"
|
||||||
|
style={{ width: '100%', minHeight: 60 }}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Notation: ${score.title}, ${score.measures.length} bars, key of ${score.key.tonic}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PhraseSheetMusic;
|
||||||
618
src/components/PhraseTrainer.tsx
Normal file
618
src/components/PhraseTrainer.tsx
Normal file
@@ -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<PhraseHandle, PhraseTrainerProps>(
|
||||||
|
function PhraseTrainer({ listening, micError, windowWidth }, ref) {
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { playNote } = useAudioPlayer();
|
||||||
|
const phrase = getPhraseSettings(settings);
|
||||||
|
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
||||||
|
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<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 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 },
|
||||||
|
}, 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<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 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<number | null>(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 (
|
||||||
|
<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">
|
||||||
|
<div className="instruction-text phrase-status">{statusText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport */}
|
||||||
|
<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>
|
||||||
|
<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' || 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>
|
||||||
|
)}
|
||||||
|
</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="library">Exercises</option>
|
||||||
|
<option value="import">Imported file</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(phrase.material === 'melody' || phrase.material === 'scale') && (
|
||||||
|
<>
|
||||||
|
<label>Key
|
||||||
|
<select value={phrase.keyTonic} onChange={e => setPhrase({ keyTonic: e.target.value })}>
|
||||||
|
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Mode
|
||||||
|
<select value={phrase.keyMode} onChange={e => setPhrase({ keyMode: e.target.value })}>
|
||||||
|
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
|
||||||
|
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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: Number(e.target.value) === 3 ? 3 : 4 })}>
|
||||||
|
<option value={4}>4/4</option>
|
||||||
|
<option value={3}>3/4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Rhythm
|
||||||
|
<select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Number(e.target.value) === 2 ? 2 : 1 })}>
|
||||||
|
<option value={1}>Simple</option>
|
||||||
|
<option value={2}>Mixed</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 & down</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Meter
|
||||||
|
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}>
|
||||||
|
<option value={4}>4/4</option>
|
||||||
|
<option value={3}>3/4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>Octave
|
||||||
|
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
|
||||||
|
<option value={-1}>-1</option>
|
||||||
|
<option value={0}>0</option>
|
||||||
|
<option value={1}>+1</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 && (
|
||||||
|
<>
|
||||||
|
<label className="phrase-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={phrase.fretWindowEnabled}
|
||||||
|
onChange={e => setPhrase({ fretWindowEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Fret window
|
||||||
|
</label>
|
||||||
|
{phrase.fretWindowEnabled && (
|
||||||
|
<>
|
||||||
|
<label>Min fret
|
||||||
|
<input type="number" min={0} max={24} value={phrase.fretMin}
|
||||||
|
onChange={e => setPhrase({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
|
||||||
|
</label>
|
||||||
|
<label>Max fret
|
||||||
|
<input type="number" min={0} max={24} value={phrase.fretMax}
|
||||||
|
onChange={e => setPhrase({ fretMax: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label>Pace
|
||||||
|
<select value={phrase.pace} onChange={e => setPhrase({ pace: e.target.value as PhraseSettings['pace'] })}>
|
||||||
|
<option value="step">At your pace</option>
|
||||||
|
<option value="tempo">With tempo</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{phrase.pace === 'tempo' && (
|
||||||
|
<label>BPM
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={180}
|
||||||
|
value={phrase.bpm}
|
||||||
|
onChange={e => setPhrase({ bpm: Math.max(30, Math.min(180, parseInt(e.target.value) || 60)) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<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' && (
|
||||||
|
<label className="phrase-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={phrase.clickSound}
|
||||||
|
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Click sound
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{phrase.pace === 'tempo' && (
|
||||||
|
<label className="phrase-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={phrase.repeat}
|
||||||
|
onChange={e => setPhrase({ repeat: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Repeat automatically
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{phrase.pace === 'tempo' && phrase.clickSound && (
|
||||||
|
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</p>
|
||||||
|
)}
|
||||||
|
<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;
|
||||||
192
src/components/SingleNoteTrainer.tsx
Normal file
192
src/components/SingleNoteTrainer.tsx
Normal file
@@ -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<SingleNoteHandle, SingleNoteTrainerProps>(
|
||||||
|
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<number | null>(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 (
|
||||||
|
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
|
||||||
|
<div className="sheet-music-container">
|
||||||
|
<SheetMusic
|
||||||
|
targetMidi={targetMidi}
|
||||||
|
playedMidi={virtualNote ?? displayedPitch?.midi}
|
||||||
|
keySignature={settings.keySignature}
|
||||||
|
clef={activeClef}
|
||||||
|
transpose={activeTranspose}
|
||||||
|
width={Math.min(windowWidth - 40, 500)}
|
||||||
|
height={
|
||||||
|
activeClef === 'grand'
|
||||||
|
? (windowHeight < 500 ? 190 : 260)
|
||||||
|
: (windowHeight < 500 ? 120 : 180)
|
||||||
|
}
|
||||||
|
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
||||||
|
hoverMidi={settings.showHint ? hoveredMidi : null}
|
||||||
|
theme={settings.theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!settings.zenMode && (
|
||||||
|
<div className="feedback-area">
|
||||||
|
{feedbackMessage ? (
|
||||||
|
<div key={feedbackMessage} className="success-message animate-pop">
|
||||||
|
{feedbackMessage.startsWith("Good! ") ? (
|
||||||
|
<>
|
||||||
|
<div className="success-prefix">Good!</div>
|
||||||
|
<div className="success-note">{feedbackMessage.replace("Good! ", "")}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
feedbackMessage
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="instruction-text">
|
||||||
|
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(settings.showHint || settings.showFretboard) && (
|
||||||
|
<div className="hint-card">
|
||||||
|
{settings.showHint && (
|
||||||
|
<div className="hint-note landscape-hint-note">
|
||||||
|
{getNoteDetails(targetMidi + activeTranspose).scientific}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentInstrumentDef.showTuning && (
|
||||||
|
currentInstrumentDef.id === 'piano' ? (
|
||||||
|
<PianoKeys
|
||||||
|
minMidi={36}
|
||||||
|
maxMidi={84}
|
||||||
|
markedNotes={settings.showHint ? [targetMidi] : []}
|
||||||
|
interactive={settings.showFretboard || settings.showHint}
|
||||||
|
showTooltips={settings.showHint}
|
||||||
|
displayTranspose={activeTranspose}
|
||||||
|
onPlayNote={handleVirtualInstrumentPlay}
|
||||||
|
onHover={setHoveredMidi}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
currentTuning && (
|
||||||
|
<Fretboard
|
||||||
|
tuning={currentTuning}
|
||||||
|
positions={hintPositions}
|
||||||
|
interactive={settings.showFretboard || settings.showHint}
|
||||||
|
showTooltips={settings.showHint}
|
||||||
|
displayTranspose={activeTranspose}
|
||||||
|
onPlayNote={handleVirtualInstrumentPlay}
|
||||||
|
onHover={setHoveredMidi}
|
||||||
|
showHints={settings.showHint}
|
||||||
|
maxFrets={15}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!settings.zenMode && (
|
||||||
|
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
className="hint-button"
|
||||||
|
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
||||||
|
title="Keyboard Shortcut: P or R"
|
||||||
|
>
|
||||||
|
<Volume2 size={18} />
|
||||||
|
Play Note
|
||||||
|
</button>
|
||||||
|
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
||||||
|
<SkipForward size={18} />
|
||||||
|
Skip Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SingleNoteTrainer;
|
||||||
427
src/exercises/abcParser.test.ts
Normal file
427
src/exercises/abcParser.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
671
src/exercises/abcParser.ts
Normal file
671
src/exercises/abcParser.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
'[': '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<Score> {
|
||||||
|
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<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, ModeId> = {
|
||||||
|
'': '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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
60
src/exercises/library.test.ts
Normal file
60
src/exercises/library.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/exercises/library.ts
Normal file
54
src/exercises/library.ts
Normal file
@@ -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<string, Result<Score>>();
|
||||||
|
|
||||||
|
/** Parse a bundled exercise (memoized; failures are cached too). */
|
||||||
|
export function loadExercise(id: string, options?: AbcParseOptions): Result<Score> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
11
src/exercises/library/broken-chord-study.abc
Normal file
11
src/exercises/library/broken-chord-study.abc
Normal file
@@ -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 |]
|
||||||
11
src/exercises/library/dotted-rhythm-study.abc
Normal file
11
src/exercises/library/dotted-rhythm-study.abc
Normal file
@@ -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 |]
|
||||||
11
src/exercises/library/e-minor-study.abc
Normal file
11
src/exercises/library/e-minor-study.abc
Normal file
@@ -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 |]
|
||||||
11
src/exercises/library/first-position-study.abc
Normal file
11
src/exercises/library/first-position-study.abc
Normal file
@@ -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 |]
|
||||||
15
src/exercises/library/ode-to-joy.abc
Normal file
15
src/exercises/library/ode-to-joy.abc
Normal file
@@ -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 |]
|
||||||
13
src/exercises/library/twinkle.abc
Normal file
13
src/exercises/library/twinkle.abc
Normal file
@@ -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 |]
|
||||||
@@ -10,7 +10,62 @@ export interface RhythmSettings {
|
|||||||
volume: number;
|
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 {
|
export interface AppSettings {
|
||||||
|
phrase?: PhraseSettings;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
showHint: boolean;
|
showHint: boolean;
|
||||||
showFretboard: boolean;
|
showFretboard: boolean;
|
||||||
@@ -20,7 +75,7 @@ export interface AppSettings {
|
|||||||
instrument: string;
|
instrument: string;
|
||||||
rhythm: RhythmSettings;
|
rhythm: RhythmSettings;
|
||||||
zenMode: boolean;
|
zenMode: boolean;
|
||||||
gameMode: 'sight_reading' | 'ear_training';
|
gameMode: 'sight_reading' | 'ear_training' | 'phrase';
|
||||||
customMinFret?: number;
|
customMinFret?: number;
|
||||||
customMaxFret?: number;
|
customMaxFret?: number;
|
||||||
autoPlaySightReading?: boolean;
|
autoPlaySightReading?: boolean;
|
||||||
@@ -56,6 +111,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
},
|
},
|
||||||
zenMode: false,
|
zenMode: false,
|
||||||
gameMode: 'sight_reading',
|
gameMode: 'sight_reading',
|
||||||
|
phrase: DEFAULT_PHRASE_SETTINGS,
|
||||||
customMinFret: 0,
|
customMinFret: 0,
|
||||||
customMaxFret: 12,
|
customMaxFret: 12,
|
||||||
autoPlaySightReading: false,
|
autoPlaySightReading: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user