Let a behavior judge a key press against the beat of the music (#438)
feuerwerk / gate (push) Successful in 8m24s
feuerwerk / suite (push) Successful in 20m19s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-30 22:54:55 +01:00
1 parent 9f8203ac4d
commit 5c0d52da66
21 files changed
+756 -19

No files matched your search

+2
View File
@@ -85,6 +85,8 @@ export interface PointerState {
inside: boolean;
down: boolean;
pressed: boolean;
/** When it was pressed, in milliseconds of `performance.now()`; null where nobody said (#438). */
pressedAt?: number | null;
released: boolean;
/** How far the wheel turned this tick, in notches, positive downwards (see `wheelNotches`). */
wheel: number;
@@ -1,3 +1,4 @@
import { HIT_GRID_OPTIONS } from '../../../music/hits';
import { textOf } from '../../../model/Properties';
import type { PropertyDef } from '../../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../../NodeRegistry';
@@ -94,6 +95,44 @@ export const OnMusicFinished: NodeDef = {
hiddenParams: hiddenEmpty('song', 'pattern'),
};
/** Of `On key hit` (#438): what is hit, a key or the mouse. */
export const HIT_BY_OPTIONS = [
{ value: 'key', label: 'Key' },
{ value: 'click', label: 'Click' },
];
export const CALIBRATION_INPUT = 'calibration';
export const OnKeyHit: NodeDef = {
type: 'OnKeyHit',
label: 'On key hit',
category: 'event',
description:
'Runs when the key is pressed while a song or pattern plays, and says how far the press was from ' +
'the beat: "offset" in seconds from the nearest spot of the Grid, below 0 early and above 0 late, ' +
'with "bar" and "beat" of that spot. The press counts the moment the key went down, between two ' +
'frames, against what was heard then, the output latency of the device taken off; "seconds" is ' +
'where the song was. Grid is the beat, eighths or sixteenths, swung where the pattern swings. ' +
'With Hit by Click it is the left mouse button, landing on the owner or, without one, anywhere ' +
'in the view. "calibration" is added to every press: a player who hears late sets it below 0. ' +
'Nothing comes while no music plays, nor while the game is paused. Pick a Song or a Pattern to ' +
'measure against only that one, or connect "playback" for only that playing; with several ' +
'playing it runs once for each. Judging the offset is up to the graph.',
inputs: [playbackPort(), { ...num(CALIBRATION_INPUT), default: 0 }],
outputs: [exec(), num('playback'), num('offset'), num('bar'), num('beat'), num('seconds')],
params: [
{ key: 'by', label: 'Hit by', kind: 'enum', default: 'key', options: HIT_BY_OPTIONS },
{ key: 'key', label: 'Key', kind: 'key', default: ' ' },
{ key: 'grid', label: 'Grid', kind: 'enum', default: 'beat', options: [...HIT_GRID_OPTIONS] },
songParam,
patternParam,
orderParam,
],
hiddenParams: (params) => [
...(textOf(params.by ?? 'key') === 'click' ? ['key'] : []),
...hiddenEmpty('song', 'pattern')(params),
],
};
export const OnSongMarker: NodeDef = {
type: 'OnSongMarker',
label: 'On song marker',
+2 -1
View File
@@ -128,7 +128,7 @@ import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnCompositionFinished } from './events/OnCompositionFinished';
import { OnVideoFinished } from './events/OnVideoFinished';
import { OnBar, OnBeat, OnMusicFinished, OnSongMarker } from './events/musicEvents';
import { OnBar, OnBeat, OnKeyHit, OnMusicFinished, OnSongMarker } from './events/musicEvents';
import { OnSoundFinished } from './events/OnSoundFinished';
import { MusicPlaying, SoundPlaying } from './data/MusicPlaying';
import { SongPosition } from './data/SongPosition';
@@ -200,6 +200,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnSongMarker,
OnMusicFinished,
OnSoundFinished,
OnKeyHit,
OnBehavior,
OnStateEntered,
OnStateExited,
+36
View File
@@ -267,6 +267,42 @@ export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): n
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)));
+74
View File
@@ -0,0 +1,74 @@
/**
* A key hit in time with the music (#438): how far from the nearest spot of a grid a press was heard,
* for a rhythm game to judge. The press counts where the playing was heard the moment the key went
* down, between two frames; the grid is the beat, eighths or sixteenths, swung where its pattern is.
*/
import { gridSpots, heardPosition, type SpotAt, type SpotsIn, spotsBetween, type Timeline } from './beats';
import { type Swing, swungTick } from './swing';
import { type Meter, positionOf, secondsOfTicks, TICKS_PER_QUARTER, ticksPerBeat } from './time';
export type HitGrid = 'beat' | '1/8' | '1/16';
export const HIT_GRID_OPTIONS: readonly { value: HitGrid; label: string }[] = [
{ value: 'beat', label: 'Beat' },
{ value: '1/8', label: '1/8' },
{ value: '1/16', label: '1/16' },
];
export function heldHitGrid(value: unknown): HitGrid {
return HIT_GRID_OPTIONS.some((option) => option.value === value) ? (value as HitGrid) : 'beat';
}
/** How far apart the spots of a grid are, in ticks: a beat is what the meter counts, an eighth in 6/8. */
export function hitStep(grid: HitGrid, meter: Meter): number {
if (grid === '1/8') return TICKS_PER_QUARTER / 2;
if (grid === '1/16') return TICKS_PER_QUARTER / 4;
return ticksPerBeat(meter);
}
/**
* The spots of the grid, each where swing has it heard (#372): never before its place, and never past
* the pair it lies in, so a spot a little before the run may be swung into it.
*/
export function hitSpots(step: number, swing: Swing | null): SpotsIn {
const grid = gridSpots(step);
if (!swing) return grid;
return (low, high, lowOpen, highOpen) =>
grid(low - swing.pair, high, false, highOpen)
.map((tick) => swungTick(tick, swing))
.filter((tick) => (lowOpen ? tick > low : tick >= low) && (highOpen ? tick < high : tick <= high));
}
/** A press measured against the grid, as `On key hit` hands it on. */
export interface MeasuredHit {
/** Seconds from the nearest spot to the press: below 0 it came early, above 0 late. */
offset: number;
/** The bar and the beat of that spot, from 1 as the ruler counts them. */
bar: number;
beat: number;
/** Where the song or pattern was heard at the press, as `Song position` says it. */
seconds: number;
}
/**
* The press heard `heard` seconds into a playing's time, measured against the nearest spot of the
* grid `step` ticks apart; null where it is not playing then, before its start or after its end.
* Halfway between two spots it counts as late for the first.
*/
export function measureHit(
timeline: Timeline,
heard: number,
spots: SpotsIn,
step: number,
): MeasuredHit | null {
const position = heardPosition(timeline, heard);
if (!position.playing || !(heard >= 0)) return null;
// Swing moves a spot no further than a step on: two steps either way hold the nearest.
const reach = secondsOfTicks(2 * step, timeline.tempo);
let nearest: SpotAt | null = null;
for (const spot of spotsBetween(timeline, heard - reach, heard + reach, spots))
if (!nearest || Math.abs(heard - spot.at) < Math.abs(heard - nearest.at)) nearest = spot;
if (!nearest) return null;
const at = positionOf(nearest.tick, timeline.meter);
return { offset: heard - nearest.at, bar: at.bar, beat: at.beat, seconds: position.seconds };
}
+3 -2
View File
@@ -415,11 +415,12 @@ export class Playbacks {
* Every playing with how far into its playing time it is heard now: the audio clock less the
* output latency, from the moment its first note is heard (#338). With none playing the clock is
* not asked: asking for it makes the audio context, which a page may make only after a gesture.
* `heardAt` names another second of the clock as heard, a key press's (#438): asked only then too.
*/
times(): HeardTime[] {
times(heardAt?: () => number): HeardTime[] {
const list = this.current();
if (list.length === 0) return [];
const heard = this.host.now() - this.host.latency();
const heard = heardAt ? heardAt() : this.host.now() - this.host.latency();
// Of a switch (#338) one is heard at a time: the old one up to where the new one is.
const told = list.filter(
({ until, successor, runner }) =>
+10 -4
View File
@@ -538,6 +538,7 @@ export class World {
/** Forgets a pending click edge without running a tick (used while paused). */
dropPointerEdges(): void {
this.pointer.pressed = false;
this.pointer.pressedAt = null;
this.pointer.released = false;
this.pointer.wheel = 0;
}
@@ -575,12 +576,17 @@ export class World {
this.pointer.wheel += notches;
}
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
setPointerDown(down: boolean): void {
/**
* Game view: the mouse button changed. Sets the edge flag the current tick reports; `at` is when,
* in milliseconds of `performance.now()` (#438).
*/
setPointerDown(down: boolean, at: number | null = null): void {
if (down === this.pointer.down) return;
this.pointer.down = down;
if (down) this.pointer.pressed = true;
else this.pointer.released = true;
if (down) {
this.pointer.pressed = true;
this.pointer.pressedAt = at;
} else this.pointer.released = true;
}
/** Stops the systems and drops every entity; the world is unusable afterwards. */
+4
View File
@@ -157,6 +157,10 @@ export class AudioSwitch implements AudioSink {
return this.both.flatMap((sink) => sink.playingTimes?.() ?? []);
}
playingTimesAt(at: number): HeardTime[] {
return this.both.flatMap((sink) => sink.playingTimesAt?.(at) ?? sink.playingTimes?.() ?? []);
}
/** Only on the clock of the sink a stinger is played in: one diverted since counts on another. */
landOn(id: number, at: LandingAt): number | null {
const sink = this.of(id);
+5
View File
@@ -134,6 +134,11 @@ export interface AudioSink {
* what beats, bars and `Song position` go by. A sink without it plays none to be followed.
*/
playingTimes?(): readonly HeardTime[];
/**
* The same, as heard at `at` milliseconds of the page's clock (`performance.now()`): when a key went
* down, between two frames (#438). A sink without it is asked `playingTimes` at the frame instead.
*/
playingTimesAt?(at: number): readonly HeardTime[];
/**
* The second of the audio clock `at` names on the clock of the playing numbered `id` (#338), no
* sooner than something started now is heard: where a stinger in time with it starts. Null where
+38 -1
View File
@@ -24,7 +24,7 @@ import {
type Value,
} from '../../core/graph';
import { GraphCache } from '../../core/graph/graphIndex';
import { PLAYBACK_INPUT } from '../../core/graph/nodes/events/musicEvents';
import { CALIBRATION_INPUT, PLAYBACK_INPUT } from '../../core/graph/nodes/events/musicEvents';
import { VOICE_INPUT } from '../../core/graph/nodes/events/OnSoundFinished';
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
import { keyName } from '../../core/keys';
@@ -60,8 +60,10 @@ import {
barPulses,
beatPulses,
heardPlayings,
hitPulses,
markerPulses,
MusicFollower,
type MusicHit,
type PlaybackOf,
} from './musicPulses';
import { musicFinishedPulses, PlayedEnds, soundFinishedPulses } from './playedEnds';
@@ -75,6 +77,12 @@ import { VideoSystem } from './VideoSystem';
export type { Breakpoint, TimeScaled } from './behaviorContext';
/**
* The oldest a press may be when its frame comes and still be measured by `On key hit` (#438), in
* milliseconds: one made while the run stood still is not measured against music heard long after.
*/
export const HIT_MAX_AGE = 250;
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
export const TRACE_FADE = 0.35;
@@ -681,6 +689,10 @@ export class BehaviorSystem implements System {
const passed = this.music.follow(heardPlayings(this.project, this.audio));
const playbackOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, PLAYBACK_INPUT, contextOf(i));
const voiceOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, VOICE_INPUT, contextOf(i));
const calibrationOf: PlaybackOf = (i, node) =>
i.interpreter.eventInput(node, CALIBRATION_INPUT, contextOf(i));
let hits: MusicHit[] | null = null;
const hitsOf = () => (hits ??= this.musicHits(world));
const finished = this.ends.follow(this.audio.endings?.() ?? []);
const kinds: (() => Pulse[])[] = [
() =>
@@ -702,6 +714,13 @@ export class BehaviorSystem implements System {
() => ticking.flatMap((i) => markerPulses(i, passed, playbackOf)),
() => ticking.flatMap((i) => musicFinishedPulses(i, finished, playbackOf)),
() => ticking.flatMap((i) => soundFinishedPulses(i, finished, voiceOf)),
// A paused game measures no hit: its music stands still.
() =>
frozen
? []
: ticking.flatMap((i) =>
hitPulses(i, hitsOf, playbackOf, calibrationOf, () => pointer.pressed && pointed(i)),
),
() =>
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
() =>
@@ -736,6 +755,24 @@ export class BehaviorSystem implements System {
this.persist();
}
/**
* The presses of this frame, keys and the left mouse button, each with what was heard of the music
* the moment it was made (#438). One that says no time counts as made now.
*/
private musicHits(world: World): MusicHit[] {
const now = performance.now();
const { pointer } = world;
const presses = [
...this.input.justHit.map((hit) => ({ by: 'key' as const, key: hit.key, at: hit.at })),
...(pointer.pressed ? [{ by: 'click' as const, key: '', at: pointer.pressedAt ?? null }] : []),
];
return presses.flatMap(({ by, key, at }) => {
const made = at ?? now;
if (now - made > HIT_MAX_AGE) return [];
return [{ by, key, playings: heardPlayings(this.project, this.audio, made) }];
});
}
runningIds(): string[] {
return this.instances.filter((i) => !i.host).map((i) => i.entity.objectId);
}
+23 -3
View File
@@ -1,5 +1,11 @@
import { keyName } from '../../core/keys';
/** A key that went down, and when: milliseconds of the page's clock, null where nobody said (#438). */
export interface KeyHit {
key: string;
at: number | null;
}
/**
* Keyboard state tracker for a running world. Records which keys are held and which went down /
* up since the last `endFrame()`, so systems can react to edges as well as to held keys.
@@ -9,6 +15,8 @@ export class Input {
private readonly down = new Set<string>();
private readonly pressed = new Set<string>();
private readonly released = new Set<string>();
/** The presses since the last `endFrame()`, in their order: a key pressed twice is in twice. */
private readonly hits: KeyHit[] = [];
/**
* The name each physical key (`KeyboardEvent.code`) went down as. A modifier changed in between
* can give the release another name ("1" down, "!" up with Shift); the key it lets go of is still
@@ -20,7 +28,7 @@ export class Input {
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.accepts()) return;
this.press(e.key, e.code);
this.press(e.key, e.code, e.timeStamp);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => {
@@ -32,10 +40,15 @@ export class Input {
// The state machine below is what the DOM handlers above drive – and what tests drive
// directly, since a headless run has no window to dispatch key events on.
press(key: string, code = ''): void {
/** `at` is when it went down, in milliseconds of `performance.now()`: `KeyboardEvent.timeStamp`. */
press(key: string, code = '', at: number | null = null): void {
const name = keyName(key);
if (code) this.byCode.set(code, name);
if (!this.down.has(name)) this.pressed.add(name); // ignore auto-repeat
if (!this.down.has(name)) {
// ignore auto-repeat
this.pressed.add(name);
this.hits.push({ key: name, at });
}
this.down.add(name);
}
@@ -75,6 +88,7 @@ export class Input {
this.down.clear();
this.pressed.clear();
this.released.clear();
this.hits.length = 0;
}
isDown(key: string): boolean {
@@ -93,9 +107,15 @@ export class Input {
return this.down;
}
/** Every press since the last `endFrame()`, with when it was made. */
get justHit(): readonly KeyHit[] {
return this.hits;
}
/** Clears the per-frame edge sets; call once after all systems consumed them. */
endFrame(): void {
this.pressed.clear();
this.released.clear();
this.hits.length = 0;
}
}
+55 -5
View File
@@ -1,9 +1,11 @@
/**
* What a game hears of its music (#338): the songs and patterns playing, each with its time, and the
* beats, bars and markers each has passed since the frame before – what `On beat`, `On bar`,
* `On song marker` and `Song position` go by. A composition's playings are none of them.
* `On song marker` and `Song position` go by. A composition's playings are none of them. And what
* was heard of them the moment a key went down, which `On key hit` measures the press by (#438).
*/
import { asNumber, type GraphNode, type Value } from '../../core/graph';
import { keyName } from '../../core/keys';
import { textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import {
@@ -17,8 +19,10 @@ import {
passedMarkers,
type Timeline,
} from '../../core/music/beats';
import { heldHitGrid, hitSpots, hitStep, measureHit } from '../../core/music/hits';
import { heldSongMarkers, type SongMarker } from '../../core/music/markers';
import { patternMeter } from '../../core/music/pattern';
import { patternMeter, patternSwing } from '../../core/music/pattern';
import type { Swing } from '../../core/music/swing';
import { songMeter } from '../../core/music/song';
import { COMMON_TIME } from '../../core/music/time';
import type { AudioSink } from './Audio';
@@ -36,6 +40,8 @@ export interface HeardPlaying {
/** Seconds into its playing time it is heard now; null before it starts. */
heard: number | null;
markers: readonly SongMarker[];
/** A pattern's swing (#372): where `On key hit` hears its eighths or sixteenths (#438). */
swing: Swing | null;
}
/** The song or pattern a playing stands for, by its name (`song:<id>`); null for anything else. */
@@ -45,9 +51,13 @@ export function playedAsset(name: string): { kind: 'song' | 'pattern'; asset: st
return kind === 'song' || kind === 'pattern' ? { kind, asset: name.slice(split + 1) } : null;
}
/** The playings of songs and patterns, oldest first. */
export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[] {
return (audio.playingTimes?.() ?? []).flatMap((time): HeardPlaying[] => {
/**
* The playings of songs and patterns, oldest first: as heard now, or at `at` milliseconds of the
* page's clock, when a key went down (#438).
*/
export function heardPlayings(project: Project, audio: AudioSink, at: number | null = null): HeardPlaying[] {
const times = at !== null && audio.playingTimesAt ? audio.playingTimesAt(at) : audio.playingTimes?.();
return (times ?? []).flatMap((time): HeardPlaying[] => {
const played = playedAsset(time.name);
if (!played) return [];
const { kind, asset: id } = played;
@@ -64,6 +74,7 @@ export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[
timeline: { tempo, meter, from, loop, length },
heard: time.heard,
markers: song ? heldSongMarkers(song.markers) : [],
swing: pattern ? patternSwing(pattern.props) : null,
},
];
});
@@ -176,6 +187,45 @@ export function markerPulses(
});
}
/** A press `On key hit` measures (#438): of a key, or of the left mouse button, and what was heard then. */
export interface MusicHit {
by: 'key' | 'click';
/** The key's name; empty for a click. */
key: string;
playings: readonly HeardPlaying[];
}
/**
* Every press of the node's key, or click where one reaches it, measured against each playing it
* hears that was heard then: one pulse each. The hits are asked for only where such a node is.
*/
export function hitPulses(
instance: Instance,
hits: () => readonly MusicHit[],
playbackOf: PlaybackOf,
calibrationOf: PlaybackOf,
clickReaches: () => boolean,
): Pulse[] {
return instance.interpreter.eventNodes('OnKeyHit').flatMap((node) => {
const by = textOf(node.params.by ?? 'key') === 'click' ? 'click' : 'key';
const key = by === 'key' ? keyName(textOf(node.params.key ?? ' ')) : '';
const pressed = hits().filter((hit) => hit.by === by && hit.key === key);
if (pressed.length === 0 || (by === 'click' && !clickReaches())) return [];
const wanted = playbackOf(instance, node);
const calibration = asNumber(calibrationOf(instance, node)) || 0;
const grid = heldHitGrid(textOf(node.params.grid ?? 'beat'));
return pressed.flatMap((hit) =>
hit.playings.flatMap((playing): Pulse[] => {
if (playing.heard === null || !hears(node, wanted, playing, ['song', 'pattern'])) return [];
const step = hitStep(grid, playing.timeline.meter);
const spots = hitSpots(step, playing.swing);
const measured = measureHit(playing.timeline, playing.heard + calibration, spots, step);
return measured ? [{ instance, node, outputs: { playback: playing.id, ...measured } }] : [];
}),
);
});
}
/**
* Where the playing a `Song position` asks for stands: the one numbered `playback`, else the one
* started last of the song or pattern picked, of every one where none is.
+21
View File
@@ -1547,6 +1547,27 @@ export class SoundLibrary implements AudioSink {
return this.sequences.times();
}
/** The same, as heard at `at` milliseconds of the page's clock: when a key went down (#438). */
playingTimesAt(at: number): HeardTime[] {
return this.sequences.times(() => this.heardAt(at));
}
/**
* The second of the audio clock heard at `at` milliseconds of the page's clock. The browser says
* which second its device puts out when, its latency in; older ones do not, and the clock less
* the latency, run back by how long ago `at` was, stands in.
*/
private heardAt(at: number): number {
const context = this.live();
if (!context) return this.clock() - this.latency();
// Safari has no `getOutputTimestamp`.
const stamps = context as { getOutputTimestamp?: () => AudioTimestamp };
const { contextTime, performanceTime } = stamps.getOutputTimestamp?.() ?? {};
if (contextTime !== undefined && performanceTime !== undefined && performanceTime > 0)
return contextTime + (at - performanceTime) / 1000 - (this.guard?.latency ?? 0);
return context.currentTime - this.latency() - (performance.now() - at) / 1000;
}
/**
* What goes into the master of a playing, taken off before its level into `to` (#340): what a bake
* keeps. Says whether there was such a playing.
+2 -1
View File
@@ -29,7 +29,8 @@ export function routePointer(
target.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
report(e);
world()?.setPointerDown(true);
// When the button went down, not when Pixi got round to it: what a hit in time is measured by (#438).
world()?.setPointerDown(true, (e.nativeEvent as Partial<Event> | undefined)?.timeStamp ?? e.timeStamp);
});
target.on('globalpointermove', (e: FederatedPointerEvent) => report(e));
target.on('pointerup', () => world()?.setPointerDown(false));
+12
View File
@@ -114,4 +114,16 @@ describe('AudioSwitch (#423)', () => {
audio.restore();
expect(audio.playing(DIVERTED_IDS + 1)).toBe(false);
});
it('asks both sinks what was heard when a key went down, the one that cannot say at the frame (#438)', () => {
const time = (id: number, heard: number) => ({ id, key: id, name: 'song:s', heard }) as never;
const live = { ...sinkFrom(1), playingTimesAt: (at: number) => [time(1, at / 1000)] };
const aside = { ...sinkFrom(DIVERTED_IDS), playingTimes: () => [time(DIVERTED_IDS, 9)] };
const audio = new AudioSwitch(live);
audio.divert(aside);
expect(audio.playingTimesAt(2500).map((t) => [t.id, t.heard])).toEqual([
[1, 2.5],
[DIVERTED_IDS, 9],
]);
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
);
expect(events).toHaveLength(25);
expect(events).toHaveLength(26);
for (const def of events) {
expect(
def.params.map((p) => p.key),
+323
View File
@@ -0,0 +1,323 @@
/**
* 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']);
});
});
+1
View File
@@ -2711,6 +2711,7 @@ const EVENT_NODES: Record<string, string> = {
OnSongMarker: 'music-events.test.ts',
OnMusicFinished: 'played-ends.test.ts',
OnSoundFinished: 'played-ends.test.ts',
OnKeyHit: 'key-hit.test.ts',
OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts',
};
+2 -1
View File
@@ -306,7 +306,8 @@ describe('a painted map works out only the cells around what changed (#348)', ()
expect(end.whole.polygons.length).toBeGreaterThan(5);
expect(end.whole.oneWay).toHaveLength(2);
expect(end.whole.triggerCells.length).toBeGreaterThan(3);
});
// About 4 s alone; with the CI suite on the same cores it took 7.5 s (gate 761).
}, 30_000);
it('a cell two rows off is cut again when the autotile between turns into a box', () => {
const m = patchMap();
+24
View File
@@ -2090,6 +2090,30 @@ describe('the patterns and songs a game plays (#336)', () => {
),
).toBe(false);
});
it('says where each was heard when a key went down: by what the device put out, else the clock (#438)', async () => {
// With the limiter at the output, late by it on both ways.
const library = new SoundLibrary();
library.playSequence(
'pattern:p',
sequence(() => 77, { length: 480_000 }),
'',
);
await settle();
expect(library.latency()).toBeCloseTo(0.005, 12);
const context = (library as unknown as { context: { currentTime: number } & Record<string, unknown> })
.context;
context.currentTime = 1;
const now = library.playingTimes()[0]!.heard!;
// Without a time stamp of the device: the clock, run back by how long ago the key went down.
expect(library.playingTimesAt(performance.now() - 100)[0]!.heard).toBeCloseTo(now - 0.1, 2);
// With one: the second it put out at that moment of the page, on from there.
context.getOutputTimestamp = () => ({ contextTime: 0.9, performanceTime: 5000 });
expect(library.playingTimesAt(5040)[0]!.heard).toBeCloseTo(now - 0.06, 9);
// Before the device has put out anything it says 0: the clock again.
context.getOutputTimestamp = () => ({ contextTime: 0, performanceTime: 0 });
expect(library.playingTimesAt(performance.now())[0]!.heard).toBeCloseTo(now, 2);
});
});
describe('the automation of the tracks of a song (#337)', () => {
+79
View File
@@ -1021,6 +1021,16 @@ export async function song(t) {
node('sg65-fins', 'OnMusicFinished', 0, 2200, { song: id }),
node('sg65-js', 'Join', 260, 2200, { a: 'finished song ' }),
node('sg65-logs', 'Log', 520, 2200),
// 65k7 (#438): every press of H, measured against the song's beat: offset, bar, beat, seconds.
node('sg65-hit', 'OnKeyHit', 0, 2400, { key: 'h', song: id }),
node('sg65-jh1', 'Join', 260, 2400, { a: 'hit ' }),
node('sg65-jh2', 'Join', 520, 2400, { b: ' ' }),
node('sg65-jh3', 'Join', 780, 2400),
node('sg65-jh4', 'Join', 1040, 2400, { b: ' ' }),
node('sg65-jh5', 'Join', 1300, 2400),
node('sg65-jh6', 'Join', 1560, 2400, { b: ' ' }),
node('sg65-jh7', 'Join', 1820, 2400),
node('sg65-logh', 'Log', 2080, 2400),
],
edges: [
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
@@ -1070,6 +1080,18 @@ export async function song(t) {
edge('sg65-f6', 'sg65-fins', 'exec', 'sg65-logs', 'exec'),
edge('sg65-f7', 'sg65-fins', 'stopped', 'sg65-js', 'b'),
edge('sg65-f8', 'sg65-js', 'text', 'sg65-logs', 'message'),
edge('sg65-h1', 'sg65-hit', 'offset', 'sg65-jh1', 'b'),
edge('sg65-h2', 'sg65-jh1', 'text', 'sg65-jh2', 'a'),
edge('sg65-h3', 'sg65-jh2', 'text', 'sg65-jh3', 'a'),
edge('sg65-h4', 'sg65-hit', 'bar', 'sg65-jh3', 'b'),
edge('sg65-h5', 'sg65-jh3', 'text', 'sg65-jh4', 'a'),
edge('sg65-h6', 'sg65-jh4', 'text', 'sg65-jh5', 'a'),
edge('sg65-h7', 'sg65-hit', 'beat', 'sg65-jh5', 'b'),
edge('sg65-h8', 'sg65-jh5', 'text', 'sg65-jh6', 'a'),
edge('sg65-h9', 'sg65-jh6', 'text', 'sg65-jh7', 'a'),
edge('sg65-h10', 'sg65-hit', 'seconds', 'sg65-jh7', 'b'),
edge('sg65-h11', 'sg65-hit', 'exec', 'sg65-logh', 'exec'),
edge('sg65-h12', 'sg65-jh7', 'text', 'sg65-logh', 'message'),
],
},
});
@@ -1162,6 +1184,63 @@ export async function song(t) {
beats.every((b) => b.late >= 0 && b.late <= 0.15 && Math.abs(b.fraction * 0.5 - b.late) < 0.02),
JSON.stringify({ beats, markersHeard: markersHeard.length, panelBusiest: await panelBusiest() }),
);
// 65k7. A hit in time (#438): H pressed three times inside one frame, 4 ms apart. On key hit
// measures each the moment it went down, so their offsets lie as far apart as their time
// stamps (give or take a beat, where the nearest changes) – a frame would give all three the
// same. Bar, beat and offset agree with where the song was, and that agrees within 30 ms with
// the audio clock less the latency, run back to each press.
const hitFrom = t.consoleLog.length;
const pressed = JSON.parse(
await evaluate(`(async () => {
// Just after a frame, so all three fall into the next one.
await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
const stamps = [];
for (let i = 0; i < 3; i++) {
const down = new KeyboardEvent('keydown', { key: 'h', code: 'KeyH' });
stamps.push(down.timeStamp);
window.dispatchEvent(down);
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'h', code: 'KeyH' }));
const until = performance.now() + 4;
while (performance.now() < until);
}
const playing = __gm.sound.playbacks().find((p) => p.name === ${JSON.stringify(`song:${id}`)});
const now = performance.now();
const heard = __gm.sound.clock() - __gm.music.latency() - (playing?.startedAt ?? NaN);
return JSON.stringify({ stamps, now, heard });
})()`),
);
const hitLines = await waitFor(
async () => t.consoleLog.slice(hitFrom).filter((text) => /hit /.test(text)),
(lines) => lines.length >= 3,
20,
);
const hits = hitLines.flatMap((text) => {
const m = /hit ([-\d.e]+) (\d+) (\d+) ([-\d.e]+)/.exec(text);
return m ? [{ offset: +m[1], bar: +m[2], beat: +m[3], seconds: +m[4] }] : [];
});
/** What is left of `x` off the nearest multiple of `round`. */
const off = (x, round) => x - round * Math.round(x / round);
// Where the song was at each press, by the clock: the intro bar, then bar 2 round the loop.
const expectedAt = pressed.stamps.map((stamp) => {
const into = pressed.heard - (pressed.now - stamp) / 1000;
return into < 2 ? into : 2 + ((into - 2) % 2);
});
const apart = hits.slice(1).map((h, i) => {
const stamps = (pressed.stamps[i + 1] - pressed.stamps[i]) / 1000;
return { stamps, offsets: h.offset - hits[i].offset };
});
check(
'On key hit measures each press the moment it went down, not the frame: three in one frame 4 ms apart, as far apart',
hits.length === 3 &&
apart.every((a) => a.stamps >= 0.004 && Math.abs(off(a.offsets - a.stamps, 0.5)) < 0.001) &&
hits.every(
(h, i) =>
Math.abs(h.offset) <= 0.25 &&
Math.abs(off(h.seconds - ((h.bar - 1) * 2 + (h.beat - 1) * 0.5) - h.offset, 2)) < 1e-6 &&
Math.abs(off(h.seconds - expectedAt[i], 2)) < 0.03,
),
JSON.stringify({ hits, apart, pressed, expectedAt }),
);
// 65k3. Music follows the game (#338): W held over a beat asks Switch song for "Half", bar 2 beat 3,
// on the next bar. The playing keeps its number on buses of its own, heard from that bar line
// to the sample; On beat starts over from the marker. V held turns the pattern's track down to