Files
feuerwerk/src/runtime/systems/CompositionSystem.ts
T
adminandClaude Opus 5.5 77ac9d1aaf
feuerwerk / gate (push) Successful in 4m31s
feuerwerk / suite (push) Successful in 18m21s
Play sounds, patterns and songs on the tracks of a composition (#418)
A trailer needs its music and effects on the same timeline as its
pictures. Until now a composition could only place objects, so sound
had to be started by hand from a behavior and drifted against the
picture.

- Audio items: a sound, a clip of a sound, a pattern or a song dragged
  from the asset panel onto a track becomes an item as long as one
  pass of it. It may share a track with object and video items and
  overlap them.
- Editing works as for other items: move, resize, trim on the left
  (moves the offset), fit to media, delete, copy and paste, box
  selection. Sounds draw their waveform, the colour tells the kind.
- The inspector shows what it plays, start, length, offset, volume
  (-60 to +12 dB) and bus. The bus is "From asset", the master or one
  of the buses, as asked for in the issue.
- An item longer than its material plays in rounds. Sounds and
  patterns start over, a song runs on in its loop. Loop marks on the
  item follow the same rule, a song's first mark sits at its loop end.
- compositionAudio.ts turns the items into sequences, each with its
  own tempo. CompositionSound plays them, in the tab and in a game.
- In the tab the items are heard from the playhead on, straight into
  the master like every tab. A scrub is silent. Playing a song or
  pattern in its own tab stops the composition, and the other way
  round.
- In a game they are planned on the audio clock for the moment the
  composition clock reaches them, with the scheduler's LEAD made up.
  While the clock stands (waiting for a video, paused, placement off)
  they are silent and resume at the right spot.

Found along the way:

- The asset panel never let a song out of its list, so a song could
  not be dragged anywhere. Song and scene tab reject it as they reject
  a pattern.
- The asset panel scrolled to the selected asset on every rebuild of
  its list, so folding a group far below the selection jumped back up.
  Only a new selection scrolls now.
- At the end of the list, folding a group pulled every row down by the
  rows that went away. ScrollContainer has a new opt-in holdOffset,
  set only in the asset panel, that keeps the offset until the user
  scrolls up.

Limits: a time scale other than 1 or a frame longer than 0.1 s moves
the picture and not the sound. A baked song plays its notes here, not
the bake.

Tests:
- tsc, eslint, Prettier clean. Gate on melektaus-atx green,
  309 files, 4788 tests (4772 plus 16 new).
- Unit: composition-audio.test.ts 13 new, scroll-container 3 new.
  Counter-checks: without catching up on load time or without the
  silence on pause the unit tests go red.
- e2e 68h: the sound is planned 1.2 ms after the composition reaches
  0.5 s. Without the LEAD make-up it is 54 ms late and red.
- e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a
  song onto a track, 20 frames each.
- e2e 04: folding Sound at the end of the list keeps its header at
  y 234. With the old panel it jumps to 984 and the check is red.
- Not run in a browser: the loop marks of a song item, copy and paste
  of audio items, the bus choice.
- README and wiki count 2072 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:28:36 +02:00

215 lines
8.4 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.
import { isHandleEnabled } from '../../core/graph';
import { trackWriteAt } from '../../core/model/animation';
import {
compositionAt,
compositionFrameAt,
compositionFps,
compositionLength,
compositionLoops,
type LiveItem,
} from '../../core/model/composition';
import type { CompositionAsset } from '../../core/model/Asset';
import { curveLookup } from '../../core/model/curve';
import { contentId, placedWithContent } from '../../core/model/instance';
import { findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { findVideo, VIDEO_WAIT_MS } from '../../core/model/video';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
import { CompositionSound } from '../compositionSound';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bind } from './AnimationSystem';
import { type AudioSink, SILENT_AUDIO } from './Audio';
import type { System } from './System';
export interface CompositionFinish {
/** The placed composition. */
entity: Entity;
compositionId: string;
startedBy: readonly object[];
}
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
interface Spawned {
entity: Entity;
bases: Map<string, PropertyValue>;
videos: Entity[];
}
interface Run {
id: number;
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
live: Map<string, Spawned>;
/** Seconds the clock has stood so far for a video without its picture, see `waits`. */
waited: number;
/** Its audio items (#418), heard while its clock runs. */
sound: CompositionSound;
}
/** What the view that shows a run knows of its driven videos (#404). */
export interface VideoPictures {
/** Whether it did not show the sprite `objectId`, its video still without the picture asked for. */
missing(objectId: string): boolean;
}
/**
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
* when the item begins and destroys it when it ends, writes the clips over them and drives their
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
* a cut would stay behind.
*
* While the view still waits for the picture of a video an item brings in, the composition's
* clock stands, so the video starts at its first frame and not where the clock has got to meanwhile.
* Its audio items are heard only while the clock runs: they stop while it stands – for a video, a
* placement switched off, a paused game – and start again where it stands.
*/
export class CompositionSystem implements System {
/** A paused frame stops the sound of every composition; nothing else moves. */
readonly runsWhilePaused = true;
finished: CompositionFinish[] = [];
private readonly runs = new Map<Entity, Run>();
constructor(
private readonly plays = true,
private readonly pictures: VideoPictures | null = null,
private readonly audio: AudioSink = SILENT_AUDIO,
) {}
update(world: World, dt: number): void {
this.finished = [];
for (const [placement, run] of this.runs)
if (!world.entities.includes(placement)) {
run.sound.stop();
this.runs.delete(placement);
}
if (world.frozen) {
for (const run of this.runs.values()) run.sound.stop();
return;
}
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
for (const placement of started) this.advance(world, placement, dt);
}
stop(): void {
this.finished = [];
for (const run of this.runs.values()) run.sound.stop();
this.runs.clear();
}
private advance(world: World, placement: Entity, dt: number): void {
const player = placement.composition!;
if (!this.plays) {
world.log.push('Play composition is ignored in the editor run');
delete placement.composition;
return;
}
let run = this.runs.get(placement);
if (run && run.id !== player.run) this.end(world, placement);
const comp = findAssetOf(world.project, placement.assetId, 'composition');
if (!comp || player.stopped) {
this.end(world, placement);
delete placement.composition;
return;
}
run = this.runs.get(placement);
if (!run)
this.runs.set(
placement,
(run = { id: player.run, live: new Map(), waited: 0, sound: new CompositionSound(this.audio) }),
);
const runs = player.shown && isHandleEnabled(placement) && !this.waits(run, dt);
if (runs) player.time += dt;
player.shown = true;
const length = compositionLength(world.project, comp);
let frame = compositionFrameAt(comp, player.time);
if (frame >= length && compositionLoops(comp) && length > 0) {
player.time %= length / compositionFps(comp);
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
} else if (frame >= length) {
this.end(world, placement);
delete placement.composition;
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
return;
}
this.show(world, comp, placement, run, frame);
if (runs) run.sound.start(world.project, comp, player.time);
else run.sound.stop();
}
/** Whether a video of a live item still has no picture; at most `VIDEO_WAIT_MS` at a stretch. */
private waits(run: Run, dt: number): boolean {
const pictures = this.pictures;
const missing =
pictures !== null &&
[...run.live.values()].some(({ videos }) => videos.some((v) => pictures.missing(v.objectId)));
if (!missing) {
run.waited = 0;
return false;
}
run.waited += dt;
return run.waited * 1000 < VIDEO_WAIT_MS;
}
/** Takes every item of the placement's run away. */
private end(world: World, placement: Entity): void {
const run = this.runs.get(placement);
if (!run) return;
run.sound.stop();
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
this.runs.delete(placement);
}
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
const live = compositionAt(world.project, comp, frame);
const alive = new Set(live.map((l) => l.item.id));
for (const [id, { entity }] of run.live) {
if (alive.has(id)) continue;
if (world.entities.includes(entity)) world.remove(entity);
run.live.delete(id);
}
let born = false;
for (const item of live) {
if (run.live.has(item.item.id)) continue;
const spawned = spawn(world, placement, item);
if (!spawned) continue;
run.live.set(item.item.id, spawned);
born = true;
}
// In the order the tracks lay them, whenever they began.
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
const curves = curveLookup(world.project);
for (const item of live) {
const spawned = run.live.get(item.item.id);
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
}
}
}
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
const id = contentId(placement.objectId, item.object.id);
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
const entity = entities.find((e) => e.objectId === id);
if (!entity) return null;
// Below the item, a sprite set to be driven by hand keeps its own time.
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
return { entity, bases: new Map(), videos };
}
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
for (const sprite of spawned.videos) {
const texture = sprite.props.texture;
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
}
for (const clip of item.clips) {
for (const b of bind(world, clip.animation, spawned.entity)) {
let base = spawned.bases.get(b.base);
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
if (value !== undefined) b.write(value);
}
}
}