Skip to content

Commit 99db031

Browse files
committed
fix: preserve audio automation plateaus
1 parent 4f344c5 commit 99db031

4 files changed

Lines changed: 200 additions & 27 deletions

File tree

packages/core/src/runtime/mediaVolumeEnvelope.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,74 @@
11
/** @vitest-environment jsdom */
22
import { describe, expect, it } from "vitest";
3-
import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope";
3+
import { probeAndCacheElementVolume, probeElementVolumeKeyframes } from "./mediaVolumeEnvelope";
4+
5+
describe("probeElementVolumeKeyframes", () => {
6+
it("retains the last plateau sample before a short volume change", () => {
7+
const audio = document.createElement("audio");
8+
audio.dataset.start = "0";
9+
audio.dataset.duration = "2";
10+
audio.dataset.volume = "0.8";
11+
12+
const keyframes = probeElementVolumeKeyframes(
13+
audio,
14+
(time) => {
15+
audio.volume = time < 1.05 ? 0.8 : 0.2;
16+
},
17+
2,
18+
10,
19+
);
20+
21+
expect(keyframes).toContainEqual({ time: 1, volume: 0.8 });
22+
expect(keyframes).toContainEqual({ time: 1.1, volume: 0.2 });
23+
});
24+
25+
it("samples a short transition at a clip end between frame intervals", () => {
26+
const audio = document.createElement("audio");
27+
audio.dataset.start = "0";
28+
audio.dataset.duration = "1.05";
29+
audio.dataset.volume = "0.7";
30+
31+
const keyframes = probeElementVolumeKeyframes(
32+
audio,
33+
(time) => {
34+
audio.volume = time < 1.02 ? 0.7 : 0.1;
35+
},
36+
1.05,
37+
10,
38+
);
39+
40+
expect(keyframes).toEqual([
41+
{ time: 0, volume: 0.7 },
42+
{ time: 1, volume: 0.7 },
43+
{ time: 1.05, volume: 0.1 },
44+
]);
45+
});
46+
47+
it("preserves every sampled point of a continuous ramp", () => {
48+
const audio = document.createElement("audio");
49+
audio.dataset.start = "0";
50+
audio.dataset.duration = "0.5";
51+
audio.dataset.volume = "0";
52+
53+
const keyframes = probeElementVolumeKeyframes(
54+
audio,
55+
(time) => {
56+
audio.volume = time * 2;
57+
},
58+
0.5,
59+
10,
60+
);
61+
62+
expect(keyframes).toEqual([
63+
{ time: 0, volume: 0 },
64+
{ time: 0.1, volume: 0.2 },
65+
{ time: 0.2, volume: 0.4 },
66+
{ time: 0.3, volume: 0.6 },
67+
{ time: 0.4, volume: 0.8 },
68+
{ time: 0.5, volume: 1 },
69+
]);
70+
});
71+
});
472

