mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(core,engine): make a group's preview match what the render bakes
Findings 8-11 and 16 — the render/preview parity set. All group audio, all invisible to preview or to export alone, which is why each had a passing test beside it. 9 — The preview bus wired only the automation lane, and readElementAutomation reads data-automation, so a group's own `data-volume` never reached the graph at all: the bus stayed at unity while the render applied it. The export was ~8 dB quieter than what had been auditioned. The test named for this asserted only `resolves.not.toBeNull()`. 10 — The volume lane was scheduled on the FX chain's SOURCE, ahead of the effects, breaking the contract scheduleVolumeLane's own docstring states: "the volume lane rides the fader, after the effects — where a DAW puts it, and the order the render bakes it in". The element path honoured it; the group path did not, so any nonlinear group effect previewed differently than it rendered. Both now land on a dedicated post-FX fader node: input → chain → fader → mute → output. 11 — The bus deliberately outlives stopAll(), and groupInput early -returned on an existing entry, so after a replay or a seek no new ramps were booked and the gain held the previous pass's last value — 0 after a fade-out, i.e. silent for the rest of the session. It re-anchors once per play generation now (ElementFxHandle gains `reanchor`), and the record keeps its `fx` handle so setRate can re-aim a group's automation, which its docblock already claimed it did. 8 — The sub-mix summed members at unity (normalize=0) into a pcm_s16le intermediate, so an over-unity sum hard-clipped at ±1 BEFORE the group's FX and fader ran: pulling the fader down, or the Giant preset's compressor, then operated on distortion. The intermediate is float now. Both downstream readers already took float; applyVolumeEnvelopeToWav did not, and does now, so a group's envelope is still baked sample-accurately rather than silently degrading to the expression path. The new level test is the one that matters here: EVERY tone in that file is built on ffmpeg's `sine` source, which peaks at ~0.125 full scale, so nothing in the suite could reach a clip whatever gain it asked for. `writePeakTone` states the amplitude outright, and the test fails against the 16-bit intermediate. 16 — mixGroupMembers forked mixAudioTracks' filter build and dropped its automation-degradation retry, so a member envelope past this ffmpeg build's expression limits failed the whole composition's audio where an ungrouped one degrades to base volume with a warning. The retry is back, reported on a successful result the same way. The group track is also pushed with its volumeKeyframes when the envelope could not be baked, instead of losing the group's automation silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ce6426ddcb
commit
2cd8b43087
@@ -35,7 +35,13 @@ export interface HfAudioGroup {
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
function parseGroupVolume(el: Element | undefined): number {
|
||||
/**
|
||||
* A group element's `data-volume`, defaulting to 1 for a missing, unparseable
|
||||
* or absent element. Shared so the preview bus and `resolveAudioGroups` (which
|
||||
* the render reads) cannot drift: the export was ~8 dB quieter than what was
|
||||
* auditioned for exactly as long as the preview ignored this.
|
||||
*/
|
||||
export function readAudioGroupVolume(el: Element | null | undefined): number {
|
||||
const raw = el?.getAttribute("data-volume");
|
||||
const parsed = raw ? parseFloat(raw) : 1;
|
||||
return Number.isFinite(parsed) ? parsed : 1;
|
||||
@@ -50,7 +56,7 @@ function buildGroup(id: string, memberIds: string[], el: Element | undefined): H
|
||||
memberIds,
|
||||
...(fxChain ? { fxChain } : {}),
|
||||
...(automation ? { automation } : {}),
|
||||
volume: parseGroupVolume(el),
|
||||
volume: readAudioGroupVolume(el),
|
||||
hidden: el?.hasAttribute("data-hidden") ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +98,16 @@ export function readElementAutomation(el: {
|
||||
*/
|
||||
export interface ElementFxHandle {
|
||||
dispose(): void;
|
||||
/**
|
||||
* Re-book every envelope against a fresh reference frame.
|
||||
*
|
||||
* For a graph that OUTLIVES the source feeding it — a group bus, which is
|
||||
* built once and kept for the session — a replay or a seek starts a new pass
|
||||
* over the same chain. Without re-anchoring, the envelopes stay committed to
|
||||
* the first pass's absolute context times: past their last point that is a
|
||||
* stuck value, which for a fade-out is silence for the rest of the session.
|
||||
*/
|
||||
reanchor(timing: AutomationTiming): void;
|
||||
/**
|
||||
* Re-aim every booked envelope at a new playback rate.
|
||||
*
|
||||
@@ -277,6 +287,13 @@ export function attachElementFxChain(
|
||||
}
|
||||
|
||||
return {
|
||||
reanchor: (next: AutomationTiming) => {
|
||||
if (disposed) return;
|
||||
const at = timingNow();
|
||||
if (at) cancelParamLane(automated, at.scheduledAt);
|
||||
frame = { ...next };
|
||||
scheduleFor(readChain(el).chain, frame);
|
||||
},
|
||||
setRate: (rate: number) => {
|
||||
const at = timingNow();
|
||||
if (disposed || !at || !Number.isFinite(rate) || rate <= 0 || rate === at.rate) return;
|
||||
|
||||
@@ -735,16 +735,17 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
|
||||
// muteGain(4) — the group bus is built lazily inside a's schedule call.
|
||||
// Then b: b-gain(5), b-solo(6).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7);
|
||||
// muteGain(4), fader(5) — the group bus is built lazily inside a's
|
||||
// schedule call. Then b: b-gain(6), b-solo(7).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8);
|
||||
const aGain = mock.gainNodes[0]!;
|
||||
const aSolo = mock.gainNodes[1]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
const groupOutput = mock.gainNodes[3]!;
|
||||
const muteGain = mock.gainNodes[4]!;
|
||||
const bGain = mock.gainNodes[5]!;
|
||||
const bSolo = mock.gainNodes[6]!;
|
||||
const fader = mock.gainNodes[5]!;
|
||||
const bGain = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
|
||||
// Each member feeds its own solo gain, and both solo gains feed the
|
||||
// shared bus — neither connects straight to master.
|
||||
@@ -755,11 +756,14 @@ describe("WebAudioTransport", () => {
|
||||
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(bSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
|
||||
// The bus's input never reaches master directly — it lands on the mute
|
||||
// gain (B5) first (the dry passthrough, since neither member's group has
|
||||
// a chain-bearing `<hf-audio-group>`), then the output gain, then master.
|
||||
// The bus's input never reaches master directly. It runs through the
|
||||
// chain (dry here — neither member's group has a chain-bearing
|
||||
// `<hf-audio-group>`) onto the FADER, then the mute gain (B5), then the
|
||||
// output gain, then master. The fader sits POST-FX because that is where
|
||||
// the render bakes group volume in.
|
||||
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(groupInput.connect).toHaveBeenCalledWith(muteGain);
|
||||
expect(groupInput.connect).toHaveBeenCalledWith(fader);
|
||||
expect(fader.connect).toHaveBeenCalledWith(muteGain);
|
||||
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
@@ -782,18 +786,36 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
const muteGain = mock.gainNodes[4]!;
|
||||
const groupOutput = mock.gainNodes[3]!;
|
||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(muteGain);
|
||||
const fader = mock.gainNodes[5]!;
|
||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(fader);
|
||||
expect(fader.connect).toHaveBeenCalledWith(muteGain);
|
||||
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
// No element to read, so the fader sits at unity.
|
||||
expect(fader.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => {
|
||||
// The old assertion here was `resolves.not.toBeNull()` — it was named for
|
||||
// group volume and checked only that scheduling did not throw, so the bus
|
||||
// sitting at unity while the render applied data-volume went unseen. The
|
||||
// export was ~8 dB quieter than what had been auditioned.
|
||||
it("puts the group's own data-volume on the bus fader", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-label="Voiceover" data-volume="0.4"></hf-audio-group>`;
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull();
|
||||
|
||||
expect(mock.gainNodes[5]!.gain.value).toBeCloseTo(0.4, 6);
|
||||
});
|
||||
|
||||
it("leaves the fader at unity when the group carries no data-volume", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-label="Voiceover"></hf-audio-group>`;
|
||||
const { transport, gen } = setupGroupTransport();
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
// No throw wiring the group's automation reader against a real
|
||||
// <hf-audio-group> element that carries no fx/automation attrs.
|
||||
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull();
|
||||
expect(mock.gainNodes[5]!.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("destroy() disposes every group bus", async () => {
|
||||
@@ -820,13 +842,37 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Surviving stopAll() is the point of the bus — and the trap. Its envelopes
|
||||
// were booked against the FIRST pass's absolute context times, so a replay
|
||||
// or a seek left the fader holding that pass's last value: 0 after a
|
||||
// fade-out, i.e. silent for the rest of the session.
|
||||
it("re-anchors the reused bus once per play generation, and only once", async () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[5]!;
|
||||
|
||||
// Something moved the fader mid-pass (a ramp reaching its last point).
|
||||
fader.gain.value = 0;
|
||||
transport.stopAll();
|
||||
|
||||
const gen2 = transport.startGeneration();
|
||||
await scheduleGrouped(transport, gen2, "a", "vo");
|
||||
expect(fader.gain.value).toBeCloseTo(0.5, 6);
|
||||
|
||||
// A second member in the SAME pass must not re-book on top of the first.
|
||||
fader.gain.value = 0;
|
||||
await scheduleGrouped(transport, gen2, "b", "vo");
|
||||
expect(fader.gain.value).toBe(0);
|
||||
});
|
||||
|
||||
describe('solo — "Hear only this" (B5)', () => {
|
||||
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
const aSolo = mock.gainNodes[1]!;
|
||||
const bSolo = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
|
||||
transport.setSolo(new Set(["other-clip"]));
|
||||
@@ -843,7 +889,7 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
const aSolo = mock.gainNodes[1]!;
|
||||
const bSolo = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
|
||||
transport.setSolo(new Set(["a"]));
|
||||
@@ -858,7 +904,7 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
const aSolo = mock.gainNodes[1]!;
|
||||
const bSolo = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
|
||||
transport.setSolo(new Set(["vo"]));
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||
import { audioGroupOf, isAudibleUnderSolo } from "../audioGroups.js";
|
||||
import { audioGroupOf, isAudibleUnderSolo, readAudioGroupVolume } from "../audioGroups.js";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
@@ -136,9 +136,17 @@ export class WebAudioTransport {
|
||||
string,
|
||||
{
|
||||
input: GainNode;
|
||||
/** Post-FX fader: `data-volume` plus the volume lane. */
|
||||
fader: GainNode;
|
||||
muteGain: GainNode;
|
||||
analyser: AnalyserNode;
|
||||
levelBuf: Float32Array;
|
||||
/** Kept so `setRate` can re-aim this bus's FX automation, the way it does
|
||||
* every source's — its docblock claims it already did. */
|
||||
fx: ElementFxHandle | null;
|
||||
/** Play generation the current envelopes were booked against. */
|
||||
generation: number;
|
||||
reanchor(timing: AutomationTiming): void;
|
||||
dispose(): void;
|
||||
}
|
||||
>();
|
||||
@@ -308,7 +316,18 @@ export class WebAudioTransport {
|
||||
*/
|
||||
private groupInput(groupId: string, doc: Document, timing: AutomationTiming): GainNode | null {
|
||||
const existing = this._groups.get(groupId);
|
||||
if (existing) return existing.input;
|
||||
if (existing) {
|
||||
// The bus outlives `stopAll()` on purpose, so a replay or a seek reuses
|
||||
// this graph — but its envelopes were committed to the FIRST pass's
|
||||
// absolute context times. Left alone they hold their last value forever,
|
||||
// which for a fade-out is silence for the rest of the session. Re-anchor
|
||||
// once per play generation, not once per member scheduled.
|
||||
if (existing.generation !== this._playGeneration) {
|
||||
existing.generation = this._playGeneration;
|
||||
existing.reanchor(timing);
|
||||
}
|
||||
return existing.input;
|
||||
}
|
||||
if (!this._ctx || !this._masterGain) return null;
|
||||
|
||||
const input = this._ctx.createGain();
|
||||
@@ -329,24 +348,42 @@ export class WebAudioTransport {
|
||||
const muteGain = this._ctx.createGain();
|
||||
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
||||
muteGain.connect(output);
|
||||
// The group's fader, POST-FX: `data-volume` is the static position and the
|
||||
// volume lane rides it, which is where a DAW puts it and the order the
|
||||
// render bakes it in (`scheduleVolumeLane`'s own contract). Scheduling it
|
||||
// on `input` instead put the fader ahead of the effects, so any nonlinear
|
||||
// group effect — a compressor, the Giant preset — previewed differently
|
||||
// than it rendered.
|
||||
const fader = this._ctx.createGain();
|
||||
fader.gain.value = readAudioGroupVolume(groupEl);
|
||||
fader.connect(muteGain);
|
||||
const fx = attachElementFxChain(
|
||||
this._ctx,
|
||||
groupEl ?? { getAttribute: () => null },
|
||||
input,
|
||||
muteGain,
|
||||
fader,
|
||||
timing,
|
||||
);
|
||||
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
||||
if (groupEl) scheduleVolumeLane(groupEl, fader, timing);
|
||||
|
||||
this._groups.set(groupId, {
|
||||
input,
|
||||
fader,
|
||||
muteGain,
|
||||
analyser,
|
||||
levelBuf: new Float32Array(analyser.fftSize),
|
||||
fx,
|
||||
generation: this._playGeneration,
|
||||
reanchor: (at: AutomationTiming) => {
|
||||
fader.gain.value = readAudioGroupVolume(groupEl);
|
||||
fx?.reanchor(at);
|
||||
if (groupEl) scheduleVolumeLane(groupEl, fader, at);
|
||||
},
|
||||
dispose: () => {
|
||||
try {
|
||||
fx?.dispose();
|
||||
input.disconnect();
|
||||
fader.disconnect();
|
||||
muteGain.disconnect();
|
||||
output.disconnect();
|
||||
analyser.disconnect();
|
||||
@@ -588,6 +625,16 @@ export class WebAudioTransport {
|
||||
swallow("webAudioTransport.setRate", err);
|
||||
}
|
||||
}
|
||||
// Group buses are not in `_activeSources` — they outlive it — so their FX
|
||||
// automation needs re-aiming here too, or a rate change leaves a group's
|
||||
// envelopes running the old plan over audio at the new speed.
|
||||
for (const group of this._groups.values()) {
|
||||
try {
|
||||
group.fx?.setRate(safeRate);
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.setRate.group", err);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user