/** * 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 { 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, 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']); }); });