mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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:
@@ -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