Skip to content

fix(core,player,studio): bound trimmed audio playback to the clip window#1430

Merged
vanceingalls merged 4 commits into
mainfrom
fix/parent-proxy-trim
Jun 15, 2026
Merged

fix(core,player,studio): bound trimmed audio playback to the clip window#1430
vanceingalls merged 4 commits into
mainfrom
fix/parent-proxy-trim

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

Trimmed audio clips kept playing past the clip edge — the audio ran on to the source file's natural end even though the timeline clip ended earlier. This happened across every audio path, so fixing one didn't fix the symptom in the Studio.

Root cause

Three independent paths each ignored the clip's authored window (data-duration / media-start):

  1. Parent audio proxy (<hyperframes-player> when iframe autoplay is blocked) — the proxy played to the source end and never re-read live trims.
  2. Runtime element gating (core/runtime) — the duration resolver computed clip length only from source-file length capped by host-composition duration, never consulting the element's own data-duration.
  3. WebAudio transport — the audible path in the Studio (the iframe <audio> is muted while WebAudio plays the decoded buffer). schedulePlayback called AudioBufferSourceNode.start(when, offset) with no duration, so the buffer played from the trim offset to the file's natural end regardless of the clip length.

Confirmed live: audioOwner: "runtime", the iframe <audio> was muted: true + paused: true at its trimmed edge (element gating worked), but WebAudio kept the buffer running — so the trim never bound the audible output.

Fix

  • WebAudio (webAudioTransport.ts + init.ts): schedulePlayback now takes the clip's data-duration and passes a bounded duration to start(when, offset, duration), so the buffer stops at the trimmed edge. Past-end schedules are skipped.
  • Runtime element gating (init.ts): the duration resolver caps each clip by min(sourceLength, hostWindow, ownDataDuration), so a trimmed <audio>/<video> element pauses at its edge.
  • Parent proxy (parent-media.ts): the proxy re-reads its source clip's live data-start/data-duration each tick and pauses outside the clip window, resuming on re-entry.

Studio trim UX (same root area)

  • Resize (useTimelineEditing.ts): a start-edge drag now live-patches the media-start/playback-start offset to the iframe, so dragging the left edge trims into the source instead of only repositioning the clip.
  • Waveform (AudioWaveform.tsx + useRenderClipContent.ts): the rendered peaks are windowed to the trimmed slice [mediaStart, mediaStart + duration·rate], so the waveform tracks the clip edges instead of squeezing the whole file into the clip width.

Tests

  • webAudioTransport.test.ts: 5 new cases for the clip-duration bound (in-progress, future, past-end skip, rate scaling, unbounded-when-omitted).
  • parent-media.test.ts: pause past trimmed end; re-read live data-duration.

Verification

In Studio: trim the music clip, play past the trimmed edge → audio now goes silent at the edge (previously ran to the source end). Untrimmed playback unchanged.

🤖 Generated with Claude Code

vanceingalls commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

@vanceingalls vanceingalls changed the title fix(player): bound the parent audio proxy to its clip window fix(core,player,studio): bound trimmed audio playback to the clip window Jun 14, 2026
vanceingalls and others added 3 commits June 14, 2026 16:21
When iframe autoplay is blocked, audible playback is promoted to a parent-frame
audio proxy. The proxy read the clip's data-start/data-duration once at adopt
time and mirrorTime() only skipped (never paused) the element outside that
window — so a trimmed/moved music clip kept playing the full source past its
on-timeline end, even though the iframe element was correctly paused.

Fix: the proxy keeps a reference to its source iframe element and re-reads
data-start/data-duration each mirror tick (live trims/moves apply), pauses the
proxy when the playhead leaves [start, start+duration), and resumes it when the
playhead re-enters during parent-owned playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Trimmed audio played to the source file's natural end instead of
stopping at the clip edge, on every audio path:

- WebAudio (the audible path in Studio): schedulePlayback now passes
  the clip's data-duration as the third start() arg, so the decoded
  buffer stops at the trimmed edge instead of running to the file end.
- Runtime element gating: the duration resolver caps each clip by its
  own data-duration (min of source length, host window, authored
  duration), so a trimmed <audio>/<video> element pauses at its edge.

