Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/reference/audio-effects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ Preview and render agree on both clocks.
which is about `3.981` — the same ceiling `data-volume` uses. A lane that boosts
above unity is valid and will play; `1` is unity, not the maximum.

The lane multiplies `data-volume`, the way clip gain offsets volume keyframes in
an editor: `data-volume="0.5"` under a lane at `1` plays at `0.5`. The product
shares the same +12 dB ceiling. This holds on clips and on groups.

On a clip, `data-volume` is applied **ahead of** its effects, so a compressor or
limiter in its `data-fx-chain` reacts to it; the lane comes after them. A group's
`data-volume` stays after its effects, like a track fader.

A lane holds its first value backwards to the start of its clip and its last
value forward to the end. **A bed that begins before the voice therefore needs an
explicit "no cut" point at `t: 0`, or it starts out already ducked.** Maximum 512
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Audio has no visual lifecycle.
| --- | --- | --- |
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. Two groups of readers disagree, so the right name depends on the element. **Read only `data-media-start`:** the timing compiler, the HTML parser, `hyperframes validate` (which only inspects `<audio>`), and the engine's audio mixer (which feeds ffmpeg `-ss`). **Read `data-playback-start` first, falling back to `data-media-start`:** the runtime player, Studio (which also writes it), and `hyperframes snapshot`. Because the audio mixer reads only `data-media-start`, a `<video>` authored with just `data-playback-start` renders a trimmed picture over untrimmed audio. Set the name by kind: **`<video>` / `<audio>` → `data-media-start`**; **nested composition → `data-playback-start`** — composition hosts are inspected only by the playback-start-first readers (the media-start-only ones are all `<video>`/`<audio>`-scoped). `data-media-start` still works there as a fallback, but `data-playback-start` is the canonical name Studio writes and normalises to for new composition hosts, so the other name works until an edit rewrites it (it is the [child-timeline offset](/concepts/compositions)). |
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `10`. A `rate` lane in `data-automation` overrides it; see [Speed ramps](/reference/speed-ramps) |
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB) |
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB). Multiplies any volume lane, and on a clip it applies ahead of the clip's audio effects |
| `data-fade-in` / `data-fade-out` | Video and audio | Clip-edge fades in seconds: gain rises 0 → 1 over the first `data-fade-in` seconds and falls 1 → 0 over the last `data-fade-out` seconds of the clip. Linear, anchored to the clip's own edges (a fade-out follows a later trim), and multiplied on top of `data-volume` and any volume lane. Fades that together exceed the clip are scaled to meet inside it. Studio drags them from the dots at an audio clip's top corners or types them in the inspector's Fade rows |
| `data-has-audio="true"` | Video | Declares that the video contributes audio |

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/audio/audioFxAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import {
isConstantLane,
isBentSegment,
resolveAutomationRange,
sampleAutomationCurve,
sampleAutomationLane,
Expand Down Expand Up @@ -62,8 +63,7 @@ function isStraight(
scale: "linear" | "log",
map: FxParamTarget["map"],
): boolean {
const bent = Boolean(point.curve) || (point.viaX !== undefined && point.viaY !== undefined);
return !bent && scale === "linear" && !map;
return !isBentSegment(point) && scale === "linear" && !map;
}

function curvePointCount(duration: number): number {
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/audioAutomation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
applyCurve,
shapeProgress,
fxAutomationTarget,
gainedVolumePoints,
HF_AUDIO_AUTOMATION_ATTR,
HF_AUDIO_AUTOMATION_DATA_KEY,
isBentSegment,
isConstantLane,
parseAutomation,
parseAutomationTarget,
Expand Down Expand Up @@ -572,3 +574,59 @@ describe("chain node ids", () => {
expect(parseAudioFxChain(json).nodes[0]!.id).toBe("n7");
});
});

