Files
hyperframes/packages/engine/src/services/audioVolumeEnvelope.ts
T
Vance IngallsandClaude Opus 5 7393095be8 fix(studio,core,engine): close the defects a max-effort review found in the fixes
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>
2026-08-20 16:39:39 -07:00

190 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}