Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/media/src/audio-iterator-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const audioIteratorManager = ({
getSequenceDurationInSeconds,
getMediaEndTimestamp,
getStartTime,
getIsPlaying,
initialMuted,
initialVolume,
toneFrequency,
Expand All @@ -89,6 +90,7 @@ export const audioIteratorManager = ({
getSequenceDurationInSeconds: () => number;
getMediaEndTimestamp: () => number;
getStartTime: () => number;
getIsPlaying: () => boolean;
initialMuted: boolean;
initialVolume: number;
toneFrequency: number;
Expand Down Expand Up @@ -280,6 +282,7 @@ export const audioIteratorManager = ({
onDone,
logLevel,
currentTime,
startFromSecond,
getAudioContextCurrentTimeMockedInTest,
}: {
iterator: AudioIterator;
Expand All @@ -298,6 +301,7 @@ export const audioIteratorManager = ({
onDestroyed: () => void;
logLevel: LogLevel;
currentTime: number;
startFromSecond: number;
getAudioContextCurrentTimeMockedInTest: () => number;
}) => {
waitForTurn({
Expand All @@ -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) => {
Expand Down Expand Up @@ -364,6 +377,7 @@ export const audioIteratorManager = ({
onDone,
logLevel,
currentTime,
startFromSecond,
getAudioContextCurrentTimeMockedInTest,
});
next();
Expand Down Expand Up @@ -512,6 +526,7 @@ export const audioIteratorManager = ({
},
logLevel,
currentTime: sharedAudioContext.audioContext.currentTime,
startFromSecond,
getAudioContextCurrentTimeMockedInTest,
});
};
Expand Down
1 change: 1 addition & 0 deletions packages/media/src/media-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ export class MediaPlayer {
getMediaEndTimestamp: () => this.getMediaEndTimestamp(),
getSequenceEndTimestamp: () => this.getSequenceEndTimestamp(),
getStartTime: () => this.getStartTime(),
getIsPlaying: () => this.playing,
initialMuted,
initialVolume,
toneFrequency: this.toneFrequency,
Expand Down
104 changes: 103 additions & 1 deletion packages/media/src/test/audio-anchor-reschedule.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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 (
<Sequence from={from} premountFor={from}>
<Audio
src="/voice-note.m4a"
playbackRate={playbackRate}
disallowFallbackToHtml5Audio
/>
</Sequence>
);
};

const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const playerRef = React.createRef<PlayerRef>();
root.render(
<Player
ref={playerRef}
acknowledgeRemotionLicense
component={Composition}
compositionWidth={100}
compositionHeight={100}
durationInFrames={4000}
fps={30}
inputProps={{}}
_experimentalKeepAudioContextAlive
/>,
);
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,
);
1 change: 1 addition & 0 deletions packages/media/src/test/audio-iterator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const prepare = async (options?: {
let playbackUnblocks = 0;

const manager = audioIteratorManager({
getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => {
Expand Down
1 change: 1 addition & 0 deletions packages/media/src/test/audio-seek-tolerance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ test.each([
scheduledTime: 0,
});
const manager = audioIteratorManager({
getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ test('same goes for audio', async () => {
}

const manager = audioIteratorManager({
getIsPlaying: () => true,
audioTrack,
delayPlaybackHandleIfNotPremounting: () => ({
unblock: () => {},
Expand Down
Loading