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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 10:31:16 +02:00
1 parent 875c76026a
commit 4d0a696327
37 files changed
+657 -83

No files matched your search

+8 -2
View File
@@ -118,7 +118,7 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
/**
* The sound follows the playhead while it runs, straight into the master as every tab plays; an
* edit that changes what it plays starts it again there.
* edit that changes what it plays starts it again there, and so does a hitch that left it apart.
*/
private hear(runs: boolean): void {
const asset = this.canvas.isPlaying ? this.asset() : null;
@@ -129,7 +129,13 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
this.sound.stop();
return;
}
this.sound.start(this.state.project, asset, this.canvas.playhead / compositionFps(asset), false);
this.sound.follow(this.state.project, asset, this.canvas.playhead / compositionFps(asset), false);
}
/** Seconds its sound is ahead of the playhead, null with nothing heard (test hook). */
soundAhead(): number | null {
const asset = this.asset();
return asset ? this.sound.aheadOf(this.canvas.playhead / compositionFps(asset)) : null;
}
/** Until when the playhead waits for a video it shows, see `waitsForVideo`. */
@@ -62,6 +62,7 @@ import { findAsset, findAssetOf } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { fadeGain, findSound } from '../../core/model/sound';
import { assetRefLabel } from '../../core/properties/assetRef';
import { TO_COMPOSITION_BUS } from '../../core/properties/buses';
import { playedSoundById } from '../../core/properties/playedSound';
import {
activeDrag,
@@ -1589,9 +1590,9 @@ export class CompositionTimeline extends Widget {
}
}
/** A new audio item's, as its asset says: unity, on the bus its routing sends it to. */
/** A new audio item's: unity, into the composition's bus (#418). */
function audioOf(kind: CompositionAudioKind, asset: string): NonNullable<CompositionItem['audio']> {
return { kind, asset, volume: 0, bus: '' };
return { kind, asset, volume: 0, bus: TO_COMPOSITION_BUS };
}
/** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */
+13 -5
View File
@@ -96,11 +96,19 @@ export function documentHooks({ state, editor }: HookContext) {
objectScreen: (objectId: string) => editor.compositionEditor.preview.objectScreen(objectId),
/** Where the picture draws a sprite right now; null while it is left out, a video without its picture. */
drawn: (objectId: string) => editor.compositionEditor.preview.drawnRect(objectId),
/** Whether its audio items are heard (#418), and the numbers of their playings. */
sound: () => ({
playing: editor.compositionEditor.sound.playing,
playings: [...editor.compositionEditor.sound.playings],
}),
/**
* Whether its audio items are heard (#418), the numbers of their playings, how often they were
* started again to meet the picture and how far the sound is ahead of the playhead, in seconds.
*/
sound: () => {
const { sound } = editor.compositionEditor;
return {
playing: sound.playing,
playings: [...sound.playings],
resyncs: sound.resyncs,
ahead: editor.compositionEditor.soundAhead(),
};
},
},
tile: {
current: () => editor.tileEditor.currentAssetId,
+53 -3
View File
@@ -19,11 +19,14 @@ import {
import { fadedItem, heldMove, movedWithClips, resizedItem } from '../../core/model/compositionEdit';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import { FADE_CURVES, findSound, isFadeCurve } from '../../core/model/sound';
import type { PropertyBag } from '../../core/model/Properties';
import { FROM_ASSET_LABEL, TO_COMPOSITION_LABEL } from '../../core/properties/buses';
import { VIDEO_BUS_KEY, VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../core/properties/videos';
import { DEFAULT_FADE_CURVE } from '../../core/properties/sounds';
import { ITEM_MIN_DB, SOUND_ITEM_MAX_DB } from '../../core/music/song';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber, type MenuEntry } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { Button, formatNumber, Label, type MenuEntry } from '../../ui';
import { createFieldRow, labelledRow } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { assetLink } from './objectSection';
@@ -40,6 +43,9 @@ const FADE_ROWS = {
out: { key: 'composition:fadeOut', curve: 'composition:curveOut', label: 'Fade out' },
} as const;
/** What the Video clock of an item's own sprite says: Free or Driven, the composition sets its time. */
const DRIVEN_BY_COMPOSITION = 'Driven (by composition)';
const AUDIO_TITLES: Record<CompositionAudioKind, string> = {
sound: 'SOUND ITEM',
pattern: 'PATTERN ITEM',
@@ -62,7 +68,8 @@ const BUS_DEF: PropertyDef = {
label: 'Bus',
kind: 'asset',
assetTypes: ['bus'],
none: 'From asset',
none: FROM_ASSET_LABEL,
toComposition: true,
master: true,
default: '',
};
@@ -188,6 +195,49 @@ export function compositionItemOf(
return comp && item ? { comp, item } : undefined;
}
/**
* How a composition holds an object of it: `item` for the object an item stands for, whose video the
* composition drives; `content` for one below it. Null outside a composition.
*/
export function compositionHold(
project: Project,
holderId: string,
objectId: string,
): 'item' | 'content' | null {
const comp = findAssetOf(project, holderId, 'composition');
if (!comp) return null;
return comp.items.some((i) => i.id === objectId) ? 'item' : 'content';
}
/** What a composition's sprite reads as: the item's own video driven by the composition. */
export function heldValues<T extends PropertyBag>(values: T, hold: ReturnType<typeof compositionHold>): T {
return hold === 'item' ? { ...values, [VIDEO_CLOCK_KEY]: 'driven' } : values;
}
/**
* A row as a composition's sprite shows it: the item's own Video time is the composition's and not
* shown, its Video bus goes into the composition where it picks none.
*/
export function heldDefs(
defs: readonly PropertyDef[],
hold: ReturnType<typeof compositionHold>,
): PropertyDef[] {
if (hold === null) return [...defs];
return defs.flatMap((def): PropertyDef[] => {
if (hold === 'item' && def.key === VIDEO_TIME_KEY) return [];
if (def.key === VIDEO_BUS_KEY && def.kind === 'asset')
return [{ ...def, none: TO_COMPOSITION_LABEL, fromAsset: true }];
return [def];
});
}
/** In place of the Video clock row of an item's own sprite: the composition sets its time. */
export function addDrivenClockRow(host: InspectorHost): void {
const { row } = labelledRow('Video clock');
row.add(new Label(DRIVEN_BY_COMPOSITION), 1);
host.body.add(row);
}
/**
* When the object's item lives in the composition, and how far into its video it begins (#417). A
* length typed in or an edge moved is the item's own from then on; "Fit to media" hands it back.
+9 -1
View File
@@ -20,7 +20,13 @@ import {
soundEntries,
} from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx';
import { MASTER_BUS } from '../../core/properties/buses';
import {
FROM_ASSET_BUS,
FROM_ASSET_LABEL,
MASTER_BUS,
TO_COMPOSITION_BUS,
TO_COMPOSITION_LABEL,
} from '../../core/properties/buses';
import { keyName } from '../../core/keys';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
@@ -409,6 +415,8 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
choices: (project, def) => {
const extras = def.kind === 'asset' ? def : undefined;
return [
...(extras?.fromAsset ? [{ id: FROM_ASSET_BUS, label: FROM_ASSET_LABEL }] : []),
...(extras?.toComposition ? [{ id: TO_COMPOSITION_BUS, label: TO_COMPOSITION_LABEL }] : []),
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
...assetRefChoices(project, extras?.assetTypes).flatMap((asset) =>
+23 -6
View File
@@ -77,7 +77,16 @@ import {
viewOf,
} from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
import { addTimingRows, refreshTimingRows, timingSignature } from './compositionSection';
import {
addDrivenClockRow,
addTimingRows,
compositionHold,
heldDefs,
heldValues,
refreshTimingRows,
timingSignature,
} from './compositionSection';
import { VIDEO_CLOCK_KEY } from '../../core/properties/videos';
/**
* The rows a placed object shows for what it was placed from: a behavior's declared variables but
@@ -108,8 +117,11 @@ export function objectSignature(
: isLiveInstance(project, holder, object)
? 'instance'
: '';
const values = resolveSceneObjectProps(project, asset, object);
const rows = schemaForObject(project, asset, object).filter((def) => isRowShown(def, values, project));
const hold = compositionHold(project, holderId, objectId);
const values = heldValues(resolveSceneObjectProps(project, asset, object), hold);
const rows = heldDefs(schemaForObject(project, asset, object), hold).filter((def) =>
isRowShown(def, values, project),
);
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
// A variable turned private leaves the rows, with nothing else about it changed (#257).
@@ -260,8 +272,11 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
const shown = shownObject(project, holderId, objectId);
if (!shown) return;
const { object, asset } = shown;
const values = resolveSceneObjectProps(project, asset, object);
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values, project));
const hold = compositionHold(project, holderId, objectId);
const values = heldValues(resolveSceneObjectProps(project, asset, object), hold);
const own = heldDefs(objectRows(project, asset, object), hold).filter((def) =>
isRowShown(def, values, project),
);
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
host.objectPicker = objectPicker(host.state, holderId, objectId, host.objectChoices);
const behavior = asset.type === 'behavior';
@@ -275,7 +290,9 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
host,
asset.variables.length > 0 ? 'All its variables are private.' : 'The behavior declares no variables.',
);
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, behavior);
for (const def of own)
if (hold === 'item' && def.key === VIDEO_CLOCK_KEY) addDrivenClockRow(host);
else propertyRow(host, holderId, objectId, asset, def, values, behavior);
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
}
+1 -1
View File
@@ -10,7 +10,7 @@ const INFO_WIDTH = 220;
/**
* The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song, video
* with sound and bus and the master, the links between them where each one goes. Edited as the routing of a song is
* with sound, composition and bus and the master, the links between them where each one goes. Edited as the routing of a song is
* (#350); a click on a node shows its asset in the inspector, a double click opens it.
*/
export class RoutingEditor extends VStack {