A song track can now change its volume and pan over time, played on the audio clock wherever the song is heard. This is stage 1 of #337: the model and the playback. Lanes cannot be drawn in the song tab yet. - A track holds automation lanes for volume (dB) and pan. Each point has a tick, a value and an easing from the shared list, curve assets included. Two points on one tick make a jump. Before the first point its value holds, after the last point the last value holds. - The rules are pure functions in core/music/automation.ts: the value at a tick and the pieces of a lane inside one scheduling window. - The scheduler hands those pieces to the host in every window. The host sets them as ramps or jumps on the gain and panner of the track's bus. Shapes the clock cannot ramp directly (dB as gain, curved easings) are sampled every 10 ms. At the loop end the value jumps back with the playhead. - This works in the song tab, for Play song, inside a composition (across the song loop, on the item's own bus) and in a bake. - While a lane has points the fader no longer sets that value. When the lane goes, the fader takes over from the current value. - Automation enters the bake fingerprint only when there is some, so existing bakes stay valid. - The level helpers moved to core/music/level.ts, so song.ts and automation.ts do not import each other. Known gap: a delaying sound FX on a bus after the track (a pitch shifter) makes the automation late by that delay. Tests: - automation.test.ts (13, new), 3 new in sound-library.test.ts. Song, bake and composition tests together 286/286. - 12 counter-checks, all compile, all red. - e2e 65 on atx: ALL PASSED, 60 checks. New 65h2: in the song tab the bus level follows a volume lane, 0.09 dB off, also with the fader at -60 dB. Red when the song tab does not pass the automation on. - Gate on atx: 328 files, 5053 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
/**
|
||
* What the scheduler plays (#332), before there are patterns and songs to make it of (#333, #334):
|
||
* notes of instruments at ticks, and a loop the playhead runs round. Everything here is counted in
|
||
* ticks of playing time – `elapsed` since the start, which only ever grows – and of the sequence –
|
||
* where the playhead stands, which jumps back at the end of a loop.
|
||
*/
|
||
import type { FadeCurve } from '../model/sound';
|
||
import type { SequenceLane } from './automation';
|
||
|
||
export interface SequenceNote {
|
||
/** Where it starts in the sequence, and how long it is held, in ticks. */
|
||
tick: number;
|
||
length: number;
|
||
/** As MIDI counts it: 60 is C4. */
|
||
note: number;
|
||
/** 0 to 1. */
|
||
velocity: number;
|
||
/** The instrument asset that plays it. */
|
||
instrument: string;
|
||
/** A factor on its loudness, 1 where left out: the volume of a song's item (#334). */
|
||
volume?: number;
|
||
/** The track of a song it is heard on (#334); straight out where left out. */
|
||
bus?: string;
|
||
/** What a Delay synced to beats counts by, where it is not the sequence's: a pattern in a composition (#418). */
|
||
tempo?: number;
|
||
}
|
||
|
||
/**
|
||
* A sound played as a clip of a song (#334): from `offset` seconds into it, round and round until
|
||
* `length` ticks have passed. It keeps its own speed whatever the tempo.
|
||
*/
|
||
export interface SequenceClip {
|
||
tick: number;
|
||
length: number;
|
||
sound: string;
|
||
/** The sound FX it goes through, `''` for none. */
|
||
fx: string;
|
||
offset: number;
|
||
volume: number;
|
||
bus: string;
|
||
/** As a note's. */
|
||
tempo?: number;
|
||
}
|
||
|
||
/**
|
||
* A ramp of the level of a bus the sequence plays on: from `tick` for `length` ticks it rises from
|
||
* silence to `gain` along `curve`, or falls from there to silence – the fades of a composition's
|
||
* item. One 0 ticks long sets the bus to `gain` there.
|
||
*/
|
||
export interface SequenceLevel {
|
||
tick: number;
|
||
length: number;
|
||
bus: string;
|
||
gain: number;
|
||
rise: boolean;
|
||
curve: FadeCurve;
|
||
}
|
||
|
||
export interface Sequence {
|
||
notes: readonly SequenceNote[];
|
||
clips?: readonly SequenceClip[];
|
||
levels?: readonly SequenceLevel[];
|
||
/** The automation of a song's tracks (#337), each lane on the bus it acts on. */
|
||
lanes?: readonly SequenceLane[];
|
||
}
|
||
|
||
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
|
||
export interface LoopRange {
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
/** A loop with nothing in it is none. */
|
||
export function heldLoop(loop: LoopRange | null): LoopRange | null {
|
||
if (!loop || !(loop.end > loop.start) || !(loop.start >= 0)) return null;
|
||
return loop;
|
||
}
|
||
|
||
/**
|
||
* Where the playhead stands after `elapsed` ticks of playing from `from`. Started before the end of
|
||
* the loop, it runs round it once it gets there; started behind it, it plays on.
|
||
*/
|
||
export function playheadAt(elapsed: number, from: number, loop: LoopRange | null): number {
|
||
if (!loop || from >= loop.end) return from + elapsed;
|
||
const toEnd = loop.end - from;
|
||
if (elapsed < toEnd) return from + elapsed;
|
||
return loop.start + ((elapsed - toEnd) % (loop.end - loop.start));
|
||
}
|
||
|
||
/** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */
|
||
export interface DueNote extends SequenceNote {
|
||
/** Ticks of playing time from the start. */
|
||
at: number;
|
||
}
|
||
|
||
/**
|
||
* The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end
|
||
* of a loop the window goes on at its start, so the next round is planned ahead like any other
|
||
* stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead
|
||
* has jumped back.
|
||
*/
|
||
export function notesIn(
|
||
sequence: Sequence,
|
||
from: number,
|
||
loop: LoopRange | null,
|
||
start: number,
|
||
end: number,
|
||
): DueNote[] {
|
||
const due: DueNote[] = [];
|
||
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
|
||
let elapsed = start;
|
||
let head = playheadAt(start, from, loop);
|
||
while (elapsed < end) {
|
||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||
if (!(span > 0)) break;
|
||
for (const note of sequence.notes)
|
||
if (note.tick >= head && note.tick < head + span)
|
||
due.push({
|
||
...note,
|
||
at: elapsed + (note.tick - head),
|
||
length: Math.min(note.length, stretchEnd - note.tick),
|
||
});
|
||
elapsed += span;
|
||
head = head + span >= stretchEnd ? loop!.start : head + span;
|
||
}
|
||
return due.sort((a, b) => a.at - b.at || a.note - b.note);
|
||
}
|
||
|
||
/** A clip of the sequence where it falls in playing time: `skip` ticks of it lie behind the playhead. */
|
||
export interface DueClip extends SequenceClip {
|
||
at: number;
|
||
skip: number;
|
||
}
|
||
|
||
/**
|
||
* The clips starting in the playing time from `start` up to `end`, as `notesIn` finds notes. Where
|
||
* the playhead lands – at the start with `fresh`, and after every jump back – a clip it lands in the
|
||
* middle of is taken up there, `skip` ticks into it. A clip ends where its item or the loop does.
|
||
*/
|
||
export function clipsIn(
|
||
sequence: Sequence,
|
||
from: number,
|
||
loop: LoopRange | null,
|
||
start: number,
|
||
end: number,
|
||
fresh: boolean,
|
||
): DueClip[] {
|
||
return spansIn(sequence.clips ?? [], from, loop, start, end, fresh);
|
||
}
|
||
|
||
/** The ramps of the sequence's levels as `clipsIn` finds clips: one the playhead lands inside is taken up there. */
|
||
export function levelsIn(
|
||
sequence: Sequence,
|
||
from: number,
|
||
loop: LoopRange | null,
|
||
start: number,
|
||
end: number,
|
||
fresh: boolean,
|
||
): (SequenceLevel & { at: number; skip: number })[] {
|
||
return spansIn(sequence.levels ?? [], from, loop, start, end, fresh);
|
||
}
|
||
|
||
function spansIn<T extends { tick: number; length: number }>(
|
||
clips: readonly T[],
|
||
from: number,
|
||
loop: LoopRange | null,
|
||
start: number,
|
||
end: number,
|
||
fresh: boolean,
|
||
): (T & { at: number; skip: number })[] {
|
||
const due: (T & { at: number; skip: number })[] = [];
|
||
if (clips.length === 0) return due;
|
||
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
|
||
let elapsed = start;
|
||
let head = playheadAt(start, from, loop);
|
||
let landed = fresh;
|
||
while (elapsed < end) {
|
||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||
if (!(span > 0)) break;
|
||
for (const clip of clips) {
|
||
const clipEnd = Math.min(clip.tick + clip.length, stretchEnd);
|
||
if (clip.tick >= head && clip.tick < head + span)
|
||
due.push({ ...clip, at: elapsed + (clip.tick - head), length: clipEnd - clip.tick, skip: 0 });
|
||
else if (landed && clip.tick < head && clipEnd > head)
|
||
due.push({ ...clip, at: elapsed, length: clipEnd - head, skip: head - clip.tick });
|
||
}
|
||
elapsed += span;
|
||
head = head + span >= stretchEnd ? loop!.start : head + span;
|
||
landed = true;
|
||
}
|
||
return due.sort((a, b) => a.at - b.at);
|
||
}
|