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>
231 lines
9.5 KiB
TypeScript
231 lines
9.5 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|