mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: count-in no-scoring fix, re-arm timeout for repeated notes, browser QA scripts
This commit is contained in:
@@ -17,7 +17,7 @@ import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, St
|
||||
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';
|
||||
import { keyFor, isMode, spelledPitch } from '../music/scales';
|
||||
|
||||
interface PhraseSheetMusicProps {
|
||||
score: Score;
|
||||
@@ -174,12 +174,24 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
||||
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;
|
||||
// WRITTEN pitch for the staff: sounding midi + display
|
||||
// transpose, octave recomputed via the shared helper —
|
||||
// the same sounding+transpose semantics as single-note
|
||||
// SheetMusic (guitar reads an octave above sounding).
|
||||
let written: { step: StepLetter; alter: number; octave: number };
|
||||
try {
|
||||
written = spelledPitch(p.midi + transpose, p.step, p.alter);
|
||||
} catch {
|
||||
// Non-octave transposes would need respelling; all
|
||||
// configured transposes are octave multiples.
|
||||
written = { step: p.step, alter: p.alter, octave: p.octave + Math.round(transpose / 12) };
|
||||
}
|
||||
const stateKey = `${written.step}${written.octave}`;
|
||||
const expected = measureState[stateKey] ?? sigAlter[written.step];
|
||||
const glyph = written.alter !== expected ? accidentalGlyph(written.alter) : undefined;
|
||||
measureState[stateKey] = written.alter;
|
||||
note = new StaveNote({
|
||||
keys: [keyString(p.step, p.alter, p.octave)],
|
||||
keys: [keyString(written.step, written.alter, written.octave)],
|
||||
duration: code,
|
||||
clef: staveKey,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ 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 { rawFrameCount } from '../hooks/rawFrameBus';
|
||||
import { audioEngine } from '../audio/AudioEngine';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||
import { generateMelody } from '../music/melodyGenerator';
|
||||
import { generateScaleDrill } from '../music/scaleDrills';
|
||||
@@ -26,7 +28,7 @@ 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 { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
||||
import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
|
||||
|
||||
export interface PhraseHandle {
|
||||
@@ -240,12 +242,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
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),
|
||||
// Shared clef/transpose resolution (single-note mode uses the same).
|
||||
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
|
||||
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
||||
[currentInstrumentDef, settings.difficulty]);
|
||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
||||
|
||||
// ---- Status line ----------------------------------------------------------
|
||||
const statusText = useMemo(() => {
|
||||
@@ -260,6 +260,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
}
|
||||
if (p === 'playing') {
|
||||
if (phrase.pace === 'tempo') return `${trainer.summary.matched} matched · ${trainer.summary.missed + trainer.summary.skipped} missed`;
|
||||
if (trainer.repeatedNoteBlocked) return 'Same note again — release, then strike it once more';
|
||||
return 'Play the highlighted note';
|
||||
}
|
||||
return phrase.pace === 'tempo' ? 'Press start — one bar of count-in, then play in time.' : 'Press start, then play the highlighted notes.';
|
||||
@@ -286,6 +287,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const w = window as unknown as { __phraseDebug?: unknown };
|
||||
if (localStorage.getItem('phraseDebug') === '1') {
|
||||
w.__phraseDebug = () => ({
|
||||
frames: rawFrameCount(),
|
||||
micGate: audioEngine.isAudible(),
|
||||
micBlanked: audioEngine.isMicBlanked(),
|
||||
phase: trainer.phase,
|
||||
currentIdx: trainer.currentIdx,
|
||||
statuses: [...trainer.statuses],
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
||||
import { Fretboard } from './Fretboard';
|
||||
import { PianoKeys } from './PianoKeys';
|
||||
import { audioEngine } from '../audio/AudioEngine';
|
||||
@@ -59,11 +59,9 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
|
||||
// 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),
|
||||
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
|
||||
resolveClefTranspose(currentInstrumentDef, 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 [];
|
||||
|
||||
@@ -34,10 +34,22 @@ export interface FeedOutcome {
|
||||
readonly matched: number;
|
||||
}
|
||||
|
||||
export interface PhraseMatcherOptions {
|
||||
/**
|
||||
* At-your-pace fallback: if the user keeps a repeated pitch sounding for
|
||||
* longer than this, assume a re-attack happened and re-arm (a real
|
||||
* guitarist/vocalist rarely holds one pitch that long while waiting).
|
||||
* null = never (tempo mode stays strict).
|
||||
*/
|
||||
readonly unblockAfterMs?: number | null;
|
||||
}
|
||||
|
||||
export class PhraseMatcher {
|
||||
private readonly targetMidis: (number | null)[];
|
||||
private readonly statuses: NoteStatus[];
|
||||
private readonly tracker = new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS);
|
||||
private readonly unblockAfterMs: number | null;
|
||||
private blockedSince: number | null = null;
|
||||
|
||||
private lastFrameAt = 0;
|
||||
private nullAccumMs = 0;
|
||||
@@ -49,8 +61,9 @@ export class PhraseMatcher {
|
||||
private blocked = false;
|
||||
private currentIndex = -1;
|
||||
|
||||
constructor(targetMidis: (number | null)[]) {
|
||||
constructor(targetMidis: (number | null)[], options: PhraseMatcherOptions = {}) {
|
||||
this.targetMidis = [...targetMidis];
|
||||
this.unblockAfterMs = options.unblockAfterMs ?? null;
|
||||
this.statuses = targetMidis.map(midi => (midi === null ? 'rest' : 'pending'));
|
||||
}
|
||||
|
||||
@@ -66,6 +79,7 @@ export class PhraseMatcher {
|
||||
this.candidatePitch = null;
|
||||
this.candidateFrames = 0;
|
||||
this.blocked = false;
|
||||
this.blockedSince = null;
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
@@ -125,6 +139,12 @@ export class PhraseMatcher {
|
||||
this.currentIndex >= 0 &&
|
||||
target !== null &&
|
||||
this.episodePitch === target;
|
||||
this.blockedSince = this.blocked ? (this.lastFrameAt || null) : null;
|
||||
}
|
||||
|
||||
/** Whether the current note is blocked by a sustained same-pitch episode. */
|
||||
isBlocked(): boolean {
|
||||
return this.blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +168,19 @@ export class PhraseMatcher {
|
||||
this.nullAccumMs = 0;
|
||||
}
|
||||
|
||||
// At-your-pace fallback: a very long sustained same-pitch episode is
|
||||
// most likely a re-attack the detector could not separate (contract
|
||||
// D2.4). Re-arm so the user is never permanently stuck.
|
||||
if (
|
||||
this.blocked &&
|
||||
this.unblockAfterMs !== null &&
|
||||
this.blockedSince !== null &&
|
||||
atMs - this.blockedSince > this.unblockAfterMs
|
||||
) {
|
||||
this.blocked = false;
|
||||
this.blockedSince = null;
|
||||
}
|
||||
|
||||
if (midi === null) {
|
||||
this.nullAccumMs += Math.max(0, gap);
|
||||
if (this.nullAccumMs >= RELEASE_EVIDENCE_MS) {
|
||||
|
||||
@@ -20,11 +20,18 @@ export interface RawPitchFrame {
|
||||
type Listener = (frame: RawPitchFrame) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
let emitCount = 0;
|
||||
|
||||
export function emitRawFrame(frame: RawPitchFrame): void {
|
||||
emitCount++;
|
||||
for (const l of listeners) l(frame);
|
||||
}
|
||||
|
||||
/** Total frames emitted since load (diagnostics). */
|
||||
export function rawFrameCount(): number {
|
||||
return emitCount;
|
||||
}
|
||||
|
||||
export function subscribeRawFrames(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
|
||||
@@ -48,6 +48,8 @@ const CLICK_VOLUME = 0.4;
|
||||
const PREVIEW_GROUP = 'phrase-preview';
|
||||
const CLICK_GROUP = 'phrase-clicks';
|
||||
const REPEAT_DELAY_MS = 1400;
|
||||
/** Longest a sustained episode may block a repeated note in at-your-pace mode. */
|
||||
const REARM_AFTER_SUSTAINED_MS = 2500;
|
||||
|
||||
export interface PhraseTrainerApi {
|
||||
readonly phase: PhrasePhase;
|
||||
@@ -59,6 +61,9 @@ export interface PhraseTrainerApi {
|
||||
readonly countInLeft: number;
|
||||
readonly error: string | null;
|
||||
readonly summary: PhraseSummary;
|
||||
/** True when the current note is a repeated pitch still blocked by the
|
||||
* user's sustained episode (status-line hint: release and strike again). */
|
||||
readonly repeatedNoteBlocked: boolean;
|
||||
start: () => void;
|
||||
pauseToggle: () => void;
|
||||
retry: () => void;
|
||||
@@ -82,12 +87,14 @@ export function usePhraseTrainer(
|
||||
const [currentIdx, setCurrentIdx] = useState(-1);
|
||||
const [countInLeft, setCountInLeft] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false);
|
||||
const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 });
|
||||
|
||||
// ---- Active slice (resume tails replace it) ---------------------------
|
||||
const [activeScore, setActiveScore] = useState<Score>(() => sliceScore(fullScore, selection.startBar, selection.barCount));
|
||||
|
||||
const matcherRef = useRef<PhraseMatcher | null>(null);
|
||||
const repeatBlockedRef = useRef(false);
|
||||
const phaseRef = useRef<PhrasePhase>('ready');
|
||||
const paceRef = useRef(config.pace);
|
||||
const configRef = useRef(config);
|
||||
@@ -116,7 +123,12 @@ export function usePhraseTrainer(
|
||||
|
||||
// ---- Matcher lifecycle ------------------------------------------------
|
||||
const resetMatcher = useCallback((score: Score) => {
|
||||
matcherRef.current = new PhraseMatcher(scoreEvents(score).map(e => (e.pitch ? e.pitch.midi : null)));
|
||||
matcherRef.current = new PhraseMatcher(
|
||||
scoreEvents(score).map(e => (e.pitch ? e.pitch.midi : null)),
|
||||
// At-your-pace: assume a re-attack after a long sustained episode
|
||||
// so repeated notes can never deadlock the run (tempo stays strict).
|
||||
{ unblockAfterMs: paceRef.current === 'step' ? REARM_AFTER_SUSTAINED_MS : null },
|
||||
);
|
||||
setStatuses(matcherRef.current.allStatuses());
|
||||
}, []);
|
||||
|
||||
@@ -235,6 +247,11 @@ export function usePhraseTrainer(
|
||||
} else {
|
||||
setCurrentIdx(next);
|
||||
}
|
||||
const blockedNow = m.isBlocked();
|
||||
if (blockedNow !== repeatBlockedRef.current) {
|
||||
repeatBlockedRef.current = blockedNow;
|
||||
setRepeatedNoteBlocked(blockedNow);
|
||||
}
|
||||
}, [finishRun]);
|
||||
|
||||
// ---- Frame handling ----------------------------------------------------
|
||||
@@ -260,10 +277,18 @@ export function usePhraseTrainer(
|
||||
if (paceRef.current === 'step') {
|
||||
idx = m.firstPending;
|
||||
if (idx === -1) return;
|
||||
} else if (phaseRef.current === 'countIn') {
|
||||
// Count-in: episode tracking only, NO scoring (contract D.9).
|
||||
idx = -1;
|
||||
} else {
|
||||
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
|
||||
}
|
||||
const out = m.feed(frame.midi, frame.at, idx);
|
||||
const blockedNow = m.isBlocked();
|
||||
if (blockedNow !== repeatBlockedRef.current) {
|
||||
repeatBlockedRef.current = blockedNow;
|
||||
setRepeatedNoteBlocked(blockedNow);
|
||||
}
|
||||
if (out) {
|
||||
setStatuses(m.allStatuses());
|
||||
if (paceRef.current === 'step') advanceStep(m);
|
||||
@@ -542,5 +567,6 @@ export function usePhraseTrainer(
|
||||
virtualTap,
|
||||
pause,
|
||||
stop,
|
||||
repeatedNoteBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,3 +137,18 @@ export const INSTRUMENT_DEFINITIONS: Record<string, InstrumentDefinition> = {
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clef + display transpose for rendering, shared by the single-note and
|
||||
* phrase renderers (identical semantics: sounding midi + transpose = written).
|
||||
*/
|
||||
export function resolveClefTranspose(
|
||||
instrumentDef: InstrumentDefinition,
|
||||
difficulty: string,
|
||||
): { clef: ClefMode; transpose: number } {
|
||||
const rangeDef = instrumentDef.ranges.find(r => r.id === difficulty);
|
||||
return {
|
||||
clef: rangeDef?.clef ?? instrumentDef.clefMode,
|
||||
transpose: rangeDef?.transpose ?? instrumentDef.transpose,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user