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;
|
||||
Reference in New Issue
Block a user