Skip to content

fix(core): key the preview volume envelope to the clip, not the timeline - #3198

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/preview-volume-envelope
Aug 11, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/preview-volume-envelope

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

A GSAP volume fade on an audio clip that starts after t=0 leaves the preview silent for the clip's entire length, while the encoded render is correct. This puts the preview back on the same envelope the renderer uses.

Why

Reproduced on macOS with one <audio data-start="2" data-duration="3" data-volume="1"> and a 0.05s fromTo volume fade at the clip's start. Probing el.volume through the preview:

timeline t before expected
2.00 0 0
2.05 (mid-fade) 0.667 0.667
2.10 0 1
3.00 0 1
5.50 0 1

The encoded render was already right: full volume reached just after the fade and held to the clip's end. So the same composition sounded silent while authoring and correct once rendered, which is the worst version of this to debug.

mediaVolumeEnvelope exists to keep preview and render on one envelope — its own header comment says so — and its contract is "normalise, then read with track-relative seconds". The preview skipped both halves:

  • probeElementVolumeKeyframes stamps each keyframe with the timeline seek time it sampled at.
  • normaliseEnvelope, which rebases those onto the track, had exactly one caller: the renderer's PCM baker in audioVolumeEnvelope.ts.
  • The preview passed the raw keyframes to interpolateVolumeGain along with relTime.

For a clip at t=2, every preview lookup landed two seconds before the first keyframe and clamped to its volume — 0 for a fade-in. Two controls confirm the mechanism: the same clip with no fade previews at a steady 1, and the same fade at data-start="0" previews correctly, because there timeline time and track time coincide.

How

Rebase once, at the single point that fills the cache, so the cached envelope has one documented time base instead of one per reader.

Read it with elapsed-time-in-clip instead of relTime. relTime is a position inside the media source: it carries mediaStart and the playback rate, so it only coincides with the envelope's time base for an untrimmed clip playing at 1x from zero. That second half also fixes a latent sibling — a trimmed clip (data-playback-start) read the wrong envelope point even when it started at 0.

The renderer's path is untouched; it already normalised.

Not covered here: audio clips land about 20 ms after their authored data-start in the rendered mix. That reproduces with and without volume automation, is not encoder priming (two codecs agree, and a hand-built reference through the same ffmpeg lands exactly on time), and is a separate defect in the assemble stage rather than in this envelope. Filed separately.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

New regression test in mediaVolumeEnvelope.test.ts: a clip starting at t=2 with a 0.05s fade must cache an envelope whose first keyframe is at time: 0, and reading it must give 0 at the clip's start, 1 once the fade completes, and 1 held to the end. Verified it fails on main with { time: 2 } where { time: 0 } was expected, and passes with the fix.

  • packages/core suite: 1912 passed, 100 files. packages/engine audio mixer and volume-envelope suites: 30 passed.
  • End-to-end on the original repro, preview now reads 0 → 0.667 → 1 → 1 → 1 across the fade, matching the authored tween and the encoded audio.
  • Re-rendered the same project and measured the encoded envelope: onset, 50%, 95% and tail are byte-for-byte the same numbers as before the change, so the render path is unaffected.
  • oxlint and oxfmt clean on all three changed files.

A GSAP volume fade on an audio clip that starts after t=0 left the preview
silent for the clip's whole length while the encoded render was correct.

`mediaVolumeEnvelope` is meant to keep preview and render on one envelope, and
its contract is "normalise, then read with track-relative seconds". The preview
skipped both halves. `probeElementVolumeKeyframes` stamps each keyframe with the
TIMELINE seek time it sampled at, and `normaliseEnvelope` — the function that
rebases those onto the track — had exactly one caller, the renderer's PCM baker.
The preview handed the raw keyframes to `interpolateVolumeGain` along with
`relTime`, so for a clip at t=2 every lookup fell two seconds before the first
keyframe and clamped to its volume: 0 for a fade-in.