describe("gainedVolumePoints", () => {
const ramp: HfAutomationLane = {
target: "volume",
points: [
{ t: 0, v: 0 },
{ t: 10, v: 3 },
],
};

it("caps gain times the lane at every instant, not only at the breakpoints", () => {
const points = gainedVolumePoints(ramp, 2);
expect(points).toHaveLength(3);
expect(points[1]!.t).toBeCloseTo((MAX_AUDIO_GAIN / 2 / 3) * 10, 9);
expect(points[1]!.v).toBeCloseTo(MAX_AUDIO_GAIN, 9);
// Halfway, gain 2 times 1.5 is 3, under the ceiling: the ramp to the crossing keeps it.
expect(sampleAutomationLane({ target: "volume", points }, 5)).toBeCloseTo(3, 9);
});

it("leaves only the ceiling's share of the lane when the gain plays ahead", () => {
const constant: HfAutomationLane = { target: "volume", points: [{ t: 0, v: 3 }] };
expect(gainedVolumePoints(constant, 2, true)[0]!.v).toBeCloseTo(MAX_AUDIO_GAIN / 2, 9);
expect(gainedVolumePoints(constant, 0.5, true)[0]!.v).toBe(3);
});

it("leaves a ramp at the ceiling until its falling product comes back under it", () => {
const falling: HfAutomationLane = {
target: "volume",
points: [
{ t: 0, v: 3 },
{ t: 10, v: 0 },
],
};
const points = gainedVolumePoints(falling, 2);
expect(points).toHaveLength(3);
expect(sampleAutomationLane({ target: "volume", points }, 2)).toBeCloseTo(MAX_AUDIO_GAIN, 9);
expect(sampleAutomationLane({ target: "volume", points }, 5)).toBeCloseTo(3, 9);
});

it("keeps zero gain muted without dividing by zero", () => {
expect(gainedVolumePoints(ramp, 0, true).map((p) => p.v)).toEqual([0, 0]);
});

it("draws a bent segment that crosses the ceiling as capped samples", () => {
const bent: HfAutomationLane = {
target: "volume",
points: [
{ t: 0, v: 0, viaX: 0.5, viaY: 0.8 },
{ t: 2, v: 3 },
],
};
const points = gainedVolumePoints(bent, 2);
expect(points.length).toBeGreaterThan(30);
expect(points.every((p) => p.v <= MAX_AUDIO_GAIN + 1e-9 && !isBentSegment(p))).toBe(true);
});
});
51 changes: 50 additions & 1 deletion packages/core/src/audioAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";
import { clampAudioGain, gainTimesLane, MAX_AUDIO_GAIN } from "./audioGain.js";
import { MAX_PLAYBACK_RATE, MIN_PLAYBACK_RATE } from "./playbackRateBounds.js";

export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";
Expand Down Expand Up @@ -175,6 +175,55 @@ export const VOLUME_RANGE: AutomationRange = {
default: 1,
};

/** Bounded sampling for volume curves drawn as straight pieces by the PCM baker. */
export function volumeSegmentSampleCount(duration: number): number {
return Math.min(4096, Math.max(2, Math.ceil(duration * 30)));
}

/** Whether the segment leaving `point` bends: by `curve`, or through a `viaX`/`viaY` point. */
export function isBentSegment(point: Pick<HfAutomationPoint, "curve" | "viaX" | "viaY">): boolean {
return Boolean(point.curve) || (point.viaX !== undefined && point.viaY !== undefined);
}

// With gain played upstream, leave only the lane's share of the capped product here.
export function gainedVolume(v: number, gain: number, gainAhead = false): number {
const g = clampAudioGain(gain);
const product = gainTimesLane(g, v);
return gainAhead && g > 0 ? product / g : product;
}

// Split straight segments where their product reaches the ceiling; sample bent crossings.
export function gainedVolumePoints(
lane: HfAutomationLane,
gain: number,
gainAhead = false,
): HfAutomationPoint[] {
const out: HfAutomationPoint[] = [];
const points = lane.points;
const g = clampAudioGain(gain);
for (let i = 0; i < points.length; i += 1) {
const a = points[i]!;
const b = points[i + 1];
const crosses = b !== undefined && (g * a.v - MAX_AUDIO_GAIN) * (g * b.v - MAX_AUDIO_GAIN) < 0;
if (!crosses) {
out.push({ ...a, v: gainedVolume(a.v, gain, gainAhead) });
continue;
}
out.push({ t: a.t, v: gainedVolume(a.v, gain, gainAhead) });
if (isBentSegment(a)) {
const steps = volumeSegmentSampleCount(b.t - a.t);
for (let k = 1; k < steps; k += 1) {
const t = a.t + ((b.t - a.t) * k) / steps;
out.push({ t, v: gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead) });
}
} else {
const t = a.t + ((MAX_AUDIO_GAIN / g - a.v) / (b.v - a.v)) * (b.t - a.t);
out.push({ t, v: gainedVolume(MAX_AUDIO_GAIN / g, g, gainAhead) });
}
}
return out;
}

