/** * 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(); 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 }; }