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:
21
scripts/README.md
Normal file
21
scripts/README.md
Normal file
@@ -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.
|
||||||
92
scripts/phrase_e2e.cjs
Normal file
92
scripts/phrase_e2e.cjs
Normal file
@@ -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); });
|
||||||
147
scripts/phrase_e2e2.cjs
Normal file
147
scripts/phrase_e2e2.cjs
Normal file
@@ -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); });
|
||||||
@@ -17,7 +17,7 @@ import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, St
|
|||||||
import { scoreEvents, type Score } from '../score/model';
|
import { scoreEvents, type Score } from '../score/model';
|
||||||
import { STEP_LETTERS, type StepLetter } from '../score/model';
|
import { STEP_LETTERS, type StepLetter } from '../score/model';
|
||||||
import type { NoteStatus } from '../game/PhraseMatcher';
|
import type { NoteStatus } from '../game/PhraseMatcher';
|
||||||
import { keyFor, isMode } from '../music/scales';
|
import { keyFor, isMode, spelledPitch } from '../music/scales';
|
||||||
|
|
||||||
interface PhraseSheetMusicProps {
|
interface PhraseSheetMusicProps {
|
||||||
score: Score;
|
score: Score;
|
||||||
@@ -174,12 +174,24 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
|||||||
note = new StaveNote({ keys: ['b/4'], duration: `${code}r`, clef: staveKey });
|
note = new StaveNote({ keys: ['b/4'], duration: `${code}r`, clef: staveKey });
|
||||||
} else {
|
} else {
|
||||||
const p = ev.pitch!;
|
const p = ev.pitch!;
|
||||||
const stateKey = `${p.step}${p.octave}`;
|
// WRITTEN pitch for the staff: sounding midi + display
|
||||||
const expected = measureState[stateKey] ?? sigAlter[p.step];
|
// transpose, octave recomputed via the shared helper —
|
||||||
const glyph = p.alter !== expected ? accidentalGlyph(p.alter) : undefined;
|
// the same sounding+transpose semantics as single-note
|
||||||
measureState[stateKey] = p.alter;
|
// 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({
|
note = new StaveNote({
|
||||||
keys: [keyString(p.step, p.alter, p.octave)],
|
keys: [keyString(written.step, written.alter, written.octave)],
|
||||||
duration: code,
|
duration: code,
|
||||||
clef: staveKey,
|
clef: staveKey,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useSettings } from '../context/useSettings';
|
|||||||
import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
||||||
import { usePhraseTrainer, type PhraseTrainerApi } from '../hooks/usePhraseTrainer';
|
import { usePhraseTrainer, type PhraseTrainerApi } from '../hooks/usePhraseTrainer';
|
||||||
import { getPhraseSettings, type PhraseSettings } from '../types/SettingsTypes';
|
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 { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||||
import { generateMelody } from '../music/melodyGenerator';
|
import { generateMelody } from '../music/melodyGenerator';
|
||||||
import { generateScaleDrill } from '../music/scaleDrills';
|
import { generateScaleDrill } from '../music/scaleDrills';
|
||||||
@@ -26,7 +28,7 @@ import { PhraseSheetMusic } from './PhraseSheetMusic';
|
|||||||
import { Fretboard } from './Fretboard';
|
import { Fretboard } from './Fretboard';
|
||||||
import { PianoKeys } from './PianoKeys';
|
import { PianoKeys } from './PianoKeys';
|
||||||
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
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';
|
import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
|
||||||
|
|
||||||
export interface PhraseHandle {
|
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
|
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null); // accepted by Fretboard/PianoKeys; phrase renderer has no hover preview
|
||||||
void hoveredMidi;
|
void hoveredMidi;
|
||||||
|
|
||||||
// Current range def → clef/transpose (same resolution as single-note mode).
|
// Shared clef/transpose resolution (single-note mode uses the same).
|
||||||
const currentRangeDef = useMemo(() =>
|
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
|
||||||
currentInstrumentDef.ranges.find(r => r.id === settings.difficulty),
|
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
||||||
[currentInstrumentDef, settings.difficulty]);
|
[currentInstrumentDef, settings.difficulty]);
|
||||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
|
||||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
|
||||||
|
|
||||||
// ---- Status line ----------------------------------------------------------
|
// ---- Status line ----------------------------------------------------------
|
||||||
const statusText = useMemo(() => {
|
const statusText = useMemo(() => {
|
||||||
@@ -260,6 +260,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
}
|
}
|
||||||
if (p === 'playing') {
|
if (p === 'playing') {
|
||||||
if (phrase.pace === 'tempo') return `${trainer.summary.matched} matched · ${trainer.summary.missed + trainer.summary.skipped} missed`;
|
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 '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.';
|
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 };
|
const w = window as unknown as { __phraseDebug?: unknown };
|
||||||
if (localStorage.getItem('phraseDebug') === '1') {
|
if (localStorage.getItem('phraseDebug') === '1') {
|
||||||
w.__phraseDebug = () => ({
|
w.__phraseDebug = () => ({
|
||||||
|
frames: rawFrameCount(),
|
||||||
|
micGate: audioEngine.isAudible(),
|
||||||
|
micBlanked: audioEngine.isMicBlanked(),
|
||||||
phase: trainer.phase,
|
phase: trainer.phase,
|
||||||
currentIdx: trainer.currentIdx,
|
currentIdx: trainer.currentIdx,
|
||||||
statuses: [...trainer.statuses],
|
statuses: [...trainer.statuses],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
|||||||
import { useGameLogic } from '../hooks/useGameLogic';
|
import { useGameLogic } from '../hooks/useGameLogic';
|
||||||
import { getNoteDetails } from '../music/NoteUtils';
|
import { getNoteDetails } from '../music/NoteUtils';
|
||||||
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
||||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
||||||
import { Fretboard } from './Fretboard';
|
import { Fretboard } from './Fretboard';
|
||||||
import { PianoKeys } from './PianoKeys';
|
import { PianoKeys } from './PianoKeys';
|
||||||
import { audioEngine } from '../audio/AudioEngine';
|
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.
|
// Mic feedback is gated while the app's speaker output is audible.
|
||||||
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
|
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
|
||||||
|
|
||||||
const currentRangeDef = useMemo(() =>
|
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
|
||||||
currentInstrumentDef.ranges.find(r => r.id === settings.difficulty),
|
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
||||||
[currentInstrumentDef, settings.difficulty]);
|
[currentInstrumentDef, settings.difficulty]);
|
||||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
|
||||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
|
||||||
|
|
||||||
const hintPositions = useMemo(() => {
|
const hintPositions = useMemo(() => {
|
||||||
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning) return [];
|
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning) return [];
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ export interface FeedOutcome {
|
|||||||
readonly matched: number;
|
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 {
|
export class PhraseMatcher {
|
||||||
private readonly targetMidis: (number | null)[];
|
private readonly targetMidis: (number | null)[];
|
||||||
private readonly statuses: NoteStatus[];
|
private readonly statuses: NoteStatus[];
|
||||||
private readonly tracker = new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS);
|
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 lastFrameAt = 0;
|
||||||
private nullAccumMs = 0;
|
private nullAccumMs = 0;
|
||||||
@@ -49,8 +61,9 @@ export class PhraseMatcher {
|
|||||||
private blocked = false;
|
private blocked = false;
|
||||||
private currentIndex = -1;
|
private currentIndex = -1;
|
||||||
|
|
||||||
constructor(targetMidis: (number | null)[]) {
|
constructor(targetMidis: (number | null)[], options: PhraseMatcherOptions = {}) {
|
||||||
this.targetMidis = [...targetMidis];
|
this.targetMidis = [...targetMidis];
|
||||||
|
this.unblockAfterMs = options.unblockAfterMs ?? null;
|
||||||
this.statuses = targetMidis.map(midi => (midi === null ? 'rest' : 'pending'));
|
this.statuses = targetMidis.map(midi => (midi === null ? 'rest' : 'pending'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,7 @@ export class PhraseMatcher {
|
|||||||
this.candidatePitch = null;
|
this.candidatePitch = null;
|
||||||
this.candidateFrames = 0;
|
this.candidateFrames = 0;
|
||||||
this.blocked = false;
|
this.blocked = false;
|
||||||
|
this.blockedSince = null;
|
||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +139,12 @@ export class PhraseMatcher {
|
|||||||
this.currentIndex >= 0 &&
|
this.currentIndex >= 0 &&
|
||||||
target !== null &&
|
target !== null &&
|
||||||
this.episodePitch === target;
|
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;
|
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) {
|
if (midi === null) {
|
||||||
this.nullAccumMs += Math.max(0, gap);
|
this.nullAccumMs += Math.max(0, gap);
|
||||||
if (this.nullAccumMs >= RELEASE_EVIDENCE_MS) {
|
if (this.nullAccumMs >= RELEASE_EVIDENCE_MS) {
|
||||||
|
|||||||
@@ -20,11 +20,18 @@ export interface RawPitchFrame {
|
|||||||
type Listener = (frame: RawPitchFrame) => void;
|
type Listener = (frame: RawPitchFrame) => void;
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
const listeners = new Set<Listener>();
|
||||||
|
let emitCount = 0;
|
||||||
|
|
||||||
export function emitRawFrame(frame: RawPitchFrame): void {
|
export function emitRawFrame(frame: RawPitchFrame): void {
|
||||||
|
emitCount++;
|
||||||
for (const l of listeners) l(frame);
|
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 {
|
export function subscribeRawFrames(listener: Listener): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => { listeners.delete(listener); };
|
return () => { listeners.delete(listener); };
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ const CLICK_VOLUME = 0.4;
|
|||||||
const PREVIEW_GROUP = 'phrase-preview';
|
const PREVIEW_GROUP = 'phrase-preview';
|
||||||
const CLICK_GROUP = 'phrase-clicks';
|
const CLICK_GROUP = 'phrase-clicks';
|
||||||
const REPEAT_DELAY_MS = 1400;
|
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 {
|
export interface PhraseTrainerApi {
|
||||||
readonly phase: PhrasePhase;
|
readonly phase: PhrasePhase;
|
||||||
@@ -59,6 +61,9 @@ export interface PhraseTrainerApi {
|
|||||||
readonly countInLeft: number;
|
readonly countInLeft: number;
|
||||||
readonly error: string | null;
|
readonly error: string | null;
|
||||||
readonly summary: PhraseSummary;
|
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;
|
start: () => void;
|
||||||
pauseToggle: () => void;
|
pauseToggle: () => void;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
@@ -82,12 +87,14 @@ export function usePhraseTrainer(
|
|||||||
const [currentIdx, setCurrentIdx] = useState(-1);
|
const [currentIdx, setCurrentIdx] = useState(-1);
|
||||||
const [countInLeft, setCountInLeft] = useState(0);
|
const [countInLeft, setCountInLeft] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 });
|
const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 });
|
||||||
|
|
||||||
// ---- Active slice (resume tails replace it) ---------------------------
|
// ---- Active slice (resume tails replace it) ---------------------------
|
||||||
const [activeScore, setActiveScore] = useState<Score>(() => sliceScore(fullScore, selection.startBar, selection.barCount));
|
const [activeScore, setActiveScore] = useState<Score>(() => sliceScore(fullScore, selection.startBar, selection.barCount));
|
||||||
|
|
||||||
const matcherRef = useRef<PhraseMatcher | null>(null);
|
const matcherRef = useRef<PhraseMatcher | null>(null);
|
||||||
|
const repeatBlockedRef = useRef(false);
|
||||||
const phaseRef = useRef<PhrasePhase>('ready');
|
const phaseRef = useRef<PhrasePhase>('ready');
|
||||||
const paceRef = useRef(config.pace);
|
const paceRef = useRef(config.pace);
|
||||||
const configRef = useRef(config);
|
const configRef = useRef(config);
|
||||||
@@ -116,7 +123,12 @@ export function usePhraseTrainer(
|
|||||||
|
|
||||||
// ---- Matcher lifecycle ------------------------------------------------
|
// ---- Matcher lifecycle ------------------------------------------------
|
||||||
const resetMatcher = useCallback((score: Score) => {
|
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());
|
setStatuses(matcherRef.current.allStatuses());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -235,6 +247,11 @@ export function usePhraseTrainer(
|
|||||||
} else {
|
} else {
|
||||||
setCurrentIdx(next);
|
setCurrentIdx(next);
|
||||||
}
|
}
|
||||||
|
const blockedNow = m.isBlocked();
|
||||||
|
if (blockedNow !== repeatBlockedRef.current) {
|
||||||
|
repeatBlockedRef.current = blockedNow;
|
||||||
|
setRepeatedNoteBlocked(blockedNow);
|
||||||
|
}
|
||||||
}, [finishRun]);
|
}, [finishRun]);
|
||||||
|
|
||||||
// ---- Frame handling ----------------------------------------------------
|
// ---- Frame handling ----------------------------------------------------
|
||||||
@@ -260,10 +277,18 @@ export function usePhraseTrainer(
|
|||||||
if (paceRef.current === 'step') {
|
if (paceRef.current === 'step') {
|
||||||
idx = m.firstPending;
|
idx = m.firstPending;
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
} else if (phaseRef.current === 'countIn') {
|
||||||
|
// Count-in: episode tracking only, NO scoring (contract D.9).
|
||||||
|
idx = -1;
|
||||||
} else {
|
} else {
|
||||||
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
|
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
|
||||||
}
|
}
|
||||||
const out = m.feed(frame.midi, frame.at, idx);
|
const out = m.feed(frame.midi, frame.at, idx);
|
||||||
|
const blockedNow = m.isBlocked();
|
||||||
|
if (blockedNow !== repeatBlockedRef.current) {
|
||||||
|
repeatBlockedRef.current = blockedNow;
|
||||||
|
setRepeatedNoteBlocked(blockedNow);
|
||||||
|
}
|
||||||
if (out) {
|
if (out) {
|
||||||
setStatuses(m.allStatuses());
|
setStatuses(m.allStatuses());
|
||||||
if (paceRef.current === 'step') advanceStep(m);
|
if (paceRef.current === 'step') advanceStep(m);
|
||||||
@@ -542,5 +567,6 @@ export function usePhraseTrainer(
|
|||||||
virtualTap,
|
virtualTap,
|
||||||
pause,
|
pause,
|
||||||
stop,
|
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