diff --git a/docs/PHRASE_DELIVERY.md b/docs/PHRASE_DELIVERY.md index 9b55fad..3bb59f4 100644 --- a/docs/PHRASE_DELIVERY.md +++ b/docs/PHRASE_DELIVERY.md @@ -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 | | 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 | -| 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 | -| 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 | | 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 | @@ -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, 9 files) at commit `909c301`. - 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). - Phrase mode: first note matches with a sustained synthetic pitch; cursor 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. - **Giuliani content**: not included (no verifiable source at hand). Library 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 endings, mid-tune meter/key changes); rejects with line/column errors. - **Count-in/preview drift**: click scheduling uses the shared AudioContext diff --git a/scripts/phrase_e2e2.cjs b/scripts/phrase_e2e2.cjs index d9b80f6..3aba6d0 100644 --- a/scripts/phrase_e2e2.cjs +++ b/scripts/phrase_e2e2.cjs @@ -111,6 +111,14 @@ const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12); const held = ok4; // matched without any release 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). await page.evaluate(() => { const row = document.querySelector('.phrase-transport'); diff --git a/scripts/phrase_e2e3.cjs b/scripts/phrase_e2e3.cjs new file mode 100644 index 0000000..c45e12e --- /dev/null +++ b/scripts/phrase_e2e3.cjs @@ -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); }); diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 9753ec1..9d9a0d5 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -344,6 +344,9 @@ export const PhraseTrainer = forwardRef( if (localStorage.getItem('phraseDebug') === '1') { w.__phraseDebug = () => ({ frames: rawFrameCount(), + audioNow: audioEngine.now(), + scoreStart: trainer.scoreStart, + spb: trainer.spb, micGate: audioEngine.isAudible(), micBlanked: audioEngine.isMicBlanked(), phase: trainer.phase, @@ -354,6 +357,7 @@ export const PhraseTrainer = forwardRef( firstNote: events[0]?.pitch?.midi ?? null, scoreOk, material: phrase.material, + midis: events.map(e => (e.pitch ? e.pitch.midi : null)), }); } else { delete w.__phraseDebug; diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index 79939cb..770aa5e 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -57,6 +57,10 @@ export interface PhraseTrainerApi { /** Event index currently up (cursor for renderer/hints). */ readonly currentIdx: 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 summary: PhraseSummary; /** True when the current note is a repeated pitch still blocked by the @@ -560,6 +564,8 @@ export function usePhraseTrainer( statuses, currentIdx, countInLeft, + scoreStart: scoreStartRef.current, + spb: spbRef.current, error, summary, start,