diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 656d6d57..31ec3cbd 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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; diff --git a/src/core/graph/nodes/events/musicEvents.ts b/src/core/graph/nodes/events/musicEvents.ts index 6a09f603..713f356d 100644 --- a/src/core/graph/nodes/events/musicEvents.ts +++ b/src/core/graph/nodes/events/musicEvents.ts @@ -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', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 4357923f..8182e8ac 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/music/beats.ts b/src/core/music/beats.ts index 004bceff..f6183563 100644 --- a/src/core/music/beats.ts +++ b/src/core/music/beats.ts @@ -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))); diff --git a/src/core/music/hits.ts b/src/core/music/hits.ts new file mode 100644 index 00000000..3ac14ab4 --- /dev/null +++ b/src/core/music/hits.ts @@ -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 }; +} diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index e8269400..e0fc381e 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -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 }) => diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 81390ed0..ede5abff 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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. */ diff --git a/src/runtime/audioSwitch.ts b/src/runtime/audioSwitch.ts index 03cf6ed4..66197bf8 100644 --- a/src/runtime/audioSwitch.ts +++ b/src/runtime/audioSwitch.ts @@ -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); diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index c854a0f5..9d300768 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -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 diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 7a28afed..b3f95b9f 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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); } diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index 9e9d5ab8..c14e8a5e 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -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(); private readonly pressed = new Set(); private readonly released = new Set(); + /** 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; } } diff --git a/src/runtime/systems/musicPulses.ts b/src/runtime/systems/musicPulses.ts index 14ebd32b..eca908ca 100644 --- a/src/runtime/systems/musicPulses.ts +++ b/src/runtime/systems/musicPulses.ts @@ -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:`); 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. diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 3477007e..8dcbcea8 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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. diff --git a/src/viewport/gamePointer.ts b/src/viewport/gamePointer.ts index d0a6ca08..f74d2d28 100644 --- a/src/viewport/gamePointer.ts +++ b/src/viewport/gamePointer.ts @@ -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 | undefined)?.timeStamp ?? e.timeStamp); }); target.on('globalpointermove', (e: FederatedPointerEvent) => report(e)); target.on('pointerup', () => world()?.setPointerDown(false)); diff --git a/tests/core/audio-switch.test.ts b/tests/core/audio-switch.test.ts index c4290d5a..6e7a6594 100644 --- a/tests/core/audio-switch.test.ts +++ b/tests/core/audio-switch.test.ts @@ -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], + ]); + }); }); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts index 061f0293..da26f959 100644 --- a/tests/core/event-order.test.ts +++ b/tests/core/event-order.test.ts @@ -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), diff --git a/tests/core/key-hit.test.ts b/tests/core/key-hit.test.ts new file mode 100644 index 00000000..14eb96b8 --- /dev/null +++ b/tests/core/key-hit.test.ts @@ -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 { + 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']); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 87b8916b..014824f8 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2711,6 +2711,7 @@ const EVENT_NODES: Record = { 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', }; diff --git a/tests/core/tilemap-patch.test.ts b/tests/core/tilemap-patch.test.ts index ede66716..59b3bbf7 100644 --- a/tests/core/tilemap-patch.test.ts +++ b/tests/core/tilemap-patch.test.ts @@ -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(); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 8d5d6870..1b817c56 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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 }) + .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)', () => { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 17aa4bb0..f5b335ac 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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