Studio trim UX:

- Resize live-patches the media-start/playback-start offset, so a
  start-edge drag trims into the source instead of only repositioning
  the clip.
- AudioWaveform windows the rendered peaks to the trimmed slice so the
  waveform tracks the clip edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Review follow-ups on the parent-audio-proxy / WebAudio bound:
- seekAll now re-reads live source bounds (_refreshEntryBounds) before
  gating, so a paused scrub right after a trim/move uses the current clip
  window instead of the adopt-time one.
- playAll and clip adoption only start a proxy when the playhead is inside
  the clip's window (_playEntryIfActive), so bulk starts / promotion no
  longer blip audio for clips outside their window until the next tick.
- The WebAudio buffer is now bounded by the host-composition window too
  (matching resolveDurationSeconds), so a sub-composition-nested clip stops
  at the same edge on the WebAudio and HTMLMedia paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
@vanceingalls vanceingalls force-pushed the fix/parent-proxy-trim branch from e1b76df to 3530fa3 Compare June 14, 2026 23:29

@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.

Verdict: approve with one concern + one nit. The fix targets a real, well-characterized bug across three independent audio paths, and the body's "confirmed live: audioOwner=runtime, iframe <audio> paused at edge but WebAudio kept buffer running" is exactly the kind of evidence that makes this PR easy to trust. Tests are tight on the bound math. One nit on mid-playback rate-change interaction with the bounded start() arg.

Verified at HEAD 3530fa3

What ships

  • webAudioTransport.ts: new optional clipDuration arg on schedulePlayback; extracted startBoundedSource helper that passes the third duration arg to AudioBufferSourceNode.start(when, offset, duration) so the decoded buffer stops at the clip edge.
  • init.ts (runtime element gating): resolveDurationSeconds now mixes data-duration into the min(sourceDuration, hostRemaining, ownDuration) floor; the WebAudio path on data-composition-id ancestors caps the clip span by inheritedStart + inheritedDuration - compStart so sub-composition-nested clips stop at the same edge on both HTMLMedia and WebAudio.
  • parent-media.ts (audio proxy): every entry now retains a source HTMLMediaElement ref; _refreshEntryBounds re-reads data-start/data-duration each mirrorTime / seekAll / play-start; _gateEntryPlayback pauses outside the window and resumes on re-entry; playAll and adopt-time start go through _playEntryIfActive so bulk starts don't blip out-of-window clips.
  • useTimelineEditing.ts: start-edge resize live-patches data-playback-start / data-media-start to the iframe (was: only data-start + data-duration) — the missing piece that made the left-edge drag trim into the source instead of just repositioning.
  • useRenderClipContent.ts + AudioWaveform.tsx: peaks are windowed to [mediaStart / sourceDur, (mediaStart + duration*rate) / sourceDur] so the waveform tracks the trimmed slice rather than squeezing the whole source into the clip width.

Bug + fix shape

