mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
fix(studio): make the volume fader tell the truth about the gain it writes (#3305)
* fix(studio): make the volume fader tell the truth about the gain it writes The fader travels in dB, so its stops are irrational values; serializing them through the generic two-decimal numeric formatter collapsed the bottom quarter of its travel onto "0" — a hard mute — and made the knob jump on release everywhere below unity. Both panels now use the exact serializer, which round-trips every integer stop back to itself. Raise the volume automation lane to the same ceiling the fader reaches. Clamping the lane at unity meant automating a boosted clip silently discarded the boost, and the panel disables the fader while a lane owns the level, so there was no way back. This rescales the lane's vertical axis: unity now sits a quarter of the way up rather than at the top. Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute — they replace the authored gain rather than scaling it — so a clip carrying both plays at whatever the tween names, and the fader gives no sign of it. The rule reuses the tween detector the sibling lane/tween rule already has. * fix(lint): treat a missing data-volume as unity, not as silence readAttr returns null when the attribute is absent, and Number(null) is 0 — finite, and not 1 — so a clip carrying NO data-volume cleared both filters and was reported as authored at silence. Both halves of that were false: absent means unity everywhere else in the runtime. It fired on exactly the case the rule exists to bless. The docs this PR edits say data-volume is the baseline for elements no tween touches, so a tweened clip is expected not to carry one — the common audio fade. A warning does not fail check, but an agent reading the fixHint would have written a gain to correct a level that was never wrong.
This commit is contained in:
@@ -6,6 +6,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
||||
import { PAD_X } from "./automationLaneGeometry";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
||||
import {
|
||||
normalizeAutomation,
|
||||
resolveAutomationRange,
|
||||
@@ -126,6 +127,14 @@ const ramp: HfAutomation = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* These are geometry and gesture tests, not ceiling tests: a plain 0..1 axis
|
||||
* keeps every pointer coordinate below readable. `VOLUME_RANGE` itself reaches
|
||||
* the +12 dB authoring ceiling — covered by its own case at the end of this
|
||||
* file, and by audioAutomation.test.ts.
|
||||
*/
|
||||
const UNIT_RANGE = { ...VOLUME_RANGE, max: 1 };
|
||||
|
||||
function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> = {}) {
|
||||
const target = over.target ?? "volume";
|
||||
return {
|
||||
@@ -140,7 +149,9 @@ function laneProps(over: Partial<Parameters<typeof TimelineAutomationLane>[0]> =
|
||||
onCommit: vi.fn(),
|
||||
...over,
|
||||
target,
|
||||
range: over.range ?? resolveAutomationRange(target, chain) ?? VOLUME_RANGE,
|
||||
range:
|
||||
over.range ??
|
||||
(target === "volume" ? UNIT_RANGE : (resolveAutomationRange(target, chain) ?? VOLUME_RANGE)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -934,7 +945,25 @@ describe("TimelineAutomationLane modifiers", () => {
|
||||
input?.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max);
|
||||
expect(committed?.lanes[0]?.points[0]?.v).toBe(UNIT_RANGE.max);
|
||||
});
|
||||
|
||||
it("reaches the authoring ceiling on the real volume range", () => {
|
||||
const { container, svg, props } = mount(ramp, { range: VOLUME_RANGE });
|
||||
// On the real range unity sits a quarter of the way up, not at the top.
|
||||
fire(svg, "dblclick", at(0, 1 / MAX_AUDIO_GAIN));
|
||||
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
input?.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
|
||||
// A boosted clip seeds its lane above unity; clamping the lane at 1 while
|
||||
// the fader reached +12 dB silently threw the boost away.
|
||||
expect(committed?.lanes[0]?.points[0]?.v).toBeCloseTo(MAX_AUDIO_GAIN, 6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
||||
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
|
||||
|
||||
/** The fixture's values double as unit positions, so pin it to a 0..1 axis. */
|
||||
const UNIT_RANGE = { ...VOLUME_RANGE, max: 1 };
|
||||
|
||||
const duck: HfAutomationLane = {
|
||||
target: "volume",
|
||||
points: [
|
||||
@@ -21,7 +24,7 @@ beforeEach(clearAutomationClipboard);
|
||||
|
||||
describe("automation clipboard", () => {
|
||||
it("copies the range rebased to zero", () => {
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
const entry = readClipboard("project-a");
|
||||
expect(entry?.span).toBe(2);
|
||||
expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]);
|
||||
@@ -29,11 +32,11 @@ describe("automation clipboard", () => {
|
||||
});
|
||||
|
||||
it("pastes at a new time on the same axis unchanged", () => {
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
const entry = readClipboard("project-a");
|
||||
expect(entry).not.toBeNull();
|
||||
if (!entry) return;
|
||||
const pts = pastePoints(entry, VOLUME_RANGE, 10);
|
||||
const pts = pastePoints(entry, UNIT_RANGE, 10);
|
||||
expect(pts.map((p) => p.t)).toEqual([10, 11, 12]);
|
||||
expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]);
|
||||
});
|
||||
@@ -51,7 +54,7 @@ describe("automation clipboard", () => {
|
||||
expect(frequency).toBeTruthy();
|
||||
if (!frequency) return;
|
||||
expect(frequency.scale).toBe("log");
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
const entry = readClipboard("project-a");
|
||||
if (!entry) return;
|
||||
const pts = pastePoints(entry, frequency, 0);
|
||||
@@ -74,12 +77,12 @@ describe("automation clipboard", () => {
|
||||
});
|
||||
|
||||
it("does not hand a range copied in one project to another", () => {
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
expect(readClipboard("project-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the entry for good once another project has read past it", () => {
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
readClipboard("project-b");
|
||||
// Not merely hidden from B: switching back must not resurrect a shape whose
|
||||
// source clip may have been edited or deleted while the project was closed.
|
||||
@@ -87,7 +90,7 @@ describe("automation clipboard", () => {
|
||||
});
|
||||
|
||||
it("keeps serving the entry inside its own project", () => {
|
||||
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
|
||||
copyRange("project-a", duck, UNIT_RANGE, 2, 4);
|
||||
expect(readClipboard("project-a")?.span).toBe(2);
|
||||
expect(readClipboard("project-a")?.span).toBe(2);
|
||||
});
|
||||
|
||||
@@ -52,9 +52,15 @@ describe("automationTargets", () => {
|
||||
|
||||
describe("value ↔ lane position", () => {
|
||||
it("maps a linear range straight onto the lane", () => {
|
||||
expect(toUnit(VOLUME_RANGE, 0)).toBe(0);
|
||||
expect(toUnit(VOLUME_RANGE, 1)).toBe(1);
|
||||
expect(toUnit(VOLUME_RANGE, 0.25)).toBeCloseTo(0.25, 10);
|
||||
const unit = { ...VOLUME_RANGE, max: 1 };
|
||||
expect(toUnit(unit, 0)).toBe(0);
|
||||
expect(toUnit(unit, 1)).toBe(1);
|
||||
expect(toUnit(unit, 0.25)).toBeCloseTo(0.25, 10);
|
||||
});
|
||||
|
||||
it("puts unity a quarter up the volume lane, which reaches +12 dB", () => {
|
||||
expect(toUnit(VOLUME_RANGE, VOLUME_RANGE.max)).toBe(1);
|
||||
expect(toUnit(VOLUME_RANGE, 1)).toBeCloseTo(1 / VOLUME_RANGE.max, 10);
|
||||
});
|
||||
|
||||
it("maps a log-read knob on its own scale, so its middle is geometric", () => {
|
||||
@@ -67,7 +73,7 @@ describe("value ↔ lane position", () => {
|
||||
|
||||
it("clamps a pointer that has left the lane", () => {
|
||||
expect(fromUnit(VOLUME_RANGE, -3)).toBe(0);
|
||||
expect(fromUnit(VOLUME_RANGE, 4)).toBe(1);
|
||||
expect(fromUnit(VOLUME_RANGE, 4)).toBe(VOLUME_RANGE.max);
|
||||
});
|
||||
|
||||
it("reads a zero-width range as the bottom rather than dividing by zero", () => {
|
||||
|
||||
Reference in New Issue
Block a user