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

+128 -8
View File
@@ -33,6 +33,8 @@ const DECODE_RATE = 48000;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01;
/** How finely `peaks` cuts a sound up. */
const PEAK_BUCKETS = 1024;
/**
* One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the
@@ -53,8 +55,11 @@ interface Knob {
* fall silent without a click while the echoes carry on (#324).
*/
interface Source {
/** At that second of the audio clock (#332); a worklet was told when it was made. */
start(at: number): void;
/**
* At that second of the audio clock (#332), `offset` seconds into the sound (#334); a worklet was
* told both when it was made.
*/
start(at: number, offset: number): void;
stop(): void;
ended(then: () => void): void;
fade: GainNode | null;
@@ -139,6 +144,9 @@ interface Wiring {
file: string;
loop: boolean;
rings: boolean;
/** Seconds into the sound its ways start, and whether it is a clip of a song, which fades out at its end (#334). */
offset: number;
clip: boolean;
sources: Source[];
drivers: ConstantSourceNode[];
envelopes: Map<string, EnvelopeRun>;
@@ -161,6 +169,13 @@ interface Start {
duration: number;
/** When it starts on the audio clock (#332); one already past starts now. */
at: number;
/**
* A clip of a song (#334): seconds into the sound it starts, and the second of the audio clock it
* fades out at.
*/
clip?: { offset: number; end: number };
/** The track of a song it is heard on (#334), `''` straight out. */
bus?: string;
}
/**
@@ -229,6 +244,9 @@ export class SoundLibrary implements AudioSink {
/** The same counter per owner, for the same reason – a behavior destroyed while bytes were in
* flight must not be given its voice afterwards. */
private readonly ownerStops = new Map<string, number>();
/** The tracks of the songs played so far (#334), by name. */
private readonly buses = new Map<string, Bus>();
private readonly peaked = new Map<string, Float32Array>();
/** The patterns a game plays, each on a scheduler of its own. */
private readonly sequences = new Playbacks({
now: () => this.time(),
@@ -277,7 +295,64 @@ export class SoundLibrary implements AudioSink {
if (files.length === 0) return 0;
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
const at = timing.at ?? 0;
return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at });
const bus = timing.bus ?? '';
return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at, bus });
}
/**
* A sound as a clip of a song (#334): from `at` on the audio clock, `offset` seconds into it, round
* and round until `duration` seconds have passed, then faded out – its echoes ring on. One that
* gets its bytes late starts as far into the sound as it would be by then.
*/
playClip(file: string, clip: ClipTiming, fx: FxVoice = DRY_VOICE): number {
if (file === '' || this.failed.has(file)) return 0;
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
const { at, offset, duration, volume, bus } = clip;
return this.begin({
id: 0,
file,
files: [file],
volume,
loop: true,
owner: '',
fx: layered,
duration: 0,
at,
clip: { offset, end: at + Math.max(0, duration) },
bus,
});
}
/**
* Sets the level and the pan of a track of a song (#334) in a quick glide, so a change reaches
* what is sounding on it already. A bus is made on its first use and kept.
*/
setBus(name: string, gain: number, pan: number): void {
const bus = this.bus(name);
const now = this.audio().currentTime;
bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) };
bus.gain.gain.setTargetAtTime(Math.max(0, gain), now, GLIDE / 3);
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
}
/** What each track's bus was last set to (test hook). */
busLevels(): { name: string; gain: number; pan: number }[] {
return [...this.buses].map(([name, bus]) => ({ name, ...bus.set }));
}
/** Where the voices of a track go in; straight out for `''`. */
private busInput(name: string): AudioNode {
return name === '' ? this.audio().destination : this.bus(name).gain;
}
private bus(name: string): Bus {
const known = this.buses.get(name);
if (known) return known;
const context = this.audio();
const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } };
made.gain.connect(made.pan).connect(context.destination);
this.buses.set(name, made);
return made;
}
/** A sequence without notes plays nothing, and is given no number. */
@@ -563,6 +638,28 @@ export class SoundLibrary implements AudioSink {
return this.duration(file) ?? 0;
}
/**
* The loudest sample of each of `PEAK_BUCKETS` equal stretches of a loaded sound, from 0 to 1 –
* what a clip of a song draws as its waveform (#334); null while it has not been loaded.
*/
peaks(file: string): Float32Array | null {
const known = this.peaked.get(file);
if (known) return known;
const buffer = this.buffers.get(file);
if (!buffer) return null;
const peaks = new Float32Array(PEAK_BUCKETS);
for (let c = 0; c < buffer.numberOfChannels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < data.length; i++) {
const bucket = Math.floor((i / data.length) * PEAK_BUCKETS);
peaks[bucket] = Math.max(peaks[bucket]!, Math.abs(data[i]!));
}
}
for (let b = 0; b < PEAK_BUCKETS; b++) peaks[b] = Math.min(1, peaks[b]!);
this.peaked.set(file, peaks);
return peaks;
}
/**
* Starts loading every file not held yet. Waiting for the first play left that play late: its
* start was not heard, and a graph tuning it in the meantime had moved on by the time it started.
@@ -708,12 +805,14 @@ export class SoundLibrary implements AudioSink {
};
}
private start({ id, file, files, volume, loop, owner, fx, duration, at }: Start): void {
private start({ id, file, files, volume, loop, owner, fx, duration, at, clip, bus = '' }: Start): void {
const context = this.audio();
// A context starts suspended where the page has not been interacted with; without this the
// voice would run silently and end as if it had played.
if (context.state === 'suspended') void context.resume();
const when = Math.max(at, context.currentTime);
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
if (clip && clip.end <= when) return;
const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(1, volume));
const rings = fx.layers.some(fxRings);
@@ -724,6 +823,8 @@ export class SoundLibrary implements AudioSink {
file,
loop,
rings,
offset: clip ? clip.offset + (when - at) : 0,
clip: clip !== undefined,
sources: [],
drivers: [],
envelopes: new Map(),
@@ -732,7 +833,7 @@ export class SoundLibrary implements AudioSink {
stages: 0,
};
for (const plan of fx.layers) this.wire(wiring, fxChainOf(plan, fx.params, fx.tempo)).connect(gain);
gain.connect(context.destination);
gain.connect(this.busInput(bus));
const { sources } = wiring;
this.last = {
file,
@@ -770,8 +871,9 @@ export class SoundLibrary implements AudioSink {
this.voices.push(voice);
this.started += 1;
for (const driver of wiring.drivers) driver.start();
for (const source of sources) source.start(when);
for (const source of sources) source.start(when, wiring.offset);
if (duration > 0) this.release(id, when + duration);
if (clip) this.endAt(voice, sources, clip.end);
}
/**
@@ -847,7 +949,7 @@ export class SoundLibrary implements AudioSink {
private source(wiring: Wiring, chain: Way): AudioNode {
const sound = this.sound(wiring, chain);
const own = chain.source;
if (!wiring.rings && !own) return sound;
if (!wiring.rings && !own && !wiring.clip) return sound;
const fade = gainNode(wiring.context, own?.volume ?? 1);
wiring.sources.at(-1)!.fade = fade;
return sound.connect(fade);
@@ -868,6 +970,7 @@ export class SoundLibrary implements AudioSink {
loop,
startAt: wiring.at,
};
if (wiring.offset > 0) options.skip = Math.round((wiring.offset % buffer.duration) * buffer.sampleRate);
if (own && loop) Object.assign(options, loopIn(buffer, own.loopStart, own.loopEnd));
if (!this.sent.has(file)) {
options.channels = Array.from({ length: buffer.numberOfChannels }, (_, c) =>
@@ -911,7 +1014,8 @@ export class SoundLibrary implements AudioSink {
source.loopEnd = own.loopEnd;
}
wiring.sources.push({
start: (at) => source.start(at),
// Web Audio holds an offset past the end of a looping buffer to its end, not round it.
start: (at, offset) => source.start(at, offset > 0 ? offset % buffer.duration : 0),
stop: () => source.stop(),
ended: (then) => {
source.onended = then;
@@ -978,6 +1082,22 @@ export class SoundLibrary implements AudioSink {
}
}
/** A track of a song (#334): its level, then its pan, then out; `set` is what they glide to. */
interface Bus {
gain: GainNode;
pan: StereoPannerNode;
set: { gain: number; pan: number };
}
/** When and how a clip of a song plays (#334); see `playClip`. */
export interface ClipTiming {
at: number;
offset: number;
duration: number;
volume: number;
bus: string;
}
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
interface LastVoice {
file: string;