From bf670e1496d95fcb8e754b877737d00a4d82ea51 Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:12:35 +0900 Subject: [PATCH 1/7] `remotion`: Skip resume fade-in when no audio nodes start fresh Only apply the 30ms 0-to-1 gain ramp in resume() when there are nodes in nodesToResume that need to be started. Those begin mid-waveform and can click, which is what the fade is meant to mask. If there is nothing to start, resume() merely unfreezes the nodes that suspend() froze in place, which continues sample-exact. Fading in that case caused an audible dip on every play/pause toggle. Ref #8984 --- packages/core/src/audio/shared-audio-tags.tsx | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/core/src/audio/shared-audio-tags.tsx b/packages/core/src/audio/shared-audio-tags.tsx index 01a5478d259..2ebe9a76ca9 100644 --- a/packages/core/src/audio/shared-audio-tags.tsx +++ b/packages/core/src/audio/shared-audio-tags.tsx @@ -364,22 +364,30 @@ export const SharedAudioContextProvider: React.FC<{ audioContextIsPlayingEventually.current = true; - ctxAndGain.gainNode.gain.cancelScheduledValues( - ctxAndGain.audioContext.currentTime, - ); - ctxAndGain.gainNode.gain.setValueAtTime( - 0, - ctxAndGain.audioContext.currentTime, - ); - ctxAndGain.gainNode.gain.linearRampToValueAtTime( - 1, - ctxAndGain.audioContext.currentTime + 0.03, - ); + // Nodes that were scheduled while the context was not running start + // mid-waveform, which can produce an audible click - mask it with a + // short fade-in. If there are no nodes to start, resume() merely + // unfreezes the nodes that suspend() froze in place, which continues + // sample-exact - fading would cause an audible dip on every + // play/pause toggle. + if (nodesToResume.current.size > 0) { + ctxAndGain.gainNode.gain.cancelScheduledValues( + ctxAndGain.audioContext.currentTime, + ); + ctxAndGain.gainNode.gain.setValueAtTime( + 0, + ctxAndGain.audioContext.currentTime, + ); + ctxAndGain.gainNode.gain.linearRampToValueAtTime( + 1, + ctxAndGain.audioContext.currentTime + 0.03, + ); - nodesToResume.current.forEach((r, node) => { - node.start(r.scheduledTime, r.offset, r.duration); - }); - nodesToResume.current.clear(); + nodesToResume.current.forEach((r, node) => { + node.start(r.scheduledTime, r.offset, r.duration); + }); + nodesToResume.current.clear(); + } const resumePromise = ctxAndGain.resume(); From 2a3d2c9b95f1d3f10c149e940d7ac0559c2408f5 Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:12:56 +0900 Subject: [PATCH 2/7] `@remotion/player`: Do not force re-anchor on audio context suspend Route the statechange re-anchor through the normal drift guard instead of forcing it. A plain pause does not move the playhead, so the anchor stays put and the queued audio nodes remain valid - suspend/resume becomes a freeze/unfreeze of the same nodes without a teardown. If the drift does exceed the guard, re-anchor and dispatch 'changed' so the media players rebuild their queue against the new anchor. Previously the anchor was moved silently on every suspend, leaving the frozen queue misaligned by the shift. Ref #8984 --- packages/player/src/use-playback.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 3c03c955481..7f9f215336b 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -10,7 +10,7 @@ import {useIsBackgrounded} from './is-backgrounded.js'; import {setGlobalTimeAnchor} from './set-global-time-anchor.js'; import {usePlayer} from './use-player.js'; -const shouldForceAnchorChange = (newState: RemotionAudioContextState) => { +const shouldReanchorOnStateChange = (newState: RemotionAudioContextState) => { if (newState === 'suspended' || newState === 'running-to-suspended') { return true; } @@ -108,7 +108,11 @@ export const usePlayback = ({ }, [config, frame, logLevel, playbackRate, sharedAudioContext, muted]); // When the audio context is suspended, we use the opportunity to - // re-anchor the time to be exact. + // re-anchor the time if it has drifted beyond the allowed shift. + // Small drifts are left alone on purpose: suspend() freezes the + // scheduled audio nodes in place and resume() unfreezes them + // sample-exact, so keeping the anchor lets a plain pause/play cycle + // reuse the queued nodes without tearing them down. useLayoutEffect(() => { const audioContext = sharedAudioContext?.audioContext; if (!audioContext) { @@ -125,15 +129,20 @@ export const usePlayback = ({ const callback = () => { const newState = sharedAudioContext?.getAudioContextState(); - if (newState && shouldForceAnchorChange(newState)) { - setGlobalTimeAnchor({ + if (newState && shouldReanchorOnStateChange(newState)) { + const changed = setGlobalTimeAnchor({ audioContext, audioSyncAnchor: sharedAudioContext.audioSyncAnchor, absoluteTimeInSeconds: getCurrentFrame() / config.fps, globalPlaybackRate: playbackRate, logLevel, - force: true, + force: false, }); + // The nodes queued so far were scheduled against the old anchor, + // so they have to be rebuilt if the anchor moved. + if (changed) { + sharedAudioContext.audioSyncAnchorEmitter.dispatch('changed'); + } } }; From a068b9788d010d02d7bfd55cffac793aa00dfa2b Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:13:32 +0900 Subject: [PATCH 3/7] `@remotion/player`: Remove unused force option from setGlobalTimeAnchor All call sites now go through the drift guard, so drop the force parameter together with the force-only zero-shift check and the 'forcibly' log wording. Ref #8984 --- packages/player/src/set-global-time-anchor.ts | 13 ++----------- packages/player/src/use-playback.ts | 2 -- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/player/src/set-global-time-anchor.ts b/packages/player/src/set-global-time-anchor.ts index 56813003178..18819825165 100644 --- a/packages/player/src/set-global-time-anchor.ts +++ b/packages/player/src/set-global-time-anchor.ts @@ -8,14 +8,12 @@ export const setGlobalTimeAnchor = ({ absoluteTimeInSeconds, globalPlaybackRate, logLevel, - force, }: { audioContext: AudioContext; audioSyncAnchor: {value: number}; absoluteTimeInSeconds: number; globalPlaybackRate: number; logLevel: LogLevel; - force: boolean; }): boolean => { const newAnchor = audioContext.currentTime - absoluteTimeInSeconds / globalPlaybackRate; @@ -25,20 +23,13 @@ export const setGlobalTimeAnchor = ({ const latency = audioContext.baseLatency + safeOutputLatency; // Skip small shifts to avoid audio glitches from frame-quantized re-anchoring - if (Math.abs(shift) < ALLOWED_GLOBAL_TIME_ANCHOR_SHIFT + latency && !force) { - return false; - } - - // If force is true, but shift is zero, no change is needed - if (Math.abs(shift) < Number.EPSILON) { + if (Math.abs(shift) < ALLOWED_GLOBAL_TIME_ANCHOR_SHIFT + latency) { return false; } Internals.Log.verbose( {logLevel, tag: 'audio-scheduling'}, - 'Anchor ' + - (force ? 'forcibly ' : '') + - 'changed from %s to %s with shift %s', + 'Anchor changed from %s to %s with shift %s', audioSyncAnchor.value, newAnchor, shift, diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 7f9f215336b..5e75a5ba5c2 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -100,7 +100,6 @@ export const usePlayback = ({ absoluteTimeInSeconds: frame / config.fps, globalPlaybackRate: playbackRate, logLevel, - force: false, }); if (changed) { sharedAudioContext.audioSyncAnchorEmitter.dispatch('changed'); @@ -136,7 +135,6 @@ export const usePlayback = ({ absoluteTimeInSeconds: getCurrentFrame() / config.fps, globalPlaybackRate: playbackRate, logLevel, - force: false, }); // The nodes queued so far were scheduled against the old anchor, // so they have to be rebuilt if the anchor moved. From a9362c44db0406b235a8c3011642ec824b3749e1 Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:15:05 +0900 Subject: [PATCH 4/7] `@remotion/player`: Extract SetGlobalTimeAnchorOptions type Move the inline parameter type of setGlobalTimeAnchor into a named type for readability. No behavior change. --- packages/player/src/set-global-time-anchor.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/player/src/set-global-time-anchor.ts b/packages/player/src/set-global-time-anchor.ts index 18819825165..fa66695e0ba 100644 --- a/packages/player/src/set-global-time-anchor.ts +++ b/packages/player/src/set-global-time-anchor.ts @@ -2,19 +2,21 @@ import {Internals, type LogLevel} from 'remotion'; export const ALLOWED_GLOBAL_TIME_ANCHOR_SHIFT = 0.1; +type SetGlobalTimeAnchorOptions = { + audioContext: AudioContext; + audioSyncAnchor: {value: number}; + absoluteTimeInSeconds: number; + globalPlaybackRate: number; + logLevel: LogLevel; +}; + export const setGlobalTimeAnchor = ({ audioContext, audioSyncAnchor, absoluteTimeInSeconds, globalPlaybackRate, logLevel, -}: { - audioContext: AudioContext; - audioSyncAnchor: {value: number}; - absoluteTimeInSeconds: number; - globalPlaybackRate: number; - logLevel: LogLevel; -}): boolean => { +}: SetGlobalTimeAnchorOptions): boolean => { const newAnchor = audioContext.currentTime - absoluteTimeInSeconds / globalPlaybackRate; const shift = newAnchor - audioSyncAnchor.value; From 7752574610925bf7c90409120a662dde7fa4efc4 Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:17:15 +0900 Subject: [PATCH 5/7] `remotion`: Extract inline option types in shared-audio-tags Name the repeated inline object types: RegisterAudioOptions (shared by the context value, registerAudio and useSharedAudio), UpdateAudioOptions (RegisterAudioOptions plus id) and the props types of the two providers. No behavior change. --- packages/core/src/audio/shared-audio-tags.tsx | 70 ++++++++----------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/packages/core/src/audio/shared-audio-tags.tsx b/packages/core/src/audio/shared-audio-tags.tsx index 2ebe9a76ca9..2cb76645e1f 100644 --- a/packages/core/src/audio/shared-audio-tags.tsx +++ b/packages/core/src/audio/shared-audio-tags.tsx @@ -90,21 +90,21 @@ type SharedAudioContextValue = { unscheduleAudioNode: (node: AudioBufferSourceNode) => void; }; +type RegisterAudioOptions = { + aud: AudioHTMLAttributes; + audioId: string; + premounting: boolean; + postmounting: boolean; +}; + +type UpdateAudioOptions = RegisterAudioOptions & { + id: number; +}; + type SharedAudioTagsContextValue = { - registerAudio: (options: { - aud: AudioHTMLAttributes; - audioId: string; - premounting: boolean; - postmounting: boolean; - }) => AudioElem; + registerAudio: (options: RegisterAudioOptions) => AudioElem; unregisterAudio: (id: number) => void; - updateAudio: (options: { - id: number; - aud: AudioHTMLAttributes; - audioId: string; - premounting: boolean; - postmounting: boolean; - }) => void; + updateAudio: (options: UpdateAudioOptions) => void; playAllAudios: () => void; numberOfAudioTags: number; }; @@ -191,12 +191,16 @@ const shouldSaveForLater = ( throw new Error(`Unexpected audio context state: ${state satisfies never}`); }; -export const SharedAudioContextProvider: React.FC<{ +type SharedAudioContextProviderProps = { readonly children: React.ReactNode; readonly audioLatencyHint: AudioContextLatencyCategory; readonly audioEnabled: boolean; readonly previewSampleRate: number | null; -}> = ({children, audioLatencyHint, audioEnabled, previewSampleRate}) => { +}; + +export const SharedAudioContextProvider: React.FC< + SharedAudioContextProviderProps +> = ({children, audioLatencyHint, audioEnabled, previewSampleRate}) => { const logLevel = useLogLevel(); const sampleRate = previewSampleRate ?? 48000; @@ -459,10 +463,14 @@ export const SharedAudioContextProvider: React.FC<{ ); }; -export const SharedAudioTagsContextProvider: React.FC<{ +type SharedAudioTagsContextProviderProps = { readonly numberOfAudioTags: number; readonly children: React.ReactNode; -}> = ({children, numberOfAudioTags}) => { +}; + +export const SharedAudioTagsContextProvider: React.FC< + SharedAudioTagsContextProviderProps +> = ({children, numberOfAudioTags}) => { const audios = useRef([]); const [initialNumberOfAudioTags] = useState(numberOfAudioTags); @@ -552,12 +560,7 @@ export const SharedAudioTagsContextProvider: React.FC<{ }, [refs]); const registerAudio = useCallback( - (options: { - aud: AudioHTMLAttributes; - audioId: string; - premounting: boolean; - postmounting: boolean; - }) => { + (options: RegisterAudioOptions) => { const {aud, audioId, premounting, postmounting} = options; const found = audios.current?.find((a) => a.audioId === audioId); if (found) { @@ -617,19 +620,7 @@ export const SharedAudioTagsContextProvider: React.FC<{ ); const updateAudio = useCallback( - ({ - aud, - audioId, - id, - premounting, - postmounting, - }: { - id: number; - aud: AudioHTMLAttributes; - audioId: string; - premounting: boolean; - postmounting: boolean; - }) => { + ({aud, audioId, id, premounting, postmounting}: UpdateAudioOptions) => { let changed = false; audios.current = audios.current?.map((prevA): AudioElem => { @@ -728,12 +719,7 @@ export const useSharedAudio = ({ audioId, premounting, postmounting, -}: { - aud: AudioHTMLAttributes; - audioId: string; - premounting: boolean; - postmounting: boolean; -}) => { +}: RegisterAudioOptions) => { const audioCtx = useContext(SharedAudioContext); const tagsCtx = useContext(SharedAudioTagsContext); From 8c182698c7eac4b8b5f39284a7738f01ed777c8f Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:18:49 +0900 Subject: [PATCH 6/7] `@remotion/player`: Extract inline types in use-playback Name the usePlayback parameter object (UsePlaybackOptions) and the raf/timeout union used for the queued frame call (QueuedFrameCall). No behavior change. --- packages/player/src/use-playback.ts | 43 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 5e75a5ba5c2..796297fefbe 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -29,6 +29,27 @@ const shouldReanchorOnStateChange = (newState: RemotionAudioContextState) => { ); }; +type QueuedFrameCall = + | { + type: 'raf'; + id: number; + } + | { + type: 'timeout'; + id: Timer; + }; + +type UsePlaybackOptions = { + loop: boolean; + playbackRate: number; + moveToBeginningWhenEnded: boolean; + inFrame: number | null; + outFrame: number | null; + browserMediaControlsBehavior: BrowserMediaControlsBehavior; + getCurrentFrame: ReturnType['getCurrentFrame']; + muted: boolean; +}; + export const usePlayback = ({ loop, playbackRate, @@ -38,16 +59,7 @@ export const usePlayback = ({ browserMediaControlsBehavior, getCurrentFrame, muted, -}: { - loop: boolean; - playbackRate: number; - moveToBeginningWhenEnded: boolean; - inFrame: number | null; - outFrame: number | null; - browserMediaControlsBehavior: BrowserMediaControlsBehavior; - getCurrentFrame: ReturnType['getCurrentFrame']; - muted: boolean; -}) => { +}: UsePlaybackOptions) => { const config = Internals.useUnsafeVideoConfig(); const frame = Internals.Timeline.useTimelinePosition(); const {playing, pause, emitter, isPlaying} = usePlayer(); @@ -168,16 +180,7 @@ export const usePlayback = ({ } let hasBeenStopped = false; - let reqAnimFrameCall: - | { - type: 'raf'; - id: number; - } - | { - type: 'timeout'; - id: Timer; - } - | null = null; + let reqAnimFrameCall: QueuedFrameCall | null = null; let startedTime = performance.now(); let framesAdvanced = 0; From c0e9a30f37a07ddb407e534b1d1514f89dde78c5 Mon Sep 17 00:00:00 2001 From: iliya-vidrush Date: Mon, 13 Jul 2026 13:36:55 +0900 Subject: [PATCH 7/7] `@remotion/player`: Re-anchor against frame at suspend request, not live frame The statechange handler computed the audio time anchor from getCurrentFrame() at the moment the 'statechange' event fired. That event is async relative to the suspend() request, so during a rapid play/pause burst it could read a frame from an already-resuming state, measure spurious drift, and dispatch 'changed' - tearing down and rebuilding the queued audio nodes even though no seek occurred. Capture the frame at each suspend() request in a ref and re-anchor against that instead. The suspended nodes were scheduled against that frame, so it is the correct reference and a plain pause/play cycle no longer trips the drift guard. --- packages/player/src/use-playback.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 796297fefbe..183e7736963 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -74,6 +74,12 @@ export const usePlayback = ({ const lastTimeUpdateTimestamp = useRef(0); + // The frame at which suspend() was last requested. The suspended audio + // nodes were scheduled against this frame, so the statechange handler + // must re-anchor against it rather than reading the live frame at the + // (async) moment the 'statechange' event fires. + const suspendRequestFrame = useRef(null); + const context = useContext(Internals.BufferingContextReact); if (!context) { throw new Error( @@ -144,7 +150,8 @@ export const usePlayback = ({ const changed = setGlobalTimeAnchor({ audioContext, audioSyncAnchor: sharedAudioContext.audioSyncAnchor, - absoluteTimeInSeconds: getCurrentFrame() / config.fps, + absoluteTimeInSeconds: + (suspendRequestFrame.current ?? getCurrentFrame()) / config.fps, globalPlaybackRate: playbackRate, logLevel, }); @@ -175,6 +182,7 @@ export const usePlayback = ({ } if (!playing) { + suspendRequestFrame.current = getCurrentFrame(); sharedAudioContext?.suspend?.(); return; } @@ -205,6 +213,7 @@ export const usePlayback = ({ } if (!isPlaying()) { + suspendRequestFrame.current = getCurrentFrame(); sharedAudioContext?.suspend?.(); return; } @@ -264,6 +273,7 @@ export const usePlayback = ({ if (context.buffering.current) { if (!muted) { + suspendRequestFrame.current = getCurrentFrame(); sharedAudioContext?.suspend?.(); }