/** * 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'; } /** Snapshot of all statuses (for React state mirroring). */ allStatuses(): NoteStatus[] { return [...this.statuses]; } 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'; } } }