Files
feuerwerk/tests/core/audio-switch.test.ts
T
adminandClaude Opus 5.5 e390d39314 Let a sound, note or pattern land on the beat of the music (#338)
A stinger has to start on the next beat or bar of the song that is
playing, not whenever the frame happens to run. Play sound, Play note
and Play pattern now take the number of a song or pattern playback and
plan their start on the audio clock, as the music is heard.

- New inputs sync (a playback number) and quantize (Next bar by
  default, decision 6) on Play sound, Play note and Play pattern.
- The landing is worked out in Playbacks (landOn), on the next beat,
  bar or marker of the playback as it is heard. SoundLibrary and the
  audio switch only pass it on, and only on the clock the stinger
  plays on.
- A pattern with sync plays in the tempo of the playback. It takes its
  bake only when both tempos match.
- Without a link, or when the playback is not heard, the node starts
  at once as before.
- The sync ports live in their own module musicSync.ts. Putting them
  in songSync.ts would have made an import cycle through Play song.

Tests:
- Unit: 8 new (6 in song-play, 1 each in sound-library and
  audio-switch), all green.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 74 checks. New 65k4: the pattern (own
  tempo 100) started exactly 8 beats after the song and played at the
  song's 120 BPM. Without sync it is red (6.2 beats, 100 BPM).
- Gate on atx: CHECK EXIT 0, 334 files, 5152 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:40:25 +01:00

94 lines
4.1 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);
});
});