mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
co-authored by
Claude Opus 5
parent
58cea07547
commit
ae9e680542
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user