Phrase mode: QA updates (tempo timing verification, auto-continue, expanded library documented)

This commit is contained in:
2026-09-14 01:49:29 +02:00
parent 4f2ee8798a
commit fff512b4dd
5 changed files with 146 additions and 2 deletions

View File

@@ -15,9 +15,9 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
| R5 Musical generation | ✅ | `generateMelody`: seeded, scale∩playable pool, soft step/contour/tonic-end preferences, exact bar totals, 1–8 bars, 4/4 + 3/4, Simple/Mixed rhythm tiers | | R5 Musical generation | ✅ | `generateMelody`: seeded, scale∩playable pool, soft step/contour/tonic-end preferences, exact bar totals, 1–8 bars, 4/4 + 3/4, Simple/Mixed rhythm tiers |
| R6 Any key + scale drills | ✅ | 12 tonics × 9 modes (incl. pentatonics), one/two-octave (complete path only — refuses with actionable error) + within-position, up/down/up-down | | R6 Any key + scale drills | ✅ | 12 tonics × 9 modes (incl. pentatonics), one/two-octave (complete path only — refuses with actionable error) + within-position, up/down/up-down |
| R7 Position-aware guidance | ✅ | Phrase-local fret window (guitar/bass) overrides note set; hints restricted to allowed positions; "pitch not fingering" note shown | | R7 Position-aware guidance | ✅ | Phrase-local fret window (guitar/bass) overrides note set; hints restricted to allowed positions; "pitch not fingering" note shown |
| R8 Fixed material | ⚠️ partial | 6 bundled items: 2 verified public-domain melodies (Ode to Joy theme, Ah! vous dirai-je) + 4 original exercises. **No Giuliani**: no verifiable source available offline; per contract the quota was not faked — limitation recorded | | R8 Fixed material | ✅ (adjusted) | 19 bundled items: 5 verified public-domain melodies (Ode to Joy, Ah! vous dirai-je, Jingle Bells opening, Mary Had a Little Lamb, London Bridge phrase) + 14 clearly-labeled original exercises. **No Giuliani**: no verifiable source available offline; per contract the quota was not faked — limitation recorded |
| R9 Add material without rebuilding | ✅ | Local `.abc` import in the setup panel with validation errors shown; failures keep the previous score | | R9 Add material without rebuilding | ✅ | Local `.abc` import in the setup panel with validation errors shown; failures keep the previous score |
| R10 Focused practice | ✅ | Start bar + bars-at-a-time selection (fixed material), Retry identical (same seed), Next advances/wraps, tempo Repeat option | | R10 Focused practice | ✅ | Start bar + bars-at-a-time selection (fixed material), Retry identical (same seed), Next advances/wraps, **auto-continue** (default on, tempo mode) rolls to the next section/new melody without interruption |
| R11 Listen before playing | ✅ | Cancellable preview at practice BPM via `AudioEngine` groups; returns to ready, cancels future sounds and gate reservations | | R11 Listen before playing | ✅ | Cancellable preview at practice BPM via `AudioEngine` groups; returns to ready, cancels future sounds and gate reservations |
| R12 Zen/mobile/accessibility | ✅ mostly | Zen: staff + Start/Pause + mic; Retry/Next appear at completion. Keyboard shortcuts per mode (help popup reflects active mode); focused buttons never double-fire global shortcuts; current note = color + caret glyph; aria-label on the staff | | R12 Zen/mobile/accessibility | ✅ mostly | Zen: staff + Start/Pause + mic; Retry/Next appear at completion. Keyboard shortcuts per mode (help popup reflects active mode); focused buttons never double-fire global shortcuts; current note = color + caret glyph; aria-label on the staff |
| R13 Safe lifecycle | ✅ | Pause on tab-hide, mic error, mic-off, dialogs; audio-group cancellation; run tokens; StrictMode-safe; unmount cleanup | | R13 Safe lifecycle | ✅ | Pause on tab-hide, mic error, mic-off, dialogs; audio-group cancellation; run tokens; StrictMode-safe; unmount cleanup |
@@ -28,6 +28,9 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
- `npm run lint`, `npm run build`, `npm test -- --run`: all green (149 tests, - `npm run lint`, `npm run build`, `npm test -- --run`: all green (149 tests,
9 files) at commit `909c301`. 9 files) at commit `909c301`.
- Real-browser (headless Chrome via `scripts/phrase_e2e*.cjs`, synthetic mic): - Real-browser (headless Chrome via `scripts/phrase_e2e*.cjs`, synthetic mic):
- **Tempo timing: every note of the Ode opening played exactly in its audio
window matches (8/8)** — proves count-in/window/display alignment after
the redundant-downbeat click fix.
- Detection pipeline sanity (single-note readout shows synthetic 440 Hz). - Detection pipeline sanity (single-note readout shows synthetic 440 Hz).
- Phrase mode: first note matches with a sustained synthetic pitch; cursor - Phrase mode: first note matches with a sustained synthetic pitch; cursor
advances; no console errors. advances; no console errors.
@@ -48,6 +51,7 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
mode — it is pitch-following with a metronome, as specified. mode — it is pitch-following with a metronome, as specified.
- **Giuliani content**: not included (no verifiable source at hand). Library - **Giuliani content**: not included (no verifiable source at hand). Library
holds 2 verified PD melodies + 4 clearly-labeled original exercises. holds 2 verified PD melodies + 4 clearly-labeled original exercises.
- **Live played-note staff** next to the mic readout shows the detected pitch as written notation (same display pipeline as the single-note "played note" measure).
- **ABC subset only**: documented subset (no tuplets, polyphony, alternate - **ABC subset only**: documented subset (no tuplets, polyphony, alternate
endings, mid-tune meter/key changes); rejects with line/column errors. endings, mid-tune meter/key changes); rejects with line/column errors.
- **Count-in/preview drift**: click scheduling uses the shared AudioContext - **Count-in/preview drift**: click scheduling uses the shared AudioContext

