Skip to content
Draft
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
7 changes: 5 additions & 2 deletions packages/core/src/audioAutomation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type HfAutomationLane,
} from "./audioAutomation.js";
import { mintAudioFxNodeId, parseAudioFxChain, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";

const chain: HfAudioFxChain = {
version: 1,
Expand Down Expand Up @@ -116,7 +117,7 @@ describe("normalisation", () => {
]);
});

it("clamps volume into 0..1 at parse time", () => {
it("clamps volume into the authoring gain range at parse time", () => {
const parsed = parseAutomation(
JSON.stringify({
version: 1,
Expand All @@ -131,7 +132,9 @@ describe("normalisation", () => {
],
}),
);
expect(parsed.lanes[0]!.points.map((p) => p.v)).toEqual([1, 0]);
// The lane shares the fader's ceiling. Clamping it at unity discarded the
// boost of any clip authored above 0 dB the moment it was automated.
expect(parsed.lanes[0]!.points.map((p) => p.v)).toEqual([MAX_AUDIO_GAIN, 0]);
});

it("refuses malformed input instead of silently losing an envelope", () => {
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/audioAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
import { MAX_AUDIO_GAIN } from "./audioGain.js";

export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";

Expand Down Expand Up @@ -135,8 +136,9 @@ export const PRESET_RANGE: AutomationRange = {
/**
* The value range a lane is drawn and clamped against.
*
* Volume is linear 0..1, matching `data-volume` and the existing volume
* envelope machinery — no dB conversion enters the volume path. Everything
* Volume is linear over the full authoring gain range, matching `data-volume`
* and the existing volume envelope machinery — no dB conversion enters the
* volume path. Everything
* else is read from the effect registry, so a lane can never offer a value the
* renderer would reject, and the log-scaled knobs sweep the way a DAW's do.
*/
Expand All @@ -151,9 +153,15 @@ export interface AutomationRange {
default: number;
}

/**
* One ceiling for the fader, the lane, the preview transport and the render
* mixer. Capping the lane at unity while the fader reached +12 dB made
* automating a boosted clip silently discard the boost — and the panel
* disables the fader while a lane owns it, so there was no way back.
*/
export const VOLUME_RANGE: AutomationRange = {
min: 0,
max: 1,
max: MAX_AUDIO_GAIN,
step: 0.01,
unit: "",
label: "Volume",
Expand Down
44 changes: 44 additions & 0 deletions packages/lint/src/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,50 @@ describe("media_variable_src_no_fallback", () => {
});
});

describe("audio_volume_tween_overrides_gain", () => {
const withScript = (audioAttrs: string, script: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<audio id="bgm" src="a.wav" data-start="0" data-duration="10" ${audioAttrs}></audio>
</div>
<script>${script}</script>
</body></html>`;

it("warns that the tween's values win over an authored gain", async () => {
const res = await lintHyperframeHtml(
withScript(`data-volume="1.949845"`, `tl.fromTo("#bgm", { volume: 0 }, { volume: 1 });`),
);
const finding = res.findings.find((f) => f.code === "audio_volume_tween_overrides_gain");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("bgm");
expect(finding?.message).toMatch(/5\.8 dB/);
});

it("warns about an attenuation the tween overrides, not just a boost", async () => {
const res = await lintHyperframeHtml(
withScript(`data-volume="0.3"`, `tl.to("#bgm", { volume: 1 });`),
);
expect(res.findings.some((f) => f.code === "audio_volume_tween_overrides_gain")).toBe(true);
});

it("stays quiet at unity, without a tween, or when a lane already owns the level", async () => {
const unity = await lintHyperframeHtml(
withScript(`data-volume="1"`, `tl.to("#bgm", { volume: 0 });`),
);
const noTween = await lintHyperframeHtml(
withScript(`data-volume="2"`, `tl.to("#bgm", { x: 1 });`),
);
const lane = await lintHyperframeHtml(
withScript(
`data-volume="2" data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}'`,
`tl.to("#bgm", { volume: 0 });`,
),
);
for (const res of [unity, noTween, lane]) {
expect(res.findings.some((f) => f.code === "audio_volume_tween_overrides_gain")).toBe(false);
}
});
});

describe("audio_volume_double_automation", () => {
const withScript = (audioAttrs: string, script: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
Expand Down
42 changes: 42 additions & 0 deletions packages/lint/src/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,8 +629,50 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =

// audio_volume_double_automation
findVolumeDoubleAutomationFindings,

// audio_volume_tween_overrides_gain
findVolumeTweenOverridesGainFindings,
];

/**
* Tween values on `volume` are ABSOLUTE gains, not multipliers of the authored
* `data-volume`: the probed keyframes replace that baseline outright, in
* preview and in the render alike. So a clip carrying both plays at whatever
* the tween names — `{ volume: 1 }` is 0 dB even on a clip the fader says is
* at +5.8 dB, and Studio's fader gives no sign of it.
*
* Silent before this rule, and easier to hit since the fader gained +12 dB of
* boost and `normalize-audio` writes into the very same attribute.
*/
function findVolumeTweenOverridesGainFindings(ctx: LintContext): HyperframeLintFinding[] {
const boosted = ctx.tags
.filter((tag) => isMediaTag(tag.name))
.map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume")) }))
.filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1)
// A lane already has its own rule, and it wins over both of these.
.filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation"))
.map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") }))
.filter((entry): entry is typeof entry & { id: string } => Boolean(entry.id));
if (boosted.length === 0) return [];

const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
const findings: HyperframeLintFinding[] = [];
for (const { tag, id, volume } of boosted) {
if (!tweensVolumeInSameCall(script, id)) continue;
const db = volume > 0 ? `${(20 * Math.log10(volume)).toFixed(1)} dB` : "silence";
findings.push({
code: "audio_volume_tween_overrides_gain",
severity: "warning",
message: `#${id} has data-volume="${volume}" (${db}) and a GSAP tween on \`volume\`. Tween values are absolute — they REPLACE this gain rather than scale it — so wherever the tween names a value the clip plays at that value, not at ${db}.`,
elementId: id,
fixHint:
"Write the tween's targets in the same absolute gain (e.g. `volume: 1.95`, not `volume: 1`), or reset data-volume to 1 and let the tween carry the level on its own.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}

/**
* A track can have its volume shaped by an automation lane or by a GSAP tween,
* and only the lane is heard: the runtime reads `data-automation` first and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => {
});

describe("FlatMediaSection — volume/rate/media-start", () => {
it("renders volume at its stored percentage and commits a new value on drag", () => {
it("renders unity volume as neutral 0 dB at the slider midpoint", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
Expand All @@ -149,13 +149,16 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
/>,
);
});
expect(host.textContent).toContain("50%");
expect(host.textContent).toContain("0.0 dB");
expect(
host.querySelector('[data-flat-slider-track="true"]')?.getAttribute("aria-valuenow"),
).toBe("0");
act(() => root.unmount());
});

it("commits a new volume value on slider track pointerdown", () => {
it("commits +12 dB of boost from the upper half of the volume fader", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
Expand All @@ -176,11 +179,13 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
// Six decimals, not two: at two the bottom of the dB fader collapses onto
// "0" (a hard mute) and every stop below unity writes a value the knob then
// jumps away from.
expect(onSetAttribute).toHaveBeenCalledWith("volume", "3.981072");
act(() => root.unmount());
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import {
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import { AutomationToggle } from "./propertyPanelFxControls";
import {
AUDIO_GAIN_FADER_MAX,
AUDIO_GAIN_FADER_MIN,
audioFaderPositionToGain,
formatAudioGain,
audioGainToFaderPosition,
audioGainToText,
} from "@hyperframes/core/audio-gain";

// fallow-ignore-next-line complexity
export function FlatMediaSection({
Expand Down Expand Up @@ -54,7 +62,7 @@ export function FlatMediaSection({
const el = element.element;

const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100);
const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
Expand Down Expand Up @@ -215,13 +223,16 @@ export function FlatMediaSection({
<div className="min-w-0 flex-1">
<FlatSlider
label="Volume"
value={volumePercent}
min={0}
max={100}
tier={volumePercent === 100 ? "default" : "explicitCustom"}
displayValue={`${volumePercent}%`}
value={volumeFaderPosition}
min={AUDIO_GAIN_FADER_MIN}
max={AUDIO_GAIN_FADER_MAX}
tier={volume === 1 ? "default" : "explicitCustom"}
displayValue={audioGainToText(volume)}
disabled={volumeAutomated}
onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
centerTick
onCommit={(next) =>
void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
}
/>
</div>
<AutomationToggle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import {
} from "./propertyPanelHelpers";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import {
AUDIO_GAIN_FADER_MAX,
AUDIO_GAIN_FADER_MIN,
audioFaderPositionToGain,
formatAudioGain,
audioGainToFaderPosition,
audioGainToText,
} from "@hyperframes/core/audio-gain";

// fallow-ignore-next-line complexity
export function MediaSection({
Expand Down Expand Up @@ -47,7 +55,7 @@ export function MediaSection({
const el = element.element;

const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100);
const volumeFaderPosition = audioGainToFaderPosition(volume);

const mediaStart =
Number.parseFloat(
Expand Down Expand Up @@ -250,14 +258,14 @@ export function MediaSection({
<span className={LABEL}>Volume</span>
<SliderControl
trackName="Volume"
value={volumePercent}
min={0}
max={100}
value={volumeFaderPosition}
min={AUDIO_GAIN_FADER_MIN}
max={AUDIO_GAIN_FADER_MAX}
step={1}
displayValue={`${volumePercent}%`}
formatDisplayValue={(next) => `${Math.round(next)}%`}
displayValue={audioGainToText(volume)}
formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))}
onCommit={(next) => {
void onSetAttribute("volume", formatNumericValue(next / 100));
void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)));
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 5,
t1: 7,
// Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press.
// after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0,
v1: 1,
v1: VOLUME_RANGE.max,
});
});

Expand Down Expand Up @@ -300,9 +301,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4,
t1: 6,
// Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press.
// after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0,
v1: 1,
v1: VOLUME_RANGE.max,
});
});

Expand Down Expand Up @@ -338,9 +340,10 @@ describe("useAutomationSelectionKeyboard", () => {
t0: 4,
t1: 6,
// Full height: everything the paste landed is selected, so Delete straight
// after undoes it in one press.
// after undoes it in one press. The volume axis tops out at the authoring
// ceiling, not at unity.
v0: 0,
v1: 1,
v1: VOLUME_RANGE.max,
});
});

Expand Down
Loading
Loading