Play and stop a pattern from a behavior in the game #336
A pattern could only be heard in its tab. Songs come with #334, and only then can #336 play them in a game. Until then a game plays a single pattern, looped or once, through two new nodes. - Play pattern plays the whole pattern through its instrument at its own tempo. The loop on the ruler is only for editing. Inputs are pattern, volume, loop (on by default) and detached. It gives out playback, 0 where there is no instrument or no note. - Stop pattern stops one pattern, every pattern for none, or one playback by its number. Let ring lets notes with an Envelope fall away in their release. - A playing is numbered and owned like a voice. Destroying the object, Stop sound with nothing named and the end of the run stop it. Stop sound with a voice takes a playback too. - Each playing runs on a scheduler of its own (core/music/playbacks). It waits for its files first, and a stop that came earlier wins. - The scheduler has an end for a sequence that does not loop. It stops waking there and is done once its last notes are. - The worker clock moved to viewport/wake.ts, so SoundLibrary can use it without importing music.ts. Tests: - pattern-play.test.ts (new): 8 tests, node catalog +7 cases - e2e 64 (melektaus-atx): ALL PASSED, 23 checks (+3) - Gate (melektaus-atx): CHECK EXIT 0, 248 files, 3854 tests - Counter-checks: no end in the scheduler, and no owner for the playing, each fail 1 test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ea752feef
commit
2a3b90b017
19 files changed
+745
-28
No files matched your search
@@ -56,6 +56,8 @@ function start(f: Fixture, audio?: AudioSink) {
|
||||
const sink: AudioSink = audio ?? {
|
||||
play: () => 0,
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
release: () => {},
|
||||
tune: () => {},
|
||||
length: () => 0,
|
||||
|
||||
@@ -2084,6 +2084,55 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'with none picked the bundle is empty', outputs: { params: {} } },
|
||||
],
|
||||
PlayPattern: [
|
||||
{
|
||||
name: 'no pattern is a no-op that still continues the flow',
|
||||
inputs: { pattern: '' },
|
||||
next: 'exec',
|
||||
outputs: { playback: 0 },
|
||||
after: (ctx) => expect(ctx.patterns).toEqual([]),
|
||||
},
|
||||
{
|
||||
name: 'hands the pattern on with its volume, looping and owned unless asked otherwise',
|
||||
inputs: { pattern: 'pat-1', volume: 0.5, loop: true },
|
||||
next: 'exec',
|
||||
outputs: { playback: 1 },
|
||||
after: (ctx) =>
|
||||
expect(ctx.patterns).toEqual([{ patternId: 'pat-1', volume: 0.5, loop: true, detached: false }]),
|
||||
},
|
||||
{
|
||||
name: 'plays once and detached when asked to',
|
||||
inputs: { pattern: 'pat-1', loop: false, detached: true },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ loop: false, detached: true }),
|
||||
},
|
||||
],
|
||||
StopPattern: [
|
||||
{
|
||||
name: 'stops the pattern it names, letting its notes ring when asked to',
|
||||
inputs: { pattern: 'pat-1', letRing: true },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['pattern pat-1 (let ring)']),
|
||||
},
|
||||
{
|
||||
name: 'naming none stops every pattern',
|
||||
inputs: { pattern: '' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['pattern ']),
|
||||
},
|
||||
{
|
||||
name: 'with a playback connected stops only that one, whatever pattern is named',
|
||||
inputs: { pattern: 'pat-1', playback: 4 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['voice 4']),
|
||||
},
|
||||
{
|
||||
name: 'a connected playback of 0 – a Play pattern that played nothing – stops nothing',
|
||||
inputs: { pattern: 'pat-1', playback: 0 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual([]),
|
||||
},
|
||||
],
|
||||
SoundFxParameters: [
|
||||
{
|
||||
name: 'bundles its picked parameters as numbers',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* A game plays a pattern through Play pattern and ends it through Stop pattern: each playing on a
|
||||
* scheduler of its own, numbered, owned and stopped like a voice, and gone by itself once played
|
||||
* to its end.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateSoundCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||
import type { InstrumentAsset } from '../../src/core/model/Asset';
|
||||
import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
|
||||
import { INTERVAL, LEAD, type ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
const FILE = `${'a1'.repeat(32)}.wav`;
|
||||
const Q = 480;
|
||||
|
||||
/** An audio clock moved by hand, every voice sounding until it is stopped or let go. */
|
||||
function fakeHost() {
|
||||
const host = {
|
||||
time: 0,
|
||||
sounding: new Set<number>(),
|
||||
stopped: [] as { voices: number[]; letRing: boolean }[],
|
||||
pumps: new Set<() => void>(),
|
||||
loading: [] as (() => void)[],
|
||||
};
|
||||
const playbacks = new Playbacks({
|
||||
now: () => host.time,
|
||||
latency: () => 0,
|
||||
playing: (voice) => host.sounding.has(voice),
|
||||
stop: (voices, letRing = false) => {
|
||||
host.stopped.push({ voices: [...voices], letRing });
|
||||
for (const voice of voices) host.sounding.delete(voice);
|
||||
},
|
||||
wake: (pump) => {
|
||||
host.pumps.add(pump);
|
||||
return () => host.pumps.delete(pump);
|
||||
},
|
||||
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
|
||||
});
|
||||
const run = (seconds: number) => {
|
||||
const end = host.time + seconds;
|
||||
while (host.time + INTERVAL <= end + 1e-9) {
|
||||
host.time += INTERVAL;
|
||||
for (const pump of [...host.pumps]) pump();
|
||||
}
|
||||
};
|
||||
const loaded = async () => {
|
||||
for (const resolve of host.loading.splice(0)) resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
return { host, playbacks, run, loaded };
|
||||
}
|
||||
|
||||
/** A bar of four quarters at 120 BPM: two seconds long. Its voices sound in `sounding` until stopped. */
|
||||
function bar(struck: ScheduledNote[], loop: boolean, sounding = new Set<number>()): SequencePlay {
|
||||
let next = 100;
|
||||
return {
|
||||
sequence: {
|
||||
notes: [0, 1, 2, 3].map((i) => ({
|
||||
tick: i * Q,
|
||||
length: Q / 2,
|
||||
note: 60 + i,
|
||||
velocity: 1,
|
||||
instrument: 'piano',
|
||||
})),
|
||||
},
|
||||
tempo: 120,
|
||||
length: 4 * Q,
|
||||
loop,
|
||||
layers: [],
|
||||
strike: (note) => {
|
||||
struck.push(note);
|
||||
sounding.add(next);
|
||||
return next++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('the patterns a game plays', () => {
|
||||
it('waits for its files, then plays the whole pattern once and is gone when its last note is', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
const struck: ScheduledNote[] = [];
|
||||
playbacks.start(7, 'pattern:a', bar(struck, false, host.sounding), 'logic');
|
||||
run(0.5);
|
||||
expect(struck).toEqual([]);
|
||||
await loaded();
|
||||
run(3);
|
||||
expect(struck.map((n) => n.note)).toEqual([60, 61, 62, 63]);
|
||||
struck.forEach((n, i) => expect(n.at - struck[0]!.at).toBeCloseTo(i * 0.5, 9));
|
||||
expect(host.pumps.size).toBe(0);
|
||||
expect(playbacks.has(7)).toBe(true);
|
||||
host.sounding.clear();
|
||||
expect(playbacks.has(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('with loop runs round and round, 2 s to the round, until it is stopped', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
const struck: ScheduledNote[] = [];
|
||||
playbacks.start(7, 'pattern:a', bar(struck, true, host.sounding), '');
|
||||
await loaded();
|
||||
run(5);
|
||||
expect(struck.map((n) => n.note).slice(0, 10)).toEqual([60, 61, 62, 63, 60, 61, 62, 63, 60, 61]);
|
||||
expect(struck[4]!.at - struck[0]!.at).toBeCloseTo(2, 9);
|
||||
expect(struck[0]!.at).toBeCloseTo(LEAD, 9);
|
||||
playbacks.stopId(7, true);
|
||||
expect(host.stopped.at(-1)?.letRing).toBe(true);
|
||||
expect([playbacks.has(7), host.pumps.size]).toEqual([false, 0]);
|
||||
});
|
||||
|
||||
it('never starts when it was stopped while its files were loading', async () => {
|
||||
const { host, playbacks, loaded, run } = fakeHost();
|
||||
const struck: ScheduledNote[] = [];
|
||||
playbacks.start(7, 'pattern:a', bar(struck, true), 'logic');
|
||||
playbacks.stopOwner('logic');
|
||||
await loaded();
|
||||
run(1);
|
||||
expect([struck.length, host.pumps.size, playbacks.has(7)]).toEqual([0, 0, false]);
|
||||
});
|
||||
|
||||
it('stops by name, every one for no name, by owner – and an owner of nobody stops nothing', async () => {
|
||||
const { playbacks, loaded } = fakeHost();
|
||||
playbacks.start(1, 'pattern:a', bar([], true), 'x');
|
||||
playbacks.start(2, 'pattern:a', bar([], true), '');
|
||||
playbacks.start(3, 'pattern:b', bar([], true), 'y');
|
||||
playbacks.start(4, 'pattern:c', bar([], true), '');
|
||||
await loaded();
|
||||
playbacks.stopOwner('');
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([1, 2, 3, 4]);
|
||||
playbacks.stopNamed('pattern:a');
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([3, 4]);
|
||||
playbacks.stopOwner('y');
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([4]);
|
||||
playbacks.stopNamed('');
|
||||
expect(playbacks.current()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A scene whose behavior plays a pattern of C4 and E4 on start, through an instrument of one sample. */
|
||||
function playingPattern(params: Record<string, string | number | boolean> = {}, instrument = true) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const sound = new CreateSoundCommand('Blip', FILE);
|
||||
f.stack.execute(sound);
|
||||
const ins = new CreateAssetCommand('instrument', 'Lead');
|
||||
f.stack.execute(ins);
|
||||
const lead = ins.asset as InstrumentAsset;
|
||||
lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id;
|
||||
const pattern = new CreateAssetCommand('pattern', 'Tune');
|
||||
f.stack.execute(pattern);
|
||||
const id = pattern.asset.id;
|
||||
if (instrument) f.stack.execute(new SetAssetPropertyCommand(id, 'instrument', lead.id));
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90));
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'bars', 2));
|
||||
f.stack.execute(
|
||||
new SetPatternNotesCommand(id, [
|
||||
{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 0.5 },
|
||||
{ id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 },
|
||||
]),
|
||||
);
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const play = edit.addNode('PlayPattern', { pattern: id, volume: 0.5, ...params });
|
||||
edit.connect(start, 'exec', play, 'exec');
|
||||
return { ...f, patternId: id, lead, behavior, edit, play };
|
||||
}
|
||||
|
||||
function recordingAudio() {
|
||||
const sink = {
|
||||
...SILENT_AUDIO,
|
||||
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
|
||||
notes: [] as {
|
||||
name: string;
|
||||
volume: number;
|
||||
owner: string;
|
||||
duration: number;
|
||||
at?: number;
|
||||
tempo?: number;
|
||||
}[],
|
||||
stoppedSequences: [] as string[],
|
||||
stoppedVoices: [] as number[],
|
||||
playSequence: (name: string, play: SequencePlay, owner: string) => {
|
||||
sink.sequences.push({ name, play, owner });
|
||||
return 1000 + sink.sequences.length;
|
||||
},
|
||||
playNote: ((name, _layers, _params, volume, owner, duration, timing = {}) => {
|
||||
sink.notes.push({ name, volume, owner, duration, ...timing });
|
||||
return 1;
|
||||
}) satisfies AudioSink['playNote'],
|
||||
stopSequence: (name: string, letRing = false) =>
|
||||
sink.stoppedSequences.push(`${name}${letRing ? ' (let ring)' : ''}`),
|
||||
stopVoice: (voice: number) => sink.stoppedVoices.push(voice),
|
||||
};
|
||||
return sink;
|
||||
}
|
||||
|
||||
describe('Play pattern', () => {
|
||||
it('hands the whole pattern to the player at its tempo, owned by the behavior, looping by default', () => {
|
||||
const f = playingPattern();
|
||||
const audio = recordingAudio();
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||||
const [asked] = audio.sequences;
|
||||
expect(asked?.name).toBe(`pattern:${f.patternId}`);
|
||||
expect(asked?.owner).toBe(logic.id);
|
||||
expect(asked?.play).toMatchObject({ tempo: 90, length: 2 * 4 * Q, loop: true });
|
||||
expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note, n.instrument])).toEqual([
|
||||
[0, 60, f.lead.id],
|
||||
[Q, 64, f.lead.id],
|
||||
]);
|
||||
expect(asked?.play.layers.length).toBe(2);
|
||||
});
|
||||
|
||||
it('strikes each note through the instrument at the node’s volume, owned like the playing', () => {
|
||||
const f = playingPattern({ detached: true, loop: false });
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
const [asked] = audio.sequences;
|
||||
expect([asked?.owner, asked?.play.loop]).toEqual(['', false]);
|
||||
asked!.play.strike({ at: 3, duration: 0.25, note: 60, velocity: 0.5, instrument: f.lead.id, tempo: 90 });
|
||||
expect(audio.notes).toEqual([
|
||||
{ name: `instrument:${f.lead.id}`, volume: 0.25, owner: '', duration: 0.25, at: 3, tempo: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('plays nothing and hands out 0 for a pattern without an instrument', () => {
|
||||
const f = playingPattern({}, false);
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.sequences).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop pattern', () => {
|
||||
it('stops one pattern by its name, every pattern for none, and one playing by its number', () => {
|
||||
const f = playingPattern();
|
||||
const one = f.edit.addNode('StopPattern', { pattern: f.patternId, letRing: true });
|
||||
const all = f.edit.addNode('StopPattern', { pattern: '' });
|
||||
const byNumber = f.edit.addNode('StopPattern', { pattern: f.patternId });
|
||||
f.edit.connect(f.play, 'exec', one, 'exec');
|
||||
f.edit.connect(one, 'exec', all, 'exec');
|
||||
f.edit.connect(all, 'exec', byNumber, 'exec');
|
||||
f.edit.connect(f.play, 'playback', byNumber, 'playback');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.stoppedSequences).toEqual([`pattern:${f.patternId} (let ring)`, '']);
|
||||
expect(audio.stoppedVoices).toEqual([1001]);
|
||||
});
|
||||
});
|
||||
@@ -407,6 +407,8 @@ function fakeAudio(): AudioSink & {
|
||||
sink.times.push(time);
|
||||
},
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
release: () => {},
|
||||
length: () => 0.25,
|
||||
stop: () => {},
|
||||
|
||||
@@ -43,6 +43,8 @@ function fakeAudio(): AudioSink & {
|
||||
play: (file: string, volume: number, loop: boolean, owner: string) =>
|
||||
sink.played.push({ file, volume, loop, owner }),
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
release: () => {},
|
||||
tune: () => {},
|
||||
length: () => 0,
|
||||
|
||||
@@ -226,6 +226,8 @@ export type FakeContext = NodeContext & {
|
||||
instrumentParams: Readonly<Record<string, number>>;
|
||||
tempo: number;
|
||||
}[];
|
||||
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
||||
patterns: { patternId: string; volume: number; loop: boolean; detached: boolean }[];
|
||||
silenced: string[];
|
||||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
||||
/** The scenes the context was asked to load, in order. */
|
||||
@@ -285,6 +287,11 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
notes: [],
|
||||
playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo) =>
|
||||
ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo }),
|
||||
patterns: [],
|
||||
playPattern: (patternId, volume, loop, detached) =>
|
||||
ctx.patterns.push({ patternId, volume, loop, detached }),
|
||||
stopPattern: (patternId, letRing) =>
|
||||
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
||||
// Every sound lasts a quarter of a second, as if it had been loaded.
|
||||
soundLength: () => 0.25,
|
||||
// The tail of a voice is its number in tenths of a second, so a test can tell one from another.
|
||||
|
||||
Reference in new issue
Block a user