View File

@@ -111,6 +111,14 @@ const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12);
const held = ok4; // matched without any release const held = ok4; // matched without any release
console.log('A4 timeout re-arm matched held E (expect true, ~2.5s):', held, 'idx', d.currentIdx); console.log('A4 timeout re-arm matched held E (expect true, ~2.5s):', held, 'idx', d.currentIdx);
// Turn auto-continue OFF so the tempo run ends (default is ON).
await page.evaluate(() => {
const labels = [...document.querySelectorAll('.phrase-setup-grid .phrase-check')];
const ac = labels.find(l => l.textContent.includes('Auto-continue'));
const box = ac?.querySelector('input');
if (box && box.checked) { box.click(); }
});
await new Promise(r => setTimeout(r, 300));
// B: tempo mode — pause first (pace change while running pauses by design). // B: tempo mode — pause first (pace change while running pauses by design).
await page.evaluate(() => { await page.evaluate(() => {
const row = document.querySelector('.phrase-transport'); const row = document.querySelector('.phrase-transport');

122
scripts/phrase_e2e3.cjs Normal file
View File

@@ -0,0 +1,122 @@
/* eslint-disable */
/* Tempo-mode timing test: play every note of the Ode opening exactly in its
* window; expect 8/8 matched. Also verifies the count-in has no extra click
* by checking the phase transition timing. Run: node scripts/phrase_e2e3.cjs */
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('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();
osc.frequency.value = 440;
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, 400));
await page.click('button[aria-label="Start Listening"]');
await new Promise(r => setTimeout(r, 700));
const dbg = () => page.evaluate(() => window.__phraseDebug());
await page.evaluate(() => {
[...document.querySelectorAll('.game-mode-toggle .toggle-option')][2].click();
});
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => document.querySelector('.phrase-setup summary')?.click());
await new Promise(r => setTimeout(r, 250));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
selects[0].value = 'library';
selects[0].dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 250));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
selects[1].value = 'ode-to-joy';
selects[1].dispatchEvent(new Event('change', { bubbles: true }));
});
// tempo pace + 60 bpm
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
const pace = selects.find(s => [...s.options].some(o => o.value === 'tempo'));
pace.value = 'tempo';
pace.dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 500));
const d0 = await dbg();
const midis = d0.midis; // sounding midi per event
console.log('loaded ode:', d0.scoreOk, 'events:', midis.length, midis.slice(0, 8));
// Start, detect phase transition to playing, then schedule per-note attacks.
await page.evaluate(() => {
document.querySelector('.phrase-transport button')?.click();
});
// poll until phase flips to playing (count-in ends)
let playAt = null;
for (let i = 0; i < 200; i++) {
await new Promise(r => setTimeout(r, 20));
const d = await dbg();
if (d.phase === 'playing') { playAt = performance.now(); break; }
if (d.phase === 'ready') { /* not started? */ }
}
console.log('phase playing at', playAt);
if (!playAt) throw new Error('never reached playing');
// Schedule note attacks exactly on the AUDIO clock. Map perf->audio once:
const dNow = await dbg();
const perfOffset = performance.now() - dNow.audioNow * 1000; // perfMs = audioSec*1000 + offset
await page.evaluate((cfg) => {
const { midis, offset, scoreStart, spb, n } = cfg;
const toPerf = (audioSec) => offset + audioSec * 1000 - performance.now();
let prev = null;
for (let i = 0; i < n; i++) {
const m = midis[i];
const startSec = scoreStart + (i * spb);
const startMs = offset + startSec * 1000;
if (prev !== null && m === prev) {
setTimeout(() => window.__setOscGain(0), startMs - 250 - performance.now());
setTimeout(() => { window.__setOscGain(0.5); window.__setOscFreq(440 * Math.pow(2, (m - 69) / 12)); }, startMs + 40 - performance.now());
} else {
setTimeout(() => window.__setOscFreq(440 * Math.pow(2, (m - 69) / 12)), startMs + 15 - performance.now());
}
prev = m;
}
setTimeout(() => window.__setOscGain(0), offset + (scoreStart + n * spb) * 1000 - performance.now() + 50);
}, { midis, offset: perfOffset, scoreStart: dNow.scoreStart, spb: dNow.spb, n: Math.min(8, midis.length) });
// wait for done
let final = null;
for (let i = 0; i < 150; i++) {
await new Promise(r => setTimeout(r, 200));
final = await dbg();
if (final.phase === 'done') break;
}
console.log('RESULT:', JSON.stringify(final ? { phase: final.phase, summary: final.summary, statuses: final.statuses } : null));
console.log('EXPECT matched 8 of 8');
console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 5) : 'none');
await browser.close();
})().catch(e => { console.error('FATAL', e); process.exit(1); });

