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>
130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { AudioSwitch, DIVERTED_IDS } from '../../src/runtime/audioSwitch';
|
|
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
|
|
|
/** A sink that numbers its plays from `first` and writes down every call, with its clock at `clock`. */
|
|
function sinkFrom(first: number, clock = 0) {
|
|
const calls: string[] = [];
|
|
let next = first;
|
|
const sink: AudioSink & { calls: string[]; clockAt: number } = {
|
|
...SILENT_AUDIO,
|
|
calls,
|
|
clockAt: clock,
|
|
play: (file) => (calls.push(`play ${file}`), next++),
|
|
playSequence: (name) => (calls.push(`sequence ${name}`), next++),
|
|
stopVoice: (voice) => void calls.push(`stopVoice ${voice}`),
|
|
stopPlayback: (id) => void calls.push(`stopPlayback ${id}`),
|
|
tune: (voice) => void calls.push(`tune ${voice}`),
|
|
stop: (file) => void calls.push(`stop ${file}`),
|
|
stopOwner: (owner) => void calls.push(`stopOwner ${owner}`),
|
|
setMix: (buses) => void calls.push(`setMix ${buses.map((bus) => bus.name).join(',')}`),
|
|
setMixVolume: (bus, volume) => void calls.push(`setMixVolume ${bus} ${volume}`),
|
|
resetMix: () => void calls.push('resetMix'),
|
|
length: () => 2,
|
|
clock: () => sink.clockAt,
|
|
};
|
|
return sink;
|
|
}
|
|
|
|
describe('AudioSwitch (#423)', () => {
|
|
it('plays live until it is diverted, and reaches each voice in the sink that numbered it', () => {
|
|
const live = sinkFrom(1);
|
|
const aside = sinkFrom(DIVERTED_IDS);
|
|
const audio = new AudioSwitch(live);
|
|
const before = audio.play('music', 1, true, '');
|
|
audio.divert(aside);
|
|
const after = audio.play('hit', 1, false, '');
|
|
expect(before).toBe(1);
|
|
expect(after).toBe(DIVERTED_IDS);
|
|
audio.tune(before, {});
|
|
audio.stopVoice(after);
|
|
expect(live.calls).toEqual(['play music', 'tune 1']);
|
|
expect(aside.calls).toEqual(['play hit', `stopVoice ${DIVERTED_IDS}`]);
|
|
audio.restore();
|
|
expect(audio.play('again', 1, false, '')).toBe(2);
|
|
// One of the diverted sink, gone since, reaches nobody.
|
|
audio.stopVoice(after);
|
|
expect(aside.calls).toHaveLength(2);
|
|
});
|
|
|
|
it('stops by name, owner and everything in both, and hands the mix as it stands to the one it diverts to', () => {
|
|
const live = sinkFrom(1);
|
|
const aside = sinkFrom(DIVERTED_IDS);
|
|
const audio = new AudioSwitch(live);
|
|
audio.setMix([{ name: 'mix', volume: 1, into: null, fx: null }]);
|
|
audio.setMixVolume('mix:music', 0.5);
|
|
audio.divert(aside);
|
|
expect(aside.calls).toEqual(['setMix mix', 'setMixVolume mix:music 0.5']);
|
|
audio.stop('');
|
|
audio.stopOwner('o1');
|
|
expect(live.calls.slice(-2)).toEqual(['stop ', 'stopOwner o1']);
|
|
expect(aside.calls.slice(-2)).toEqual(['stop ', 'stopOwner o1']);
|
|
// The live sink knows every file, a streamed one too.
|
|
expect(audio.length('long')).toBe(2);
|
|
});
|
|
|
|
it('keeps time by the sink it plays to', () => {
|
|
const live = sinkFrom(1, 5);
|
|
const aside = sinkFrom(DIVERTED_IDS, 0.25);
|
|
const audio = new AudioSwitch(live);
|
|
const music = audio.playSequence('composition:c', {} as never, '');
|
|
expect(audio.clock()).toBe(5);
|
|
audio.divert(aside);
|
|
expect(audio.clock()).toBe(0.25);
|
|
// A composition that meets its picture again stops its live playing and starts on the other.
|
|
audio.stopPlayback(music, 0.02);
|
|
audio.playSequence('composition:c', {} as never, '');
|
|
expect(live.calls).toEqual(['sequence composition:c', 'stopPlayback 1']);
|
|
expect(aside.calls).toEqual(['sequence composition:c']);
|
|
});
|
|
|
|
it('lands a stinger only on the clock of the sink it plays in (#338)', () => {
|
|
const live = { ...sinkFrom(1, 5), landOn: () => 6 };
|
|
const aside = { ...sinkFrom(DIVERTED_IDS, 0.25), landOn: () => 0.5 };
|
|
const audio = new AudioSwitch(live);
|
|
const song = audio.playSequence('song:s', {} as never, '');
|
|
expect(audio.landOn(song, () => 0)).toBe(6);
|
|
audio.divert(aside);
|
|
// The song plays on live, the stinger would play diverted: the two clocks do not meet.
|
|
expect(audio.landOn(song, () => 0)).toBeNull();
|
|
const there = audio.playSequence('song:s', {} as never, '');
|
|
expect(audio.landOn(there, () => 0)).toBe(0.5);
|
|
});
|
|
it('tells what ended in both sinks, and asks each voice of the one that numbered it (#373)', () => {
|
|
const live = {
|
|
...sinkFrom(1),
|
|
endings: () => [{ id: 1, name: 'music', stopped: false }],
|
|
playing: (id: number) => id === 2,
|
|
};
|
|
const aside = {
|
|
...sinkFrom(DIVERTED_IDS),
|
|
endings: () => [{ id: DIVERTED_IDS, name: 'hit', stopped: true }],
|
|
playing: (id: number) => id === DIVERTED_IDS + 1,
|
|
};
|
|
const audio = new AudioSwitch(live);
|
|
expect(audio.endings().map((end) => end.id)).toEqual([1]);
|
|
audio.divert(aside);
|
|
expect(audio.endings().map((end) => end.id)).toEqual([1, DIVERTED_IDS]);
|
|
expect([2, 3, DIVERTED_IDS, DIVERTED_IDS + 1].map((id) => audio.playing(id))).toEqual([
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
]);
|
|
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],
|
|
]);
|
|
});
|
|
});
|