573
describe("probeAndCacheElementVolume", () => {
674
it("does not seek or cache when live timeline probing is disabled", () => {

packages/core/src/runtime/mediaVolumeEnvelope.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export function interpolateVolumeGain(envelope: VolumeKeyframe[], t: number): nu
6161
if (envelope.length === 0) return 1;
6262

6363
let segment = 0;
64+
// The PCM baker intentionally inlines this lookup with a monotonic cursor
65+
// because calling this preview-oriented helper per sample would be O(N×M).
66+
// fallow-ignore-next-line code-duplication
6467
while (segment < envelope.length - 2 && t >= envelope[segment + 1]!.time) {
6568
segment += 1;
6669
}
@@ -72,7 +75,49 @@ export function interpolateVolumeGain(envelope: VolumeKeyframe[], t: number): nu
7275
return a.volume + (b.volume - a.volume) * progress;
7376
}
7477

75-
// fallow-ignore-next-line complexity
78+
function recordVolumeSample(
79+
keyframes: VolumeKeyframe[],
80+
previousSample: VolumeKeyframe | undefined,
81+
sample: VolumeKeyframe,
82+
isFinalSample: boolean,
83+
): void {
84+
const last = keyframes.at(-1);
85+
if (!last || Math.abs(last.volume - sample.volume) > 0.0001) {
86+
// Change-only compression must retain the preceding real sample so a
87+
// flat run stays flat instead of being interpolated into the next value.
88+
// During a continuous ramp, that sample is already the last keyframe.
89+
if (last && previousSample && previousSample.time > last.time) {
90+
keyframes.push(previousSample);
91+
}
92+
keyframes.push(sample);
93+
} else if (isFinalSample && sample.time > last.time) {
94+
keyframes.push(sample);
95+
}
96+
}
97+
98+
function parseFiniteDatasetNumber(value: string | undefined): number | undefined {
99+
const parsed = Number.parseFloat(value ?? "");
100+
return Number.isFinite(parsed) ? parsed : undefined;
101+
}
102+
103+
function resolveVolumeProbeWindow(
104+
el: HTMLAudioElement | HTMLVideoElement,
105+
compositionDuration: number,
106+
): { start: number; end: number; staticVolume: number } {
107+
const start = parseFiniteDatasetNumber(el.dataset.start) ?? 0;
108+
const endAttr = parseFiniteDatasetNumber(el.dataset.end);
109+
const durAttr = parseFiniteDatasetNumber(el.dataset.duration);
110+
let end = compositionDuration;
111+
if (endAttr !== undefined && endAttr > start) {
112+
end = endAttr;
113+
} else if (durAttr !== undefined && durAttr > 0) {
114+
end = start + durAttr;
115+
}
116+
const staticAttr = parseFiniteDatasetNumber(el.dataset.volume) ?? 1;
117+
const staticVolume = Math.max(0, Math.min(1, staticAttr));
118+
return { start, end, staticVolume };
119+
}
120+
76121
/**
77122
* Probe a single media element's volume automation by seeking a GSAP timeline
78123
* through the element's active window.
@@ -89,18 +134,7 @@ export function probeElementVolumeKeyframes(
89134
compositionDuration: number,
90135
sampleFps: number,
91136
): VolumeKeyframe[] | null {
92-
const start = Number.parseFloat(el.dataset.start ?? "0") || 0;
93-
const endAttr = Number.parseFloat(el.dataset.end ?? "");
94-
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
95-
const end =
96-
Number.isFinite(endAttr) && endAttr > start
97-
? endAttr
98-
: Number.isFinite(durAttr) && durAttr > 0
99-
? start + durAttr
100-
: compositionDuration;
101-
102-
const staticAttr = Number.parseFloat(el.dataset.volume ?? "");
103-
const staticVolume = Number.isFinite(staticAttr) ? Math.max(0, Math.min(1, staticAttr)) : 1;
137+
const { start, end, staticVolume } = resolveVolumeProbeWindow(el, compositionDuration);
104138

105139
// Reset to data-volume so GSAP captures the correct FROM value.
106140
el.volume = staticVolume;
@@ -110,15 +144,19 @@ export function probeElementVolumeKeyframes(
110144
const sampleEnd = Math.min(compositionDuration, end);
111145

112146
const keyframes: VolumeKeyframe[] = [];
113-
for (let t = sampleStart; t <= sampleEnd + 1e-6; t += step) {
147+
let previousSample: VolumeKeyframe | undefined;
148+
for (let t = sampleStart; t <= sampleEnd + 1e-6; t = Math.min(sampleEnd, t + step)) {
114149
const bounded = Math.min(sampleEnd, t);
115150
seekTimeline(bounded);
116151
const raw = Number(el.volume);
117-
if (!Number.isFinite(raw)) continue;
118-
const volume = Math.max(0, Math.min(1, raw));
119-
const last = keyframes.at(-1);
120-
if (!last || Math.abs(last.volume - volume) > 0.0001 || bounded === sampleEnd) {
121-
keyframes.push({ time: Number(bounded.toFixed(6)), volume: Number(volume.toFixed(6)) });
152+
if (Number.isFinite(raw)) {
153+
const volume = Math.max(0, Math.min(1, raw));
154+
const sample = {
155+
time: Number(bounded.toFixed(6)),
156+
volume: Number(volume.toFixed(6)),
157+
};
158+
recordVolumeSample(keyframes, previousSample, sample, bounded === sampleEnd);
159+
previousSample = sample;
122160
}
123161
if (bounded === sampleEnd) break;
124162
}

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { parseHTML } from "linkedom";
7+
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
78
import { defaultLogger } from "../logger.js";
89
import {
910
collectExternalAssets,
@@ -1763,6 +1764,58 @@ h1 { font-size: 2rem; }`;
17631764
});
17641765

17651766
describe("discoverAudioVolumeAutomationFromTimeline", () => {
1767+
it("emits plateau boundaries around a sampled volume change", async () => {
1768+
class TestAudioElement {
1769+
id = "music";
1770+
dataset = { start: "0", duration: "3", volume: "0.8" };
1771+
volume = 0.8;
1772+
}
1773+
class TestVideoElement {}
1774+
1775+
const audio = new TestAudioElement();
1776+
const previousWindow = globalThis.window;
1777+
const previousDocument = globalThis.document;
1778+
const previousAudioElement = globalThis.HTMLAudioElement;
1779+
const previousVideoElement = globalThis.HTMLVideoElement;
1780+
1781+
globalThis.window = {
1782+
__timelines: {
1783+
root: {
1784+
totalTime: (time: number) => {
1785+
audio.volume = time < 1.05 ? 0.8 : 0.2;
1786+
},
1787+
},
1788+
},
1789+
} as any;
1790+
globalThis.document = {
1791+
querySelector: (selector: string) =>
1792+
selector === "[data-composition-id]"
1793+
? { getAttribute: (name: string) => (name === "data-composition-id" ? "root" : null) }
1794+
: null,
1795+
getElementById: (id: string) => (id === "music" ? audio : null),
1796+
} as any;
1797+
globalThis.HTMLAudioElement = TestAudioElement as any;
1798+
globalThis.HTMLVideoElement = TestVideoElement as any;
1799+
1800+
try {
1801+
const page = {
1802+
evaluate: async (fn: (arg: unknown) => unknown, arg: unknown) => fn(arg),
1803+
} as any;
1804+
1805+
const [automation] = await discoverAudioVolumeAutomationFromTimeline(page, ["music"], 3, 10);
1806+
1807+
expect(automation?.keyframes).toContainEqual({ time: 1, volume: 0.8 });
1808+
expect(automation?.keyframes).toContainEqual({ time: 1.1, volume: 0.2 });
1809+
expect(interpolateVolumeGain(automation?.keyframes ?? [], 0.5)).toBeCloseTo(0.8, 6);
1810+
expect(interpolateVolumeGain(automation?.keyframes ?? [], 1.5)).toBeCloseTo(0.2, 6);
1811+
} finally {
1812+
globalThis.window = previousWindow;
1813+
globalThis.document = previousDocument;
1814+
globalThis.HTMLAudioElement = previousAudioElement;
1815+
globalThis.HTMLVideoElement = previousVideoElement;
1816+
}
1817+
});
1818+
17661819
it("prefers runtime duration over stale data-end while sampling video-derived audio", async () => {
17671820
class TestAudioElement {}
17681821
class TestVideoElement {

packages/producer/src/services/htmlCompiler.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2147,19 +2147,33 @@ export async function discoverAudioVolumeAutomationFromTimeline(
21472147
}
21482148

21492149
const keyframes: { time: number; volume: number }[] = [];
2150-
for (let t = sampleStart; t <= sampleEnd + 0.000001; t += step) {
2150+
let previousSample: { time: number; volume: number } | undefined;
2151+
for (let t = sampleStart; t <= sampleEnd + 0.000001; t = Math.min(sampleEnd, t + step)) {
21512152
const boundedTime = Math.min(sampleEnd, t);
21522153
seekTl(boundedTime);
21532154
const rawVolume = Number(el.volume);
2154-
if (!Number.isFinite(rawVolume)) continue;
2155+
if (!Number.isFinite(rawVolume)) {
2156+
if (boundedTime === sampleEnd) break;
2157+
continue;
2158+
}
21552159
const volume = Math.max(0, Math.min(1, rawVolume));
2160+
const sample = {
2161+
time: Number(boundedTime.toFixed(6)),
2162+
volume: Number(volume.toFixed(6)),
2163+
};
21562164
const last = keyframes.at(-1);
2157-
if (!last || Math.abs(last.volume - volume) > 0.0001 || boundedTime === sampleEnd) {
2158-
keyframes.push({
2159-
time: Number(boundedTime.toFixed(6)),
2160-
volume: Number(volume.toFixed(6)),
2161-
});
2165+
if (!last || Math.abs(last.volume - volume) > 0.0001) {
2166+
// Retain the preceding real sample when compression omitted a flat
2167+
// run. Continuous ramps already have that sample as their last
2168+
// keyframe, so their interpolation remains unchanged.
2169+
if (last && previousSample && previousSample.time > last.time) {
2170+
keyframes.push(previousSample);
2171+
}
2172+
keyframes.push(sample);
2173+
} else if (boundedTime === sampleEnd && sample.time > last.time) {
2174+
keyframes.push(sample);
21622175
}
2176+
previousSample = sample;
21632177
if (boundedTime === sampleEnd) break;
21642178
}
21652179

0 commit comments

Comments
 (0)