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
29 changes: 29 additions & 0 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `data-media-start` | `"2"` | Media playback offset / trim point in seconds. Default: `0` |
| `data-volume` | `"0.8"` | Audio/video volume, 0 to 1 |
| `data-has-audio` | `"true"` | Indicates video has an audio track |
| `data-role` | `"voice"` | Marks narration or dialogue that should trigger ducking |
| `data-duck` | `"-12dB"` | Lowers this track while audible `data-role="voice"` clips overlap it |
| `data-duck-fade` | `"0.3"` | Ducking ramp duration in seconds. Default: `0.3` |

## Music Ducking

Use `data-duck` on background music and `data-role="voice"` on narration or dialogue. Hyperframes computes the overlap windows from clip timing and renders the result through the same volume automation path used for authored fades.

```html index.html
<audio
id="music"
src="assets/music.mp3"
data-start="0"
data-duration="30"
data-track-index="1"
data-volume="0.8"
data-duck="-12dB"
></audio>
<audio
id="voiceover"
src="assets/voiceover.mp3"
data-start="4"
data-duration="12"
data-track-index="2"
data-role="voice"
></audio>
```

`data-duck` accepts dB values (`"-12dB"` or `"-12"`) and linear gains (`"0.25"`). `data-duck-fade` controls the ramp into and out of the ducked level. Runtime-triggered audio that is not represented as timed `<audio>` or audible `<video>` clips is not part of the compile-time duck calculation.

## Composition Attributes

Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
"guides/keyframes",
"guides/music-voiceover",
"guides/rendering",
"guides/remove-background",
"guides/hdr",
Expand Down
32 changes: 32 additions & 0 deletions docs/guides/music-voiceover.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Music + Voiceover
description: "Balance background music under narration with declarative audio ducking."
---

Use `data-duck` when a music bed should automatically drop under narration or dialogue. Mark the narration with `data-role="voice"` and put the duck amount on the music track.

```html index.html
<audio
id="music"
src="assets/music.mp3"
data-start="0"
data-duration="30"
data-track-index="1"
data-volume="0.8"
data-duck="-12dB"
data-duck-fade="0.3"
></audio>

<audio
id="voiceover"
src="assets/voiceover.mp3"
data-start="4"
data-duration="12"
data-track-index="2"
data-role="voice"
></audio>
```

`data-duck="-12dB"` lowers the music by 12 dB while the voice clip is audible. `data-duck-fade="0.3"` ramps into the lower level before the voice starts and ramps back out after it ends. If several voice clips are close together, short gaps are merged so the music does not pump between words.

