mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): resolve an audio bus by tag, re-read it, and clamp it like the render
Review findings 2, 6, 7 plus one tail item. All four are the same bug wearing
four hats: nothing that resolved a group element checked the tag, and one path
froze the result for the session.
**resolveGroupElement / isMemberGroupHidden (audioGroups.ts).** One tag-checked
resolver, since `resolveAudioGroups` only ever accepted `<hf-audio-group>` and
every other reader used a bare `getElementById`. An `<audio id="vo"
data-audio-group="vo" data-volume="0.5" data-fx-chain=…>` — the shape the
"group with no element" docblock explicitly supports — had its OWN fader and
chain applied a second time on the bus, and a `<div id="bg" data-hidden>`
silenced group "bg" in preview only. Null now means the documented flat sum.
**The bus is re-resolved on every reanchor**, not captured once. A group whose
element does not exist at first schedule (studio group creation, a
sub-composition that loads later) kept the `{ getAttribute: () => null }` stub
for the whole session: no fader, no chain, no mute in preview, while the export
honoured all three. The mute gain is re-read there too, which it never was.
**Preview's bus fader now uses `clampAudioGain`, the render's own clamp.** Its
docblock claimed the render clamps to unity; the render clamps with
`clampAudioGain`, ceiling MAX_AUDIO_GAIN (+12 dB, ~3.98). So
`data-volume="2"` auditioned at 1.0 and exported at 2.0 — 6 dB, up to 12 at the
ceiling. Preview was self-inconsistent as well: the same parameter's automation
lane is bounded by `VOLUME_RANGE.max`, which IS MAX_AUDIO_GAIN, so an envelope
could reach 3.98 where the static fader could not pass 1.0.
**A muted bus is now audible to the HTMLMedia fallback (media.ts).**
`el.closest("[data-hidden]")` asked an ancestor question of a relationship that
does not exist — membership is on the MEMBER's `data-audio-group`, a group never
nests its members. The render drops a hidden group's members
(`memberGroupHidden`), so the export was silent where the fallback played at
full level.
**Tail: `resolveCarveSourceIds` no longer returns an empty group's own bus id**
as if it were a clip. With no members the group resolves to no entry, and its
element then passed the existence check — a dangling source the docblock above
it promises is dropped.
Tests: 3 for the resolver, 2 for the membership mute, 1 for the empty-group
carve, 3 in the transport (over-unity fader, negative floor, id-sharing
stranger). Verified each fails on a revert of its own fix. core: 122 files.
This commit is contained in:
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
audioGroupOf,
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
isMemberGroupHidden,
|
||||
resolveAudioGroups,
|
||||
resolveCarveSourceIds,
|
||||
resolveGroupElement,
|
||||
} from "./audioGroups.js";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -158,3 +160,68 @@ describe(HF_AUDIO_GROUP_ATTR, () => {
|
||||
expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupElement", () => {
|
||||
const doc = (html: string): Document => {
|
||||
const d = document.implementation.createHTMLDocument("t");
|
||||
d.body.innerHTML = html;
|
||||
return d;
|
||||
};
|
||||
|
||||
it("returns the bus for a real <hf-audio-group>", () => {
|
||||
const d = doc(`<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`);
|
||||
expect(resolveGroupElement(d, "vo")?.tagName.toLowerCase()).toBe("hf-audio-group");
|
||||
});
|
||||
|
||||
// The trap: a bare getElementById read a member's OWN fader and chain as the
|
||||
// bus's, applying both a second time on the sub-mix.
|
||||
it("refuses an <audio> sharing the group id", () => {
|
||||
const d = doc(`<audio id="vo" data-audio-group="vo" data-volume="0.5"></audio>`);
|
||||
expect(resolveGroupElement(d, "vo")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unrelated element sharing the group id", () => {
|
||||
const d = doc(`<div id="bg" data-hidden></div>`);
|
||||
expect(resolveGroupElement(d, "bg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMemberGroupHidden", () => {
|
||||
const doc = (html: string): Document => {
|
||||
const d = document.implementation.createHTMLDocument("t");
|
||||
d.body.innerHTML = html;
|
||||
return d;
|
||||
};
|
||||
|
||||
// Membership is on the MEMBER, so the bus is never an ancestor and
|
||||
// `closest("[data-hidden]")` cannot see it.
|
||||
it("sees a muted bus that does not nest its member", () => {
|
||||
const d = doc(
|
||||
`<hf-audio-group id="vo" data-hidden></hf-audio-group>
|
||||
<div><audio id="vo-1" data-audio-group="vo"></audio></div>`,
|
||||
);
|
||||
const member = d.getElementById("vo-1");
|
||||
expect(member?.closest("[data-hidden]")).toBeNull();
|
||||
expect(isMemberGroupHidden(d, member)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for an unmuted bus and for a member with no group", () => {
|
||||
const d = doc(
|
||||
`<hf-audio-group id="vo"></hf-audio-group>
|
||||
<audio id="vo-1" data-audio-group="vo"></audio>
|
||||
<audio id="lone"></audio>`,
|
||||
);
|
||||
expect(isMemberGroupHidden(d, d.getElementById("vo-1"))).toBe(false);
|
||||
expect(isMemberGroupHidden(d, d.getElementById("lone"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCarveSourceIds — empty group", () => {
|
||||
it("drops an empty group's own bus id instead of returning it as a clip", () => {
|
||||
const d = document.implementation.createHTMLDocument("t");
|
||||
d.body.innerHTML = `<hf-audio-group id="voiceover"></hf-audio-group>`;
|
||||
// No members, so the group resolves to nothing; its element must not pass
|
||||
// the existence check as if it were a clip the analysis could read.
|
||||
expect(resolveCarveSourceIds(d, ["voiceover"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,47 @@ function buildGroup(id: string, memberIds: string[], el: Element | undefined): H
|
||||
* (label = id) so a hand-authored composition degrades gracefully. Audio
|
||||
* only in v1 — a `data-audio-group` on a `<video>` is ignored.
|
||||
*/
|
||||
/**
|
||||
* The `<hf-audio-group>` element for a group id, or null.
|
||||
*
|
||||
* Tag-checked, which a bare `getElementById` is not. Every group attribute —
|
||||
* the fader, the mute, the FX chain, the automation lane — is read off whatever
|
||||
* this returns, so an unrelated element sharing the id used to be read as a bus:
|
||||
* an `<audio id="vo" data-audio-group="vo" data-volume="0.5" data-fx-chain=…>`
|
||||
* had its own fader and chain applied a SECOND time on the bus, and a
|
||||
* `<div id="bg" data-hidden>` silenced group "bg" in preview only. The render
|
||||
* never had this problem — `resolveAudioGroups` only ever accepted the tag —
|
||||
* so the two disagreed on exactly the attributes the bus exists to carry.
|
||||
*
|
||||
* Returning null is the documented "group with no element" case, which
|
||||
* degrades to a flat sum rather than borrowing a stranger's settings.
|
||||
*/
|
||||
export function resolveGroupElement(
|
||||
doc: Pick<Document, "getElementById"> | null | undefined,
|
||||
groupId: string,
|
||||
): Element | null {
|
||||
const el = doc?.getElementById(groupId) ?? null;
|
||||
if (!el) return null;
|
||||
return el.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG ? el : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bus a member belongs to is muted.
|
||||
*
|
||||
* Membership is held by the MEMBER's `data-audio-group`; a group never nests
|
||||
* its members. So `el.closest("[data-hidden]")` cannot see a muted bus, which
|
||||
* is how the render came to drop a hidden group's members while the preview
|
||||
* fallback played them at full level.
|
||||
*/
|
||||
export function isMemberGroupHidden(
|
||||
doc: Pick<Document, "getElementById"> | null | undefined,
|
||||
el: Element | null | undefined,
|
||||
): boolean {
|
||||
const groupId = el?.getAttribute?.(HF_AUDIO_GROUP_ATTR);
|
||||
if (!groupId) return false;
|
||||
return resolveGroupElement(doc, groupId)?.hasAttribute("data-hidden") ?? false;
|
||||
}
|
||||
|
||||
export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] {
|
||||
const membersByGroup = new Map<string, string[]>();
|
||||
for (const member of root.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)) {
|
||||
@@ -114,7 +155,11 @@ export function resolveCarveSourceIds(doc: Document, ids: readonly string[]): st
|
||||
const group = groupsById.get(id);
|
||||
if (group) {
|
||||
group.memberIds.forEach(add);
|
||||
} else if (doc.getElementById(id)) {
|
||||
} else if (doc.getElementById(id) && !resolveGroupElement(doc, id)) {
|
||||
// A group with no members resolves to no entry above, and its own bus
|
||||
// element would then pass this existence check and be returned as if it
|
||||
// were a clip — a source the analysis can only fail to find, which the
|
||||
// docblock above promises is dropped.
|
||||
add(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelop
|
||||
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
|
||||
import { readElementPlaybackRate, readMediaStart } from "./playbackRate.js";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { isMemberGroupHidden } from "../audioGroups.js";
|
||||
import { findInjectedRenderFrame } from "./renderFrameSibling.js";
|
||||
export { readElementPlaybackRate, resolveNaturalMediaTimelineDuration } from "./playbackRate.js";
|
||||
|
||||
@@ -325,8 +326,13 @@ export function syncRuntimeMedia(params: {
|
||||
// `audio-track-mute` canary — see init.ts). Folded into the per-tick
|
||||
// volume, not el.muted (RULES trap: el.muted is the transport's ownership
|
||||
// flag).
|
||||
// Two independent ways to be silent, and the second is not an ancestor
|
||||
// question: membership lives on the MEMBER's `data-audio-group`, so a
|
||||
// muted BUS is invisible to `closest()`. The render drops such members
|
||||
// (`memberGroupHidden`), so without this the export was silent where the
|
||||
// fallback played at full level.
|
||||
const silencedByHidden = params.silenceHiddenAudio
|
||||
? el.closest("[data-hidden]") !== null
|
||||
? el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el)
|
||||
: false;
|
||||
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
|
||||
el.volume = effectiveVolume;
|
||||
|
||||
@@ -751,6 +751,38 @@ describe("WebAudioTransport", () => {
|
||||
// The media-element transport is the PRIMARY path for audio — the runtime
|
||||
// tries it first and only falls back to a decoded buffer. It has to reach
|
||||
// the same bus, or grouping silently applies to nothing that actually plays.
|
||||
// The render clamps a track volume with `clampAudioGain` (ceiling ~3.98),
|
||||
// so an over-unity bus previewed at 1.0 exported up to 12 dB louder than
|
||||
// it auditioned.
|
||||
it("previews an over-unity bus fader at the render's ceiling, not unity", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="2"></hf-audio-group>`;
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
// Creation order: a-gain(0), groupInput(1), groupOutput(2), muteGain(3), fader(4).
|
||||
expect(mock.gainNodes[4]!.gain.value).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
it("still floors a negative bus fader at zero", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="-1"></hf-audio-group>`;
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
expect(mock.gainNodes[4]!.gain.value).toBe(0);
|
||||
});
|
||||
|
||||
// A bare getElementById read a member's own fader and chain as the bus's.
|
||||
it("ignores a non-<hf-audio-group> element sharing the group id", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
document.body.innerHTML = `<div id="vo" data-volume="0.25" data-hidden></div>`;
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
// Flat bus: unity fader, unmuted — the documented "group with no element"
|
||||
// degradation, not the stranger's settings.
|
||||
expect(mock.gainNodes[4]!.gain.value).toBe(1);
|
||||
expect(mock.gainNodes[3]!.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("routes a grouped clip's MEDIA-ELEMENT playback to the group bus, not master", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
const el = groupedAudioEl("vo-1", "vo");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||
import { audioGroupOf, readAudioGroupVolume } from "../audioGroups.js";
|
||||
import { audioGroupOf, readAudioGroupVolume, resolveGroupElement } from "../audioGroups.js";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
@@ -18,15 +18,20 @@ function normalizeRate(rate: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The render puts every track volume through its own `clampVolume` before
|
||||
* building the filter, so an authored `<hf-audio-group data-volume="2">`
|
||||
* renders at unity. Preview has to agree or the two diverge on exactly the
|
||||
* attribute this bus exists to honour — and a negative value would invert
|
||||
* polarity in preview while rendering silent. Compositions are hand-authorable,
|
||||
* so out-of-range values do not need the studio slider to be reachable.
|
||||
* The render puts every track volume through `clampVolume`, which is
|
||||
* `clampAudioGain` — ceiling MAX_AUDIO_GAIN (+12 dB, ~3.98), not unity. Preview
|
||||
* has to agree or the two diverge on exactly the attribute this bus exists to
|
||||
* honour: an authored `<hf-audio-group data-volume="2">` previewed at 1.0 and
|
||||
* exported at 2.0, up to 6 dB quieter in the audition than in the file, and
|
||||
* 12 dB at the ceiling. Preview was also self-inconsistent — the same
|
||||
* parameter's automation lane is bounded by `VOLUME_RANGE.max`, which IS
|
||||
* MAX_AUDIO_GAIN, so an envelope could reach 3.98 where the static fader could
|
||||
* not pass 1.0. `clampAudioGain` still floors at 0, so a negative value cannot
|
||||
* invert polarity in preview while rendering silent. Compositions are
|
||||
* hand-authorable, so out-of-range values do not need a slider to be reachable.
|
||||
*/
|
||||
function clampGroupVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
return clampAudioGain(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +362,13 @@ export class WebAudioTransport {
|
||||
const output = this._ctx.createGain();
|
||||
output.connect(this._masterGain);
|
||||
|
||||
const groupEl = doc.getElementById(groupId);
|
||||
// Tag-checked, and re-resolved on every reanchor below rather than frozen:
|
||||
// a bus whose element does not exist yet (studio group creation, or a
|
||||
// sub-composition that loads later) kept the `getAttribute: () => null`
|
||||
// stub for the whole session, so its fader, chain and mute never reached
|
||||
// preview while the export honoured all three.
|
||||
const resolveEl = (): Element | null => resolveGroupElement(doc, groupId);
|
||||
const groupEl = resolveEl();
|
||||
const muteGain = this._ctx.createGain();
|
||||
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
||||
muteGain.connect(output);
|
||||
@@ -393,9 +404,12 @@ export class WebAudioTransport {
|
||||
// the previous pass's ramps still owning the param (for a fade-out,
|
||||
// silence) for the rest of the session.
|
||||
clearParamLane([{ param: fader.gain }]);
|
||||
fader.gain.value = clampGroupVolume(readAudioGroupVolume(groupEl));
|
||||
// Re-resolved, not the element captured at build time — see `resolveEl`.
|
||||
const live = resolveEl();
|
||||
fader.gain.value = clampGroupVolume(readAudioGroupVolume(live));
|
||||
muteGain.gain.value = live?.hasAttribute("data-hidden") ? 0 : 1;
|
||||
fx?.reanchor(at);
|
||||
if (groupEl) scheduleVolumeLane(groupEl, fader, at);
|
||||
if (live) scheduleVolumeLane(live, fader, at);
|
||||
},
|
||||
dispose: () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user