From 909c30192f2ac23126e80c56b62f5ec70b5aa4db Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Mon, 14 Sep 2026 01:22:55 +0200 Subject: [PATCH] Phrase mode: count-in no-scoring fix, re-arm timeout for repeated notes, browser QA scripts --- scripts/README.md | 21 ++++ scripts/phrase_e2e.cjs | 92 +++++++++++++++++ scripts/phrase_e2e2.cjs | 147 +++++++++++++++++++++++++++ src/components/PhraseSheetMusic.tsx | 24 +++-- src/components/PhraseTrainer.tsx | 16 +-- src/components/SingleNoteTrainer.tsx | 8 +- src/game/PhraseMatcher.ts | 35 ++++++- src/hooks/rawFrameBus.ts | 7 ++ src/hooks/usePhraseTrainer.ts | 28 ++++- src/music/InstrumentConfigs.ts | 15 +++ 10 files changed, 374 insertions(+), 19 deletions(-) create mode 100644 scripts/README.md create mode 100644 scripts/phrase_e2e.cjs create mode 100644 scripts/phrase_e2e2.cjs diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..16a30e8 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,21 @@ +# Browser QA scripts + +Synthetic-microphone end-to-end checks for Phrase Mode (puppeteer-core + +system Chrome). They stub `getUserMedia` with a tunable oscillator feeding a +`MediaStreamDestination`, so note detection is exercised through the real +detection pipeline — not only through virtual taps. + +Run: + +```sh +npm i --no-save puppeteer-core # does not modify package.json +npm run build +npx vite preview --port 4199 & # serve dist +node scripts/phrase_e2e.cjs # matcher basics (first-note match) +node scripts/phrase_e2e2.cjs # repeated notes, release re-attack, + # timeout re-arm, tempo count-in/misses +``` + +In-app debug state is available with `localStorage.phraseDebug = '1'` +(`window.__phraseDebug()` returns phase, cursor, statuses, summary, pool, +frame count, mic-gate state). Only ever a QA aid, not part of the UI. diff --git a/scripts/phrase_e2e.cjs b/scripts/phrase_e2e.cjs new file mode 100644 index 0000000..9e62c4e --- /dev/null +++ b/scripts/phrase_e2e.cjs @@ -0,0 +1,92 @@ +/* eslint-disable */ +/* Synthetic-mic end-to-end test for Phrase Mode (puppeteer-core + system Chrome). + * Feeds a sustained oscillator into a fake getUserMedia stream, then verifies + * the phrase trainer advances when the current note matches. */ +const puppeteer = require('puppeteer-core'); + +const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const URL = 'http://localhost:4199/'; +const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12); + +(async () => { + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: 'new', + args: ['--no-sandbox', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'], + }); + const page = await browser.newPage(); + const errors = []; + page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); + page.on('pageerror', (err) => errors.push('PAGEERROR: ' + err.message)); + + await page.evaluateOnNewDocument(() => { + localStorage.setItem('phraseDebug', '1'); + // Stub the microphone with an oscillator we can retune. + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = 440; + const gain = ctx.createGain(); + gain.gain.value = 0.5; + const dest = ctx.createMediaStreamDestination(); + osc.connect(gain).connect(dest); + osc.start(); + window.__setOscFreq = (hz) => { osc.frequency.value = hz; }; + navigator.mediaDevices.getUserMedia = async () => dest.stream; + }); + + await page.goto(URL, { waitUntil: 'networkidle2' }); + await new Promise(r => setTimeout(r, 800)); + + // 1. Turn the mic on (click the mic button) and verify detection works at all. + await page.click('button[aria-label="Start Listening"]'); + await new Promise(r => setTimeout(r, 1500)); + const readout = await page.$eval('.pitch-readout', el => el.textContent).catch(() => null); + console.log('STEP1 single-note readout (440Hz):', JSON.stringify(readout)); + + // 2. Switch to Phrases mode. + const buttons = await page.$$('.game-mode-toggle .toggle-option'); + await buttons[2].click(); + await new Promise(r => setTimeout(r, 1200)); + + let dbg = await page.evaluate(() => window.__phraseDebug ? window.__phraseDebug() : null); + console.log('STEP2 phrase debug:', JSON.stringify(dbg && { frames: dbg.frames, scoreOk: dbg.scoreOk, phase: dbg.phase, firstNote: dbg.firstNote, material: dbg.material })); + + // 3. Retune the oscillator to the FIRST note of the phrase and press Start. + if (dbg && dbg.firstNote !== null) { + const hz = midiToFreq(dbg.firstNote); + await page.evaluate((f) => window.__setOscFreq(f), hz); + console.log('oscillator set to', hz, 'Hz (midi', dbg.firstNote, ')'); + } + // Start button (first button in the transport row) + const startBtn = await page.evaluateHandle(() => { + const row = document.querySelector('.phrase-transport'); + return row ? row.querySelector('button') : null; + }); + await (startBtn.asElement ? startBtn.asElement() : startBtn).click(); + await new Promise(r => setTimeout(r, 1500)); + + dbg = await page.evaluate(() => window.__phraseDebug()); + console.log('STEP3 after Start:', JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, frames: dbg.frames, gate: dbg.micGate, blanked: dbg.micBlanked })); + + // 4. Wait for the first note to match (hold 50ms on sustained oscillator). + let matched = false; + for (let i = 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 250)); + dbg = await page.evaluate(() => window.__phraseDebug()); + if (dbg.currentIdx >= 1 || (dbg.statuses && dbg.statuses[0] === 'matched')) { matched = true; break; } + } + console.log('STEP4 first note matched:', matched, JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, statuses: dbg.statuses, summary: dbg.summary })); + + // 5. Move the oscillator to the SECOND note and check further progress. + if (matched) { + // find second note pitch via debug statuses? read from events through a hack: statuses only. + // Instead: keep oscillator, and just report the state after another few seconds. + await new Promise(r => setTimeout(r, 2500)); + dbg = await page.evaluate(() => window.__phraseDebug()); + console.log('STEP5 after 2.5s more:', JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, summary: dbg.summary })); + } + + console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 6) : 'none'); + await browser.close(); +})().catch(e => { console.error('FATAL', e); process.exit(1); }); diff --git a/scripts/phrase_e2e2.cjs b/scripts/phrase_e2e2.cjs new file mode 100644 index 0000000..d9b80f6 --- /dev/null +++ b/scripts/phrase_e2e2.cjs @@ -0,0 +1,147 @@ +/* eslint-disable */ +/* Synthetic-mic end-to-end tests, part 2: repeated notes, release re-attack, + * timeout re-arm, tempo mode. Run: node phrase_e2e2.cjs (preview on :4199) */ +const puppeteer = require('puppeteer-core'); + +const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const URL = 'http://localhost:4199/'; +const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12); + +(async () => { + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: 'new', + args: ['--no-sandbox', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'], + }); + const page = await browser.newPage(); + const errors = []; + page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); + page.on('pageerror', (err) => errors.push('PAGEERROR: ' + err.message)); + + await page.evaluateOnNewDocument(() => { + localStorage.setItem('phraseDebug', '1'); + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + const gain = ctx.createGain(); + gain.gain.value = 0.5; + const dest = ctx.createMediaStreamDestination(); + osc.connect(gain).connect(dest); + osc.start(); + window.__setOscFreq = (hz) => { osc.frequency.value = hz; }; + window.__setOscGain = (g) => { gain.gain.value = g; }; + navigator.mediaDevices.getUserMedia = async () => dest.stream; + }); + + await page.goto(URL, { waitUntil: 'networkidle2' }); + await new Promise(r => setTimeout(r, 500)); + await page.click('button[aria-label="Start Listening"]'); + await new Promise(r => setTimeout(r, 800)); + + const dbg = () => page.evaluate(() => window.__phraseDebug()); + + // Switch to Phrases, then select the Ode exercise via the setup panel. + const toggles = await page.$$('.game-mode-toggle .toggle-option'); + await toggles[2].click(); + await new Promise(r => setTimeout(r, 600)); + // open setup panel and choose material=library, exercise=ode-to-joy + await page.evaluate(() => { + const summary = document.querySelector('.phrase-setup summary'); + if (summary) summary.click(); + }); + await new Promise(r => setTimeout(r, 300)); + await page.evaluate(() => { + const selects = [...document.querySelectorAll('.phrase-setup-grid select')]; + const material = selects[0]; + material.value = 'library'; + material.dispatchEvent(new Event('change', { bubbles: true })); + }); + await new Promise(r => setTimeout(r, 300)); + await page.evaluate(() => { + const selects = [...document.querySelectorAll('.phrase-setup-grid select')]; + const exSel = selects[1]; // Exercise select (material === library) + exSel.value = 'ode-to-joy'; + exSel.dispatchEvent(new Event('change', { bubbles: true })); + }); + await new Promise(r => setTimeout(r, 600)); + let d = await dbg(); + console.log('SETUP ode loaded:', d.scoreOk, 'firstNote(midi):', d.firstNote); + + // Ode starts E E F G: play E4 (64) = 329.63 Hz + await page.evaluate((f) => window.__setOscFreq(f), midiToFreq(64)); + const startBtn = await page.evaluateHandle(() => document.querySelector('.phrase-transport button')); + await (startBtn.asElement ? startBtn.asElement() : startBtn).click(); + await new Promise(r => setTimeout(r, 1200)); + d = await dbg(); + console.log('A started:', d.phase, 'idx', d.currentIdx, 'statuses', JSON.stringify(d.statuses?.slice(0, 3))); + + // A1: first E should match quickly + let ok1 = false; + for (let i = 0; i < 20; i++) { await new Promise(r => setTimeout(r, 200)); d = await dbg(); if (d.currentIdx >= 1) { ok1 = true; break; } } + console.log('A1 first E matched:', ok1, 'idx', d.currentIdx); + + // A2: second E (same pitch, sustained) must NOT match while blocked (within ~1.5s) + await new Promise(r => setTimeout(r, 1500)); + d = await dbg(); + const blockedHeld = d.currentIdx === 1 && d.statuses[1] === 'pending'; + console.log('A2 sustained E still blocked after 1.5s:', blockedHeld, 'idx', d.currentIdx); + + // A3: release (mute 300ms), re-attack → should match + await page.evaluate(() => window.__setOscGain(0)); + await new Promise(r => setTimeout(r, 400)); + await page.evaluate(() => window.__setOscGain(0.5)); + let ok3 = false; + for (let i = 0; i < 20; i++) { await new Promise(r => setTimeout(r, 200)); d = await dbg(); if (d.currentIdx >= 2) { ok3 = true; break; } } + console.log('A3 release + re-attack matched:', ok3, 'idx', d.currentIdx, JSON.stringify(d.statuses?.slice(0, 4))); + + // A4: timeout re-arm — retry, hold E through the block; after ~2.6s it should unblock. + await page.evaluate(() => { const r = document.querySelector('.phrase-transport .hint-button'); }); + await page.evaluate(() => { + const row = document.querySelector('.phrase-transport'); + const buttons = [...row.querySelectorAll('button')]; + const retry = buttons.find(b => b.textContent.includes('Retry')); + retry && retry.click(); + }); + await new Promise(r => setTimeout(r, 800)); + d = await dbg(); + // After retry the first E matches quickly (idx >= 1). Now hold E forever + // (never mute): the 2.5s timeout should re-arm and match note 1 (idx 2). + let ok4 = false; + for (let i = 0; i < 25; i++) { await new Promise(r => setTimeout(r, 250)); d = await dbg(); if (d.currentIdx >= 2) { ok4 = true; break; } } + const held = ok4; // matched without any release + console.log('A4 timeout re-arm matched held E (expect true, ~2.5s):', held, 'idx', d.currentIdx); + + // B: tempo mode — pause first (pace change while running pauses by design). + await page.evaluate(() => { + const row = document.querySelector('.phrase-transport'); + [...row.querySelectorAll('button')].find(b => ['Pause', 'Resume', 'Start'].some(t => b.textContent.includes(t)))?.click(); + }); + await new Promise(r => setTimeout(r, 500)); + await page.evaluate(() => { + const selects = [...document.querySelectorAll('.phrase-setup-grid select')]; + const pace = selects.find(s => [...s.options].some(o => o.value === 'tempo')); + if (pace) { pace.value = 'tempo'; pace.dispatchEvent(new Event('change', { bubbles: true })); } + }); + await new Promise(r => setTimeout(r, 600)); + await page.evaluate(() => { + const row = document.querySelector('.phrase-transport'); + row?.querySelector('button')?.click(); // first transport button (Start/Resume) + }); + await new Promise(r => setTimeout(r, 1000)); + d = await dbg(); + console.log('B1 count-in:', d.phase); + // Mute the oscillator so nothing matches; count-in 4 beats @60bpm = 4s + run. + await page.evaluate(() => window.__setOscGain(0)); + await new Promise(r => setTimeout(r, 1300)); + d = await dbg(); + console.log('B2 during/after count-in phase:', d.phase); + // Let it play through ~15s of phrase + await new Promise(r => setTimeout(r, 15000)); + d = await dbg(); + console.log('B3 after run:', JSON.stringify({ phase: d.phase, summary: d.summary, statuses: d.statuses?.slice(0, 6) })); + const allMissed = d.statuses && d.statuses.every(s => s === 'missed' || s === 'rest' || s === 'pending'); + console.log('B4 windows advanced with silence (misses, no deadlock):', allMissed && d.summary.missed > 0); + + console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 6) : 'none'); + await browser.close(); +})().catch(e => { console.error('FATAL', e); process.exit(1); }); diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx index 9101d4a..b4669ce 100644 --- a/src/components/PhraseSheetMusic.tsx +++ b/src/components/PhraseSheetMusic.tsx @@ -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 = ({ 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, }); diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 5de02b3..d9a06ad 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -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( const [hoveredMidi, setHoveredMidi] = useState(null); // accepted by Fretboard/PianoKeys; phrase renderer has no hover preview void hoveredMidi; - // Current range def → clef/transpose (same resolution as single-note mode). - const currentRangeDef = useMemo(() => - currentInstrumentDef.ranges.find(r => r.id === settings.difficulty), + // 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( } 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( 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], diff --git a/src/components/SingleNoteTrainer.tsx b/src/components/SingleNoteTrainer.tsx index 5e6696d..2f896a8 100644 --- a/src/components/SingleNoteTrainer.tsx +++ b/src/components/SingleNoteTrainer.tsx @@ -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 - 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 []; diff --git a/src/game/PhraseMatcher.ts b/src/game/PhraseMatcher.ts index 848d8dd..c1ea345 100644 --- a/src/game/PhraseMatcher.ts +++ b/src/game/PhraseMatcher.ts @@ -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) { diff --git a/src/hooks/rawFrameBus.ts b/src/hooks/rawFrameBus.ts index 0368cf7..8e97c5e 100644 --- a/src/hooks/rawFrameBus.ts +++ b/src/hooks/rawFrameBus.ts @@ -20,11 +20,18 @@ export interface RawPitchFrame { type Listener = (frame: RawPitchFrame) => void; const listeners = new Set(); +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); }; diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index f695305..59ddf18 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -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(null); + const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false); const [accumulated, setAccumulated] = useState({ total: 0, matched: 0, missed: 0, skipped: 0 }); // ---- Active slice (resume tails replace it) --------------------------- const [activeScore, setActiveScore] = useState(() => sliceScore(fullScore, selection.startBar, selection.barCount)); const matcherRef = useRef(null); + const repeatBlockedRef = useRef(false); const phaseRef = useRef('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, }; } diff --git a/src/music/InstrumentConfigs.ts b/src/music/InstrumentConfigs.ts index b33d2c9..be1369a 100644 --- a/src/music/InstrumentConfigs.ts +++ b/src/music/InstrumentConfigs.ts @@ -137,3 +137,18 @@ export const INSTRUMENT_DEFINITIONS: Record = { ] } }; + +/** + * 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, + }; +}