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