export const RATE_RANGE: AutomationRange = {
min: MIN_PLAYBACK_RATE,
max: MAX_PLAYBACK_RATE,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/audioGain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export function clampAudioGain(value: number): number {
return Math.max(0, Math.min(MAX_AUDIO_GAIN, value));
}

/** `data-volume` is a gain on the volume lane, as clip gain offsets the keyframes in an NLE. */
export function gainTimesLane(gain: number, lane: number): number {
return clampAudioGain(gain * lane);
}

/** `data-volume` as clip gain, ahead of the FX chain; unity when a GSAP tween's absolute values own the level. */
export function clipGain(volume: number, tweened: boolean): number {
return tweened ? 1 : clampAudioGain(volume);
}

export function clampNativeMediaVolume(value: number): number {
if (!Number.isFinite(value)) return 1;
return Math.max(0, Math.min(1, value));
Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/runtime/audioFx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { HF_AUDIO_FX_ATTR, parseAudioFxChain, type HfAudioFxChain } from "../audioFx.js";
import {
gainedVolumePoints,
HF_AUDIO_AUTOMATION_ATTR,
parseAutomation,
resolveAutomation,
Expand All @@ -27,6 +28,7 @@ import {
type AutomationTiming,
} from "../audio/audioFxAutomation.js";
import type { FxParamTarget } from "../audio/audioFxGraph.js";
import { clampAudioGain } from "../audioGain.js";
import {
audioFxWorkletsReady,
buildFxChain,
Expand Down Expand Up @@ -126,16 +128,32 @@ export interface FxAttributes {
automation: string | null;
}

/** The element's volume lane on `param`, as a target to cancel later; none when it has no lane. */
function scheduleVolume(
param: AudioParam,
/** Where a volume lane plays, and the `data-volume` it multiplies (`gainAhead`: played upstream). */
export interface VolumeTarget {
param: AudioParam;
gain?: () => number;
gainAhead?: boolean;
}

/** The element's volume lane on `target`, as a target to cancel later; none when it has no lane. */
export function scheduleVolume(
target: VolumeTarget,
automation: HfAutomation,
at: AutomationTiming,
): FxParamTarget[] {
const lane = volumeLane(automation);
if (!lane) return [];
scheduleParamLane([{ param }], lane, VOLUME_RANGE.scale, at);
return [{ param }];
const points = gainedVolumePoints(lane, target.gain?.() ?? 1, target.gainAhead);
scheduleParamLane([{ param: target.param }], { ...lane, points }, VOLUME_RANGE.scale, at);
return [{ param: target.param }];
}

function restoreStaticVolume(target: VolumeTarget, at: AutomationTiming): void {
clearParamLane([target]);
target.param.setValueAtTime(
target.gainAhead ? 1 : clampAudioGain(target.gain?.() ?? 1),
at.scheduledAt,
);
}

export function attachElementFxChain(
Expand All @@ -145,7 +163,7 @@ export function attachElementFxChain(
destination: AudioNode,
timing?: AutomationTiming,
attrs: FxAttributes = { automation: HF_AUDIO_AUTOMATION_ATTR },
volume?: AudioParam,
volume?: VolumeTarget,
): ElementFxHandle | null {
const { chain } = readChain(el);

Expand Down Expand Up @@ -226,12 +244,18 @@ export function attachElementFxChain(
return;
}
const automation = readAutomation(el, next, attrs.automation);
automated = [
...(handle
? scheduleChainAutomation(automation, next, handle.nodes, at, handle.presets)
: []),
...(volume ? scheduleVolume(volume, automation, at) : []),
];
const fxTargets = handle
? scheduleChainAutomation(automation, next, handle.nodes, at, handle.presets)
: [];
const volumeTargets = volume ? scheduleVolume(volume, automation, at) : [];
if (
volume &&
volumeTargets.length === 0 &&
automated.some((target) => target.param === volume.param)
) {
restoreStaticVolume(volume, at);
}
automated = [...fxTargets, ...volumeTargets];
};

// The reference frame every later reschedule measures from. Mutable because a
Expand Down Expand Up @@ -310,7 +334,12 @@ export function attachElementFxChain(
});
observer.observe(target, {
attributes: true,
attributeFilter: attrs.automation ? [HF_AUDIO_FX_ATTR, attrs.automation] : [HF_AUDIO_FX_ATTR],
attributeFilter: [
HF_AUDIO_FX_ATTR,
...(attrs.automation ? [attrs.automation] : []),
// The lane's ceiling depends on the gain it multiplies.
...(volume?.gain ? ["data-volume"] : []),
],
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3413,8 +3413,8 @@ export function initSandboxRuntimeModular(): void {
cueAheadSeconds,
getCompositionDuration: () =>
getSafeTimelineDurationSeconds(state.capturedTimeline, 0, timingRevision),
onElementVolume: (el, _effectiveVolume, authorVolume) =>
webAudio.setElementVolume(el, authorVolume),
onElementVolume: (el, _effectiveVolume, authorVolume, clipGain) =>
webAudio.setElementVolume(el, authorVolume, clipGain),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
isWebAudioRouted: (el) => webAudio.routesElement(el),
onAutoplayBlocked: () => {
Expand Down
Loading
Loading