Lay patterns and sounds out on tracks and play them as a song #334
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 of4ea752fand2a3b90bwere 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:
1 parent
2a3b90b017
commit
8f9c250eb6
43 files changed
+3863
-36
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user