Refactor AudioTimestampPoller to a smaller API surface

This moves the position estimation and plausibility checks inside
the class.

Mostly a no-op change, except that the plausible checks now use
the estimated timestamp from getTimestamp and the playback head
position to make them actually comparable. Before, we were comparing
the last snapshot states which may not necessarily be close together.
Given the large error threshold of 5 seconds, this shouldn't make
a practical difference though, just avoids noise and confusion.

PiperOrigin-RevId: 754035464
This commit is contained in:
tonihei 2025-05-02 09:42:54 -07:00 committed by Copybara-Service
parent def4794d96
commit ffb8d3ca66
2 changed files with 114 additions and 141 deletions

View File

@ -15,13 +15,14 @@
*/
package androidx.media3.exoplayer.audio;
import static androidx.media3.common.util.Util.sampleCountToDurationUs;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.media.AudioTimestamp;
import android.media.AudioTrack;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@ -31,16 +32,11 @@ import java.lang.annotation.Target;
* Polls the {@link AudioTrack} timestamp, if the platform supports it, taking care of polling at
* the appropriate rate to detect when the timestamp starts to advance.
*
* <p>When the audio track isn't paused, call {@link #maybePollTimestamp(long)} regularly to check
* for timestamp updates. If it returns {@code true}, call {@link #getTimestampPositionFrames()} and
* {@link #getTimestampSystemTimeUs()} to access the updated timestamp, then call {@link
* #acceptTimestamp()} or {@link #rejectTimestamp()} to accept or reject it.
* <p>When the audio track isn't paused, call {@link #maybePollTimestamp} regularly to check for
* timestamp updates.
*
* <p>If {@link #hasTimestamp()} returns {@code true}, call {@link #getTimestampSystemTimeUs()} to
* get the system time at which the latest timestamp was sampled and {@link
* #getTimestampPositionFrames()} to get its position in frames. If {@link #hasAdvancingTimestamp()}
* returns {@code true}, the caller should assume that the timestamp has been increasing in real
* time since it was sampled. Otherwise, it may be stationary.
* <p>If {@link #hasAdvancingTimestamp()} returns {@code true}, call {@link
* #getTimestampPositionUs(long, float)} to get its position.
*
* <p>Call {@link #reset()} when pausing or resuming the track.
*/
@ -91,7 +87,17 @@ import java.lang.annotation.Target;
*/
private static final int INITIALIZING_DURATION_US = 500_000;
@Nullable private final AudioTimestampWrapper audioTimestamp;
/**
* AudioTrack timestamps are deemed spurious if they are offset from the system clock by more than
* this amount.
*
* <p>This is a fail safe that should not be required on correctly functioning devices.
*/
private static final long MAX_AUDIO_TIMESTAMP_OFFSET_US = 5 * C.MICROS_PER_SECOND;
private final AudioTimestampWrapper audioTimestamp;
private final int sampleRate;
private final AudioTrackPositionTracker.Listener errorListener;
private @State int state;
private long initializeSystemTimeUs;
@ -103,28 +109,37 @@ import java.lang.annotation.Target;
* Creates a new audio timestamp poller.
*
* @param audioTrack The audio track that will provide timestamps.
* @param errorListener The {@link AudioTrackPositionTracker.Listener} for timestamp errors.
*/
public AudioTimestampPoller(AudioTrack audioTrack) {
audioTimestamp = new AudioTimestampWrapper(audioTrack);
public AudioTimestampPoller(
AudioTrack audioTrack, AudioTrackPositionTracker.Listener errorListener) {
this.audioTimestamp = new AudioTimestampWrapper(audioTrack);
this.sampleRate = audioTrack.getSampleRate();
this.errorListener = errorListener;
reset();
}
/**
* Polls the timestamp if required and returns whether it was updated. If {@code true}, the latest
* timestamp is available via {@link #getTimestampSystemTimeUs()} and {@link
* #getTimestampPositionFrames()}, and the caller should call {@link #acceptTimestamp()} if the
* timestamp was valid, or {@link #rejectTimestamp()} otherwise. The values returned by {@link
* #hasTimestamp()} and {@link #hasAdvancingTimestamp()} may be updated.
* Polls and updates the timestamp if required.
*
* <p>The value of {@link #hasAdvancingTimestamp()} may have changed after calling this method.
*
* @param systemTimeUs The current system time, in microseconds.
* @return Whether the timestamp was updated.
* @param audioTrackPlaybackSpeed The playback speed of the audio track.
* @param playbackHeadPositionEstimateUs The current position estimate using the playback head
* position, in microseconds.
*/
public boolean maybePollTimestamp(long systemTimeUs) {
if (audioTimestamp == null || (systemTimeUs - lastTimestampSampleTimeUs) < sampleIntervalUs) {
return false;
public void maybePollTimestamp(
long systemTimeUs, float audioTrackPlaybackSpeed, long playbackHeadPositionEstimateUs) {
if ((systemTimeUs - lastTimestampSampleTimeUs) < sampleIntervalUs) {
return;
}
lastTimestampSampleTimeUs = systemTimeUs;
boolean updatedTimestamp = audioTimestamp.maybeUpdateTimestamp();
if (updatedTimestamp) {
checkTimestampIsPlausibleAndUpdateErrorState(
systemTimeUs, audioTrackPlaybackSpeed, playbackHeadPositionEstimateUs);
}
switch (state) {
case STATE_INITIALIZING:
if (updatedTimestamp) {
@ -132,9 +147,6 @@ import java.lang.annotation.Target;
// We have an initial timestamp, but don't know if it's advancing yet.
initialTimestampPositionFrames = audioTimestamp.getTimestampPositionFrames();
updateState(STATE_TIMESTAMP);
} else {
// Drop the timestamp, as it was sampled before the last reset.
updatedTimestamp = false;
}
} else if (systemTimeUs - initializeSystemTimeUs > INITIALIZING_DURATION_US) {
// We haven't received a timestamp for a while, so they probably aren't available for the
@ -172,42 +184,11 @@ import java.lang.annotation.Target;
default:
throw new IllegalStateException();
}
return updatedTimestamp;
}
/**
* Rejects the timestamp last polled in {@link #maybePollTimestamp(long)}. The instance will enter
* the error state and poll timestamps infrequently until the next call to {@link
* #acceptTimestamp()}.
*/
public void rejectTimestamp() {
updateState(STATE_ERROR);
}
/**
* Accepts the timestamp last polled in {@link #maybePollTimestamp(long)}. If the instance is in
* the error state, it will begin to poll timestamps frequently again.
*/
public void acceptTimestamp() {
if (state == STATE_ERROR) {
reset();
}
}
/**
* Returns whether this instance has a timestamp that can be used to calculate the audio track
* position. If {@code true}, call {@link #getTimestampSystemTimeUs()} and {@link
* #getTimestampSystemTimeUs()} to access the timestamp.
*/
public boolean hasTimestamp() {
return state == STATE_TIMESTAMP || state == STATE_TIMESTAMP_ADVANCING;
}
/**
* Returns whether this instance has an advancing timestamp. If {@code true}, call {@link
* #getTimestampSystemTimeUs()} and {@link #getTimestampSystemTimeUs()} to access the timestamp. A
* current position for the track can be extrapolated based on elapsed real time since the system
* time at which the timestamp was sampled.
* #getTimestampPositionUs(long, float)} to access the current timestamp.
*/
public boolean hasAdvancingTimestamp() {
return state == STATE_TIMESTAMP_ADVANCING;
@ -215,25 +196,18 @@ import java.lang.annotation.Target;
/** Resets polling. Should be called whenever the audio track is paused or resumed. */
public void reset() {
if (audioTimestamp != null) {
updateState(STATE_INITIALIZING);
}
updateState(STATE_INITIALIZING);
}
/**
* If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns
* the system time at which the latest timestamp was sampled, in microseconds.
* If {@link #hasAdvancingTimestamp()} returns {@code true}, returns the latest timestamp position
* in microseconds.
*
* @param systemTimeUs The current system time, in microseconds.
* @param audioTrackPlaybackSpeed The playback speed of the audio track.
*/
public long getTimestampSystemTimeUs() {
return audioTimestamp != null ? audioTimestamp.getTimestampSystemTimeUs() : C.TIME_UNSET;
}
/**
* If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns
* the latest timestamp's position in frames.
*/
public long getTimestampPositionFrames() {
return audioTimestamp != null ? audioTimestamp.getTimestampPositionFrames() : C.INDEX_UNSET;
public long getTimestampPositionUs(long systemTimeUs, float audioTrackPlaybackSpeed) {
return computeTimestampPositionUs(systemTimeUs, audioTrackPlaybackSpeed);
}
/**
@ -241,9 +215,7 @@ import java.lang.annotation.Target;
* transition and reusing of the {@link AudioTrack}.
*/
public void expectTimestampFramePositionReset() {
if (audioTimestamp != null) {
audioTimestamp.expectTimestampFramePositionReset();
}
audioTimestamp.expectTimestampFramePositionReset();
}
private void updateState(@State int state) {
@ -271,6 +243,39 @@ import java.lang.annotation.Target;
}
}
private long computeTimestampPositionUs(long systemTimeUs, float audioTrackPlaybackSpeed) {
long timestampPositionFrames = audioTimestamp.getTimestampPositionFrames();
long timestampPositionUs = sampleCountToDurationUs(timestampPositionFrames, sampleRate);
long elapsedSinceTimestampUs = systemTimeUs - audioTimestamp.getTimestampSystemTimeUs();
elapsedSinceTimestampUs =
Util.getMediaDurationForPlayoutDuration(elapsedSinceTimestampUs, audioTrackPlaybackSpeed);
return timestampPositionUs + elapsedSinceTimestampUs;
}
private void checkTimestampIsPlausibleAndUpdateErrorState(
long systemTimeUs, float audioTrackPlaybackSpeed, long playbackHeadPositionEstimateUs) {
long timestampSystemTimeUs = audioTimestamp.getTimestampSystemTimeUs();
long timestampPositionUs = computeTimestampPositionUs(systemTimeUs, audioTrackPlaybackSpeed);
if (Math.abs(timestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) {
errorListener.onSystemTimeUsMismatch(
audioTimestamp.getTimestampPositionFrames(),
timestampSystemTimeUs,
systemTimeUs,
playbackHeadPositionEstimateUs);
updateState(STATE_ERROR);
} else if (Math.abs(timestampPositionUs - playbackHeadPositionEstimateUs)
> MAX_AUDIO_TIMESTAMP_OFFSET_US) {
errorListener.onPositionFramesMismatch(
audioTimestamp.getTimestampPositionFrames(),
timestampSystemTimeUs,
systemTimeUs,
playbackHeadPositionEstimateUs);
updateState(STATE_ERROR);
} else if (state == STATE_ERROR) {
reset();
}
}
private static final class AudioTimestampWrapper {
private final AudioTrack audioTrack;

View File

@ -137,14 +137,6 @@ import java.lang.reflect.Method;
*/
private static final int PLAYSTATE_PLAYING = AudioTrack.PLAYSTATE_PLAYING;
/**
* AudioTrack timestamps are deemed spurious if they are offset from the system clock by more than
* this amount.
*
* <p>This is a fail safe that should not be required on correctly functioning devices.
*/
private static final long MAX_AUDIO_TIMESTAMP_OFFSET_US = 5 * C.MICROS_PER_SECOND;
/**
* AudioTrack latencies are deemed impossibly large if they are greater than this amount.
*
@ -256,7 +248,7 @@ import java.lang.reflect.Method;
this.audioTrack = audioTrack;
this.outputPcmFrameSize = outputPcmFrameSize;
this.bufferSize = bufferSize;
audioTimestampPoller = new AudioTimestampPoller(audioTrack);
audioTimestampPoller = new AudioTimestampPoller(audioTrack, listener);
outputSampleRate = audioTrack.getSampleRate();
needsPassthroughWorkarounds = isPassthrough && needsPassthroughWorkarounds(outputEncoding);
isOutputPcm = Util.isEncodingLinearPcm(outputEncoding);
@ -297,40 +289,12 @@ import java.lang.reflect.Method;
// If the device supports it, use the playback timestamp from AudioTrack.getTimestamp.
// Otherwise, derive a smoothed position by sampling the track's frame position.
long systemTimeUs = clock.nanoTime() / 1000;
long positionUs;
AudioTimestampPoller audioTimestampPoller = checkNotNull(this.audioTimestampPoller);
boolean useGetTimestampMode = audioTimestampPoller.hasAdvancingTimestamp();
if (useGetTimestampMode) {
// Calculate the speed-adjusted position using the timestamp (which may be in the future).
long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames();
long timestampPositionUs = sampleCountToDurationUs(timestampPositionFrames, outputSampleRate);
long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs();
elapsedSinceTimestampUs =
Util.getMediaDurationForPlayoutDuration(elapsedSinceTimestampUs, audioTrackPlaybackSpeed);
positionUs = timestampPositionUs + elapsedSinceTimestampUs;
} else {
if (playheadOffsetCount == 0) {
// The AudioTrack has started, but we don't have any samples to compute a smoothed position.
positionUs =
stopTimestampUs != C.TIME_UNSET
? sampleCountToDurationUs(
getSimulatedPlaybackHeadPositionAfterStop(), outputSampleRate)
: getPlaybackHeadPositionUs();
} else {
// getPlaybackHeadPositionUs() only has a granularity of ~20 ms, so we base the position off
// the system clock (and a smoothed offset between it and the playhead position) so as to
// prevent jitter in the reported positions.
positionUs =
Util.getMediaDurationForPlayoutDuration(
systemTimeUs + smoothedPlayheadOffsetUs, audioTrackPlaybackSpeed);
}
positionUs = max(0, positionUs - latencyUs);
if (stopTimestampUs != C.TIME_UNSET) {
positionUs =
min(sampleCountToDurationUs(endPlaybackHeadPosition, outputSampleRate), positionUs);
}
}
long positionUs =
useGetTimestampMode
? audioTimestampPoller.getTimestampPositionUs(systemTimeUs, audioTrackPlaybackSpeed)
: getPlaybackHeadPositionEstimateUs(systemTimeUs);
if (lastSampleUsedGetTimestampMode != useGetTimestampMode) {
// We've switched sampling mode.
@ -565,33 +529,11 @@ import java.lang.reflect.Method;
return;
}
maybePollAndCheckTimestamp(systemTimeUs);
maybeUpdateLatency(systemTimeUs);
}
private void maybePollAndCheckTimestamp(long systemTimeUs) {
AudioTimestampPoller audioTimestampPoller = checkNotNull(this.audioTimestampPoller);
if (!audioTimestampPoller.maybePollTimestamp(systemTimeUs)) {
return;
}
// Check the timestamp and accept/reject it.
long timestampSystemTimeUs = audioTimestampPoller.getTimestampSystemTimeUs();
long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames();
long playbackPositionUs = getPlaybackHeadPositionUs();
if (Math.abs(timestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) {
listener.onSystemTimeUsMismatch(
timestampPositionFrames, timestampSystemTimeUs, systemTimeUs, playbackPositionUs);
audioTimestampPoller.rejectTimestamp();
} else if (Math.abs(
sampleCountToDurationUs(timestampPositionFrames, outputSampleRate) - playbackPositionUs)
> MAX_AUDIO_TIMESTAMP_OFFSET_US) {
listener.onPositionFramesMismatch(
timestampPositionFrames, timestampSystemTimeUs, systemTimeUs, playbackPositionUs);
audioTimestampPoller.rejectTimestamp();
} else {
audioTimestampPoller.acceptTimestamp();
}
checkNotNull(this.audioTimestampPoller)
.maybePollTimestamp(
systemTimeUs, audioTrackPlaybackSpeed, getPlaybackHeadPositionEstimateUs(systemTimeUs));
}
private void maybeUpdateLatency(long systemTimeUs) {
@ -619,6 +561,32 @@ import java.lang.reflect.Method;
}
}
private long getPlaybackHeadPositionEstimateUs(long systemTimeUs) {
long positionUs;
if (playheadOffsetCount == 0) {
// The AudioTrack has started, but we don't have any samples to compute a smoothed position.
positionUs =
stopTimestampUs != C.TIME_UNSET
? sampleCountToDurationUs(
getSimulatedPlaybackHeadPositionAfterStop(), outputSampleRate)
: getPlaybackHeadPositionUs();
} else {
// getPlaybackHeadPositionUs() only has a granularity of ~20 ms, so we base the position off
// the system clock (and a smoothed offset between it and the playhead position) so as to
// prevent jitter in the reported positions.
positionUs =
Util.getMediaDurationForPlayoutDuration(
systemTimeUs + smoothedPlayheadOffsetUs, audioTrackPlaybackSpeed);
}
positionUs = max(0, positionUs - latencyUs);
if (stopTimestampUs != C.TIME_UNSET) {
positionUs =
min(sampleCountToDurationUs(endPlaybackHeadPosition, outputSampleRate), positionUs);
}
return positionUs;
}
private void resetSyncParams() {
smoothedPlayheadOffsetUs = 0;
playheadOffsetCount = 0;