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>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
/**
|
|
* A rhythm game measures its presses against the music (#438): On key hit says how far from the
|
|
* nearest spot of a grid a key or a click was heard, the moment it was made rather than the frame.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { gridSpots, spotsBetween, type Timeline } from '../../src/core/music/beats';
|
|
import { hitSpots, hitStep, measureHit } from '../../src/core/music/hits';
|
|
import type { HeardTime } from '../../src/core/music/playbacks';
|
|
import { COMMON_TIME, type Meter } from '../../src/core/music/time';
|
|
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
|
import { HIT_MAX_AGE } from '../../src/runtime/systems/BehaviorSystem';
|
|
import { Input } from '../../src/runtime/systems/Input';
|
|
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
|
|
|
const Q = 480;
|
|
const BAR = 4 * Q;
|
|
|
|
function timeline(over: Partial<Timeline> = {}): Timeline {
|
|
return { tempo: 120, meter: COMMON_TIME, from: 0, loop: null, length: 4 * BAR, ...over };
|
|
}
|
|
|
|
/** A press heard `heard` seconds in, on that grid; the offset in whole milliseconds. */
|
|
function measured(tl: Timeline, heard: number, grid: '1/8' | '1/16' | 'beat', swing = 0) {
|
|
const step = hitStep(grid, tl.meter);
|
|
const swung = swing > 0 ? { amount: swing, pair: Q, bar: BAR } : null;
|
|
const hit = measureHit(tl, heard, hitSpots(step, swung), step);
|
|
return hit && { ...hit, offset: Math.round(hit.offset * 1e6) / 1e3 };
|
|
}
|
|
|
|
describe('measuring a hit against the grid', () => {
|
|
it('takes the nearest spot of the beat, eighths or sixteenths, below 0 early', () => {
|
|
const tl = timeline();
|
|
expect(measured(tl, 0.51, 'beat')).toEqual({ offset: 10, bar: 1, beat: 2, seconds: 0.51 });
|
|
expect(measured(tl, 0.74, 'beat')).toMatchObject({ offset: 240, beat: 2 });
|
|
expect(measured(tl, 0.76, 'beat')).toMatchObject({ offset: -240, beat: 3 });
|
|
expect(measured(tl, 2.03, 'beat')).toMatchObject({ offset: 30, bar: 2, beat: 1 });
|
|
expect(measured(tl, 0.26, '1/8')).toMatchObject({ offset: 10, bar: 1, beat: 1 });
|
|
expect(measured(tl, 0.115, '1/16')).toMatchObject({ offset: -10, beat: 1 });
|
|
});
|
|
|
|
it('a beat is what the meter counts: an eighth in 6/8', () => {
|
|
const meter: Meter = { beats: 6, unit: 8 };
|
|
expect(hitStep('beat', meter)).toBe(Q / 2);
|
|
expect(measured(timeline({ meter }), 0.26, 'beat')).toMatchObject({ offset: 10, bar: 1, beat: 2 });
|
|
});
|
|
|
|
it('hears the second eighth where swing pushed it (#372)', () => {
|
|
const tl = timeline();
|
|
// At 100 % the second eighth of a pair is heard on the triplet: 1/3 s in at 120 BPM.
|
|
expect(measured(tl, 0.34, '1/8', 1)).toMatchObject({ offset: 6.667, beat: 1 });
|
|
expect(measured(tl, 0.34, '1/8')).toMatchObject({ offset: 90, beat: 1 });
|
|
// The first eighth of each pair stays where it is.
|
|
expect(measured(tl, 0.51, '1/8', 1)).toMatchObject({ offset: 10, beat: 2 });
|
|
// Sixteenths are bent along with the pair they lie in: the second at 1/6 s, not 1/8 s.
|
|
expect(measured(tl, 0.17, '1/16', 1)).toMatchObject({ offset: 3.333, beat: 1 });
|
|
});
|
|
|
|
it('just before a loop comes round, the nearest spot is its start', () => {
|
|
const tl = timeline({ loop: { start: 0, end: BAR }, length: BAR });
|
|
expect(measured(tl, 1.99, 'beat')).toEqual({ offset: -10, bar: 1, beat: 1, seconds: 1.99 });
|
|
expect(measured(tl, 2.01, 'beat')).toMatchObject({ offset: 10, bar: 1, beat: 1 });
|
|
expect(measured(tl, 3.49, 'beat')).toMatchObject({ offset: -10, beat: 4 });
|
|
});
|
|
|
|
it('measures nothing before a playing starts or after one that ends is over', () => {
|
|
const tl = timeline({ length: BAR });
|
|
expect(measured(tl, -0.01, 'beat')).toBeNull();
|
|
expect(measured(tl, 2.01, 'beat')).toBeNull();
|
|
// Its end is no spot: what comes just before it is measured against the last beat.
|
|
expect(measured(tl, 1.99, 'beat')).toMatchObject({ offset: 490, beat: 4 });
|
|
});
|
|
|
|
it('a playing started off the beat knows no spot before its start', () => {
|
|
const tl = timeline({ from: Q / 2 });
|
|
expect(measured(tl, 0, 'beat')).toMatchObject({ offset: -250, bar: 1, beat: 2 });
|
|
});
|
|
|
|
it('a spot swung into a run counts there, though its place lies before it', () => {
|
|
const swing = { amount: 1, pair: Q, bar: BAR };
|
|
// The second eighth of the first pair is written at 240 and heard at 320.
|
|
expect(hitSpots(Q / 2, swing)(300, 500, false, false)).toEqual([320, 480]);
|
|
expect(hitSpots(Q / 2, swing)(320, 500, true, false)).toEqual([480]);
|
|
});
|
|
|
|
it('the spots between two moments run on across a loop seam', () => {
|
|
const tl = timeline({ from: 0, loop: { start: BAR, end: 2 * BAR }, length: 2 * BAR });
|
|
const spots = spotsBetween(tl, 3.4, 4.6, gridSpots(Q));
|
|
expect(spots).toEqual([
|
|
{ tick: BAR + 3 * Q, at: 3.5 },
|
|
{ tick: BAR, at: 4 },
|
|
{ tick: BAR + Q, at: 4.5 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
/** A song and a pattern, and a behavior that logs what On key hit hands on. */
|
|
function hitting(runWhilePaused = false) {
|
|
const f = makeSpriteScene('Box');
|
|
const create = (type: 'song' | 'pattern', name: string) => {
|
|
const command = new CreateAssetCommand(type, name);
|
|
f.stack.execute(command);
|
|
return command.asset.id;
|
|
};
|
|
const song = create('song', 'Theme');
|
|
const pattern = create('pattern', 'Riff');
|
|
const behavior = makeBehavior(f, 'Logic');
|
|
if (runWhilePaused) f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runWhilePaused', true));
|
|
place(f, behavior.id, { x: 0, y: 0 });
|
|
const edit = graphEditor(f, behavior);
|
|
/** Logs `prefix`, the playing, the offset in whole milliseconds, the bar and the beat. */
|
|
const logs = (params: Record<string, string>, prefix: string) => {
|
|
const event = edit.addNode('OnKeyHit', params);
|
|
const ms = edit.addNode('Multiply', { b: 1000 });
|
|
edit.connect(event, 'offset', ms, 'a');
|
|
const round = edit.addNode('Round');
|
|
edit.connect(ms, 'result', round, 'value');
|
|
const parts = ['playback', null, 'bar', 'beat'].map((port) => (port === null ? round : port));
|
|
let text = edit.addNode('Join', { a: prefix });
|
|
for (const part of parts) {
|
|
const join = edit.addNode('Join', { b: '' });
|
|
edit.connect(text, 'text', join, 'a');
|
|
const glue = edit.addNode('Join', { a: ' ' });
|
|
if (typeof part === 'string') edit.connect(event, part, glue, 'b');
|
|
else edit.connect(part, 'result', glue, 'b');
|
|
edit.connect(glue, 'text', join, 'b');
|
|
text = join;
|
|
}
|
|
const log = edit.addNode('Log');
|
|
edit.connect(event, 'exec', log, 'exec');
|
|
edit.connect(text, 'text', log, 'message');
|
|
return event;
|
|
};
|
|
return { ...f, song, pattern, edit, logs };
|
|
}
|
|
|
|
/**
|
|
* A sink whose song (7) and pattern (8) are heard `heard` seconds in at the moment `t0` of the page's
|
|
* clock, and on from there in real time: what a key pressed at another moment is measured by.
|
|
*/
|
|
function heardAudio(song: string, pattern: string, t0: number, withAt = true) {
|
|
const base = { baked: false, tempo: 120, from: 0, loop: null, length: 4 * BAR };
|
|
let heard: number | null = null;
|
|
const times = (at: number): HeardTime[] => {
|
|
const now = heard === null ? null : heard + (at - t0) / 1000;
|
|
return [
|
|
{ ...base, id: 7, key: 7, name: `song:${song}`, heard: now },
|
|
{ ...base, id: 8, key: 8, name: `pattern:${pattern}`, heard: now },
|
|
];
|
|
};
|
|
const asked: number[] = [];
|
|
return {
|
|
audio: {
|
|
...SILENT_AUDIO,
|
|
playingTimes: () => times(t0),
|
|
...(withAt && {
|
|
playingTimesAt: (at: number) => {
|
|
asked.push(at);
|
|
return times(at);
|
|
},
|
|
}),
|
|
},
|
|
asked,
|
|
at(seconds: number | null) {
|
|
heard = seconds;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('On key hit', () => {
|
|
it('keeps no press of a run it was taken from', () => {
|
|
const input = new Input();
|
|
input.press(' ', 'Space', 5);
|
|
expect(input.justHit).toEqual([{ key: ' ', at: 5 }]);
|
|
input.detach();
|
|
expect(input.justHit).toEqual([]);
|
|
});
|
|
|
|
it('measures each press the moment it was made, not the frame it is told in', () => {
|
|
const f = hitting();
|
|
f.logs({ key: ' ', song: f.song }, 'hit');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.5);
|
|
// Two presses in one frame: 20 ms early for beat 2, and 30 ms late for it.
|
|
input.press(' ', 'Space', t0 - 20);
|
|
input.release(' ', 'Space');
|
|
input.press(' ', 'Space', t0 + 30);
|
|
frame();
|
|
expect(world.log).toEqual(['hit 7 -20 1 2', 'hit 7 30 1 2']);
|
|
// Held, a key is no new press, nor is its auto-repeat.
|
|
input.press(' ', 'Space', t0 + 60);
|
|
frame();
|
|
expect(world.log).toHaveLength(2);
|
|
});
|
|
|
|
it('hears the song or pattern picked, or the playing "playback" names, each once', () => {
|
|
const f = hitting();
|
|
f.logs({ key: 'a' }, 'any');
|
|
f.logs({ key: 'a', pattern: f.pattern }, 'pattern');
|
|
const named = f.logs({ key: 'a' }, 'named');
|
|
const number = f.edit.addNode('Number', { value: 7 });
|
|
f.edit.connect(number, 'value', named, 'playback');
|
|
f.logs({ key: 'b' }, 'other key');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(1);
|
|
input.press('A', 'KeyA', t0 + 4);
|
|
frame();
|
|
expect(world.log).toEqual(['any 7 4 1 3', 'any 8 4 1 3', 'pattern 8 4 1 3', 'named 7 4 1 3']);
|
|
});
|
|
|
|
it('adds "calibration" to every press', () => {
|
|
const f = hitting();
|
|
const event = f.logs({ key: ' ', song: f.song }, 'hit');
|
|
const set = f.edit.addNode('Number', { value: -0.05 });
|
|
f.edit.connect(set, 'value', event, 'calibration');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.5);
|
|
input.press(' ', 'Space', t0 + 40);
|
|
frame();
|
|
expect(world.log).toEqual(['hit 7 -10 1 2']);
|
|
});
|
|
|
|
it('a click is a hit too, where Hit by says Click', () => {
|
|
const f = hitting();
|
|
f.logs({ by: 'click', song: f.song }, 'click');
|
|
f.logs({ key: ' ', song: f.song }, 'key');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
|
at(0.5);
|
|
world.movePointer(0, 0, true);
|
|
world.setPointerDown(true, t0 + 12);
|
|
frame();
|
|
expect(world.log).toEqual(['click 7 12 1 2']);
|
|
// Outside the view a behavior without an owner hears no click.
|
|
world.setPointerDown(false);
|
|
world.movePointer(0, 0, false);
|
|
world.setPointerDown(true, t0 + 12);
|
|
frame();
|
|
expect(world.log).toHaveLength(1);
|
|
});
|
|
|
|
it('tells nothing while no music plays, nor while the game is paused, to one that runs on in it', () => {
|
|
const f = hitting(true);
|
|
f.logs({ key: ' ' }, 'hit');
|
|
const t0 = performance.now();
|
|
const { audio, at, asked } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
input.press(' ', 'Space', t0);
|
|
frame();
|
|
expect(world.log).toEqual([]);
|
|
at(0.5);
|
|
world.paused = true;
|
|
input.release(' ', 'Space');
|
|
input.press(' ', 'Space', t0);
|
|
frame();
|
|
expect(world.log).toEqual([]);
|
|
// A paused game does not even ask what was heard.
|
|
expect(asked).toHaveLength(1);
|
|
world.paused = false;
|
|
input.release(' ', 'Space');
|
|
input.press(' ', 'Space', t0);
|
|
frame();
|
|
expect(world.log).toEqual(['hit 7 0 1 2', 'hit 8 0 1 2']);
|
|
});
|
|
|
|
it('a press older than HIT_MAX_AGE when its frame comes is not measured', () => {
|
|
const f = hitting();
|
|
f.logs({ key: ' ' }, 'hit');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.5);
|
|
input.press(' ', 'Space', t0 - HIT_MAX_AGE - 100);
|
|
frame();
|
|
expect(world.log).toEqual([]);
|
|
});
|
|
|
|
it('a sink that cannot say what was heard at a moment is asked at the frame', () => {
|
|
const f = hitting();
|
|
f.logs({ key: ' ', song: f.song }, 'hit');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0, false);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.52);
|
|
input.press(' ', 'Space', t0 - 100);
|
|
frame();
|
|
expect(world.log).toEqual(['hit 7 20 1 2']);
|
|
});
|
|
|
|
it('with no time to the press, it counts as made at the frame', () => {
|
|
const f = hitting();
|
|
f.logs({ key: ' ', song: f.song }, 'hit');
|
|
const { audio, at, asked } = heardAudio(f.song, f.pattern, performance.now());
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.5);
|
|
const before = performance.now();
|
|
input.press(' ');
|
|
frame();
|
|
expect(world.log).toHaveLength(1);
|
|
expect(asked[0]).toBeGreaterThanOrEqual(before);
|
|
});
|
|
|
|
it('measures a swung pattern where its eighths are heard (#372)', () => {
|
|
const f = hitting();
|
|
f.stack.execute(new SetAssetPropertyCommand(f.pattern, 'swing', 100));
|
|
f.logs({ key: ' ', pattern: f.pattern, grid: '1/8' }, 'swung');
|
|
f.logs({ key: ' ', song: f.song, grid: '1/8' }, 'straight');
|
|
const t0 = performance.now();
|
|
const { audio, at } = heardAudio(f.song, f.pattern, t0);
|
|
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
|
at(0.34);
|
|
input.press(' ', 'Space', t0);
|
|
frame();
|
|
expect(world.log).toEqual(['swung 8 7 1 1', 'straight 7 90 1 1']);
|
|
});
|
|
});
|