Fade sounds and videos in and out on the items of a composition
A trailer needs its music and its clips to come in softly and leave
softly. Until now an item of a composition could only play at one
level, and a video item could not even be turned down on its own.
- Every item with sound gets a grip at its upper left and upper right
corner. Dragging one inwards fades the item in or out. Dragging it
back to the edge removes the fade. Lower down the same edge still
trims as before.
- The fade is drawn as its curve, with the part above it darkened.
The inspector shows Fade in and Fade out in seconds, each with the
curves sound clips already know.
- The model keeps fadeIn and fadeOut in frames plus curveIn and
curveOut on the item. Old projects load unchanged. An item shorter
than its fades plays them cut to its length but keeps the setting.
- Sound, pattern and song items ramp the gain of their own bus. The
scheduler plans the ramps on the audio clock next to the notes.
Starting in the middle of a fade picks it up from there.
- Video items set the new sprite property Video volume each frame.
It multiplies with the fade and is only shown for a driven video.
The player glides to each new level over 15 ms so it does not click
between frames.
Not covered: a container item that carries a video further down gets
no grips. Only a video on the item's own sprite counts.
Tests:
- tsc, eslint, Prettier clean.
- Unit tests for composition, music, song and sound (12 files): 269
passed, 7 of them new.
- e2e 69 on melektaus-atx: ALL PASSED, 21 checks. 69p drags a grip to
a fade in of 10 frames and hears the item bus rise from 0.127 over
0.229 and 0.339 to 1 within one second.
- Counter-checks as unit tests: without the audio ramps, and without
the level on the video sprite in the game, both go red.
- Gate on melektaus-atx: 309 files, 4796 tests passed, 8 more than
at 77ac9d1. The first run failed 14 tests in 6 files that list the
default sprite properties or the bus levels in full. They now
expect videoVolume and the new level field of busLevels.
- Not tried in a browser: video fades, pattern and song items with a
fade.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77ac9d1aaf
commit
acd12326dc
26 files changed
+737
-53
No files matched your search
@@ -8,6 +8,7 @@ import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { ObjectGroup, SceneObject } from './SceneObject';
|
||||
import type { FadeCurve } from './sound';
|
||||
|
||||
export interface AssetBase {
|
||||
id: string;
|
||||
@@ -275,7 +276,9 @@ export interface CompositionTrack {
|
||||
* below 0 the item begins before the video and shows its first picture until then. `fitMedia` says
|
||||
* its length still follows that video, or the default while there is none. Items of a track may
|
||||
* overlap. An item with `audio` spawns no object: it plays a sound, a pattern or a song (#418), and
|
||||
* `offset` is how far into that it begins.
|
||||
* `offset` is how far into that it begins. `fadeIn` and `fadeOut` are the frames its sound – what it
|
||||
* plays, or its video's – rises from silence at its start and falls back at its end, shaped by
|
||||
* `curveIn` and `curveOut`; absent for none.
|
||||
*/
|
||||
export interface CompositionItem {
|
||||
id: string;
|
||||
@@ -285,6 +288,10 @@ export interface CompositionItem {
|
||||
offset: number;
|
||||
fitMedia?: boolean;
|
||||
audio?: CompositionAudio;
|
||||
fadeIn?: number;
|
||||
fadeOut?: number;
|
||||
curveIn?: FadeCurve;
|
||||
curveOut?: FadeCurve;
|
||||
}
|
||||
|
||||
export type CompositionAudioKind = 'sound' | 'pattern' | 'song';
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
} from '../properties/compositions';
|
||||
import { playedSoundById } from '../properties/playedSound';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../properties/videos';
|
||||
import { DEFAULT_FADE_CURVE } from '../properties/sounds';
|
||||
import {
|
||||
VIDEO_CLOCK_KEY,
|
||||
VIDEO_LEVEL_KEY,
|
||||
VIDEO_TIME_KEY,
|
||||
videoDrivenOf,
|
||||
videoLevelOf,
|
||||
} from '../properties/videos';
|
||||
import { animationDuration, fpsOf, frameCount, targetNamePath } from './animation';
|
||||
import { type ReadAt, writeObjectRow } from './animationPreview';
|
||||
import type {
|
||||
@@ -41,6 +48,7 @@ import {
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
import { type FadeCurve, fadeGain, isFadeCurve } from './sound';
|
||||
import { findVideo, videoLengthOf, type VideoRef } from './video';
|
||||
|
||||
/** How long an item lasts while it has no video to take its length from. */
|
||||
@@ -85,6 +93,40 @@ export function setSoundMeasure(measure: (file: string) => number | null): void
|
||||
soundSeconds = measure;
|
||||
}
|
||||
|
||||
/** Whether an item has a sound its fades shape: what an audio item plays, or the video its object wears. */
|
||||
export function itemSounds(project: Project, comp: CompositionAsset, item: CompositionItem): boolean {
|
||||
return item.audio !== undefined || itemVideo(project, comp, item) !== null;
|
||||
}
|
||||
|
||||
/** The fades of an item in whole frames as they work: the fade in no longer than the item, the fade out no longer than the rest. */
|
||||
export interface ItemFades {
|
||||
fadeIn: number;
|
||||
fadeOut: number;
|
||||
curveIn: FadeCurve;
|
||||
curveOut: FadeCurve;
|
||||
}
|
||||
|
||||
export function itemFades(item: CompositionItem): ItemFades {
|
||||
const frames = (value: number | undefined, most: number) =>
|
||||
Math.max(0, Math.min(most, Math.round(Number.isFinite(value) ? value! : 0)));
|
||||
const fadeIn = frames(item.fadeIn, item.length);
|
||||
return {
|
||||
fadeIn,
|
||||
fadeOut: frames(item.fadeOut, item.length - fadeIn),
|
||||
curveIn: item.curveIn ?? DEFAULT_FADE_CURVE,
|
||||
curveOut: item.curveOut ?? DEFAULT_FADE_CURVE,
|
||||
};
|
||||
}
|
||||
|
||||
/** How loud an item's sound is `at` frames after its start, its fades considered: 0 to 1. */
|
||||
export function fadeLevel(item: CompositionItem, at: number): number {
|
||||
const { fadeIn, fadeOut, curveIn, curveOut } = itemFades(item);
|
||||
const rise = fadeIn > 0 && at < fadeIn ? fadeGain(curveIn, at / fadeIn) : 1;
|
||||
const left = item.length - at;
|
||||
const fall = fadeOut > 0 && left < fadeOut ? fadeGain(curveOut, left / fadeOut) : 1;
|
||||
return rise * fall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds the first pass of what an audio item plays lasts: its sound once loaded, its pattern, its
|
||||
* song to the end of its loop – it goes round there, what lies behind is never heard –; 0 while that
|
||||
@@ -234,6 +276,8 @@ export interface LiveItem {
|
||||
time: number;
|
||||
/** Seconds into what its videos play: `time` past the item's offset. */
|
||||
videoTime: number;
|
||||
/** How loud its videos are heard, 0 to 1: its fades at this frame. */
|
||||
level: number;
|
||||
/** In the order they started; a later one writes over an earlier one. */
|
||||
clips: ClipAt[];
|
||||
}
|
||||
@@ -266,7 +310,11 @@ export function compositionAt(project: Project, comp: CompositionAsset, frame: n
|
||||
const at = ((frame - clip.start) / fps) * fpsOf(animation!);
|
||||
playing.push({ clip, animation: animation!, frame: Math.min(at, frameCount(animation!) - 1) });
|
||||
}
|
||||
live.push({ item: { item, object, track, time, videoTime: item.offset + time, clips: playing }, order });
|
||||
const level = fadeLevel(item, frame - item.start);
|
||||
live.push({
|
||||
item: { item, object, track, time, videoTime: item.offset + time, level, clips: playing },
|
||||
order,
|
||||
});
|
||||
});
|
||||
live.sort(
|
||||
(a, b) => b.item.track - a.item.track || a.item.item.start - b.item.item.start || a.order - b.order,
|
||||
@@ -364,7 +412,7 @@ export function heldCompositionTracks(raw: unknown): CompositionTrack[] {
|
||||
/**
|
||||
* Held to what an item may be: the id of an object it spawns and no other item does – an audio item
|
||||
* an id of its own and what it plays –, a track of the composition, a start in whole frames from 0,
|
||||
* at least a frame long, a finite offset.
|
||||
* at least a frame long, a finite offset, fades in whole frames above 0 and curves there are.
|
||||
*/
|
||||
export function heldCompositionItems(
|
||||
raw: unknown,
|
||||
@@ -392,6 +440,12 @@ export function heldCompositionItems(
|
||||
};
|
||||
if (i.fitMedia === true) item.fitMedia = true;
|
||||
if (audio) item.audio = audio;
|
||||
const fadeIn = Math.round(finite(i.fadeIn));
|
||||
const fadeOut = Math.round(finite(i.fadeOut));
|
||||
if (fadeIn > 0) item.fadeIn = fadeIn;
|
||||
if (fadeOut > 0) item.fadeOut = fadeOut;
|
||||
if (isFadeCurve(i.curveIn)) item.curveIn = i.curveIn;
|
||||
if (isFadeCurve(i.curveOut)) item.curveOut = i.curveOut;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
@@ -449,6 +503,7 @@ function driveVideos(project: Project, holder: CompositionAsset, live: LiveItem)
|
||||
const ref = objectRef(project, holder, object.id);
|
||||
ref?.setOverride(VIDEO_CLOCK_KEY, 'driven');
|
||||
ref?.setOverride(VIDEO_TIME_KEY, live.videoTime);
|
||||
if (live.level < 1) ref?.setOverride(VIDEO_LEVEL_KEY, videoLevelOf(props) * live.level);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DEFAULT_ITEM_SECONDS,
|
||||
fittedLength,
|
||||
itemEnd,
|
||||
itemFades,
|
||||
itemVideo,
|
||||
} from './composition';
|
||||
import { findAsset, type Project } from './Project';
|
||||
@@ -324,6 +325,21 @@ export function trimmedItem(
|
||||
return { ...rest, start, length: item.length - by, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* The item with the handle of its fade in or out dragged to `frame`: the fade from its edge to there,
|
||||
* none where that is at or past the edge, no further than the other fade lets it. A fade of 0 is left
|
||||
* out of the item.
|
||||
*/
|
||||
export function fadedItem(item: CompositionItem, end: 'in' | 'out', frame: number): CompositionItem {
|
||||
const fades = itemFades(item);
|
||||
const asked = end === 'in' ? frame - item.start : itemEnd(item) - frame;
|
||||
const room = item.length - (end === 'in' ? fades.fadeOut : fades.fadeIn);
|
||||
const frames = Math.max(0, Math.min(room, Math.round(asked)));
|
||||
const key = end === 'in' ? 'fadeIn' : 'fadeOut';
|
||||
const { [key]: _, ...rest } = item;
|
||||
return frames > 0 ? { ...rest, [key]: frames } : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an item's video lies in the composition, in frames: where it begins, how long its first pass
|
||||
* is, whether it goes round, and how long every pass after the first is where that differs – a song
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clipsIn, heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import type { FadeCurve } from '../model/sound';
|
||||
import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import {
|
||||
COMMON_TIME,
|
||||
DEFAULT_TEMPO,
|
||||
@@ -44,6 +45,20 @@ export interface ScheduledClip {
|
||||
tempo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ramp of a bus's level handed to the player (#418): from `at` for `duration` seconds, along the
|
||||
* part of the ramp from `done` (0 to 1) on – a playhead that lands inside one takes it up there.
|
||||
*/
|
||||
export interface ScheduledRamp {
|
||||
at: number;
|
||||
duration: number;
|
||||
bus: string;
|
||||
gain: number;
|
||||
rise: boolean;
|
||||
curve: FadeCurve;
|
||||
done: number;
|
||||
}
|
||||
|
||||
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
|
||||
export interface SchedulerHost {
|
||||
/** Seconds on the audio clock. */
|
||||
@@ -60,6 +75,8 @@ export interface SchedulerHost {
|
||||
play(note: ScheduledNote): number;
|
||||
/** Starts a clip the same way; a host that plays no clips leaves it out. */
|
||||
playClip?(clip: ScheduledClip): number;
|
||||
/** Ramps the level of a bus; a host without levels leaves it out. */
|
||||
ramp?(ramp: ScheduledRamp): void;
|
||||
playing(voice: number): boolean;
|
||||
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
|
||||
stop(voices: readonly number[], letRing?: boolean): void;
|
||||
@@ -216,6 +233,19 @@ export class Scheduler {
|
||||
});
|
||||
if (voice !== 0) this.voices.push(voice);
|
||||
}
|
||||
if (this.host.ramp)
|
||||
for (const due of levelsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
|
||||
const whole = due.length + due.skip;
|
||||
this.host.ramp({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
bus: due.bus,
|
||||
gain: due.gain,
|
||||
rise: due.rise,
|
||||
curve: due.curve,
|
||||
done: whole > 0 ? due.skip / whole : 0,
|
||||
});
|
||||
}
|
||||
this.fresh = false;
|
||||
this.until = to;
|
||||
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* 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, 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.
|
||||
* 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 } from '../model/composition';
|
||||
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';
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
notesIn,
|
||||
type Sequence,
|
||||
type SequenceClip,
|
||||
type SequenceLevel,
|
||||
type SequenceNote,
|
||||
} from './sequence';
|
||||
import { dbToGain, songLoop, songSequence, songTempo } from './song';
|
||||
@@ -69,15 +70,18 @@ export function compositionPlayings(
|
||||
const route = routed ? playedRoute(project, 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: [] }, buses: [] }));
|
||||
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;
|
||||
}
|
||||
@@ -94,6 +98,8 @@ export function compositionSpan(
|
||||
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;
|
||||
@@ -113,6 +119,7 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
|
||||
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;
|
||||
@@ -125,7 +132,7 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
|
||||
volume: 1,
|
||||
bus,
|
||||
};
|
||||
return { notes: [], clips: [clip], buses: [own], routedAs: ref.asset.id };
|
||||
return { notes: [], clips: [clip], levels, buses: [own], routedAs: ref.asset.id };
|
||||
}
|
||||
const withFx = (fxId: string, tempo: number): BusLevel => {
|
||||
const fx = fxVoiceOf(project, fxId, tempo);
|
||||
@@ -140,6 +147,7 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
|
||||
return {
|
||||
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, bus })),
|
||||
clips: [],
|
||||
levels,
|
||||
buses: [withFx(pickedFx('', pattern.props), tempo)],
|
||||
routedAs: pattern.id,
|
||||
};
|
||||
@@ -160,11 +168,35 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { SendTap } from '../graph/Graph';
|
||||
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||
import type { BakePlayback } from './bake';
|
||||
import type { MixSend } from './mixer';
|
||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import {
|
||||
LEAD,
|
||||
type ScheduledClip,
|
||||
type ScheduledNote,
|
||||
type ScheduledRamp,
|
||||
Scheduler,
|
||||
type SchedulerHost,
|
||||
} from './Scheduler';
|
||||
import type { LoopRange, Sequence } from './sequence';
|
||||
import { ticksOfSeconds } from './time';
|
||||
|
||||
@@ -55,6 +62,8 @@ export interface SequencePlay {
|
||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
/** Ramps the level of one of its buses (#418). */
|
||||
ramp?(ramp: ScheduledRamp): void;
|
||||
/** Its bake (#340), played in place of its notes and clips where the host can. */
|
||||
bake?: BakePlay;
|
||||
/**
|
||||
@@ -82,7 +91,7 @@ export interface Runner {
|
||||
stop(letRing?: boolean): void;
|
||||
}
|
||||
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead' | 'ramp'> {
|
||||
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
|
||||
prepare(needs: SequenceNeeds, id: number): Promise<void>;
|
||||
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
|
||||
@@ -149,6 +158,7 @@ export class Playbacks {
|
||||
ahead,
|
||||
play: (note) => (keep(planned, note), play.strike(note)),
|
||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||
...(play.ramp && { ramp: play.ramp }),
|
||||
},
|
||||
() => play.sequence,
|
||||
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 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';
|
||||
|
||||
export interface SequenceNote {
|
||||
/** Where it starts in the sequence, and how long it is held, in ticks. */
|
||||
@@ -40,9 +41,24 @@ export interface SequenceClip {
|
||||
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[];
|
||||
}
|
||||
|
||||
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
|
||||
@@ -126,8 +142,30 @@ export function clipsIn(
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
): DueClip[] {
|
||||
const clips = sequence.clips ?? [];
|
||||
const due: 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;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const VIDEO_ROWS: readonly PropertyDef[] = [
|
||||
*/
|
||||
export const VIDEO_CLOCK_KEY = 'videoClock';
|
||||
export const VIDEO_TIME_KEY = 'videoTime';
|
||||
/** How loud a driven video is heard through this sprite, 0–1, on top of the video's own Volume; a composition's fades set it. */
|
||||
export const VIDEO_LEVEL_KEY = 'videoVolume';
|
||||
|
||||
export const VIDEO_CLOCK_OPTIONS = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
@@ -47,6 +49,17 @@ export const SPRITE_VIDEO_ROWS: readonly PropertyDef[] = [
|
||||
shownFor: { key: VIDEO_CLOCK_KEY, values: ['driven'] },
|
||||
shownForVideo: 'texture',
|
||||
},
|
||||
{
|
||||
key: VIDEO_LEVEL_KEY,
|
||||
label: 'Video volume',
|
||||
kind: 'number',
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
shownFor: { key: VIDEO_CLOCK_KEY, values: ['driven'] },
|
||||
shownForVideo: 'texture',
|
||||
},
|
||||
];
|
||||
|
||||
/** The rows of a clip of a video, set in the Video tab. */
|
||||
@@ -71,6 +84,10 @@ export function videoTimeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VIDEO_TIME_KEY], 'number', 0);
|
||||
}
|
||||
|
||||
export function videoLevelOf(bag: PropertyBag): number {
|
||||
return Math.min(1, Math.max(0, coerceAs(bag[VIDEO_LEVEL_KEY], 'number', 1)));
|
||||
}
|
||||
|
||||
/** Start and end of a clip in seconds, `end` 0 for the end of the video. */
|
||||
export function videoSpanOf(bag: PropertyBag): { start: number; end: number } {
|
||||
const seconds = (key: string) => Math.max(0, coerceAs(bag[key], 'number', 0));
|
||||
|
||||
Reference in new issue
Block a user