Files
feuerwerk/src/core/music/compositionAudio.ts
T
adminandClaude Opus 5.5 4d0a696327 Keep composition sound in step and give compositions a bus of their own
A composition's sound ran on the audio clock while its picture ran on
the frames. A frame counts at most 0.1 s, so after a hitch the sound
stayed ahead of the picture until the next stop. Songs in a trailer
drifted off the cut. The items of a composition also had no bus of
their own, and a video sprite could not pick where its sound goes.

- CompositionSound.follow compares the audio clock with the picture
  each frame. More than 40 ms apart, it starts the sound again where
  the picture is. This holds in the tab and in a run.
- A time scale other than 1 now restarts the sound again and again.
  It stays in time but comes in pieces.
- The sprite of a video item shows "Driven (by composition)" as a
  fixed Video clock row. Video time is hidden there, because the
  composition sets it every frame.
- Every composition has a bus in the mix and a node in the master
  routing. A new one goes to the master unless other compositions are
  linked elsewhere, apart from where the sounds go.
- Audio items pick From asset, To composition, Master or a bus. New
  items start with To composition. Items saved before keep From asset,
  so music already sent through a bus does not bypass it.
- Video sprites have a new row Video bus: From asset by default, the
  master or a bus. In a composition the default is the composition's
  bus, also for video sprites inside a container item.
- The chosen bus is part of the key of the video player, so a sprite
  with another bus gets a player of its own.

Found along the way:

- Six tests with full lists of sprite defaults expected the old rows
  and now list Video bus too.

Tests:
- tsc, eslint, Prettier clean
- Unit: 971 green in the 28 files touched
- npm run check on melektaus-atx: green, 309 files, 4804 tests
- e2e 68 (melektaus-atx): 13 checks green, 68i new. The sound item
  and the video item go into mix:cp68-cut, which goes to the master.
- e2e 69 (melektaus-atx): ALL PASSED, 23 checks, 69q and 69r new.
  After a hitch of 500 ms the sound restarted twice and then lay
  0.5 to 5 ms off the playhead.
- Counter-checks in unit tests: without the comparison the hitch test
  fails, without the bus in the preload the run test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:16 +02:00

