mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
A review of the five fix commits found eleven real defects, including a regression one of them introduced. Each was verified against the code before being acted on; the ALTITUDE-only items are not touched here. REGRESSION, from "group rows survive a collapse". Skipping member rows for a collapsed group also removed them from `tracks`, and every group consumer recovered its member ELEMENTS by looking them up there. Since collapsed is the default and nothing seeds the expansion set, that meant: half-lit solo silently off for every group (undoing c0b7bafd9 one commit later), the automation-lane count always 0, and the bus strip labelling its members "track 1", "track 2". Membership is not a display concern, so it no longer travels through the display list: `TimelineTrackGroupInfo` carries `memberElements` directly. Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the booked automation — an AudioParam value write inside a live curve throws, and this runs inside `schedulePlayback`, whose catch turns a throw into `return null`: the MEMBER would have silently dropped out of the pass. Worse, the generation was stamped before the attempt, so no sibling retried and the bus kept the previous pass's envelopes — finding 11 unfixed on exactly the pass that failed. Now: clear first, stamp only on success, and isolate the call. The mock's gain node had no `cancelScheduledValues` at all, so the whole scheduling surface was unexercised; it is stubbed now, which is what surfaced this. `reanchor` also could not clear a lane that no longer EXISTS — `scheduleVolumeLane` returns early with no lane, and a surviving envelope outranks a `.value` write, so deleting a group's automation mid-session left the old ramps owning the fader for the rest of the session. The preview fader applied `data-volume` unclamped while the render clamps to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at unity, `-1` previewed with inverted polarity and rendered silent. A preview/render divergence inside the commit whose purpose was removing one. Pitch shift. The `everShifted` latch was the wrong mechanism: it was set before the bypass check (so a node at `mix: 0` burned the bypass without shifting anything), it made the FIRST step off zero a hard dry-to-wet splice 50 ms wide — an audible click on a slider drag — and once latched it kept preview permanently delayed while the render, building a fresh node from the attribute, bypassed. Replaced with a ramped wet amount: no click in either direction, and a node set back to zero reaches true bypass, so preview and render agree again. Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers` was caught one frame up and not rethrown, so the carve's auto-group still saw success and persisted `sources: [groupId]` for a group that was never written — the exact failure the throw was added to prevent. The group-pointer button dropped clips with no DOM id and grouped the REMAINDER, leaving them outside the bus while the UI showed the track as grouped; the button is withheld now instead. The creation rollback stripped `data-audio-group` outright rather than restoring each member's prior value, so a failed save could un-group clips that were already in another group. `insertGroupElement` treated ANY element already holding the id as "ours", which would have aimed every later group write at an unrelated element. `setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`. Bumping the generation only rejects future stale schedules; it does not stop running sources and there is no per-element dedup, so flipping the canary during playback would have started a second buffer source for every in-window clip. `invalidateGroupInfoCache` was missed by the DOM-edit path: the rack reaches `<hf-audio-group>` through the DOM editor, not through the timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one chokepoint every attribute write passes — so this does not stay a per-caller obligation. Both defects in the ffmpeg-header test are mine: it early-returned instead of skipping when ffmpeg is absent (reporting green having asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout as a requirement, which would fail on a legal canonical header the parser also handles. Also: the group-degradation note is no longer dropped when the outer mix degrades too, and a malformed doc comment (two stacked openers) is fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
/**
|
||
* Sample-accurate volume automation.
|
||
*
|
||
* The audio mixer's primary path for time-varying volume bakes the envelope
|
||
* directly into the prepared PCM rather than encoding it as an FFmpeg `volume`
|
||
* expression. The expression approach nests one `if(lt(t,...))` per keyframe and
|
||
* overflows FFmpeg's expression evaluator past ~95 levels (a dense GSAP fade
|
||
* emits hundreds of keyframes), which fails the whole mix and drops the audio
|
||
* track. Multiplying the samples in-house has no such ceiling, is exact at every
|
||
* sample, and keeps the downstream ffmpeg `amix`/AAC encode untouched — so the
|
||
* output (and the golden baselines) only change where a fade is actually applied.
|
||
*
|
||
* The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
|
||
* `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
|
||
* the caller can fall back to the expression path rather than corrupting audio.
|
||
*/
|
||
|
||
import { readFileSync, renameSync, writeFileSync } from "fs";
|
||
import { randomBytes } from "crypto";
|
||
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
|
||
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
||
import { riffChunks } from "./wavChunks.js";
|
||
|
||
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
|
||
const FLOAT_FORMAT = 3; // WAVE_FORMAT_IEEE_FLOAT
|
||
|
||
interface WavLayout {
|
||
numChannels: number;
|
||
sampleRate: number;
|
||
dataOffset: 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;
|
||
}
|
||
|
||
/**
|
||
* Locate the `fmt ` and `data` chunks and validate the format we know how to edit.
|
||
*
|
||
* Scans every chunk rather than assuming an ordering: the loop always advances
|
||
* past a chunk's body (using its declared size), so `data` may precede `fmt `
|
||
* and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
|
||
* anything unexpected so the caller falls back to the expression path.
|
||
*/
|
||
interface WavFmt {
|
||
numChannels: number;
|
||
sampleRate: number;
|
||
/** 16-bit integer PCM, or 32-bit IEEE float. Anything else is unreadable. */
|
||
float: boolean;
|
||
}
|
||
|
||
/** 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;
|
||
|
||
for (const { id, body, size } of riffChunks(buffer)) {
|
||
if (id === "fmt " && body + 16 <= buffer.length) {
|
||
fmt = readFmtChunk(buffer, body);
|
||
} else if (id === "data") {
|
||
data = { offset: body, size: Math.min(size, buffer.length - body) };
|
||
}
|
||
}
|
||
|
||
if (!fmt || !data) return null;
|
||
return { ...fmt, dataOffset: data.offset, dataSize: data.size };
|
||
}
|
||
|
||
/**
|
||
* A gain lookup that walks forward through the envelope with a segment cursor,
|
||
* so a whole track costs O(N+M) rather than O(N×M). `interpolateVolumeGain`
|
||
* restarts from segment 0 on every call — fine for the preview path (once per
|
||
* RAF tick), not for a per-sample walk over 48k×duration frames.
|
||
*
|
||
* The cursor only ever advances, so callers must pass non-decreasing times.
|
||
* Returns null when the keyframes normalise to nothing, which the callers read
|
||
* as "no automation here".
|
||
*/
|
||
export function createEnvelopeWalker(
|
||
keyframes: AudioVolumeKeyframe[],
|
||
trackStart: number,
|
||
baseVolume: number,
|
||
): ((time: number) => number) | null {
|
||
const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume);
|
||
const first = envelope[0];
|
||
if (!first) return null;
|
||
|
||
let segment = 0;
|
||
return (time: number): number => {
|
||
for (;;) {
|
||
const next = envelope[segment + 1];
|
||
if (segment >= envelope.length - 2 || !next || time < next.time) break;
|
||
segment += 1;
|
||
}
|
||
const a = envelope[segment] ?? first;
|
||
const b = envelope[segment + 1] ?? a;
|
||
const span = b.time - a.time;
|
||
const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span));
|
||
return a.volume + (b.volume - a.volume) * progress;
|
||
};
|
||
}
|
||
|
||
/** 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.
|
||
*
|
||
* @returns `true` if the envelope was applied; `false` if the file is neither
|
||
* 16-bit PCM nor 32-bit float (caller should fall back to the expression path).
|
||
*/
|
||
export function applyVolumeEnvelopeToWav(
|
||
wavPath: string,
|
||
keyframes: AudioVolumeKeyframe[],
|
||
trackStart: number,
|
||
baseVolume: number,
|
||
): boolean {
|
||
const gainAt = createEnvelopeWalker(keyframes, trackStart, baseVolume);
|
||
if (!gainAt) return false;
|
||
|
||
try {
|
||
const buffer = readFileSync(wavPath);
|
||
const layout = parseWavLayout(buffer);
|
||
if (!layout) return false;
|
||
|
||
scaleSamples(buffer, layout, gainAt);
|
||
|
||
// Write to a uniquely-named sibling then atomically rename over the
|
||
// original. The random name avoids following a pre-planted symlink at a
|
||
// predictable path, and the rename means a crash mid-write can't leave a
|
||
// truncated WAV for the downstream mix.
|
||
const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`;
|
||
writeFileSync(tempPath, buffer);
|
||
renameSync(tempPath, wavPath);
|
||
return true;
|
||
} catch {
|
||
// Any read/parse/write failure → leave the file untouched and let the
|
||
// caller fall back to the ffmpeg expression path rather than losing audio.
|
||
return false;
|
||
}
|
||
}
|