Rebase once, where the cache is filled, so the cached envelope has a single
documented time base. Read it with elapsed-time-in-clip rather than `relTime`,
which is a position inside the media SOURCE — it carries `mediaStart` and the
playback rate, and only coincides with the envelope's time base for an
untrimmed clip playing at 1x from zero. That second half also fixes a latent
sibling: a trimmed clip read the wrong envelope point even when it started at 0.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  1. Test coverage is asymmetric to the fix. The regression test at packages/core/src/runtime/mediaVolumeEnvelope.test.ts:150-186 locks in the cache-write half (envelope stored track-relative), but never drives the read-side change at packages/core/src/runtime/media.ts:279-282. For the untrimmed / 1x case in the test, relTime === elapsedInClip — so reverting the media.ts arm (elapsedInClip → relTime) still passes this test. The PR body explicitly claims the read-side change also fixes a trimmed sibling (data-playback-start) and, by extension, clip.playbackRate !== 1 — neither is covered. Non-blocker, but the two axes the PR itself cites deserve their own assertion (e.g. a clip with mediaStart=5 or playbackRate=2 at t=3, asserting the read matches the envelope's track-relative point rather than the mediaStart-carrying relTime).

  2. CLI-feedback linkage not cited. The body describes an author-reproduced case but doesn't include a Slack ts= / fingerprint for the CLI-feedback signal that prompted the fix. Adding a permalink helps future repeat-signal correlation and closes the loop for the reporter. Non-blocker.

  3. Pre-existing cache-staleness surface (noted for awareness, not this PR). probeAndCacheVolumeKeyframes in packages/core/src/runtime/init.ts:1864-1876 early-returns when the element is already cached. If a fade is edited in-place on the same DOM element, the stored envelope goes stale. Not introduced here — the new normalisation just means the stale envelope is now stale on the correct axis. Fine to defer.

Verdict

Diagnosis is clean and the mechanism trace is exact: two probes stamp timeline time, the renderer already rebased at its single reader, the preview didn't, so a clip after t=0 clamps to the fade's first sample. Rebasing once at the cache-fill point matches the header contract ("normalise, then read with track-relative seconds") and collapses two time bases into one. The read-side switch from relTime to elapsedInClip also fixes the trimmed / playback-rate variants the author calls out. Renderer path untouched — encoded audio is byte-identical, verified in the test plan. All 40+ required checks green including preview parity, Windows render, and cross-shard regression. Named exports, import type clean, oxfmt/oxlint clean. Cache is a per-runtime WeakMap<HTMLMediaElement>, so no cross-clip identity aliasing. Ship it; fold the trimmed / rate-≠-1 test case into the next touch.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 629b7f90.

Read through the three changed files, the renderer's PCM baker (packages/engine/src/services/audioVolumeEnvelope.ts), the producer's own timeline probe (htmlCompiler.ts:2196+), and the read site in media.ts. The fix is right and the "rebase once, read with elapsed-in-clip" framing is the correct shape.

Two independent probe paths and both now agree on the time base. The preview runtime's cache (via probeAndCacheElementVolume) is now normalised at write; interpolateVolumeGain(_, elapsedInClip) reads it correctly. The producer's audio-mix pipeline runs a byte-forked probe (discoverAudioVolumeAutomationFromTimeline in htmlCompiler.ts) that emits raw timeline-stamped keyframes, ships them through the plan, and applyVolumeEnvelopeToWav normalises at read via normaliseEnvelope(keyframes, trackStart, baseVolume). Two different code paths, two different normalise sites, one consistent time base at every read. No renderer-side breakage — the PCM baker's input contract is untouched.

Playback rate and trimming come out right. GSAP authors envelopes in TIMELINE time, so elapsedInClip = timeSeconds - clip.start is the correct indexer regardless of playback rate: a 2× clip with a fade authored at timeline [t=2, t=2.05] fades in 0.05s of preview wall-clock, matching the mixer's baked output. The data-playback-start (trimmed) sibling the PR body mentions is closed for the same reason — relTime's mediaStart term never enters the envelope index anymore.

Reader stays inside the active guard. elapsedInClip = params.timeSeconds - clip.start could go negative in principle, but the read at media.ts:273 sits inside the isActive branch (:259) which requires params.timeSeconds >= clip.start (:255), so it never does. interpolateVolumeGain also anchors envelope[0] at time=0 via normaliseEnvelope's prepend, so any tiny floating-point-negative elapsed clamps cleanly to the baseVolume.

Test locks the fingerprint case. mediaVolumeEnvelope.test.ts:147+ asserts envelope[0].time === 0 for a clip at start=2, which is the specific bug's tell (before: {time: 2, ...}; after: {time: 0, ...}). The direct interpolateVolumeGain checks at t=0/0.05/0.5/1 exactly express the preview's expected 0 → 0.667 → 1 → 1 → 1 read across the fade window.

One nit — semantic drift on the clip.volumeKeyframes type. After this fix, clip.volumeKeyframes at media.ts:64 is semantically a normalised envelope, but the type is still VolumeKeyframe[] with no marker. If a future consumer in the runtime treats it as raw timeline-stamped (like the producer's serialised audio.volumeKeyframes in the plan / element.volumeKeyframes in audioMixer.ts), they'll be silently wrong. A type brand (NormalisedVolumeEnvelope = VolumeKeyframe[] & { __normalised: true }) or at minimum a // NORMALISED, indexed by elapsed-in-clip seconds docstring on the field would close that gap. Not blocking.

One observation — duplicate probe code. probeElementVolumeKeyframes in packages/core and discoverAudioVolumeAutomationFromTimeline in packages/producer are structurally the same sampler with byte-forked keyframe compression. Not this PR's concern — flagging as a future consolidation opportunity so preview + render can't drift on sampling semantics if either side gets touched.

Sub-composition fringe (pre-existing, not this PR). resolveVolumeProbeWindow reads raw el.dataset.start, which is relative to a sub-composition parent, but the timeline probed is the root. So a clip inside a sub-composition never gets its automation window sampled and takes the fallback path. Same behaviour before and after this PR — flagging so we don't confuse it later with a regression from this change.

What I didn't verify. Did not stand up an end-to-end preview+render locally to reproduce the 0 → 0.667 → 1 → 1 → 1 timing. Reading matches the test and Miguel's manual measurement in the PR body.

Otherwise LGTM. Stamp routing per standing rule.

— Review by Rames D Jusso

@miguel-heygen
miguel-heygen merged commit eee9b26 into main Aug 11, 2026
58 checks passed
@miguel-heygen
miguel-heygen deleted the fix/preview-volume-envelope branch August 11, 2026 03:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants