Phrase mode: count-in no-scoring fix, re-arm timeout for repeated notes, browser QA scripts

This commit is contained in:
2026-09-14 01:22:55 +02:00
parent 486f3c5dcd
commit 909c30192f
10 changed files with 374 additions and 19 deletions

21
scripts/README.md Normal file
View 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
View 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
View 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); });