mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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;
|
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 raw = el?.getAttribute("data-volume");
|
||||||
const parsed = raw ? parseFloat(raw) : 1;
|
const parsed = raw ? parseFloat(raw) : 1;
|
||||||
return Number.isFinite(parsed) ? parsed : 1;
|
return Number.isFinite(parsed) ? parsed : 1;
|
||||||
@@ -50,7 +56,7 @@ function buildGroup(id: string, memberIds: string[], el: Element | undefined): H
|
|||||||
memberIds,
|
memberIds,
|
||||||
...(fxChain ? { fxChain } : {}),
|
...(fxChain ? { fxChain } : {}),
|
||||||
...(automation ? { automation } : {}),
|
...(automation ? { automation } : {}),
|
||||||
volume: parseGroupVolume(el),
|
volume: readAudioGroupVolume(el),
|
||||||
hidden: el?.hasAttribute("data-hidden") ?? false,
|
hidden: el?.hasAttribute("data-hidden") ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ export function readElementAutomation(el: {
|
|||||||
*/
|
*/
|
||||||
export interface ElementFxHandle {
|
export interface ElementFxHandle {
|
||||||
dispose(): void;
|
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.
|
* Re-aim every booked envelope at a new playback rate.
|
||||||
*
|
*
|
||||||
@@ -277,6 +287,13 @@ export function attachElementFxChain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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) => {
|
setRate: (rate: number) => {
|
||||||
const at = timingNow();
|
const at = timingNow();
|
||||||
if (disposed || !at || !Number.isFinite(rate) || rate <= 0 || rate === at.rate) return;
|
if (disposed || !at || !Number.isFinite(rate) || rate <= 0 || rate === at.rate) return;
|
||||||
|
|||||||
@@ -735,16 +735,17 @@ describe("WebAudioTransport", () => {
|
|||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
|
||||||
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
|
// 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.
|
// muteGain(4), fader(5) — the group bus is built lazily inside a's
|
||||||
// Then b: b-gain(5), b-solo(6).
|
// schedule call. Then b: b-gain(6), b-solo(7).
|
||||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7);
|
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8);
|
||||||
const aGain = mock.gainNodes[0]!;
|
const aGain = mock.gainNodes[0]!;
|
||||||
const aSolo = mock.gainNodes[1]!;
|
const aSolo = mock.gainNodes[1]!;
|
||||||
const groupInput = firstGroupInput(mock);
|
const groupInput = firstGroupInput(mock);
|
||||||
const groupOutput = mock.gainNodes[3]!;
|
const groupOutput = mock.gainNodes[3]!;
|
||||||
const muteGain = mock.gainNodes[4]!;
|
const muteGain = mock.gainNodes[4]!;
|
||||||
const bGain = mock.gainNodes[5]!;
|
const fader = mock.gainNodes[5]!;
|
||||||
const bSolo = mock.gainNodes[6]!;
|
const bGain = mock.gainNodes[6]!;
|
||||||
|
const bSolo = mock.gainNodes[7]!;
|
||||||
|
|
||||||
// Each member feeds its own solo gain, and both solo gains feed the
|
// Each member feeds its own solo gain, and both solo gains feed the
|
||||||
// shared bus — neither connects straight to master.
|
// shared bus — neither connects straight to master.
|
||||||
@@ -755,11 +756,14 @@ describe("WebAudioTransport", () => {
|
|||||||
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
expect(bSolo.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
|
// The bus's input never reaches master directly. It runs through the
|
||||||
// gain (B5) first (the dry passthrough, since neither member's group has
|
// chain (dry here — neither member's group has a chain-bearing
|
||||||
// a chain-bearing `<hf-audio-group>`), then the output gain, then master.
|
// `<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).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(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||||
});
|
});
|
||||||
@@ -782,18 +786,36 @@ describe("WebAudioTransport", () => {
|
|||||||
|
|
||||||
const muteGain = mock.gainNodes[4]!;
|
const muteGain = mock.gainNodes[4]!;
|
||||||
const groupOutput = mock.gainNodes[3]!;
|
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(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
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>`;
|
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
|
// No throw wiring the group's automation reader against a real
|
||||||
// <hf-audio-group> element that carries no fx/automation attrs.
|
// <hf-audio-group> element that carries no fx/automation attrs.
|
||||||
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull();
|
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 () => {
|
it("destroy() disposes every group bus", async () => {
|
||||||
@@ -820,13 +842,37 @@ describe("WebAudioTransport", () => {
|
|||||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
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)', () => {
|
describe('solo — "Hear only this" (B5)', () => {
|
||||||
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
|
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
|
||||||
const { transport, mock, gen } = setupGroupTransport();
|
const { transport, mock, gen } = setupGroupTransport();
|
||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
const aSolo = mock.gainNodes[1]!;
|
const aSolo = mock.gainNodes[1]!;
|
||||||
const bSolo = mock.gainNodes[6]!;
|
const bSolo = mock.gainNodes[7]!;
|
||||||
const groupInput = firstGroupInput(mock);
|
const groupInput = firstGroupInput(mock);
|
||||||
|
|
||||||
transport.setSolo(new Set(["other-clip"]));
|
transport.setSolo(new Set(["other-clip"]));
|
||||||
@@ -843,7 +889,7 @@ describe("WebAudioTransport", () => {
|
|||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
const aSolo = mock.gainNodes[1]!;
|
const aSolo = mock.gainNodes[1]!;
|
||||||
const bSolo = mock.gainNodes[6]!;
|
const bSolo = mock.gainNodes[7]!;
|
||||||
const groupInput = firstGroupInput(mock);
|
const groupInput = firstGroupInput(mock);
|
||||||
|
|
||||||
transport.setSolo(new Set(["a"]));
|
transport.setSolo(new Set(["a"]));
|
||||||
@@ -858,7 +904,7 @@ describe("WebAudioTransport", () => {
|
|||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
const aSolo = mock.gainNodes[1]!;
|
const aSolo = mock.gainNodes[1]!;
|
||||||
const bSolo = mock.gainNodes[6]!;
|
const bSolo = mock.gainNodes[7]!;
|
||||||
|
|
||||||
transport.setSolo(new Set(["vo"]));
|
transport.setSolo(new Set(["vo"]));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type AutomationTiming,
|
type AutomationTiming,
|
||||||
} from "../audio/audioFxAutomation.js";
|
} from "../audio/audioFxAutomation.js";
|
||||||
import { VOLUME_RANGE } from "../audioAutomation.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 { swallow } from "./diagnostics";
|
||||||
import { clampAudioGain } from "../audioGain.js";
|
import { clampAudioGain } from "../audioGain.js";
|
||||||
import { getDebugSurface } from "./globals.js";
|
import { getDebugSurface } from "./globals.js";
|
||||||
@@ -136,9 +136,17 @@ export class WebAudioTransport {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
input: GainNode;
|
input: GainNode;
|
||||||
|
/** Post-FX fader: `data-volume` plus the volume lane. */
|
||||||
|
fader: GainNode;
|
||||||
muteGain: GainNode;
|
muteGain: GainNode;
|
||||||
analyser: AnalyserNode;
|
analyser: AnalyserNode;
|
||||||
levelBuf: Float32Array;
|
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;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -308,7 +316,18 @@ export class WebAudioTransport {
|
|||||||
*/
|
*/
|
||||||
private groupInput(groupId: string, doc: Document, timing: AutomationTiming): GainNode | null {
|
private groupInput(groupId: string, doc: Document, timing: AutomationTiming): GainNode | null {
|
||||||
const existing = this._groups.get(groupId);
|
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;
|
if (!this._ctx || !this._masterGain) return null;
|
||||||
|
|
||||||
const input = this._ctx.createGain();
|
const input = this._ctx.createGain();
|
||||||
@@ -329,24 +348,42 @@ export class WebAudioTransport {
|
|||||||
const muteGain = this._ctx.createGain();
|
const muteGain = this._ctx.createGain();
|
||||||
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
|
||||||
muteGain.connect(output);
|
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(
|
const fx = attachElementFxChain(
|
||||||
this._ctx,
|
this._ctx,
|
||||||
groupEl ?? { getAttribute: () => null },
|
groupEl ?? { getAttribute: () => null },
|
||||||
input,
|
input,
|
||||||
muteGain,
|
fader,
|
||||||
timing,
|
timing,
|
||||||
);
|
);
|
||||||
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
if (groupEl) scheduleVolumeLane(groupEl, fader, timing);
|
||||||
|
|
||||||
this._groups.set(groupId, {
|
this._groups.set(groupId, {
|
||||||
input,
|
input,
|
||||||
|
fader,
|
||||||
muteGain,
|
muteGain,
|
||||||
analyser,
|
analyser,
|
||||||
levelBuf: new Float32Array(analyser.fftSize),
|
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: () => {
|
dispose: () => {
|
||||||
try {
|
try {
|
||||||
fx?.dispose();
|
fx?.dispose();
|
||||||
input.disconnect();
|
input.disconnect();
|
||||||
|
fader.disconnect();
|
||||||
muteGain.disconnect();
|
muteGain.disconnect();
|
||||||
output.disconnect();
|
output.disconnect();
|
||||||
analyser.disconnect();
|
analyser.disconnect();
|
||||||
@@ -588,6 +625,16 @@ export class WebAudioTransport {
|
|||||||
swallow("webAudioTransport.setRate", err);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ function writeTone(path: string, freq: number, seconds: number, gain: number): v
|
|||||||
if (result.status !== 0) throw new Error(`Could not write tone: ${result.stderr}`);
|
if (result.status !== 0) throw new Error(`Could not write tone: ${result.stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sine of an EXACT peak amplitude.
|
||||||
|
*
|
||||||
|
* `writeTone` builds on ffmpeg's `sine` source, whose output is ~0.125 full
|
||||||
|
* scale, so its `gain` argument is a relative knob rather than a level: a tone
|
||||||
|
* asked for at 0.7 lands at about -21 dBFS. Fine for the relative comparisons
|
||||||
|
* above, useless for anything about headroom — `aevalsrc` states the amplitude
|
||||||
|
* outright.
|
||||||
|
*/
|
||||||
|
function writePeakTone(path: string, freq: number, seconds: number, peak: number): void {
|
||||||
|
const result = spawnSync(
|
||||||
|
getFfmpegBinary(),
|
||||||
|
[
|
||||||
|
"-nostdin",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
`aevalsrc=${peak}*sin(2*PI*${freq}*t):d=${seconds}:s=48000`,
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
if (result.status !== 0) throw new Error(`Could not write tone: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
const track = (id: string, end: number, volume = 1) => ({
|
const track = (id: string, end: number, volume = 1) => ({
|
||||||
id,
|
id,
|
||||||
src: `${id}.wav`,
|
src: `${id}.wav`,
|
||||||
@@ -268,4 +297,54 @@ describe.skipIf(!HAS_FFMPEG)("mix level arithmetic", () => {
|
|||||||
const flatTail = meanVolumeDb(flatOut, 3, 4);
|
const flatTail = meanVolumeDb(flatOut, 3, 4);
|
||||||
expect(Math.abs(groupedTail - flatTail)).toBeLessThan(0.5);
|
expect(Math.abs(groupedTail - flatTail)).toBeLessThan(0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Members sum at unity (normalize=0), so an over-unity sum used to hard-clip
|
||||||
|
// at ±1 in the 16-bit intermediate BEFORE the group's fader and FX chain ran
|
||||||
|
// — pulling the group down then operated on distortion. Every existing probe
|
||||||
|
// here sums to ≤ 0.8, which is exactly why nothing caught it; preview cannot
|
||||||
|
// reproduce it either, because its bus is float.
|
||||||
|
it("does not clip an over-unity member sum before the group fader", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
// Two coherent copies of the same tone: 0.7 + 0.7 = 1.4, comfortably over.
|
||||||
|
// `writePeakTone`, not `writeTone` — ffmpeg's `sine` source is nowhere near
|
||||||
|
// full scale (its output peaks at ~0.125), so every tone in this file sits
|
||||||
|
// around -21 dBFS and NOTHING here can reach a clip no matter what gain is
|
||||||
|
// asked for. That is a large part of why this class of bug survived.
|
||||||
|
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
|
||||||
|
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
|
||||||
|
// The reference: the level that sum SHOULD reach once the group's 0.5
|
||||||
|
// fader has been applied — 1.4 × 0.5 = 0.7, one tone's worth.
|
||||||
|
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
|
||||||
|
|
||||||
|
const groupedOut = join(projectDir, `clip-grouped-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const refOut = join(projectDir, `clip-ref-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const grouped = await processCompositionAudio(
|
||||||
|
[
|
||||||
|
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5 },
|
||||||
|
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5 },
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Both are ONE track into the outer mix, so the outer graph is identical
|
||||||
|
// and the levels are directly comparable. Clipped, the flat-topped sum
|
||||||
|
// reads well over a dB hot even after the fader halves it.
|
||||||
|
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -885,20 +885,25 @@ async function mixGroupMembers(
|
|||||||
totalDuration: number,
|
totalDuration: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; degradedAutomation?: boolean }> {
|
||||||
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
||||||
const outputDir = dirname(outputPath);
|
const outputDir = dirname(outputPath);
|
||||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
const inputFilters = memberTracks.map((track, i) => {
|
const buildInputFilters = (ignoreKeyframes: boolean) =>
|
||||||
|
memberTracks.map((track, i) => {
|
||||||
const delayMs = Math.round(track.start * 1000);
|
const delayMs = Math.round(track.start * 1000);
|
||||||
const trimDuration = track.end - track.start + (track.tailSeconds ?? 0);
|
const trimDuration = track.end - track.start + (track.tailSeconds ?? 0);
|
||||||
const volumeFilter = buildVolumeExpression(track);
|
const volumeFilter = buildVolumeExpression(track, ignoreKeyframes);
|
||||||
return `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`;
|
return `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`;
|
||||||
});
|
});
|
||||||
const mixInputs = memberTracks.map((_, i) => `[a${i}]`).join("");
|
const mixInputs = memberTracks.map((_, i) => `[a${i}]`).join("");
|
||||||
|
|
||||||
const runOnce = async (useNormalize: boolean): Promise<RunFfmpegResult> => {
|
const runOnce = async (
|
||||||
|
useNormalize: boolean,
|
||||||
|
ignoreKeyframes = false,
|
||||||
|
): Promise<RunFfmpegResult> => {
|
||||||
|
const inputFilters = buildInputFilters(ignoreKeyframes);
|
||||||
const mixFilter = useNormalize
|
const mixFilter = useNormalize
|
||||||
? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0[out]`
|
? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0[out]`
|
||||||
: // amix's default normalize divides by input count; compensate by THIS
|
: // amix's default normalize divides by input count; compensate by THIS
|
||||||
@@ -922,8 +927,15 @@ async function mixGroupMembers(
|
|||||||
scriptPath,
|
scriptPath,
|
||||||
"-map",
|
"-map",
|
||||||
"[out]",
|
"[out]",
|
||||||
|
// Float, not pcm_s16le: `normalize=0` sums the members at unity, so any
|
||||||
|
// over-unity sum hard-clipped at ±1 in the intermediate — BEFORE the
|
||||||
|
// group's FX chain and its fader ran. Pulling the group down, or the
|
||||||
|
// Giant preset's compressor, then operated on distortion. Preview
|
||||||
|
// cannot reproduce it (its bus is float), and it only shows up in the
|
||||||
|
// export. Both readers downstream take float: `readWav` (format 3) and
|
||||||
|
// `applyVolumeEnvelopeToWav`.
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"pcm_s16le",
|
"pcm_f32le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-t",
|
"-t",
|
||||||
@@ -943,14 +955,32 @@ async function mixGroupMembers(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = await runOnce(true);
|
let useNormalize = true;
|
||||||
|
let result = await runOnce(useNormalize);
|
||||||
if (!result.success && groupNormalizeOptionUnsupported(result.stderr)) {
|
if (!result.success && groupNormalizeOptionUnsupported(result.stderr)) {
|
||||||
result = await runOnce(false);
|
useNormalize = false;
|
||||||
|
result = await runOnce(useNormalize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same defence `mixAudioTracks` has, which this forked without: a
|
||||||
|
// member's volume automation becomes an ffmpeg `volume` expression whose
|
||||||
|
// evaluator limits are build-dependent, so a dense envelope can fail the
|
||||||
|
// whole run. Ungrouped, that track degrades to base volume with a warning;
|
||||||
|
// grouped, it took the entire composition's audio down with it.
|
||||||
|
let degradedAutomation = false;
|
||||||
|
const hasAutomation = memberTracks.some((track) => (track.volumeKeyframes?.length ?? 0) > 0);
|
||||||
|
if (!result.success && !signal?.aborted && hasAutomation) {
|
||||||
|
const retry = await runOnce(useNormalize, true);
|
||||||
|
if (retry.success) {
|
||||||
|
result = retry;
|
||||||
|
degradedAutomation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (signal?.aborted) return { success: false, error: "Group sub-mix cancelled" };
|
if (signal?.aborted) return { success: false, error: "Group sub-mix cancelled" };
|
||||||
if (!result.success)
|
if (!result.success)
|
||||||
return { success: false, error: formatFfmpegError(result.exitCode, result.stderr) };
|
return { success: false, error: formatFfmpegError(result.exitCode, result.stderr) };
|
||||||
return { success: true };
|
return { success: true, degradedAutomation };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processCompositionAudio(
|
export async function processCompositionAudio(
|
||||||
@@ -1305,6 +1335,7 @@ export async function processCompositionAudio(
|
|||||||
// clock is composition time (offset 0): a group has no `data-start` of its
|
// clock is composition time (offset 0): a group has no `data-start` of its
|
||||||
// own, and members are already delayed to their composition positions
|
// own, and members are already delayed to their composition positions
|
||||||
// inside the sub-mix, so the group WAV's t=0 IS composition time.
|
// inside the sub-mix, so the group WAV's t=0 IS composition time.
|
||||||
|
const groupsDegradedAutomation: string[] = [];
|
||||||
for (const [groupId, memberTracks] of groupTracks) {
|
for (const [groupId, memberTracks] of groupTracks) {
|
||||||
const meta = groupMeta.get(groupId);
|
const meta = groupMeta.get(groupId);
|
||||||
if (!meta) continue;
|
if (!meta) continue;
|
||||||
@@ -1329,6 +1360,7 @@ export async function processCompositionAudio(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (subMix.degradedAutomation) groupsDegradedAutomation.push(groupId);
|
||||||
|
|
||||||
// Composition-time automation (offset 0, duration totalDuration) — same
|
// Composition-time automation (offset 0, duration totalDuration) — same
|
||||||
// resolve/lane/bake path a member uses, just anchored at the group clock
|
// resolve/lane/bake path a member uses, just anchored at the group clock
|
||||||
@@ -1385,6 +1417,10 @@ export async function processCompositionAudio(
|
|||||||
mediaStart: 0,
|
mediaStart: 0,
|
||||||
duration: totalDuration,
|
duration: totalDuration,
|
||||||
volume: bakedEnvelope ? 1.0 : meta.volume,
|
volume: bakedEnvelope ? 1.0 : meta.volume,
|
||||||
|
// Same fallback an ungrouped track gets: when the envelope could not be
|
||||||
|
// baked into the samples, hand the keyframes to the outer mix's volume
|
||||||
|
// expression instead of dropping the group's automation on the floor.
|
||||||
|
...(bakedEnvelope || !laneKeyframes?.length ? {} : { volumeKeyframes: laneKeyframes }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (failures.length > 0) return bail();
|
if (failures.length > 0) return bail();
|
||||||
@@ -1397,9 +1433,18 @@ export async function processCompositionAudio(
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A group whose sub-mix had to drop member automation reports it the same
|
||||||
|
// way mixAudioTracks reports its own degradation: on a SUCCESSFUL result, so
|
||||||
|
// the render ships and the caller can still say what was lost.
|
||||||
|
const degradedGroups = [...groupsDegradedAutomation];
|
||||||
|
const degradedNote =
|
||||||
|
degradedGroups.length > 0
|
||||||
|
? `Volume automation exceeded this ffmpeg build's expression limits in group(s) ${degradedGroups.join(", ")}; rendered at base volume`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mixResult,
|
...mixResult,
|
||||||
durationMs: Date.now() - startMs,
|
durationMs: Date.now() - startMs,
|
||||||
error: mixResult.error,
|
error: mixResult.error ?? degradedNote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,4 +173,76 @@ describe("applyVolumeEnvelopeToWav", () => {
|
|||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The group sub-mix writes float precisely so an over-unity member sum keeps
|
||||||
|
// its headroom until the group's own fader and FX act on it. If the baker
|
||||||
|
// could not read that file it returned false, and the group's automation was
|
||||||
|
// dropped on the floor by the caller.
|
||||||
|
describe("32-bit float input", () => {
|
||||||
|
/** Stereo float32 WAV, every sample `value` — over 1.0 on purpose. */
|
||||||
|
function writeConstantFloatWav(path: string, frames: number, value: number): void {
|
||||||
|
const dataSize = frames * CHANNELS * 4;
|
||||||
|
const buffer = Buffer.alloc(44 + dataSize);
|
||||||
|
buffer.write("RIFF", 0, "ascii");
|
||||||
|
buffer.writeUInt32LE(36 + dataSize, 4);
|
||||||
|
buffer.write("WAVE", 8, "ascii");
|
||||||
|
buffer.write("fmt ", 12, "ascii");
|
||||||
|
buffer.writeUInt32LE(16, 16);
|
||||||
|
buffer.writeUInt16LE(3, 20); // WAVE_FORMAT_IEEE_FLOAT
|
||||||
|
buffer.writeUInt16LE(CHANNELS, 22);
|
||||||
|
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
||||||
|
buffer.writeUInt32LE(SAMPLE_RATE * CHANNELS * 4, 28);
|
||||||
|
buffer.writeUInt16LE(CHANNELS * 4, 32);
|
||||||
|
buffer.writeUInt16LE(32, 34);
|
||||||
|
buffer.write("data", 36, "ascii");
|
||||||
|
buffer.writeUInt32LE(dataSize, 40);
|
||||||
|
for (let i = 0; i < frames * CHANNELS; i += 1) buffer.writeFloatLE(value, 44 + i * 4);
|
||||||
|
writeFileSync(path, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatSampleAt = (path: string, frame: number, channel = 0): number =>
|
||||||
|
readFileSync(path).readFloatLE(44 + (frame * CHANNELS + channel) * 4);
|
||||||
|
|
||||||
|
it("scales float samples and keeps them above 1.0 unclamped", () => {
|
||||||
|
const path = join(tmp(), "float.wav");
|
||||||
|
writeConstantFloatWav(path, SAMPLE_RATE, 1.4);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyVolumeEnvelopeToWav(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{ time: 0, volume: 1 },
|
||||||
|
{ time: 1, volume: 1 },
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Unity envelope: unchanged, and NOT clamped down to 1.0.
|
||||||
|
expect(floatSampleAt(path, 0)).toBeCloseTo(1.4, 5);
|
||||||
|
expect(floatSampleAt(path, SAMPLE_RATE - 1)).toBeCloseTo(1.4, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the envelope across the file", () => {
|
||||||
|
const path = join(tmp(), "float-fade.wav");
|
||||||
|
writeConstantFloatWav(path, SAMPLE_RATE, 1.4);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyVolumeEnvelopeToWav(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{ time: 0, volume: 1 },
|
||||||
|
{ time: 1, volume: 0 },
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(floatSampleAt(path, 0)).toBeCloseTo(1.4, 5);
|
||||||
|
expect(floatSampleAt(path, Math.floor(SAMPLE_RATE / 2))).toBeCloseTo(0.7, 2);
|
||||||
|
expect(floatSampleAt(path, SAMPLE_RATE - 1)).toBeCloseTo(0, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,13 +22,17 @@ import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
|||||||
import { riffChunks } from "./wavChunks.js";
|
import { riffChunks } from "./wavChunks.js";
|
||||||
|
|
||||||
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
|
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
|
||||||
const SUPPORTED_BITS = 16;
|
const FLOAT_FORMAT = 3; // WAVE_FORMAT_IEEE_FLOAT
|
||||||
|
|
||||||
interface WavLayout {
|
interface WavLayout {
|
||||||
numChannels: number;
|
numChannels: number;
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
dataOffset: number;
|
dataOffset: number;
|
||||||
dataSize: number;
|
dataSize: number;
|
||||||
|
/** 16-bit integer, or 32-bit float — the group sub-mix writes float so an
|
||||||
|
* over-unity member sum is not hard-clipped before the group's own FX and
|
||||||
|
* fader get to act on it. */
|
||||||
|
float: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,34 +43,49 @@ interface WavLayout {
|
|||||||
* and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
|
* and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
|
||||||
* anything unexpected so the caller falls back to the expression path.
|
* anything unexpected so the caller falls back to the expression path.
|
||||||
*/
|
*/
|
||||||
function parseWavLayout(buffer: Buffer): WavLayout | null {
|
interface WavFmt {
|
||||||
if (buffer.length < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") return null;
|
numChannels: number;
|
||||||
if (buffer.toString("ascii", 8, 12) !== "WAVE") return null;
|
sampleRate: number;
|
||||||
|
/** 16-bit integer PCM, or 32-bit IEEE float. Anything else is unreadable. */
|
||||||
|
float: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
|
/** The `fmt ` chunk, or null for a format this cannot safely edit in place. */
|
||||||
|
function readFmtChunk(buffer: Buffer, body: number): WavFmt | null {
|
||||||
|
const format = buffer.readUInt16LE(body);
|
||||||
|
const bits = buffer.readUInt16LE(body + 14);
|
||||||
|
const float = format === FLOAT_FORMAT;
|
||||||
|
if (!float && format !== PCM_FORMAT) return null;
|
||||||
|
if (bits !== (float ? 32 : 16)) return null;
|
||||||
|
const numChannels = buffer.readUInt16LE(body + 2);
|
||||||
|
if (numChannels < 1) return null;
|
||||||
|
return { numChannels, sampleRate: buffer.readUInt32LE(body + 4), float };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRiffWave(buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||||
|
buffer.toString("ascii", 8, 12) === "WAVE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWavLayout(buffer: Buffer): WavLayout | null {
|
||||||
|
if (!isRiffWave(buffer)) return null;
|
||||||
|
|
||||||
|
let fmt: WavFmt | null = null;
|
||||||
let data: { offset: number; size: number } | null = null;
|
let data: { offset: number; size: number } | null = null;
|
||||||
|
|
||||||
for (const { id, body, size } of riffChunks(buffer)) {
|
for (const { id, body, size } of riffChunks(buffer)) {
|
||||||
if (id === "fmt " && body + 16 <= buffer.length) {
|
if (id === "fmt " && body + 16 <= buffer.length) {
|
||||||
if (buffer.readUInt16LE(body) !== PCM_FORMAT) return null;
|
fmt = readFmtChunk(buffer, body);
|
||||||
fmt = {
|
|
||||||
numChannels: buffer.readUInt16LE(body + 2),
|
|
||||||
sampleRate: buffer.readUInt32LE(body + 4),
|
|
||||||
bitsPerSample: buffer.readUInt16LE(body + 14),
|
|
||||||
};
|
|
||||||
} else if (id === "data") {
|
} else if (id === "data") {
|
||||||
data = { offset: body, size: Math.min(size, buffer.length - body) };
|
data = { offset: body, size: Math.min(size, buffer.length - body) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fmt || !data) return null;
|
if (!fmt || !data) return null;
|
||||||
if (fmt.bitsPerSample !== SUPPORTED_BITS || fmt.numChannels < 1) return null;
|
return { ...fmt, dataOffset: data.offset, dataSize: data.size };
|
||||||
return {
|
|
||||||
numChannels: fmt.numChannels,
|
|
||||||
sampleRate: fmt.sampleRate,
|
|
||||||
dataOffset: data.offset,
|
|
||||||
dataSize: data.size,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,11 +122,41 @@ export function createEnvelopeWalker(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
/** Every sample scaled by the envelope, in place, in whichever of the two
|
||||||
|
* formats the layout reports. Float is NOT clamped: it is the format the group
|
||||||
|
* sub-mix writes precisely so an over-unity sum keeps its headroom until
|
||||||
|
* something downstream chooses to reduce it. */
|
||||||
|
function scaleSamples(
|
||||||
|
buffer: Buffer,
|
||||||
|
layout: WavLayout,
|
||||||
|
gainAt: (seconds: number) => number,
|
||||||
|
): void {
|
||||||
|
const { numChannels, sampleRate, dataOffset, dataSize, float } = layout;
|
||||||
|
const bytesPerSample = float ? 4 : 2;
|
||||||
|
const frameBytes = numChannels * bytesPerSample;
|
||||||
|
const frameCount = Math.floor(dataSize / frameBytes);
|
||||||
|
const scaleOne = float
|
||||||
|
? (at: number, gain: number) => buffer.writeFloatLE(buffer.readFloatLE(at) * gain, at)
|
||||||
|
: (at: number, gain: number) => {
|
||||||
|
const scaled = Math.round(buffer.readInt16LE(at) * gain);
|
||||||
|
buffer.writeInt16LE(scaled < -32768 ? -32768 : scaled > 32767 ? 32767 : scaled, at);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||||
|
const gain = gainAt(frame / sampleRate);
|
||||||
|
const base = dataOffset + frame * frameBytes;
|
||||||
|
for (let channel = 0; channel < numChannels; channel += 1) {
|
||||||
|
scaleOne(base + channel * bytesPerSample, gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiply a prepared WAV's samples by a time-varying gain envelope in place.
|
* Multiply a prepared WAV's samples by a time-varying gain envelope in place.
|
||||||
*
|
*
|
||||||
* @returns `true` if the envelope was applied; `false` if the file isn't the
|
* @returns `true` if the envelope was applied; `false` if the file is neither
|
||||||
* expected 16-bit PCM (caller should fall back to the expression path).
|
* 16-bit PCM nor 32-bit float (caller should fall back to the expression path).
|
||||||
*/
|
*/
|
||||||
export function applyVolumeEnvelopeToWav(
|
export function applyVolumeEnvelopeToWav(
|
||||||
wavPath: string,
|
wavPath: string,
|
||||||
@@ -123,20 +172,7 @@ export function applyVolumeEnvelopeToWav(
|
|||||||
const layout = parseWavLayout(buffer);
|
const layout = parseWavLayout(buffer);
|
||||||
if (!layout) return false;
|
if (!layout) return false;
|
||||||
|
|
||||||
const { numChannels, sampleRate, dataOffset, dataSize } = layout;
|
scaleSamples(buffer, layout, gainAt);
|
||||||
const bytesPerSample = SUPPORTED_BITS / 8;
|
|
||||||
const frameBytes = numChannels * bytesPerSample;
|
|
||||||
const frameCount = Math.floor(dataSize / frameBytes);
|
|
||||||
|
|
||||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
||||||
const gain = gainAt(frame / sampleRate);
|
|
||||||
const base = dataOffset + frame * frameBytes;
|
|
||||||
for (let channel = 0; channel < numChannels; channel += 1) {
|
|
||||||
const at = base + channel * bytesPerSample;
|
|
||||||
const scaled = Math.round(buffer.readInt16LE(at) * gain);
|
|
||||||
buffer.writeInt16LE(scaled < -32768 ? -32768 : scaled > 32767 ? 32767 : scaled, at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to a uniquely-named sibling then atomically rename over the
|
// Write to a uniquely-named sibling then atomically rename over the
|
||||||
// original. The random name avoids following a pre-planted symlink at a
|
// original. The random name avoids following a pre-planted symlink at a
|
||||||
|
|||||||
Reference in New Issue
Block a user