diff --git a/packages/media/src/audio-iterator-manager.ts b/packages/media/src/audio-iterator-manager.ts
index 98d16ee50f9..ced28e143f8 100644
--- a/packages/media/src/audio-iterator-manager.ts
+++ b/packages/media/src/audio-iterator-manager.ts
@@ -76,6 +76,7 @@ export const audioIteratorManager = ({
getSequenceDurationInSeconds,
getMediaEndTimestamp,
getStartTime,
+ getIsPlaying,
initialMuted,
initialVolume,
toneFrequency,
@@ -89,6 +90,7 @@ export const audioIteratorManager = ({
getSequenceDurationInSeconds: () => number;
getMediaEndTimestamp: () => number;
getStartTime: () => number;
+ getIsPlaying: () => boolean;
initialMuted: boolean;
initialVolume: number;
toneFrequency: number;
@@ -280,6 +282,7 @@ export const audioIteratorManager = ({
onDone,
logLevel,
currentTime,
+ startFromSecond,
getAudioContextCurrentTimeMockedInTest,
}: {
iterator: AudioIterator;
@@ -298,6 +301,7 @@ export const audioIteratorManager = ({
onDestroyed: () => void;
logLevel: LogLevel;
currentTime: number;
+ startFromSecond: number;
getAudioContextCurrentTimeMockedInTest: () => number;
}) => {
waitForTurn({
@@ -321,7 +325,16 @@ export const audioIteratorManager = ({
sequenceStartTime: getStartTime(),
});
- return scheduledTime - getAudioContextCurrentTimeMockedInTest();
+ // Always finish the initial prebuffer, even for future or very slow clips.
+ // Bound further paused decoding by prepared media, not a future start time.
+ const preparedDuration = guessedNextTimestamp - startFromSecond;
+ if (getIsPlaying()) {
+ return scheduledTime - getAudioContextCurrentTimeMockedInTest();
+ }
+
+ return hasEnoughAudioToStartPlayback(preparedDuration)
+ ? preparedDuration / playbackRate
+ : 0;
},
fn: () => iterator.getNextFn(),
onDone: (result, next) => {
@@ -364,6 +377,7 @@ export const audioIteratorManager = ({
onDone,
logLevel,
currentTime,
+ startFromSecond,
getAudioContextCurrentTimeMockedInTest,
});
next();
@@ -512,6 +526,7 @@ export const audioIteratorManager = ({
},
logLevel,
currentTime: sharedAudioContext.audioContext.currentTime,
+ startFromSecond,
getAudioContextCurrentTimeMockedInTest,
});
};
diff --git a/packages/media/src/media-player.ts b/packages/media/src/media-player.ts
index 2169ffc81bd..3d87a843a47 100644
--- a/packages/media/src/media-player.ts
+++ b/packages/media/src/media-player.ts
@@ -453,6 +453,7 @@ export class MediaPlayer {
getMediaEndTimestamp: () => this.getMediaEndTimestamp(),
getSequenceEndTimestamp: () => this.getSequenceEndTimestamp(),
getStartTime: () => this.getStartTime(),
+ getIsPlaying: () => this.playing,
initialMuted,
initialVolume,
toneFrequency: this.toneFrequency,
diff --git a/packages/media/src/test/audio-anchor-reschedule.test.tsx b/packages/media/src/test/audio-anchor-reschedule.test.tsx
index d3b4b630a0b..c2e8a3cc3fd 100644
--- a/packages/media/src/test/audio-anchor-reschedule.test.tsx
+++ b/packages/media/src/test/audio-anchor-reschedule.test.tsx
@@ -1,8 +1,9 @@
import {Player, type PlayerRef} from '@remotion/player';
import React from 'react';
import {createRoot} from 'react-dom/client';
-import {Internals} from 'remotion';
+import {Internals, Sequence} from 'remotion';
import {expect, test, vi} from 'vitest';
+import {page} from 'vitest/browser';
import {Audio} from '../audio/audio';
test('replaces queued audio after a suspension re-anchors a stationary Player', async () => {
@@ -172,3 +173,104 @@ test('paused seeks unschedule unstarted nodes while playing cleanup still stops
vi.restoreAllMocks();
}
});
+
+test.each([
+ {from: 0, playbackRate: 1},
+ {from: 90, playbackRate: 1},
+ {from: 90, playbackRate: 0.01},
+])(
+ 'keep-alive bounds paused audio from frame $from at $playbackRate× without blocking playback',
+ async ({from, playbackRate}) => {
+ const nodes: AudioBufferSourceNode[] = [];
+ const originalCreateBufferSource =
+ AudioContext.prototype.createBufferSource;
+ const createBufferSourceSpy = vi
+ .spyOn(AudioContext.prototype, 'createBufferSource')
+ .mockImplementation(function (this: AudioContext) {
+ const node = originalCreateBufferSource.call(this);
+ nodes.push(node);
+ return node;
+ });
+ let audioContext: AudioContext | null = null;
+ const Composition: React.FC = () => {
+ audioContext =
+ React.useContext(Internals.SharedAudioContext)?.audioContext ?? null;
+ return (
+
+
+
+ );
+ };
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const playerRef = React.createRef();
+ root.render(
+ ,
+ );
+ try {
+ await vi.waitFor(() => {
+ expect(
+ nodes.reduce((sum, node) => sum + (node.buffer?.duration ?? 0), 0),
+ ).toBeGreaterThanOrEqual(0.1);
+ });
+ if (!audioContext) throw new Error('Audio context did not initialize');
+ const context: AudioContext = audioContext;
+ const resumeButton = document.createElement('button');
+ resumeButton.textContent = 'Resume audio context';
+ resumeButton.onclick = () => context.resume();
+ container.appendChild(resumeButton);
+ await page.getByRole('button', {name: 'Resume audio context'}).click();
+ await vi.waitFor(() => expect(context.state).toBe('running'));
+ const startedAt = context.currentTime;
+ // Let the real audio clock move beyond the two-second decode horizon.
+ await vi.waitFor(
+ () => {
+ expect(context.currentTime - startedAt).toBeGreaterThan(3);
+ },
+ {timeout: 5000},
+ );
+ expect(playerRef.current?.getCurrentFrame()).toBe(0);
+ expect(playerRef.current?.isPlaying()).toBe(false);
+ const queuedDuration = nodes.reduce(
+ (sum, node) => sum + (node.buffer?.duration ?? 0),
+ 0,
+ );
+ // The final decoded chunk can straddle the two-second boundary.
+ expect(queuedDuration).toBeGreaterThanOrEqual(
+ Math.max(1.9 * playbackRate, 0.1),
+ );
+ expect(queuedDuration).toBeLessThan(Math.max(2.2 * playbackRate, 0.2));
+ playerRef.current?.play();
+ await vi.waitFor(
+ () => {
+ expect(playerRef.current?.getCurrentFrame()).toBeGreaterThan(
+ from + 5,
+ );
+ },
+ {timeout: 5000},
+ );
+ } finally {
+ root.unmount();
+ container.remove();
+ createBufferSourceSpy.mockRestore();
+ if (audioContext) await (audioContext as AudioContext).close();
+ }
+ },
+ 15000,
+);
diff --git a/packages/media/src/test/audio-iterator.test.ts b/packages/media/src/test/audio-iterator.test.ts
index 2c6189df726..ef802561f81 100644
--- a/packages/media/src/test/audio-iterator.test.ts
+++ b/packages/media/src/test/audio-iterator.test.ts
@@ -57,6 +57,7 @@ const prepare = async (options?: {
let playbackUnblocks = 0;
const manager = audioIteratorManager({
+ getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => {
diff --git a/packages/media/src/test/audio-seek-tolerance.test.ts b/packages/media/src/test/audio-seek-tolerance.test.ts
index 5b866d739c1..a65fdb6f320 100644
--- a/packages/media/src/test/audio-seek-tolerance.test.ts
+++ b/packages/media/src/test/audio-seek-tolerance.test.ts
@@ -79,6 +79,7 @@ test.each([
scheduledTime: 0,
});
const manager = audioIteratorManager({
+ getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => undefined,
diff --git a/packages/media/src/test/video-with-no-frames-in-beginning.test.ts b/packages/media/src/test/video-with-no-frames-in-beginning.test.ts
index 256bcf0b5ad..423332d0f3e 100644
--- a/packages/media/src/test/video-with-no-frames-in-beginning.test.ts
+++ b/packages/media/src/test/video-with-no-frames-in-beginning.test.ts
@@ -108,6 +108,7 @@ test('same goes for audio', async () => {
}
const manager = audioIteratorManager({
+ getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => {},