Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/packages/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,28 @@ The pre-built runtime IIFE is available as a direct import:
import runtime from '@hyperframes/core/runtime';
```

### Audio Ducking

Use `duck` from composition JavaScript to write deterministic volume ramps onto a GSAP timeline. The helper reads the timed media tracks you pass in and does not add any HTML authoring attributes:

```typescript
import { duck } from '@hyperframes/core';

const timeline = gsap.timeline({ paused: true });
const music = document.querySelector<HTMLAudioElement>('#music');
const voice = document.querySelector<HTMLAudioElement>('#voice');

if (music && voice) {
duck(music, [voice], {
timeline,
amount: '-12dB',
fade: 0.3,
});
}
```

To affect preview and render, pass the real DOM media elements as the tracks **and** the composition's captured GSAP timeline — those ramps are probed into the same volume envelope as hand-authored fades. Plain timing objects (`{ start, duration }`) and the returned keyframe array are for computing ducking offline (tests, custom pipelines); on their own they do not influence playback. `duck` writes its ramps in composition time, so it works the same for tracks placed at a non-zero `data-start`.

## Frame Adapters

The core package defines the [Frame Adapter](/concepts/frame-adapters) interface and provides the built-in GSAP adapter:
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ describe("@hyperframes/core public API exports", () => {
it("exports parseAnimatedGifMetadata", () => {
expect(typeof core.parseAnimatedGifMetadata).toBe("function");
});

it("exports duck", () => {
expect(typeof core.duck).toBe("function");
});
});

describe("inline-script exports", () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ export type { FitTextOptions, FitTextResult } from "./text/index.js";

// Runtime helpers (composition-side)
export { getVariables } from "./runtime/getVariables.js";
export {
duck,
type DuckAmount,
type DuckOptions,
type DuckTimelineLike,
type DuckTrack,
type DuckTrackTiming,
} from "./runtime/audioDucking.js";

// Variable validation (CLI / tooling-side)
export {
Expand Down
161 changes: 161 additions & 0 deletions packages/core/src/runtime/audioDucking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
duck,
type DuckTimelineLike,
type DuckTrack,
type DuckTrackTiming,
} from "./audioDucking.js";
import { interpolateVolumeGain, normaliseEnvelope } from "./mediaVolumeEnvelope.js";

interface TimelineOp {
kind: "set" | "to";
target: DuckTrack;
at: number | undefined;
volume: number;
duration?: number;
ease?: "none";
overwrite?: false;
}

function recordingTimeline(ops: TimelineOp[]): DuckTimelineLike {
return {
set(target, vars, at) {
ops.push({ kind: "set", target, at, volume: vars.volume });
},
to(target, vars, at) {
ops.push({
kind: "to",
target,
at,
volume: vars.volume,
duration: vars.duration,
ease: vars.ease,
overwrite: vars.overwrite,
});
},
};
}

function audioTrack(start: number, duration: number, volume = 1): DuckTrackTiming {
return { start, duration, volume };
}

function requireOp(ops: TimelineOp[], index: number): TimelineOp {
const op = ops[index];
expect(op).toBeDefined();
if (!op) throw new Error(`Missing timeline op ${index}`);
return op;
}

describe("duck", () => {
it("generates volume ramps around voice overlaps", () => {
const keyframes = duck(audioTrack(0, 8, 0.8), audioTrack(2, 2), {
amount: "-12dB",
fade: 0.5,
});

expect(keyframes.map((kf) => kf.time)).toEqual([1.5, 2, 4, 4.5]);
expect(keyframes[0]?.volume).toBe(0.8);
expect(keyframes[1]?.volume).toBeCloseTo(0.20095, 5);
expect(keyframes[2]?.volume).toBeCloseTo(0.20095, 5);
expect(keyframes[3]?.volume).toBe(0.8);
});

it("writes generated ramps to a timeline", () => {
const music = audioTrack(0, 8, 0.8);
const voice = audioTrack(2, 2);
const ops: TimelineOp[] = [];

duck(music, voice, {
timeline: recordingTimeline(ops),
amount: "-12dB",
fade: 0.5,
});

expect(ops).toHaveLength(4);
expect(requireOp(ops, 0)).toMatchObject({ kind: "set", target: music, at: 1.5, volume: 0.8 });
expect(requireOp(ops, 1)).toMatchObject({
kind: "to",
target: music,
at: 1.5,
duration: 0.5,
ease: "none",
overwrite: false,
});
expect(requireOp(ops, 1).volume).toBeCloseTo(0.20095, 5);
expect(requireOp(ops, 2)).toMatchObject({ kind: "to", target: music, at: 2, duration: 2 });
expect(requireOp(ops, 3)).toMatchObject({
kind: "to",
target: music,
at: 4,
duration: 0.5,
volume: 0.8,
});
});

it("merges voice gaps shorter than two fades", () => {
const keyframes = duck(audioTrack(0, 5), [audioTrack(1, 1), audioTrack(2.3, 0.7)], {
amount: 0.25,
fade: 0.25,
});

expect(keyframes).toEqual([
{ time: 0.75, volume: 1 },
{ time: 1, volume: 0.25 },
{ time: 3, volume: 0.25 },
{ time: 3.25, volume: 1 },
]);
});

it("uses resolved timeline duration for voice clips", () => {
const keyframes = duck(audioTrack(0, 8), audioTrack(1, 6), {
amount: 0.5,
fade: 0.5,
});

expect(keyframes).toEqual([
{ time: 0.5, volume: 1 },
{ time: 1, volume: 0.5 },
{ time: 7, volume: 0.5 },
{ time: 7.5, volume: 1 },
]);
});

it("produces keyframes compatible with the shared volume envelope", () => {
const keyframes = duck(audioTrack(0, 4), audioTrack(1, 1), {
amount: "-12dB",
fade: 0.25,
});
const envelope = normaliseEnvelope(keyframes, 0, 1);

expect(interpolateVolumeGain(envelope, 0.5)).toBe(1);
expect(interpolateVolumeGain(envelope, 1.5)).toBeCloseTo(0.251189, 5);
expect(interpolateVolumeGain(envelope, 2.5)).toBe(1);
});

it("reads timed media elements without writing schema attributes", () => {
const music = document.createElement("audio");
music.id = "music";
music.dataset.start = "0";
music.dataset.duration = "4";
music.dataset.volume = "0.6";

const voice = document.createElement("audio");
voice.id = "voice";
voice.dataset.start = "1";
voice.dataset.duration = "1";

const keyframes = duck(music, [voice], { amount: 0.5, fade: 0.25 });

expect(keyframes).toEqual([
{ time: 0.75, volume: 0.6 },
{ time: 1, volume: 0.3 },
{ time: 2, volume: 0.3 },
{ time: 2.25, volume: 0.6 },
]);
expect(music.hasAttribute("data-duck")).toBe(false);
expect(music.hasAttribute("data-duck-fade")).toBe(false);
expect(music.hasAttribute("data-role")).toBe(false);
expect(music.getAttributeNames().some((name) => name.startsWith("data-hf-duck"))).toBe(false);
});
});
Loading
Loading