A rhythm game has to know how far a press lay from the beat. Input only kept that a key went down during a frame, so a press was known to a frame (16.7 ms) at best. Hit windows are ±20 to 50 ms. - Input and the game pointer keep the time stamp of each real press. Auto repeat of a held key does not count. - The new event node On key hit hears a key or a click. It gives the offset to the nearest point of the grid (beat, 1/8 or 1/16), with bar, beat and the heard second. A calibration input moves the press. - The SoundLibrary maps the press onto the heard song position through getOutputTimestamp, minus the limiter. Safari lacks it, there the audio clock minus the latency is wound back to the press. - Playbacks.times takes the heard second as a parameter, so the song position can be asked for a moment in the past. - A pattern with swing puts its grid points where the swing moves the notes. The end of a pattern without loop is no grid point. - A press older than 0.25 s when its frame comes is dropped, which covers presses during the toolbar pause. Exactly half way between two points counts as late for the first one. - Without music playing no event comes, as decided in the issue. - The 400 step test in tilemap-patch.test.ts gets a 30 s limit. It took 7.45 s in gate 761 while the suite shared the cores of stein2. Tests: 23 new unit tests (key-hit 19, sound-library 1, audio-switch 1, catalog and event count). 23 mutations, all red. e2e 65 ALL PASSED, 79 checks. In the new 65k7 three presses of H in one frame lie 4.4 and 4.1 ms apart, and so do their offsets. Without the time stamps all three get the same offset and only 65k7 goes red. Gate 5251 of 5252 green, the one red count in event-order.test.ts is fixed and checked on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
||
* Where a playing of a song or a pattern is heard, and the beats, bars and markers it has passed
|
||
* (#338). The audio side keeps each playing's time as an anchor on its clock; a game looks once a
|
||
* frame, so whatever it passed since the last look is told late, and too late is not told at all.
|
||
*/
|
||
import type { SongMarker } from './markers';
|
||
import { playheadAt, type LoopRange } from './sequence';
|
||
import { type Meter, positionOf, secondsOfTicks, ticksOfSeconds, ticksPerBar, ticksPerBeat } from './time';
|
||
|
||
/** The time of one playing: how fast, in what meter, from where, round what, to where. */
|
||
export interface Timeline {
|
||
tempo: number;
|
||
meter: Meter;
|
||
/** Where it started, in ticks. */
|
||
from: number;
|
||
/** What it runs round once it gets there; null plays it once to `length`. */
|
||
loop: LoopRange | null;
|
||
/** Where it ends when it does not loop, in ticks. */
|
||
length: number;
|
||
}
|
||
|
||
/** The most a spot passed is told late, and less where half a beat is shorter. */
|
||
export const LATE_MAX = 0.15;
|
||
|
||
/**
|
||
* How late a spot may be told: half a beat, at most `LATE_MAX`. A frame that slips loses no beat;
|
||
* after a long stall the old ones are gone, and no burst of them follows.
|
||
*/
|
||
export function lateLimit(timeline: Pick<Timeline, 'tempo' | 'meter'>): number {
|
||
return Math.min(LATE_MAX, secondsOfTicks(ticksPerBeat(timeline.meter), timeline.tempo) / 2);
|
||
}
|
||
|
||
/** What `Song position` hands on. */
|
||
export interface HeardPosition {
|
||
bar: number;
|
||
beat: number;
|
||
/** How far into the beat, 0 to below 1. */
|
||
fraction: number;
|
||
/** Seconds from the start of the song to where it stands; back to the loop's start with it. */
|
||
seconds: number;
|
||
tick: number;
|
||
/** Heard now: started, and not played to its end. */
|
||
playing: boolean;
|
||
}
|
||
|
||
/**
|
||
* Where a playing stands at `heard` seconds of its playing time, as heard: before it starts (null, or
|
||
* less than 0) where it starts, after its end at the end.
|
||
*/
|
||
export function heardPosition(timeline: Timeline, heard: number | null): HeardPosition {
|
||
const { tempo, meter, from, loop, length } = timeline;
|
||
let tick = from;
|
||
let playing = false;
|
||
if (heard !== null && heard >= 0) {
|
||
tick = playheadAt(ticksOfSeconds(heard, tempo), from, loop);
|
||
playing = !ends(timeline) || tick < length;
|
||
if (!playing) tick = length;
|
||
}
|
||
const beat = ticksPerBeat(meter);
|
||
const at = positionOf(tick, meter);
|
||
const inBeat = tick - Math.floor(tick / beat) * beat;
|
||
return {
|
||
bar: at.bar,
|
||
beat: at.beat,
|
||
fraction: Math.min(Math.max(inBeat / beat, 0), 1 - Number.EPSILON),
|
||
seconds: secondsOfTicks(tick, tempo),
|
||
tick,
|
||
playing,
|
||
};
|
||
}
|
||
|
||
/** A spot the playhead passed: the tick it is at, and how many seconds ago it was heard. */
|
||
export interface Passed {
|
||
tick: number;
|
||
late: number;
|
||
}
|
||
|
||
/**
|
||
* The ticks among the playhead's run from `low` to `high` that are spots of a kind. `lowOpen` leaves
|
||
* `low` itself out (it was told before), `highOpen` leaves `high` out (the loop's end is its start).
|
||
*/
|
||
export type SpotsIn = (low: number, high: number, lowOpen: boolean, highOpen: boolean) => number[];
|
||
|
||
/** Every multiple of `step` in the run: the beats or the bars. */
|
||
export function gridSpots(step: number): SpotsIn {
|
||
return (low, high, lowOpen, highOpen) => {
|
||
const spots: number[] = [];
|
||
let n = Math.ceil(low / step);
|
||
if (lowOpen && n * step <= low) n++;
|
||
for (; n * step < high || (!highOpen && n * step === high); n++) spots.push(n * step);
|
||
return spots;
|
||
};
|
||
}
|
||
|
||
/** Those of `ticks` in the run: the markers. */
|
||
export function tickSpots(ticks: readonly number[]): SpotsIn {
|
||
return (low, high, lowOpen, highOpen) =>
|
||
[...new Set(ticks)].filter((t) => (lowOpen ? t > low : t >= low) && (highOpen ? t < high : t <= high));
|
||
}
|
||
|
||
/** Whether it ends rather than going round. */
|
||
function ends(timeline: Timeline): boolean {
|
||
return timeline.loop === null;
|
||
}
|
||
|
||
/** One stretch of playing time the playhead runs straight through: from `start` on, at `head`, to `end`. */
|
||
interface Stretch {
|
||
start: number;
|
||
head: number;
|
||
end: number;
|
||
}
|
||
|
||
/** The stretch playing time `elapsed` (in ticks) lies in. */
|
||
function stretchAt({ from, loop }: Timeline, elapsed: number): Stretch {
|
||
if (!loop || from >= loop.end) return { start: 0, head: from, end: Infinity };
|
||
const toEnd = loop.end - from;
|
||
if (elapsed < toEnd) return { start: 0, head: from, end: toEnd };
|
||
const round = loop.end - loop.start;
|
||
const start = toEnd + Math.floor((elapsed - toEnd) / round) * round;
|
||
return { start, head: loop.start, end: start + round };
|
||
}
|
||
|
||
/** The stretch that runs up to where `stretch` begins; null for the first. */
|
||
function stretchBefore({ from, loop }: Timeline, stretch: Stretch): Stretch | null {
|
||
if (!loop || stretch.start <= 0) return null;
|
||
const toEnd = loop.end - from;
|
||
const round = loop.end - loop.start;
|
||
const start = stretch.start - round;
|
||
return start >= toEnd
|
||
? { start, head: loop.start, end: stretch.start }
|
||
: { start: 0, head: from, end: toEnd };
|
||
}
|
||
|
||
/** No more stretches than this are looked back through: a loop shorter than a frame is no tune. */
|
||
const MOST_STRETCHES = 16;
|
||
|
||
/**
|
||
* The spots the playhead passed after `before` up to `now` (seconds of playing time heard, `before`
|
||
* null where nothing was heard of it yet), newest first – only those no more than `lateLimit` ago.
|
||
* A loop's start is passed each time it comes round; nothing at or after the end of one that ends.
|
||
*/
|
||
export function passed(timeline: Timeline, before: number | null, now: number, spots: SpotsIn): Passed[] {
|
||
const low = Math.max(before ?? -Infinity, now - lateLimit(timeline));
|
||
if (!(now >= 0) || !(now > low)) return [];
|
||
const { tempo, length } = timeline;
|
||
const elapsedLow = ticksOfSeconds(low, tempo);
|
||
const high = ticksOfSeconds(now, tempo);
|
||
const found: Passed[] = [];
|
||
let stretch: Stretch | null = stretchAt(timeline, high);
|
||
let highOpen = false;
|
||
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
|
||
// What was heard at `low` was told before; where the run reaches back past a stretch's start,
|
||
// that start is among it – the very start of the playing too, before which nothing was heard.
|
||
const lowInside = elapsedLow >= stretch.start;
|
||
const headLow = stretch.head + ((lowInside ? elapsedLow : stretch.start) - stretch.start);
|
||
let headHigh = stretch.head + ((highOpen ? stretch.end : high) - stretch.start);
|
||
let open = highOpen;
|
||
if (ends(timeline) && headHigh >= length) {
|
||
headHigh = length;
|
||
open = true;
|
||
}
|
||
const ticks = spots(headLow, headHigh, lowInside, open).sort((a, b) => b - a);
|
||
for (const tick of ticks) {
|
||
const at = stretch.start + (tick - stretch.head);
|
||
found.push({ tick, late: Math.max(0, now - secondsOfTicks(at, tempo)) });
|
||
}
|
||
if (lowInside) break;
|
||
stretch = stretchBefore(timeline, stretch);
|
||
highOpen = true;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/** A beat passed, as `On beat` tells it. */
|
||
export interface PassedBeat extends Passed {
|
||
bar: number;
|
||
beat: number;
|
||
}
|
||
|
||
/** The newest beat passed, if one is still to be told. */
|
||
export function passedBeat(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
|
||
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBeat(timeline.meter)));
|
||
if (!newest) return null;
|
||
const at = positionOf(newest.tick, timeline.meter);
|
||
return { ...newest, bar: at.bar, beat: at.beat };
|
||
}
|
||
|
||
/** A marker passed, as `On song marker` tells it. */
|
||
export interface PassedMarker extends Passed {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
/** Every marker on a tick passed, newest first; markers sharing a tick in their own order. */
|
||
export function passedMarkers(
|
||
timeline: Timeline,
|
||
before: number | null,
|
||
now: number,
|
||
markers: readonly SongMarker[],
|
||
): PassedMarker[] {
|
||
if (markers.length === 0) return [];
|
||
return passed(timeline, before, now, tickSpots(markers.map((m) => m.tick))).flatMap((spot) =>
|
||
markers.filter((m) => m.tick === spot.tick).map((m) => ({ ...spot, id: m.id, name: m.name })),
|
||
);
|
||
}
|
||
|
||
/** Of what passed, those passed last: one spot, but every marker on it. */
|
||
export function newestPassed<T extends Passed>(list: readonly T[]): T[] {
|
||
const [first] = list;
|
||
return first ? list.filter((p) => p.late === first.late) : [];
|
||
}
|
||
|
||
/** Where a change a game asks of its music lands (#338): at once, or on the next of these. */
|
||
export type Quantize = 'now' | 'beat' | 'bar' | 'marker';
|
||
|
||
export const QUANTIZE_OPTIONS: readonly { value: Quantize; label: string }[] = [
|
||
{ value: 'now', label: 'Now' },
|
||
{ value: 'beat', label: 'Next beat' },
|
||
{ value: 'bar', label: 'Next bar' },
|
||
{ value: 'marker', label: 'Next marker' },
|
||
];
|
||
|
||
export function heldQuantize(value: unknown): Quantize {
|
||
return QUANTIZE_OPTIONS.some((option) => option.value === value) ? (value as Quantize) : 'bar';
|
||
}
|
||
|
||
/** The spots a change waits for; a song without markers, or a pattern, waits for the next bar. */
|
||
export function quantizeSpots(
|
||
quantize: Exclude<Quantize, 'now'>,
|
||
meter: Meter,
|
||
markers: readonly SongMarker[],
|
||
): SpotsIn {
|
||
if (quantize === 'beat') return gridSpots(ticksPerBeat(meter));
|
||
if (quantize === 'marker' && markers.length > 0) return tickSpots(markers.map((m) => m.tick));
|
||
return gridSpots(ticksPerBar(meter));
|
||
}
|
||
|
||
/** The furthest ahead a spot is looked for on a stretch without end. */
|
||
const LOOK_AHEAD = 600;
|
||
|
||
/** The stretch that follows `stretch`; null after the last one of a playing that ends. */
|
||
function stretchAfter(timeline: Timeline, stretch: Stretch): Stretch | null {
|
||
return stretch.end === Infinity ? null : stretchAt(timeline, stretch.end);
|
||
}
|
||
|
||
/**
|
||
* The first spot at or after `elapsed` seconds of playing time, in seconds of playing time; null where
|
||
* none comes before a playing that ends does – a change then lands at once.
|
||
*/
|
||
export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): number | null {
|
||
const { tempo, length } = timeline;
|
||
const low = ticksOfSeconds(Math.max(0, elapsed), tempo);
|
||
let stretch: Stretch | null = stretchAt(timeline, low);
|
||
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
|
||
const headLow = stretch.head + (Math.max(low, stretch.start) - stretch.start);
|
||
// One that runs on without end is looked through for `LOOK_AHEAD` seconds, not for ever.
|
||
let headHigh =
|
||
stretch.head + (Math.min(stretch.end, low + ticksOfSeconds(LOOK_AHEAD, tempo)) - stretch.start);
|
||
if (ends(timeline)) headHigh = Math.min(headHigh, length);
|
||
if (headHigh > headLow) {
|
||
const [first] = spots(headLow, headHigh, false, true).sort((a, b) => a - b);
|
||
if (first !== undefined) return secondsOfTicks(stretch.start + (first - stretch.head), tempo);
|
||
}
|
||
if (ends(timeline) && headHigh >= length) return null;
|
||
stretch = stretchAfter(timeline, stretch);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** A spot of a playing: the tick it is at, and the second of playing time it is heard. */
|
||
export interface SpotAt {
|
||
tick: number;
|
||
at: number;
|
||
}
|
||
|
||
/**
|
||
* The spots heard from `low` to `high` seconds of playing time, both in, oldest first: a loop's start
|
||
* each time it comes round, nothing at or after the end of one that ends.
|
||
*/
|
||
export function spotsBetween(timeline: Timeline, low: number, high: number, spots: SpotsIn): SpotAt[] {
|
||
const { tempo, length } = timeline;
|
||
const tickLow = ticksOfSeconds(Math.max(0, low), tempo);
|
||
const tickHigh = ticksOfSeconds(high, tempo);
|
||
const found: SpotAt[] = [];
|
||
let stretch: Stretch | null = stretchAt(timeline, tickLow);
|
||
for (let n = 0; stretch && stretch.start <= tickHigh && n < MOST_STRETCHES; n++) {
|
||
const headLow = stretch.head + (Math.max(tickLow, stretch.start) - stretch.start);
|
||
let headHigh = stretch.head + (Math.min(tickHigh, stretch.end) - stretch.start);
|
||
// A loop's end is where the next stretch starts over: its spot is that one's start.
|
||
let open = tickHigh >= stretch.end;
|
||
const last = ends(timeline) && headHigh >= length;
|
||
if (last) {
|
||
headHigh = length;
|
||
open = true;
|
||
}
|
||
if (headHigh >= headLow) {
|
||
for (const tick of spots(headLow, headHigh, false, open).sort((a, b) => a - b))
|
||
found.push({ tick, at: secondsOfTicks(stretch.start + (tick - stretch.head), tempo) });
|
||
}
|
||
if (last) break;
|
||
stretch = stretchAfter(timeline, stretch);
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/** The newest bar line passed, if one is still to be told. */
|
||
export function passedBar(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
|
||
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBar(timeline.meter)));
|
||
if (!newest) return null;
|
||
return { ...newest, bar: positionOf(newest.tick, timeline.meter).bar, beat: 1 };
|
||
}
|