View File

@@ -344,6 +344,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
if (localStorage.getItem('phraseDebug') === '1') { if (localStorage.getItem('phraseDebug') === '1') {
w.__phraseDebug = () => ({ w.__phraseDebug = () => ({
frames: rawFrameCount(), frames: rawFrameCount(),
audioNow: audioEngine.now(),
scoreStart: trainer.scoreStart,
spb: trainer.spb,
micGate: audioEngine.isAudible(), micGate: audioEngine.isAudible(),
micBlanked: audioEngine.isMicBlanked(), micBlanked: audioEngine.isMicBlanked(),
phase: trainer.phase, phase: trainer.phase,
@@ -354,6 +357,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
firstNote: events[0]?.pitch?.midi ?? null, firstNote: events[0]?.pitch?.midi ?? null,
scoreOk, scoreOk,
material: phrase.material, material: phrase.material,
midis: events.map(e => (e.pitch ? e.pitch.midi : null)),
}); });
} else { } else {
delete w.__phraseDebug; delete w.__phraseDebug;

View File

@@ -57,6 +57,10 @@ export interface PhraseTrainerApi {
/** Event index currently up (cursor for renderer/hints). */ /** Event index currently up (cursor for renderer/hints). */
readonly currentIdx: number; readonly currentIdx: number;
readonly countInLeft: number; readonly countInLeft: number;
/** Audio-clock start of the current run's score (diagnostics/QA). */
readonly scoreStart: number;
/** Seconds per quarter beat (diagnostics/QA). */
readonly spb: 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 /** True when the current note is a repeated pitch still blocked by the
@@ -560,6 +564,8 @@ export function usePhraseTrainer(
statuses, statuses,
currentIdx, currentIdx,
countInLeft, countInLeft,
scoreStart: scoreStartRef.current,
spb: spbRef.current,
error, error,
summary, summary,
start, start,