Phrase mode: PhraseMatcher (episode/latching logic) + transport schedule (tested)

This commit is contained in:
2026-09-14 00:47:29 +02:00
parent 8e20098102
commit 7c5632fab6
3 changed files with 540 additions and 0 deletions

View File

@@ -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);
});
});

216
src/game/PhraseMatcher.ts Normal file
View File

@@ -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';
}
}
}

View File

@@ -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;
}