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:
248
src/game/PhraseMatcher.test.ts
Normal file
248
src/game/PhraseMatcher.test.ts
Normal 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
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/game/PhraseTransport.ts
Normal file
76
src/game/PhraseTransport.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user