PR body's "three independent paths" framing is accurate and proven by the diff:

  1. WebAudio buffer never boundedstart(when, offset) (no third arg) was the actual silent-fail; the iframe <audio> was correctly paused but inaudible-anyway because it was muted. This is the load-bearing fix.
  2. Runtime element gating missed data-duration in the min() — fixed by mixing it into a [sourceDuration, hostRemaining, explicitDuration] candidates list.
  3. Parent proxy read data-start/data-duration once at adopt time and mirror-skipped (didn't pause) outside the window — fixed by re-reading live + pausing.

Fix shapes are consistent across the three paths (everyone now reads data-duration live; gating is symmetric). The "review follow-ups" commit (3530fa3) adds the sub-composition WebAudio bound + seekAll live-refresh + _playEntryIfActive for adoption — these close the same gap on tangent paths.

Edge cases verified

  • Past-end schedule: schedulePlayback returns null + disconnects nodes when remaining <= 0. ✅ tested.
  • Future-start schedule with bound: start(scheduledAt + delay, mediaStart, clipSourceLen) — bound is in buffer seconds, so real-time stop is clipSourceLen / safeRate = clipDuration wall-clock seconds. Math checks out. ✅ tested with rate=2.
  • Unbounded (legacy): clipDuration = +∞hasBound = false → 2-arg start(). ✅ tested.
  • Seek to past-end (proxy): seekAll skips set when relTime >= duration, and mirrorTime pauses on the next tick via _gateEntryPlayback. ✅.
  • Live trim during playback (proxy): _refreshEntryBounds re-read each mirrorTime. ✅ tested.
  • Stack coherence with HF#1424 (beat detection) / HF#1439 (keyframes dragged onto beats): a keyframe dragged to a beat past the clip's data-duration would land outside the audio window — runtime element gating + proxy gate both pause cleanly, no leaked audio. WebAudio buffer just doesn't get scheduled past-end. Clean.

Determinism contract

No new Date.now() / performance.now() calls. All time math goes through AudioContext.currentTime (deterministic per-context) or the existing _getCurrentTime injection. ✅.

:x: emoji significance

Most likely interpretation: regression-shards CI was still in-progress when Vance posted (the only non-success checks are 8 regression-shards (shard-N) jobs still running at the time of review). Every other required check (CI: Test/Typecheck/Build/Lint/CLI smoke/SDK contract, Windows render, Player perf, preview parity, CodeQL) is green. No known-broken state in the PR body or comments. I read the :x: as "regression not done yet, hold on stamp" rather than a defect callout.

Concerns

webAudioTransport.ts:131,144-146Mid-playback rate change after a bounded source is scheduled. The third arg to start(when, offset, duration) is in buffer-sample seconds, fixed at schedule time. setRate() (line 211) mutates sourceNode.playbackRate.value on already-started sources without rescheduling, so a rate bump while a bounded source is playing shortens the audible window in wall time (the buffer is consumed faster than the originally-budgeted duration / oldRate seconds → stops before the trim edge). The doc comment on setRate already acknowledges "Sources scheduled to start in the future keep their original wallclock start time — callers that need rate-correct future starts should stopAll() and reschedule." Worth extending that contract to active sources too when a clip is bounded, or have setRate call stopAll() + reschedule for any clip with a finite bound. Not a launch blocker — the symptom is "audio cuts off slightly early when user changes playback speed mid-clip in Studio," which is way better than the current "audio runs past trim edge." But it's a determinism-adjacent footgun that's worth a follow-up.

Nits

parent-media.ts:147parseFloat(m.source.getAttribute("data-duration") || "Infinity") falls back to "Infinity" only when the attribute is missing; an empty-string attribute (data-duration="") yields NaN, and relTime >= NaN is false → the gate degrades silently to "always inside window." Unlikely in practice (the Studio writes formatted numbers), but Number.isFinite(parsed) ? parsed : Infinity is the robust shape and matches how the runtime resolver guards elsewhere. (nit)
init.ts:1469-1486 — the candidates.filter(...).Math.min(...) shape is clean but allocates per resolve. If this is hot (per-frame for every <audio>/<video> in the composition), a 3-branch min would be cheaper — though probably below noise. (nit)

Stack notes

Sibling subagents are reviewing HF#1424 (beat detection foundation) and HF#1439 (drag keyframes with live beat snapping) in parallel. Cross-PR check: a beat-snapped keyframe past the audio clip's trimmed end will land outside the WebAudio bound and outside the proxy gate — both paths pause cleanly. No cross-coupling issue between this PR and #1439's drag behavior.

CI

38/45 checks green at HEAD 3530fa3 (CI required-suite all green: Test, Typecheck, Build, Lint, CLI smoke, SDK contract+smoke, Studio load smoke; Player perf load/fps/scrub/drift/parity all green; Windows render tests green; preview-regression green; CodeQL JS+Python green). 8 regression-shards (shard-1..8) still in-progress. This is the likely source of the :x: reaction — recommend holding stamp until those land green.

Review by Rames D Jusso

@miguel-heygen miguel-heygen 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.

Strengths

  1. webAudioTransport.ts:startBoundedSource — clean extraction of the start-with-bound logic into a named helper with a clear boolean contract (returns false when the window is already elapsed). The explicit if (hasBound) branches keep the legacy unbounded path orthogonal to the new bounded path, preventing regression.

  2. init.ts:1456–1489 (resolveDurationSeconds) — the candidates = [sourceDuration, hostRemaining, explicitDuration].filter(...)Math.min(...candidates) pattern is notably better than the previous two-branch if/return chain; it makes the three-way contract explicit and handles any subset of finite constraints without extra cases.

  3. parent-media.ts:_refreshEntryBounds — re-reading data-start/data-duration from the live DOM element each tick is the right architectural choice here. Using a source? reference (with isConnected guard) avoids any risk of stale closure data while keeping the GC story clean.


Blockers

None found.


Important

  1. trimFractions and renderAudioClip are untested. packages/studio/src/hooks/useRenderClipContent.test.ts has zero coverage for the new trimFractions() helper (useRenderClipContent.test.ts only tests normalizeCompositionSrc). The waveform window is the most user-visible part of the fix — a regression there (e.g. when rate == 0, when sourceDuration == el.duration, or when mediaStart > sourceDuration) would be silent. A few unit tests for trimFractions with edge inputs (untrimmed, start-only trim, full trim, rate > 1, sourceDuration unknown) would close this gap. The renderAudioClip refactor similarly has no test that passes trimStartFraction/trimEndFraction into AudioWaveform.

  2. useTimelineEditing.ts live-patch of media-start/playback-start has no dedicated test. The new branch at useTimelineEditing.ts:152–160 (patching data-playback-start vs data-media-start depending on playbackStartAttr) is the other UI-visible piece of the fix but has no coverage in timelineEditing.test.ts. A test that exercises a start-edge drag on an element whose playbackStartAttr is "playback-start" vs "media-start" and asserts the correct attribute name is patched would be valuable.

  3. Sub-composition host-window calc in the WebAudio path (init.ts:2191–2199) uses a different start resolution than the HTMLMedia path. The HTMLMedia resolver calls resolveMediaStartSeconds(element, inheritedStart) for the audio clip's start (which respects data-hf-auto-start). The WebAudio path uses rawEl.dataset.start directly (line 2178). Both feed the same clipDuration cap formula, but if an auto-start audio element inside a sub-composition has data-hf-auto-start (no explicit data-start), rawEl.dataset.start would be undefined/NaN, causing the whole WebAudio schedule to be skipped (Number.isFinite check at line 2180). This is a pre-existing behavior, but the new host-window cap at 2191–2199 makes it newly observable: for those elements the clipDuration defaults to Infinity, defeating the trim. Worth a follow-up issue if auto-start audio clips with explicit data-duration trims are a real scenario.


Nits

  • useRenderClipContent.ts:82 — the // fallow-ignore-next-line complexity suppression is appropriate (pre-existing handler), but the same technique applied three times across useTimelineEditing.ts:184, 260, 344 adds noise; consider a single file-level suppression or a refactor of the outer function if that's in-scope.
  • parent-media.ts:148–158_playEntryIfActive silently skips the _audioOwner === "parent" check that _gateEntryPlayback relies on (line 158). Both methods call _playEntry unconditionally when inside the window. This is fine because _playEntryIfActive is only invoked from playAll and adopt (both of which check owner/paused before calling it), but the asymmetry is a future footgun — a brief comment at the call sites would preempt confusion.

Verdict: APPROVE

Reasoning: All three independent audio trim paths are now correctly bounded; the fix is well-reasoned, the math is sound, and the new tests cover the critical WebAudio and parent-proxy contracts. The missing unit tests for trimFractions and the useTimelineEditing live-patch are notable but not merge-blocking given the manual verification described in the PR and the breadth of existing CI coverage passing.

— Magi

…aN bounds

A bounded WebAudio source's wall-clock length is baked into start()'s duration
arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in
place on a later rate change does not rescale that bound, so a trimmed clip ends
early (fast) or late (slow). setRate now reports whether the rate changed and
exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active
clips at the new rate when any bounded source is live. The per-clip schedule
loop is extracted to a shared closure so play() and the rate path agree.

Also guard _refreshEntryBounds against a non-numeric duration attribute parsing
to NaN, which would make every window check false and let the proxy play past
its clip end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
@vanceingalls vanceingalls merged commit a95e49d into main Jun 15, 2026
47 checks passed
@vanceingalls vanceingalls deleted the fix/parent-proxy-trim branch June 15, 2026 00:14
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