Lay patterns and sounds out on tracks and play them as a song #334
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s

Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 16:28:25 +02:00
1 parent 2a3b90b017
commit 8f9c250eb6
43 files changed
+3863 -36

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands';
import type { PatternAsset, SongAsset } from '../../src/core/model/Asset';
import {
heldSongItems,
heldSongTracks,
itemFrom,
newItem,
type SongItem,
songGrid,
songLength,
songLoop,
songSequence,
trackBus,
trackLevel,
} from '../../src/core/music/song';
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
import { secondsOfTicks } from '../../src/core/music/time';
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
import { makeProject, roundTrip } from '../helpers';
const BAR = 1920;
/** A song with its four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */
function makeSong() {
const f = makeProject();
const create = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const piano = new CreateAssetCommand('instrument', 'Piano');
const organ = new CreateAssetCommand('instrument', 'Organ');
for (const command of [create, pattern, piano, organ]) f.stack.execute(command);
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [
{ id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 },
{ id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 },
]),
);
f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' });
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
const track = (i: number) => song().tracks[i]!.id;
const item = (fields: Partial<SongItem> & Pick<SongItem, 'tick' | 'length'>): SongItem => ({
...newItem({ track: track(0), kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
...fields,
});
const lay = (...items: SongItem[]) => f.stack.execute(new SetSongCommand(song().id, { items }));
return {
...f,
song,
track,
item,
lay,
patternId: pattern.asset.id,
piano: piano.asset.id,
organ: organ.asset.id,
};
}
describe('the song asset (#334)', () => {
it('starts with four tracks, nothing on them, at 120 BPM in 4/4 on a grid of a bar', () => {
const f = makeSong();
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']);
expect(songGrid(f.song().props)).toBe(BAR);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
expect(songGrid(f.song().props)).toBe(160);
});
it('is at least four bars long, else up to the bar line after its last item, and loops all of it', () => {
const f = makeSong();
expect(songLength(f.song())).toBe(4 * BAR);
f.lay(f.item({ tick: 4 * BAR, length: 100 }));
expect(songLength(f.song())).toBe(5 * BAR);
expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR });
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: BAR, end: 2 * BAR }));
expect(songLoop(f.song())).toEqual({ start: BAR, end: 2 * BAR });
f.stack.undo();
expect(f.song().loop).toBeUndefined();
});
it('keeps tracks and items to their rules on load, and a song survives the round trip', () => {
const tracks = heldSongTracks([{ id: 'a', name: '', volume: 99, pan: -4, mute: 1 }, 'x', { id: 'a' }]);
expect(tracks.map((t) => [t.name, t.volume, t.pan, t.mute])).toEqual([
['Track 1', 6, -1, false],
['Track 2', 0, 0, false],
]);
expect(tracks[1]!.id).not.toBe('a');
expect(heldSongTracks([])).toHaveLength(1);
const items = heldSongItems(
[
{
id: 'i',
track: 'a',
kind: 'pattern',
asset: 'p',
tick: 10.4,
length: 0.2,
offset: 3.7,
volume: 5,
transpose: 99,
},
{ track: 'gone', kind: 'pattern', tick: 0, length: 10 },
{ track: 'a', kind: 'video', tick: 0, length: 10 },
{ track: 'a', kind: 'sound', tick: -1, length: 10 },
{ track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25 },
],
tracks,
);
expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([
[0, 10, 1.25, 0, 0],
[10, 1, 4, 0, 48],
]);
const f = makeSong();
f.lay(f.item({ tick: BAR, length: BAR, transpose: -2, volume: -6 }));
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: 0, end: BAR }));
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
expect([back.tracks, back.items, back.loop]).toEqual([f.song().tracks, f.song().items, f.song().loop]);
});
it('takes a track with its items, and an undo brings both back', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: 0, length: BAR, track: f.track(1) }));
const before = JSON.stringify(f.song());
const tracks = f.song().tracks.filter((t) => t.id !== f.track(0));
f.stack.execute(new SetSongCommand(f.song().id, { tracks }));
expect(f.song().items.map((i) => i.track)).toEqual([tracks[0]!.id]);
f.stack.undo();
expect(JSON.stringify(f.song())).toBe(before);
expect(SetSongCommand.changes(f.song(), { items: f.song().items })).toBe(false);
});
});
describe('what a song plays (#334)', () => {
it('repeats a pattern for as long as its item is, each note held to the item and the pattern', () => {
const f = makeSong();
f.lay(f.item({ tick: BAR, length: 2 * BAR + 480 }));
const { notes } = songSequence(f.project, f.song());
expect(notes.map((n) => [n.tick, n.length, n.note])).toEqual([
[BAR, 480, 60],
[BAR + 960, 960, 64],
[2 * BAR, 480, 60],
[2 * BAR + 960, 960, 64],
[3 * BAR, 480, 60],
]);
expect(notes.every((n) => n.instrument === f.piano && n.bus === trackBus(f.song().id, f.track(0)))).toBe(
true,
);
});
it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR, offset: 960, transpose: 12, instrument: f.organ, volume: -6 }));
const { notes } = songSequence(f.project, f.song());
expect(notes.map((n) => [n.tick, n.length, n.note, n.instrument])).toEqual([
[0, 960, 76, f.organ],
[960, 480, 72, f.organ],
]);
expect(notes[0]!.volume).toBeCloseTo(0.501, 3);
});
it('plays a sound as a clip with its offset and FX, and leaves out what has nothing to play', () => {
const f = makeSong();
f.lay(
f.item({
kind: 'sound',
asset: 'snd',
tick: 480,
length: BAR,
offset: 0.25,
fx: 'fx1',
track: f.track(2),
}),
f.item({ kind: 'sound', asset: 'gone', tick: 0, length: BAR }),
f.item({ asset: 'gone', tick: 0, length: BAR }),
);
expect(songSequence(f.project, f.song())).toEqual({
notes: [],
clips: [
{
tick: 480,
length: BAR,
sound: 'snd',
fx: 'fx1',
offset: 0.25,
volume: 1,
bus: trackBus(f.song().id, f.track(2)),
},
],
});
});
it('silences a muted track, and while any track is soloed every one that is not', () => {
const f = makeSong();
const [a, b] = f.song().tracks;
const level = trackLevel({ ...a!, volume: -6, pan: 0.5 }, f.song().tracks);
expect(level.gain).toBeCloseTo(0.501, 3);
expect(level.pan).toBe(0.5);
expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0);
const soloed = [{ ...a!, solo: true }, b!];
expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]);
expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(0);
});
it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => {
const f = makeSong();
const pattern = f.item({ tick: BAR, length: BAR, offset: 120 });
expect(itemFrom(pattern, BAR + 480, 120)).toMatchObject({
tick: BAR + 480,
length: BAR - 480,
offset: 600,
});
const sound = f.item({ kind: 'sound', asset: 'snd', tick: BAR, length: BAR, offset: 0.5 });
expect(itemFrom(sound, BAR + 480, 120)).toMatchObject({ tick: BAR + 480, offset: 1 });
expect(itemFrom(sound, BAR, 120)).toBeNull();
expect(itemFrom(sound, 2 * BAR, 120)).toBeNull();
});
});
describe('clips on the scheduler (#334)', () => {
const clip = (tick: number, length: number) => ({
tick,
length,
sound: 's',
fx: '',
offset: 0,
volume: 1,
bus: '',
});
const sequence: Sequence = { notes: [], clips: [clip(0, 4 * BAR), clip(2 * BAR, BAR)] };
it('takes a clip up mid-way where the playhead starts, and only there', () => {
expect(clipsIn(sequence, BAR, null, 0, 10, true).map((c) => [c.at, c.skip, c.length])).toEqual([
[0, BAR, 3 * BAR],
]);
expect(clipsIn(sequence, BAR, null, 0, 10, false)).toEqual([]);
expect(clipsIn(sequence, BAR, null, BAR, BAR + 10, false).map((c) => [c.at, c.skip])).toEqual([[BAR, 0]]);
});
it('ends a clip at the end of the loop and takes it up again after the jump back', () => {
const loop = { start: BAR, end: 2 * BAR + 960 };
const due = clipsIn(sequence, BAR, loop, 0, 2 * BAR, true);
expect(due.map((c) => [c.at, c.skip, c.length])).toEqual([
[0, BAR, BAR + 960],
[BAR, 0, 960],
[BAR + 960, BAR, BAR + 960],
]);
});
it('hands a clip to the player at its second, as far into the sound as the playhead is', () => {
const played: ScheduledClip[] = [];
let now = 10;
const scheduler = new Scheduler(
{
now: () => now,
latency: () => 0,
play: () => 0,
playClip: (c) => played.push(c),
playing: () => true,
stop: () => {},
wake: () => () => {},
},
() => ({ notes: [], clips: [{ ...clip(0, 4 * BAR), offset: 0.25, volume: 0.5, bus: 'b' }] }),
{ tempo: 120 },
);
scheduler.start(BAR + 480);
expect(played).toHaveLength(1);
expect(played[0]!.at).toBeCloseTo(10.05, 9);
expect(played[0]!.offset).toBeCloseTo(0.25 + secondsOfTicks(BAR + 480, 120), 9);
expect(played[0]!.duration).toBeCloseTo(secondsOfTicks(4 * BAR - BAR - 480, 120), 9);
expect([played[0]!.volume, played[0]!.bus, played[0]!.tempo]).toEqual([0.5, 'b', 120]);
now += 0.5;
scheduler.pump();
expect(played).toHaveLength(1);
});
it('gives the notes of a song their volume and track on the way to the player', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: 480, volume: -6 }));
const notes: unknown[] = [];
const scheduler = new Scheduler(
{
now: () => 0,
latency: () => 0,
play: (n) => notes.push(n),
playing: () => true,
stop: () => {},
wake: () => () => {},
},
() => songSequence(f.project, f.song()),
);
scheduler.start();
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ note: 60, bus: trackBus(f.song().id, f.track(0)) });
expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3);
});
});
describe('a pattern laid out in a song', () => {
it('is found by its id: an edit to the pattern is heard everywhere it lies', () => {
const f = makeSong();
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: BAR, length: BAR, track: f.track(1) }));
f.stack.execute(
new SetPatternNotesCommand(f.patternId, [{ id: 'n', tick: 240, length: 10, note: 70, velocity: 1 }]),
);
expect(songSequence(f.project, f.song()).notes.map((n) => [n.tick, n.note])).toEqual([
[240, 70],
[BAR + 240, 70],
]);
expect((f.project.assets.find((a) => a.id === f.patternId) as PatternAsset).notes).toHaveLength(1);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import { newItem, type SongItem } from '../../src/core/music/song';
import {
clampMove,
draggedLength,
droppedLength,
duplicateOffset,
moveDelta,
moved,
placedCopies,
split,
trimDelta,
trimmed,
} from '../../src/editor/song/songEdit';
import {
clampTracks,
HEAD_BOXES,
headPartAt,
itemAt,
itemsWithin,
sliderValue,
type SongView,
TRACK_HEIGHT,
} from '../../src/editor/song/songView';
const BAR = 1920;
const TRACKS = ['t1', 't2', 't3'];
const rows = new Map(TRACKS.map((id, i) => [id, i]));
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false }));
function item(
id: string,
track: string,
tick: number,
length: number,
extra: Partial<SongItem> = {},
): SongItem {
return { ...newItem({ track, kind: 'pattern', asset: 'p', tick, length }), id, ...extra };
}
describe('moving and sizing items (#334)', () => {
const items = [item('a', 't1', BAR, BAR), item('b', 't2', 3 * BAR, BAR)];
const both = new Set(['a', 'b']);
it('moves the picked items on the grid, across tracks, never before 0 or off the tracks', () => {
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 700, 1, BAR, false)).toEqual({
ticks: 0,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 1000, 2, BAR, false)).toEqual({
ticks: BAR,
tracks: 1,
});
expect(moveDelta(items, both, rows, 3, items[0]!, -5000, 0, BAR, false)).toEqual({
ticks: -BAR,
tracks: 0,
});
expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 7, 0, BAR, true)).toEqual({
ticks: 7,
tracks: 0,
});
expect(clampMove(items, both, rows, 3, 0, -1)).toEqual({ ticks: 0, tracks: 0 });
expect(moved(items, both, TRACKS, 480, 1).map((i) => [i.tick, i.track])).toEqual([
[BAR + 480, 't2'],
[3 * BAR + 480, 't3'],
]);
});
it('lengthens by the right end to the grid, and never back past its own start', () => {
expect(draggedLength(items[0]!, 3 * BAR + 100, BAR, false)).toBe(2 * BAR);
expect(draggedLength(items[0]!, 0, BAR, false)).toBe(BAR);
expect(draggedLength(items[0]!, BAR + 5, BAR, true)).toBe(5);
});
it('cuts back by the left end what it plays with it, and pulls it out no further than its start', () => {
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0.25 });
const picked = new Set(['s']);
// 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes.
expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240);
expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 1);
const [cut] = trimmed([sound], picked, 480, 120);
expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]);
const pattern = item('p', 't1', BAR, BAR, { offset: 120 });
expect(trimDelta([pattern], new Set(['p']), pattern, 0, 60, false, 120)).toBe(-120);
expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 });
});
it('splits the picked items the point falls inside, and gives the right-hand parts', () => {
const cut = split(items, new Set(['a', 'b']), BAR + 480, 120);
expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([
['t1', BAR, 480, 0],
['t1', BAR + 480, BAR - 480, 480],
['t2', 3 * BAR, BAR, 0],
]);
expect(cut.right).toEqual([cut.items[1]!.id]);
});
it('puts a duplicate right behind the picked items, and a paste on their tracks', () => {
expect(duplicateOffset(items, both, BAR)).toBe(3 * BAR);
const copies = placedCopies(items, 480, ['t2', 't3']);
expect(copies.map((i) => [i.tick, i.track])).toEqual([
[480, 't2'],
[480 + 2 * BAR, 't2'],
]);
expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true);
});
it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => {
expect(droppedLength(null, 3 * BAR, 120, BAR)).toBe(3 * BAR);
expect(droppedLength(1.25, null, 120, BAR)).toBe(1200);
expect(droppedLength(null, null, 120, BAR)).toBe(BAR);
});
});
describe('where things are in the Song tab (#334)', () => {
const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0 };
const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)];
it('finds an item and which end of it is under the pointer', () => {
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' });
expect(itemAt(view, items, tracks, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' });
expect(itemAt(view, items, tracks, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({
id: 'b',
part: 'start',
});
expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull();
expect(itemsWithin(view, items, tracks, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([
'a',
'b',
]);
});
it('scrolls no further than the tracks and the row that adds one', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT - 28);
});
it('tells the parts of a head apart and reads a slider from left to right', () => {
const { mute, solo, volume, pan } = HEAD_BOXES;
expect(headPartAt(mute.x + 4, mute.y + 4)).toBe('mute');
expect(headPartAt(solo.x + 4, solo.y + 4)).toBe('solo');
expect(headPartAt(volume.x + 10, volume.y + 4)).toBe('volume');
expect(headPartAt(pan.x + 10, pan.y + 4)).toBe('pan');
expect(headPartAt(20, 8)).toBe('name');
expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60);
expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6);
expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0);
});
});
+101 -3
View File
@@ -60,11 +60,12 @@ interface FakeSource {
playbackRate: FakeParam;
onended: (() => void) | null;
started: boolean;
/** The second of the audio clock it was started at. */
/** The second of the audio clock it was started at, and how far into its sound (#334). */
startedAt: number;
startedOffset: number;
stopped: boolean;
connect: (target: unknown) => unknown;
start: (at?: number) => void;
start: (at?: number, offset?: number) => void;
stop: () => void;
}
@@ -112,6 +113,8 @@ const clocks: {
stopped: boolean;
onended: (() => void) | null;
}[] = [];
/** The pan of every track's panner made (#334), in order. */
const panners: FakeParam[] = [];
/** The curve of every WaveShaper made, in order. */
const shapers: { curve: Float32Array | null }[] = [];
let contextState = 'running';
@@ -183,10 +186,12 @@ function fakeContext() {
onended: null,
started: false,
startedAt: -1,
startedOffset: 0,
stopped: false,
start: (at?: number) => {
start: (at?: number, offset?: number) => {
source.started = true;
source.startedAt = at ?? 0;
source.startedOffset = offset ?? 0;
},
stop: () => {
source.stopped = true;
@@ -216,6 +221,11 @@ function fakeContext() {
return delay;
},
createChannelMerger: () => wired('merger', {}),
createStereoPanner: () => {
const panner = wired('panner', { pan: param(0) });
panners.push(panner.pan);
return panner;
},
createChannelSplitter: () => wired('splitter', {}),
createConstantSource: () => {
const clock = wired('clock', {
@@ -259,6 +269,7 @@ beforeEach(() => {
disconnects.length = 0;
clocks.length = 0;
shapers.length = 0;
panners.length = 0;
contextState = 'running';
decodes = [];
vi.stubGlobal(
@@ -1315,3 +1326,90 @@ describe('notes with an Envelope (#335)', () => {
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([-12, 0]);
});
});
describe('clips and tracks of a song (#334)', () => {
it('starts a clip at its second, as far into its sound as it says, round and round', async () => {
const library = new SoundLibrary();
// The sound is 1.5 s long: 2 s in is half a second into its second round.
library.playClip(FILE, { at: 2, offset: 2, duration: 3, volume: 0.5, bus: '' });
await settle();
await arrive();
expect(sources[0]).toMatchObject({ startedAt: 2, startedOffset: 0.5, loop: true });
expect(library.stats().last).toMatchObject({ at: 2, volume: 0.5, loop: true });
});
it('fades a clip out at its end and lets it go, a source only once', async () => {
const library = new SoundLibrary();
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' });
await settle();
await arrive();
// The voice's gain first, then the fade of its source.
expect(gains[1]!.ramps).toEqual([
['set', 1, 5],
['linear', 0, 5.01],
]);
expect(clocks.map((clock) => clock.stopAt)).toEqual([5.01]);
expect(sources[0]!.stopped).toBe(false);
clockTo();
expect(sources[0]!.stopped).toBe(true);
expect(library.stats().playing).toBe(0);
});
it('starts a pitched clip in its worklet as many samples in as the offset is', async () => {
const library = new SoundLibrary();
const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan;
library.playClip(
FILE,
{ at: 1, offset: 1.75, duration: 1, volume: 1, bus: '' },
{ plan: pitched, params: {} },
);
await settle();
await arrive();
// 0.25 s into the second round, at the sound's own 800 samples a second.
expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 });
});
it("sends a song's voices through the bus of their track: its level, then its pan, then out", async () => {
const library = new SoundLibrary();
library.setBus('song:s:t', 0.5, -0.25);
library.playNote(
'instrument:i',
[
{
pitch: [],
stages: [],
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
},
],
{},
1,
'',
0,
{ bus: 'song:s:t' },
);
await settle();
await arrive();
expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination']));
expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0);
expect(wires).not.toContain('gain > destination');
expect(gains[0]!.glides).toEqual([0.5]);
expect(panners[0]!.glides).toEqual([-0.25]);
library.setBus('song:s:t', 0, 1);
expect(panners).toHaveLength(1);
expect([gains[0]!.glides, panners[0]!.glides]).toEqual([
[0.5, 0],
[-0.25, 1],
]);
});
it('says how loud each stretch of a loaded sound is, and nothing before its bytes are there', async () => {
const library = new SoundLibrary();
expect(library.peaks(FILE)).toBeNull();
void library.load(FILE);
await settle();
await arrive();
const peaks = library.peaks(FILE);
expect(peaks).toHaveLength(1024);
expect(library.peaks(FILE)).toBe(peaks);
});
});