fix(engine): keep a group's headroom through its FX chain, not just up to it

Residual of finding 8, found by following the float sub-mix downstream
instead of stopping at the file it writes.

The float intermediate fixed the clip for a group with no FX chain. A
group WITH one runs that sum through applyAudioFxChain, whose writeWav
clamps to ±1 and emits 16-bit — so the headroom was handed straight back
one step later, still upstream of the fader. Same bug, same shape, one
node further along: measured 1.6 dB hot on two 0.7-peak tones summing to
1.4 under a transparent 0 dB chain and a 0.5 group fader.

writeWav now takes a `float` flag and readWav reports the format it read,
so the FX pass writes back whatever it was handed. Only the group
sub-mix is float; every element track is still 16-bit, which is what the
envelope baker wanted when that was made 16-bit in the first place. Safe
only because the baker now reads float too — before that commit it was
not, and this would have silently degraded automation to the expression
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:38 -07:00
co-authored by Claude Opus 5
parent 29ed258c27
commit 07203f59d1
2 changed files with 84 additions and 15 deletions
+38 -15
View File
@@ -35,6 +35,9 @@ interface WavData {
samples: Float32Array;
sampleRate: number;
channels: number;
/** True when the source was 32-bit float rather than 16-bit PCM. Carried so
* the output can be written back in the same format — see `writeWav`. */
float: boolean;
}
/**
@@ -77,7 +80,12 @@ export function readWav(path: string): WavData {
}
const { format, channels, sampleRate, bits, data } = readWavChunks(buf);
if (!data) throw new AudioFxRenderError(`WAV has no data chunk: ${path}`);
return { samples: decodeSamples(data, format, bits, path), sampleRate, channels };
return {
samples: decodeSamples(data, format, bits, path),
sampleRate,
channels,
float: format === 3 && bits === 32,
};
}
/** Interleaved samples as floats, for the two formats the mixer emits upstream. */
@@ -102,38 +110,51 @@ function decodeSamples(data: Buffer, format: number, bits: number, path: string)
}
/**
* Write 16-bit PCM, interleaved, preserving the channel count.
* Write interleaved samples, preserving the channel count.
*
* 16-bit rather than the float32 this used to emit: the very next step in the
* mixer bakes the volume envelope into the samples, and that baker accepts only
* 16-bit PCM. Emitting float meant enabling any effect silently downgraded a
* track's volume automation to the ffmpeg expression path, which is capped at 32
* straight segments — so a curved envelope was quantised and a dense one could
* fall back to rendering at base volume.
* 16-bit by default rather than the float32 this used to emit: the very next
* step in the mixer bakes the volume envelope into the samples, and that baker
* accepted only 16-bit PCM. Emitting float meant enabling any effect silently
* downgraded a track's volume automation to the ffmpeg expression path, which is
* capped at 32 straight segments — so a curved envelope was quantised and a
* dense one could fall back to rendering at base volume.
*
* `float` opts back out, for the ONE input that needs it: a group sub-mix. Its
* members sum at unity, so the sum can legitimately exceed full scale, and the
* group's fader is applied downstream — clamping here handed that headroom back
* one step before the thing that was going to reduce it, which is the same bug
* the float intermediate exists to avoid. Safe now only because the envelope
* baker reads float too; before that it was not.
*/
export function writeWav(
path: string,
samples: Float32Array,
sampleRate: number,
channels = 1,
float = false,
): void {
const n = samples.length;
const bytes = n * 2;
const bytesPerSample = float ? 4 : 2;
const bytes = n * bytesPerSample;
const buf = Buffer.alloc(44 + bytes);
buf.write("RIFF", 0, "ascii");
buf.writeUInt32LE(36 + bytes, 4);
buf.write("WAVE", 8, "ascii");
buf.write("fmt ", 12, "ascii");
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20); // WAVE_FORMAT_PCM
buf.writeUInt16LE(float ? 3 : 1, 20); // IEEE_FLOAT / PCM
buf.writeUInt16LE(channels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(sampleRate * channels * 2, 28);
buf.writeUInt16LE(channels * 2, 32);
buf.writeUInt16LE(16, 34);
buf.writeUInt32LE(sampleRate * channels * bytesPerSample, 28);
buf.writeUInt16LE(channels * bytesPerSample, 32);
buf.writeUInt16LE(float ? 32 : 16, 34);
buf.write("data", 36, "ascii");
buf.writeUInt32LE(bytes, 40);
for (let i = 0; i < n; i++) {
if (float) {
buf.writeFloatLE(samples[i] ?? 0, 44 + i * 4);
continue;
}
// Clamp before scaling: a limiter set to 0 dB or a resonant filter can push
// past full scale, and wrapping would turn that into a click.
const v = Math.max(-1, Math.min(1, samples[i] ?? 0));
@@ -267,7 +288,7 @@ export async function applyAudioFxChain(
throw new AudioFxRenderError(`Audio FX input is missing: ${inputWav}`);
}
const { samples, sampleRate, channels } = readWav(inputWav);
const { samples, sampleRate, channels, float } = readWav(inputWav);
const planes = deinterleave(samples, channels);
// An empty track has nothing to process — and an OfflineAudioContext of zero
// length throws, which is fatal for the WHOLE render rather than this track:
@@ -414,7 +435,9 @@ export async function applyAudioFxChain(
)
: null;
if (gainAt) applyEnvelopeToPlanes(outPlanes, sampleRate, gainAt);
writeWav(outputWav, interleave(outPlanes), sampleRate, outPlanes.length);
// Same format in as out: a float input is a group sub-mix whose headroom
// must survive to its fader (see writeWav).
writeWav(outputWav, interleave(outPlanes), sampleRate, outPlanes.length, float);
return { path: outputWav, envelopeBaked: gainAt !== null };
} finally {
await page.close().catch(() => undefined);
@@ -347,4 +347,50 @@ describe.skipIf(!HAS_FFMPEG)("mix level arithmetic", () => {
// reads well over a dB hot even after the fader halves it.
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
});
// The same property, for a group that carries an FX CHAIN. That path runs the
// sum through applyAudioFxChain, whose writeWav clamps to ±1 and emits 16-bit
// — so the headroom the float sub-mix preserved was handed back before the
// fader, one step later than the original bug but with the same result.
// A transparent chain isolates the clamp from anything the effects do.
it("does not clip an over-unity sum before the fader when the group has FX", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-work-"));
tempDirs.push(projectDir, workDir);
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
// 0 dB gain: in the chain, doing nothing to the level.
const transparentChain = JSON.stringify({
version: 1,
nodes: [{ type: "gain", id: "g", params: { gain: 0 } }],
});
const groupedOut = join(projectDir, `clipfx-grouped-${MIXED_AUDIO_FILENAME}`);
const refOut = join(projectDir, `clipfx-ref-${MIXED_AUDIO_FILENAME}`);
const grouped = await processCompositionAudio(
[
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
],
projectDir,
workDir,
groupedOut,
2,
);
const reference = await processCompositionAudio(
[track("ref", 2)],
projectDir,
workDir,
refOut,
2,
);
expect(grouped.success).toBe(true);
expect(reference.success).toBe(true);
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
});
});