The ducking pass uses resolved clip timing, so it works best for timed `<audio>` clips and audible `<video data-has-audio="true">` clips. Audio started imperatively from script is outside this compile-time calculation.
25 changes: 25 additions & 0 deletions docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ Common sizes:
| `data-track-index` | All | Yes | Timeline track number. Controls z-ordering (higher = in front). Clips on the same track cannot overlap. |
| `data-media-start` | video, audio | No | Playback offset / trim point in source file (seconds). Default: `0`. See [Data Attributes](/concepts/data-attributes). |
| `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. |
| `data-role` | audio, video | No | Semantic audio role. Set `data-role="voice"` on narration or dialogue tracks that should trigger music ducking. |
| `data-duck` | audio, video | No | Duck this track while any audible `data-role="voice"` clip overlaps it. Accepts dB (`"-12dB"` or `"-12"`) or a linear gain (`"0.25"`). |
| `data-duck-fade` | audio, video | No | Ducking ramp duration in seconds. Default: `0.3`. |
| `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. |
| `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). |
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. The framework carries the values through, but your composition script must read and apply them manually. |
Expand Down Expand Up @@ -129,8 +132,30 @@ Common sizes:
- `data-duration` is **optional** — defaults to the remaining duration of the source file from `data-media-start`
- Audio clips are invisible — do not add `class="clip"` (there is nothing to show/hide)
- `data-volume` controls volume — use `"0.5"` for background music at 50% volume
- `data-duck` lowers background music under clips marked `data-role="voice"`
- `data-media-start` trims the beginning of the audio source, just like video
- Multiple audio clips can overlap on different tracks for layered sound design

```html
<audio
id="music"
src="./assets/music.mp3"
data-start="0"
data-duration="30"
data-track-index="1"
data-volume="0.8"
data-duck="-12dB"
data-duck-fade="0.3"
></audio>
<audio
id="voiceover"
src="./assets/voiceover.mp3"
data-start="4"
data-duration="12"
data-track-index="2"
data-role="voice"
></audio>
```
</Accordion>

<Accordion title="Composition Clips (Nested)">
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/compiler/htmlCompiler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from "path";
import {
compileTimingAttrs,
compileAudioDucking,
injectDurations,
extractResolvedMedia,
clampDurations,
Expand Down Expand Up @@ -37,7 +38,7 @@ export async function compileHtml(
const { html: staticCompiled, unresolved } = compileTimingAttrs(rawHtml);
let html = staticCompiled;

if (!probeMediaDuration) return html;
if (!probeMediaDuration) return compileAudioDucking(html);

// Phase 1: Resolve missing durations
const mediaUnresolved = unresolved.filter(
Expand Down Expand Up @@ -86,5 +87,5 @@ export async function compileHtml(
html = clampDurations(html, clampList);
}

return html;
return compileAudioDucking(html);
}
1 change: 1 addition & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Timing compiler (browser-safe)
export {
compileTimingAttrs,
compileAudioDucking,
injectDurations,
extractResolvedMedia,
clampDurations,
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { describe, it, expect } from "vitest";
import {
compileTimingAttrs,
compileAudioDucking,
injectDurations,
extractResolvedMedia,
clampDurations,
} from "./timingCompiler.js";
import { parseVolumeKeyframesAttribute } from "../runtime/mediaVolumeEnvelope.js";

function duckKeyframesFor(html: string, id: string) {
const match = html.match(new RegExp(`<audio\\b[^>]*id=["']${id}["'][^>]*>`, "i"));
if (!match) throw new Error(`Missing audio tag ${id}`);
const attr = match[0].match(/data-hf-duck-keyframes='([^']+)'/);
return parseVolumeKeyframesAttribute(attr?.[1]);
}

describe("compileTimingAttrs", () => {
it("adds data-end when data-start and data-duration are present on a video", () => {
Expand Down Expand Up @@ -185,3 +194,53 @@ describe("clampDurations", () => {
expect(result).toContain('data-end="7"');
});
});

describe("compileAudioDucking", () => {
it("emits duck keyframes around voice overlaps", () => {
const html = compileTimingAttrs(`
<audio id="music" src="music.mp3" data-start="0" data-duration="8" data-volume="0.8" data-duck="-12dB" data-duck-fade="0.5"></audio>
<audio id="voice" src="voice.mp3" data-start="2" data-duration="2" data-role="voice"></audio>
`).html;

const keyframes = duckKeyframesFor(compileAudioDucking(html), "music");

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("merges voice gaps shorter than two fades", () => {
const html = compileTimingAttrs(`
<audio id="music" src="music.mp3" data-start="0" data-duration="5" data-duck="0.25" data-duck-fade="0.25"></audio>
<audio id="voice-a" src="a.mp3" data-start="1" data-duration="1" data-role="voice"></audio>
<audio id="voice-b" src="b.mp3" data-start="2.3" data-duration="0.7" data-role="voice"></audio>
`).html;

const keyframes = duckKeyframesFor(compileAudioDucking(html), "music");

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 playback-rate voice clips", () => {
const html = compileTimingAttrs(`
<audio id="music" src="music.mp3" data-start="0" data-duration="8" data-duck="0.5" data-duck-fade="0.5"></audio>
<audio id="voice" src="voice.mp3" data-start="1" data-duration="6" data-playback-rate="0.5" data-role="voice"></audio>
`).html;

const keyframes = duckKeyframesFor(compileAudioDucking(html), "music");

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