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:
Vance Ingalls
2026-08-20 16:39:37 -07:00
co-authored by Claude Opus 5
parent ce6426ddcb
commit 2cd8b43087
8 changed files with 416 additions and 68 deletions
+8 -2
View File
@@ -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,
};
}
+17
View File
@@ -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"]));
+51 -4
View File
@@ -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;
}