231 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a composition plays of its audio items (#418), as sequences the scheduler plays on the audio
* clock. Time is counted in milliseconds – ticks at `COMPOSITION_TEMPO` –, so a frame of any `fps`
* lands within a millisecond. A pattern or a song keeps its own tempo inside its item: its notes are
* laid out here in the composition's time, each with the tempo a Delay synced to beats counts by.
*
* Every item is heard on a bus of its own at its volume, its fades ramps of that bus' level, through
* the sound FX of its pattern or song; a song's tracks are buses of that item. One playing goes to
* one place of the mix, so the items are split into one sequence for each place they are sent to.
*/
import type { CompositionAsset, CompositionItem } from '../model/Asset';
import { compositionFps, compositionLength, compositionLoops, itemFades } from '../model/composition';
import { findAssetOf, type Project } from '../model/Project';
import { findSound } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { itemRoute, MASTER_MIX, type MixRoute } from './mixer';
import { fxVoiceOf } from './needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern';
import type { BusLevel } from './playbacks';
import {
clipsIn,
type LoopRange,
notesIn,
type Sequence,
type SequenceClip,
type SequenceLevel,
type SequenceNote,
} from './sequence';
import { dbToGain, songLoop, songSequence, songTempo } from './song';
import { songBuses } from './songBuses';
import { secondsOfTicks, ticksOfSeconds } from './time';
/** The tempo a composition's sequences count at: 1000 ticks to the second. */
export const COMPOSITION_TEMPO = 125;
/** Ticks of a composition's sequence: milliseconds. */
export function compositionTicks(seconds: number): number {
return Math.round(ticksOfSeconds(seconds, COMPOSITION_TEMPO));
}
/** One sequence of a composition and where it goes. */
export interface CompositionPlaying {
route: MixRoute;
sequence: Sequence;
buses: BusLevel[];
}
/** The bus an audio item is heard on, at its volume; a song's tracks go into it. */
export function audioItemBus(compId: string, itemId: string): string {
return `composition:${compId}:${itemId}`;
}
/**
* Every audio item of the composition on a track there is, as sequences – one for each place of the
* mix they go to, in the order the first item for it lies in the list. With `routed` off all go to
* the master, as one.
*/
export function compositionPlayings(
project: Project,
comp: CompositionAsset,
routed = true,
): CompositionPlaying[] {
const fps = compositionFps(comp);
const tracks = new Set(comp.tracks.map((t) => t.id));
const byRoute = new Map<string, CompositionPlaying>();
for (const item of comp.items) {
if (!item.audio || !tracks.has(item.track)) continue;
const laid = laidOut(project, comp.id, item, fps);
if (!laid) continue;
const route = routed ? itemRoute(project, comp.id, item.audio.bus, laid.routedAs) : MASTER_MIX;
const key = JSON.stringify(route);
let playing = byRoute.get(key);
if (!playing)
byRoute.set(key, (playing = { route, sequence: { notes: [], clips: [], levels: [] }, buses: [] }));
(playing.sequence.notes as SequenceNote[]).push(...laid.notes);
(playing.sequence.clips as SequenceClip[]).push(...laid.clips);
(playing.sequence.levels as SequenceLevel[]).push(...laid.levels);
playing.buses.push(...laid.buses);
}
const playings = [...byRoute.values()];
for (const { sequence } of playings) {
(sequence.notes as SequenceNote[]).sort((a, b) => a.tick - b.tick || a.note - b.note);
(sequence.clips as SequenceClip[]).sort((a, b) => a.tick - b.tick);
(sequence.levels as SequenceLevel[]).sort((a, b) => a.tick - b.tick);
}
return playings;
}
/** How long one run of the composition is in ticks, and the loop it goes round where it loops. */
export function compositionSpan(
project: Project,
comp: CompositionAsset,
): { length: number; loop: LoopRange | null } {
const length = compositionTicks(compositionLength(project, comp) / compositionFps(comp));
return { length, loop: compositionLoops(comp) && length > 0 ? { start: 0, end: length } : null };
}
interface Laid {
notes: SequenceNote[];
clips: SequenceClip[];
/** Its fades, on its bus. */
levels: SequenceLevel[];
buses: BusLevel[];
/** The asset its routing is read from: the sound of a clip, else what it plays. */
routedAs: string;
}
/**
* An audio item in the composition's ticks: where it begins and ends, and from how far into what it
* plays. An offset below 0 starts that much later in the item.
*/
function laidOut(project: Project, compId: string, item: CompositionItem, fps: number): Laid | null {
const audio = item.audio!;
const late = Math.max(0, -item.offset);
const begin = compositionTicks(item.start / fps + late);
const end = compositionTicks((item.start + item.length) / fps);
if (end <= begin) return null;
const into = Math.max(0, item.offset);
const seconds = secondsOfTicks(end - begin, COMPOSITION_TEMPO);
const bus = audioItemBus(compId, item.id);
const own: BusLevel = { name: bus, gain: dbToGain(audio.volume), pan: 0 };
const levels = fadesOf(item, fps, bus, own.gain);
if (audio.kind === 'sound') {
const ref = findSound(project, audio.asset);
if (!ref?.asset.file) return null;
const clip = {
tick: begin,
length: end - begin,
sound: audio.asset,
fx: pickedFx('', ref.asset.props),
offset: into,
volume: 1,
bus,
};
return { notes: [], clips: [clip], levels, buses: [own], routedAs: ref.asset.id };
}
const withFx = (fxId: string, tempo: number): BusLevel => {
const fx = fxVoiceOf(project, fxId, tempo);
return fx ? { ...own, fx } : own;
};
if (audio.kind === 'pattern') {
const pattern = findAssetOf(project, audio.asset, 'pattern');
if (!pattern || !patternInstrument(pattern.props)) return null;
const tempo = patternTempo(pattern.props);
const length = patternLength(pattern);
const heard = played(patternSequence(pattern), { start: 0, end: length }, tempo, into, seconds);
return {
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, bus })),
clips: [],
levels,
buses: [withFx(pickedFx('', pattern.props), tempo)],
routedAs: pattern.id,
};
}
const song = findAssetOf(project, audio.asset, 'song');
if (!song) return null;
const tempo = songTempo(song.props);
const heard = played(songSequence(project, song), songLoop(song), tempo, into, seconds);
// Its tracks are the item's: two items of one song do not share them.
const prefix = `song:${song.id}`;
const renamed = (name: string) => (name.startsWith(prefix) ? `${bus}${name.slice(prefix.length)}` : name);
const tracks = songBuses(project, song).map(({ name, into: to, sends, ...level }) => ({
...level,
name: renamed(name),
...(to !== undefined && { into: renamed(to) }),
...(sends && { sends: sends.map((send) => ({ ...send, to: send.to ? renamed(send.to) : bus })) }),
}));
return {
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, ...(n.bus && { bus: renamed(n.bus) }) })),
clips: heard.clips.map((c) => ({ ...c, tick: begin + c.tick, bus: renamed(c.bus) })),
levels,
buses: [withFx(pickedFx('', song.props), tempo), ...tracks],
routedAs: song.id,
};
}
/**
* The fades of an item as ramps of its bus, none without: up from silence at its start – at once
* where it has no fade in, as the round before may have faded it out – and down at its end.
*/
function fadesOf(item: CompositionItem, fps: number, bus: string, gain: number): SequenceLevel[] {
const { fadeIn, fadeOut, curveIn, curveOut } = itemFades(item);
if (fadeIn === 0 && fadeOut === 0) return [];
const tick = (frame: number) => compositionTicks(frame / fps);
const start = tick(item.start);
const end = tick(item.start + item.length);
const rise = {
tick: start,
length: tick(item.start + fadeIn) - start,
bus,
gain,
rise: true,
curve: curveIn,
};
if (fadeOut === 0) return [rise];
const fall = tick(item.start + item.length - fadeOut);
return [rise, { tick: fall, length: end - fall, bus, gain, rise: false, curve: curveOut }];
}
/**
* What a pattern or a song plays from `into` seconds on for `seconds`, round its loop, in ticks of
* the composition from 0: each note and clip with its own tempo, none ringing past the end.
*/
function played(
sequence: Sequence,
loop: LoopRange,
tempo: number,
into: number,
seconds: number,
): { notes: SequenceNote[]; clips: SequenceClip[] } {
const from = Math.round(ticksOfSeconds(into, tempo));
const to = from + Math.ceil(ticksOfSeconds(seconds, tempo));
const span = compositionTicks(seconds);
const at = (ticks: number) => compositionTicks(secondsOfTicks(ticks - from, tempo));
const notes = notesIn(sequence, 0, loop, from, to).flatMap((n) => {
const tick = at(n.at);
const length = Math.min(compositionTicks(secondsOfTicks(n.length, tempo)), span - tick);
if (tick >= span || length <= 0) return [];
const { at: _, ...note } = n;
return [{ ...note, tick, length: Math.max(1, length), tempo }];
});
const clips = clipsIn(sequence, 0, loop, from, to, true).flatMap((c) => {
const tick = at(c.at);
const length = Math.min(compositionTicks(secondsOfTicks(c.length, tempo)), span - tick);
if (tick >= span || length <= 0) return [];
const { at: _, skip, ...clip } = c;
return [{ ...clip, tick, length, offset: c.offset + secondsOfTicks(skip, tempo), tempo }];
});
return { notes, clips };
}