From 7c5632fab64db7e82b815caf69f2acf18ba9a2df Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Mon, 14 Sep 2026 00:47:29 +0200 Subject: [PATCH] Phrase mode: PhraseMatcher (episode/latching logic) + transport schedule (tested) --- src/game/PhraseMatcher.test.ts | 248 +++++++++++++++++++++++++++++++++ src/game/PhraseMatcher.ts | 216 ++++++++++++++++++++++++++++ src/game/PhraseTransport.ts | 76 ++++++++++ 3 files changed, 540 insertions(+) create mode 100644 src/game/PhraseMatcher.test.ts create mode 100644 src/game/PhraseMatcher.ts create mode 100644 src/game/PhraseTransport.ts diff --git a/src/game/PhraseMatcher.test.ts b/src/game/PhraseMatcher.test.ts new file mode 100644 index 0000000..7f79c0f --- /dev/null +++ b/src/game/PhraseMatcher.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { PhraseMatcher, RELEASE_EVIDENCE_MS, FRAME_GAP_RESET_MS } from './PhraseMatcher'; +import { buildSchedule, sanitizeBpm, tickToAudioTime } from './PhraseTransport'; +import { generateMelody } from '../music/melodyGenerator'; + +// Frame cadence of the real detector: ~16.7 ms at 60 fps. +const DT = 16.7; + +function frames(matcher: PhraseMatcher, midis: (number | null)[], from: number, idx: number) { + let t = from; + for (const m of midis) { + matcher.feed(m, t, idx); + t += DT; + } + return t; +} + +describe('PhraseMatcher — at-your-pace behavior', () => { + it('C–D–E advances only in order; wrong notes wait', () => { + const m = new PhraseMatcher([60, 62, 64]); + // Play E first — nothing happens. + let out = m.feed(64, 100, 0); + expect(out).toBeNull(); + frames(m, [64, 64, 64, 64, 64], 116, 0); + expect(m.status(0)).toBe('pending'); + // Play C → matches note 0 after ~50ms hold. + let t = 100; + for (let i = 0; i < 8; i++) { + out = m.feed(60, t, 0); + t += DT; + if (out) break; + } + expect(out).toEqual({ matched: 0 }); + expect(m.status(0)).toBe('matched'); + // D next. + for (let i = 0; i < 8; i++) { + out = m.feed(62, t, 1); + t += DT; + if (out) break; + } + expect(out).toEqual({ matched: 1 }); + }); + + it('a single stale frame cannot satisfy the hold', () => { + const m = new PhraseMatcher([60]); + expect(m.feed(60, 100, 0)).toBeNull(); + expect(m.status(0)).toBe('pending'); + }); + + it('repeated C–C: held note does not score twice; release + replay does', () => { + const m = new PhraseMatcher([60, 60]); + let t = 100; + for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; } + expect(m.status(0)).toBe('matched'); + // Switch to note 1 (same pitch) and keep holding. + m.setCurrent(1); + let out = m.feed(60, t, 1); + expect(out).toBeNull(); // blocked: same sounding episode + t = frames(m, [60, 60, 60, 60, 60, 60], t + DT, 1); + expect(m.status(1)).toBe('pending'); + // Release: >=80ms of fresh null frames. + t = frames(m, [null, null, null, null, null, null], t + DT, 1); + // Replay C: fresh attack → match. + for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); } + expect(out).toEqual({ matched: 1 }); + expect(m.status(1)).toBe('matched'); + }); + + it('a 20ms dropout does not re-arm; 80ms release does (contract D)', () => { + const m = new PhraseMatcher([60, 60]); + let t = 100; + for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; } + expect(m.status(0)).toBe('matched'); + m.setCurrent(1); + // 1-frame dropout (17ms) — flicker, still blocked. + m.feed(null, t, 1); + t += DT; + let out = m.feed(60, t, 1); + expect(out).toBeNull(); + t = frames(m, [60, 60, 60, 60, 60], t + DT, 1); + expect(m.status(1)).toBe('pending'); + // Now a real 85ms release. + t = frames(m, [null, null, null, null, null, null], t + DT, 1); // ~100ms null + for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); } + expect(out).toEqual({ matched: 1 }); + }); + + it('a sample gap >100ms resets hold and release evidence', () => { + const m = new PhraseMatcher([60]); + // Hold progress, then a processing gap of 500ms. + m.feed(60, 100, 0); + m.feed(60, 116, 0); + m.feed(60, 116 + 500, 0); // gap → reset, fresh start + expect(m.status(0)).toBe('pending'); + // 50ms of hold after the gap is required again. + let matched = false; + let t = 616; + for (let i = 0; i < 8; i++) { matched = matched || m.feed(60, t, 0) !== null; t += DT; } + expect(matched).toBe(true); + }); + + it('a genuinely different pitch establishes a new episode (fast P–Q–P)', () => { + const m = new PhraseMatcher([60, 60]); + let t = 100; + for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; } + expect(m.status(0)).toBe('matched'); + m.setCurrent(1); + // Play Q (64) for 3+ frames: new episode → unblocked. + t = frames(m, [64, 64, 64], t, 1); + // Now play C: fresh attack → should match. + let out: { matched: number } | null = null; + for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); } + expect(out).toEqual({ matched: 1 }); + }); + + it('a one-frame flicker to a wrong pitch does not unblock', () => { + const m = new PhraseMatcher([60, 60]); + let t = 100; + for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; } + expect(m.status(0)).toBe('matched'); + m.setCurrent(1); + // Single flicker frame at 64 — must not establish a new episode. + m.feed(64, t, 1); + m.feed(60, t + DT, 1); + t = frames(m, [60, 60, 60, 60, 60], t + 2 * DT, 1); + expect(m.status(1)).toBe('pending'); // still blocked + }); + + it('virtual taps: repeated taps match repeated notes, no hold needed', () => { + const m = new PhraseMatcher([60, 60, 67]); + expect(m.tap(62, 0)).toBeNull(); // wrong pitch + expect(m.tap(60, 0)).toEqual({ matched: 0 }); + expect(m.tap(60, 1)).toEqual({ matched: 1 }); // re-tap works immediately + expect(m.tap(67, 2)).toEqual({ matched: 2 }); + }); + + it('latched results never change; skip is not a hit', () => { + const m = new PhraseMatcher([60, 62]); + m.setStatus(0, 'skipped'); + m.setStatus(0, 'matched'); // ignored — skip is final + expect(m.status(0)).toBe('skipped'); + expect(m.summary()).toEqual({ total: 2, matched: 0, missed: 0, skipped: 1, rest: 0 }); + }); + + it('rests auto-resolve and are excluded from the summary', () => { + const m = new PhraseMatcher([60, null, 62]); + expect(m.status(1)).toBe('rest'); + expect(m.summary().total).toBe(2); + }); +}); + +describe('PhraseMatcher — tempo behavior', () => { + it('expireWindow marks pending notes missed, keeps latched matches, resolves rests', () => { + const m = new PhraseMatcher([60, null, 62]); + // User plays 62 during window 0: no early match. + m.feed(62, 100, 0); + m.expireWindow(0); + expect(m.status(0)).toBe('missed'); + m.expireWindow(1); + expect(m.status(1)).toBe('rest'); + // Play 62 → match window 2 before it expires. + let hit = false; + for (let i = 0; i < 5; i++) { hit = hit || m.feed(62, 200 + 16 * i, 2) !== null; } + m.expireWindow(2); + expect(m.status(2)).toBe('matched'); + }); + + it('a held C cannot score C–rest–C twice (contract D.3)', () => { + const m = new PhraseMatcher([60, null, 60]); + // Sustain C across all three windows. + let t = 100; + for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; } + expect(m.status(0)).toBe('matched'); + m.setCurrent(1); // rest window: frames continue + t = frames(m, [60, 60, 60, 60, 60, 60, 60, 60], t, 1); + m.setCurrent(2); + const out = m.feed(60, t, 2); + expect(out).toBeNull(); // blocked — still the same sounding episode + t = frames(m, [60, 60, 60, 60, 60, 60], t + DT, 2); + expect(m.status(2)).toBe('pending'); + }); + + it('early match latches before the window ends (tempo)', () => { + const m = new PhraseMatcher([64]); + let out: { matched: number } | null = null; + let t = 100; + for (let i = 0; i < 6 && !out; i++) { out = m.feed(64, t, 0); t += DT; } + expect(out).toEqual({ matched: 0 }); + m.expireWindow(0); + expect(m.status(0)).toBe('matched'); // not overwritten by the miss pass + }); +}); + +describe('PhraseTransport — schedule math', () => { + const pool = Array.from({ length: 25 }, (_, i) => 48 + i); + const meter = { numerator: 4 as const, denominator: 4 as const }; + + it('at 60 BPM a 4/4 count-in starts the score exactly at +4s', () => { + const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 2, meter, rhythmLevel: 1, seed: 1 }, pool); + if (!res.ok) throw new Error(res.error); + const schedule = buildSchedule(res.value.score, 60, 0, 4); + const countIns = schedule.filter(e => e.kind === 'count-in-beat'); + expect(countIns.map(e => e.at)).toEqual([0, 1, 2, 3]); + const firstNoteEnd = schedule.find(e => e.kind === 'note-end')!; + // First note starts at score start = 4s; ends at 4s + dur. + const firstEvent = res.value.score.voices[0].events[0]; + expect(firstNoteEnd.at).toBeCloseTo(4 + (firstEvent.durationTicks / 480), 5); + }); + + it('schedules every note end and a done entry at the score end', () => { + const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 4, meter, rhythmLevel: 1, seed: 5 }, pool); + if (!res.ok) throw new Error(res.error); + const schedule = buildSchedule(res.value.score, 90, 10, 0); + const noteEnds = schedule.filter(e => e.kind === 'note-end'); + expect(noteEnds).toHaveLength(res.value.score.voices[0].events.length); + const done = schedule.find(e => e.kind === 'done')!; + expect(done.at).toBeCloseTo(10 + (16 * 480 / 480) * (60 / 90), 5); // 4 bars = 16 beats + }); + + it('no count-in beats when countInBeats = 0', () => { + const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 1, meter, rhythmLevel: 1, seed: 1 }, pool); + if (!res.ok) throw new Error(res.error); + expect(buildSchedule(res.value.score, 60, 0, 0).filter(e => e.kind === 'count-in-beat')).toHaveLength(0); + }); + + it('schedule is sorted and beats cover the whole score', () => { + const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 2, meter, rhythmLevel: 2, seed: 9 }, pool); + if (!res.ok) throw new Error(res.error); + const schedule = buildSchedule(res.value.score, 120, 0, 3); + for (let i = 1; i < schedule.length; i++) expect(schedule[i].at).toBeGreaterThanOrEqual(schedule[i - 1].at); + const beats = schedule.filter(e => e.kind === 'beat'); + expect(beats).toHaveLength(8); // 2 bars × 4 beats + }); + + it('sanitizeBpm clamps nonsense', () => { + expect(sanitizeBpm(NaN)).toBe(60); + expect(sanitizeBpm(10)).toBe(30); + expect(sanitizeBpm(999)).toBe(180); + expect(tickToAudioTime(480, 60, 0)).toBe(1); + }); +}); + +describe('PhraseMatcher — timing constants are as specified', () => { + it('uses the contract values', () => { + expect(RELEASE_EVIDENCE_MS).toBe(80); + expect(FRAME_GAP_RESET_MS).toBe(100); + }); +}); diff --git a/src/game/PhraseMatcher.ts b/src/game/PhraseMatcher.ts new file mode 100644 index 0000000..a7e43c0 --- /dev/null +++ b/src/game/PhraseMatcher.ts @@ -0,0 +1,216 @@ +/** + * Phrase-mode note matcher (contract D). + * + * Extends the single-note MatchTracker philosophy to sequences: + * - Per-logical-note one-way LATCHED results (a note can only resolve once). + * - "Episode" tracking: one sustained sounding pitch cannot earn credit for a + * later note on the same pitch. After a note with pitch P is (attempted) + * sounded, a following note with the same pitch stays BLOCKED until the + * user provides release evidence: >= 80 ms of fresh null frames, or a + * genuinely different pitch (2-frame stable), which establishes a new + * attack/episode. + * - Flicker tolerance: single-frame dropouts neither re-arm nor unblock; a + * sample gap > 100 ms resets the hold timer and release accumulation. + * - Exact octave matching, reusing the existing MatchTracker (50 ms hold, + * 150 ms grace) for hold confirmation. + * + * Virtual (on-screen) taps are handled by the trainer as discrete attacks, + * not through feed(): each tap is its own episode and may match immediately. + */ +import { MatchTracker } from './MatchTracker'; +import { NOTE_MATCH_GRACE_MS, NOTE_MATCH_THRESHOLD_MS } from '../AppConfig'; + +export type NoteStatus = 'pending' | 'matched' | 'missed' | 'skipped' | 'rest'; + +/** Freshness window: frames older than this reset the hold timer. */ +export const FRAME_GAP_RESET_MS = 100; +/** Release evidence: sustained null frames needed to re-arm a repeated pitch. */ +export const RELEASE_EVIDENCE_MS = 80; +/** A different pitch must persist this many frames to establish a new episode. */ +const EPISODE_STABLE_FRAMES = 2; + +export interface FeedOutcome { + /** Index of the note that just got matched (latched). */ + readonly matched: number; +} + +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 lastFrameAt = 0; + private nullAccumMs = 0; + /** Pitch of the user's current sounding episode (null = released). */ + private episodePitch: number | null = null; + private candidatePitch: number | null = null; + private candidateFrames = 0; + /** True while the current note needs release evidence (repeated pitch). */ + private blocked = false; + private currentIndex = -1; + + constructor(targetMidis: (number | null)[]) { + this.targetMidis = [...targetMidis]; + this.statuses = targetMidis.map(midi => (midi === null ? 'rest' : 'pending')); + } + + reset(): void { + this.statuses.fill('pending'); + for (let i = 0; i < this.statuses.length; i++) { + if (this.targetMidis[i] === null) this.statuses[i] = 'rest'; + } + this.tracker.reset(); + this.lastFrameAt = 0; + this.nullAccumMs = 0; + this.episodePitch = null; + this.candidatePitch = null; + this.candidateFrames = 0; + this.blocked = false; + this.currentIndex = -1; + } + + status(idx: number): NoteStatus { + return this.statuses[idx] ?? 'pending'; + } + + setStatus(idx: number, status: NoteStatus): void { + // One-way latch: a resolved note never changes. + if (this.statuses[idx] === 'pending' || this.statuses[idx] === 'rest') { + this.statuses[idx] = status; + } + } + + get firstPending(): number { + for (let i = 0; i < this.statuses.length; i++) { + if (this.statuses[i] === 'pending') return i; + } + return -1; + } + + summary(): { total: number; matched: number; missed: number; skipped: number; rest: number } { + let matched = 0, missed = 0, skipped = 0, rest = 0, total = 0; + for (let i = 0; i < this.statuses.length; i++) { + if (this.targetMidis[i] === null) { rest++; continue; } + total++; + if (this.statuses[i] === 'matched') matched++; + else if (this.statuses[i] === 'missed') missed++; + else if (this.statuses[i] === 'skipped') skipped++; + } + return { total, matched, missed, skipped, rest }; + } + + /** + * Announce which note is currently "up" (cursor). Recomputes the + * repeated-pitch block from the current episode. + */ + setCurrent(idx: number): void { + if (idx === this.currentIndex) return; + this.currentIndex = idx; + this.tracker.reset(); + this.updateBlock(); + } + + private updateBlock(): void { + const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null; + // Blocked when the target pitch was ALREADY sounding when the note + // became current: a repeated pitch needs release/re-attack evidence. + // (A fresh attack of the target is handled by the episode logic below, + // which clears the block when a new episode establishes.) + this.blocked = + this.currentIndex >= 0 && + target !== null && + this.episodePitch === target; + } + + /** + * Feed one FRESH raw detection frame (caller must exclude gated/blanked + * frames — speaker output and metronome clicks). + * + * @param midi raw detected pitch (null = silence/unpitched) + * @param atMs frame arrival time (performance clock, ms) + * @param currentIdx the note currently up + * @returns match outcome when a note latched, else null + */ + feed(midi: number | null, atMs: number, currentIdx: number): FeedOutcome | null { + if (currentIdx !== this.currentIndex) this.setCurrent(currentIdx); + + const gap = this.lastFrameAt === 0 ? 0 : atMs - this.lastFrameAt; + this.lastFrameAt = atMs; + if (gap > FRAME_GAP_RESET_MS) { + // Processing gap (throttled tab, gated period): forget the hold + // timer and accumulated release evidence. + this.tracker.reset(); + this.nullAccumMs = 0; + } + + if (midi === null) { + this.nullAccumMs += Math.max(0, gap); + if (this.nullAccumMs >= RELEASE_EVIDENCE_MS) { + this.episodePitch = null; + this.candidatePitch = null; + this.candidateFrames = 0; + this.blocked = false; // released → re-attack allowed + } + this.tracker.update(false, atMs); + return null; + } + + this.nullAccumMs = 0; + + if (midi === this.episodePitch) { + this.candidatePitch = null; + this.candidateFrames = 0; + } else if (midi === this.candidatePitch) { + if (++this.candidateFrames >= EPISODE_STABLE_FRAMES) { + // A genuinely different pitch establishes a new episode — + // both an attack of another note and a re-attack after a + // quick pitch change. A new episode is a new attack: any + // pending repeated-pitch block is cleared. + this.episodePitch = midi; + this.candidatePitch = null; + this.candidateFrames = 0; + this.blocked = false; + } + } else { + this.candidatePitch = midi; + this.candidateFrames = 1; + } + + const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null; + if (target === null || this.statuses[this.currentIndex] !== 'pending' || this.blocked) { + this.tracker.update(false, atMs); + return null; + } + + const matching = midi === target; + if (this.tracker.update(matching, atMs)) { + this.statuses[this.currentIndex] = 'matched'; + return { matched: this.currentIndex }; + } + return null; + } + + /** Virtual input: a discrete attack (tap). May match immediately. */ + tap(midi: number, currentIdx: number): FeedOutcome | null { + this.setCurrent(currentIdx); + // Every tap is a fresh attack/episode. + this.episodePitch = midi; + this.candidatePitch = null; + this.candidateFrames = 0; + this.nullAccumMs = 0; + const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null; + if (target === midi && this.statuses[this.currentIndex] === 'pending') { + this.statuses[this.currentIndex] = 'matched'; + this.tracker.reset(); + return { matched: this.currentIndex }; + } + return null; + } + + /** Tempo mode: resolve an expired window (call at each event end). */ + expireWindow(idx: number): void { + if (this.statuses[idx] === 'pending') { + this.statuses[idx] = this.targetMidis[idx] === null ? 'rest' : 'missed'; + } + } +} diff --git a/src/game/PhraseTransport.ts b/src/game/PhraseTransport.ts new file mode 100644 index 0000000..38814c9 --- /dev/null +++ b/src/game/PhraseTransport.ts @@ -0,0 +1,76 @@ +/** + * Pure schedule math for phrase tempo practice (contract E). + * + * The trainer hook builds a Schedule once per run (from the audio-clock + * start time) and processes it in lookahead order. Scheduling sounds early + * (lookahead) is fine, but state transitions fire only when the clock + * reaches the boundary — this module produces those boundaries. + */ +import { PPQ, scoreEvents, type Score } from '../score/model'; + +export type ScheduleEntryKind = 'count-in-beat' | 'beat' | 'note-end' | 'done'; + +export interface ScheduleEntry { + readonly kind: ScheduleEntryKind; + /** Absolute time on the transport AudioContext clock (seconds). */ + readonly at: number; + /** Beat number within the count-in (count-in-beat only). */ + readonly beat?: number; + /** Event index (note-end only). */ + readonly index?: number; +} + +export const MIN_PHRASE_BPM = 30; +export const MAX_PHRASE_BPM = 180; + +export function sanitizeBpm(bpm: number): number { + if (!Number.isFinite(bpm)) return 60; + return Math.max(MIN_PHRASE_BPM, Math.min(MAX_PHRASE_BPM, Math.round(bpm))); +} + +/** + * Build the transport schedule for a run. + * + * @param score the practice slice + * @param bpm quarter-note tempo + * @param startAt audio-clock time at which the count-in begins + * @param countInBeats full measure of count-in beats (0 = no count-in) + */ +export function buildSchedule(score: Score, bpm: number, startAt: number, countInBeats: number): ScheduleEntry[] { + const spb = 60 / sanitizeBpm(bpm); // seconds per quarter beat + const entries: ScheduleEntry[] = []; + + for (let i = 0; i < countInBeats; i++) { + entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i }); + } + + const scoreStart = startAt + countInBeats * spb; + const totalTicks = score.measures.reduce((a, m) => a + m.durationTicks, 0); + if (totalTicks === 0) return entries; + + // Quarter-note beats while playing (downbeat accent at bar starts). + const beatCount = Math.floor(totalTicks / PPQ); + const barTicks = score.meter.numerator * PPQ; + for (let i = 0; i < beatCount; i++) { + const tick = i * PPQ; + entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: (tick % barTicks === 0) ? i : undefined, index: i }); + } + + scoreEvents(score).forEach((e, i) => { + entries.push({ + kind: 'note-end', + at: scoreStart + ((e.startTick + e.durationTicks) / PPQ) * spb, + index: i, + }); + }); + + entries.push({ kind: 'done', at: scoreStart + (totalTicks / PPQ) * spb }); + entries.sort((a, b) => a.at - b.at); + return entries; +} + +/** Audio-clock time at which a tick would sound (for tests and the trainer). */ +export function tickToAudioTime(tick: number, bpm: number, scoreStart: number): number { + const spb = 60 / sanitizeBpm(bpm); + return scoreStart + (tick / PPQ) * spb; +}