mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: PhraseMatcher (episode/latching logic) + transport schedule (tested)
This commit is contained in:
216
src/game/PhraseMatcher.ts
Normal file
216
src/game/PhraseMatcher.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user