Use ceiling divide logic in AudioTrackPositionTracker.hasPendingData

This fixes a bug with playing very short audio files, introduced by
fe710871aa

The existing code using floor integer division results in playback never
transitioning to `STATE_ENDED` because at the end of playback for the
short sample clip provided `currentPositionUs=189937`,
`outputSampleRate=16000` and `(189937 * 16000) / 1000000 = 3038.992`,
while `writtenFrames=3039`. This is fixed by using `Util.ceilDivide`
so we return `3039`, which means
`AudioTrackPositionTracker.hasPendingData()` returns `false` (since
`writtenFrames ==
durationUsToFrames(getCurrentPositionUs(/* sourceEnded= */ false))`).

Issue: androidx/media#538
PiperOrigin-RevId: 554481782
(cherry picked from commit 6e91f0d4c5)
This commit is contained in:
ibaker 2023-08-07 15:17:31 +00:00 committed by Tianyi Feng
parent 4b3a4b3395
commit 7e58fde18e
5 changed files with 74 additions and 27 deletions

View file

@ -15,6 +15,10 @@
* Add additional fields to Common Media Client Data (CMCD) logging: * Add additional fields to Common Media Client Data (CMCD) logging:
streaming format (sf), stream type (st), version (v), top birate (tb), streaming format (sf), stream type (st), version (v), top birate (tb),
object duration (d) and measured throughput (mtp). object duration (d) and measured throughput (mtp).
* Audio:
* Fix a bug where `Player.getState()` never transitioned to `STATE_ENDED`
when playing very short files
([#538](https://github.com/androidx/media/issues/538)).
* Audio Offload: * Audio Offload:
* Prepend Ogg ID Header and Comment Header Pages to bitstream for * Prepend Ogg ID Header and Comment Header Pages to bitstream for
offloaded Opus playback in accordance with RFC 7845. offloaded Opus playback in accordance with RFC 7845.

View file

@ -1430,6 +1430,40 @@ public final class Util {
return (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) ? timeMs : (timeMs * 1000); return (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) ? timeMs : (timeMs * 1000);
} }
/**
* Returns the total duration (in microseconds) of {@code sampleCount} samples of equal duration
* at {@code sampleRate}.
*
* <p>If {@code sampleRate} is less than {@link C#MICROS_PER_SECOND}, the duration produced by
* this method can be reversed to the original sample count using {@link
* #durationUsToSampleCount(long, int)}.
*
* @param sampleCount The number of samples.
* @param sampleRate The sample rate, in samples per second.
* @return The total duration, in microseconds, of {@code sampleCount} samples.
*/
@UnstableApi
public static long sampleCountToDurationUs(long sampleCount, int sampleRate) {
return (sampleCount * C.MICROS_PER_SECOND) / sampleRate;
}
/**
* Returns the number of samples required to represent {@code durationUs} of media at {@code
* sampleRate}, assuming all samples are equal duration except the last one which may be shorter.
*
* <p>The result of this method <b>cannot</b> be generally reversed to the original duration with
* {@link #sampleCountToDurationUs(long, int)}, due to information lost when rounding to a whole
* number of samples.
*
* @param durationUs The duration in microseconds.
* @param sampleRate The sample rate in samples per second.
* @return The number of samples required to represent {@code durationUs}.
*/
@UnstableApi
public static long durationUsToSampleCount(long durationUs, int sampleRate) {
return Util.ceilDivide(durationUs * sampleRate, C.MICROS_PER_SECOND);
}
/** /**
* Parses an xs:duration attribute value, returning the parsed duration in milliseconds. * Parses an xs:duration attribute value, returning the parsed duration in milliseconds.
* *

View file

@ -27,6 +27,7 @@ import static androidx.media3.common.util.Util.parseXsDateTime;
import static androidx.media3.common.util.Util.parseXsDuration; import static androidx.media3.common.util.Util.parseXsDuration;
import static androidx.media3.common.util.Util.unescapeFileName; import static androidx.media3.common.util.Util.unescapeFileName;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@ -832,6 +833,21 @@ public class UtilTest {
assertThrows(NoSuchElementException.class, () -> maxValue(new SparseLongArray())); assertThrows(NoSuchElementException.class, () -> maxValue(new SparseLongArray()));
} }
@Test
public void sampleCountToDuration_thenDurationToSampleCount_returnsOriginalValue() {
// Use co-prime increments, to maximise 'discord' between sampleCount and sampleRate.
for (long originalSampleCount = 0; originalSampleCount < 100_000; originalSampleCount += 97) {
for (int sampleRate = 89; sampleRate < 1_000_000; sampleRate += 89) {
long calculatedSampleCount =
Util.durationUsToSampleCount(
Util.sampleCountToDurationUs(originalSampleCount, sampleRate), sampleRate);
assertWithMessage("sampleCount=%s, sampleRate=%s", originalSampleCount, sampleRate)
.that(calculatedSampleCount)
.isEqualTo(originalSampleCount);
}
}
}
@Test @Test
public void parseXsDuration_returnsParsedDurationInMillis() { public void parseXsDuration_returnsParsedDurationInMillis() {
assertThat(parseXsDuration("PT150.279S")).isEqualTo(150279L); assertThat(parseXsDuration("PT150.279S")).isEqualTo(150279L);

View file

@ -17,6 +17,8 @@ package androidx.media3.exoplayer.audio;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.castNonNull; import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.common.util.Util.durationUsToSampleCount;
import static androidx.media3.common.util.Util.sampleCountToDurationUs;
import static java.lang.Math.max; import static java.lang.Math.max;
import static java.lang.Math.min; import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
@ -238,7 +240,10 @@ import java.lang.reflect.Method;
outputSampleRate = audioTrack.getSampleRate(); outputSampleRate = audioTrack.getSampleRate();
needsPassthroughWorkarounds = isPassthrough && needsPassthroughWorkarounds(outputEncoding); needsPassthroughWorkarounds = isPassthrough && needsPassthroughWorkarounds(outputEncoding);
isOutputPcm = Util.isEncodingLinearPcm(outputEncoding); isOutputPcm = Util.isEncodingLinearPcm(outputEncoding);
bufferSizeUs = isOutputPcm ? framesToDurationUs(bufferSize / outputPcmFrameSize) : C.TIME_UNSET; bufferSizeUs =
isOutputPcm
? sampleCountToDurationUs(bufferSize / outputPcmFrameSize, outputSampleRate)
: C.TIME_UNSET;
rawPlaybackHeadPosition = 0; rawPlaybackHeadPosition = 0;
rawPlaybackHeadWrapCount = 0; rawPlaybackHeadWrapCount = 0;
passthroughWorkaroundPauseOffset = 0; passthroughWorkaroundPauseOffset = 0;
@ -274,7 +279,7 @@ import java.lang.reflect.Method;
if (useGetTimestampMode) { if (useGetTimestampMode) {
// Calculate the speed-adjusted position using the timestamp (which may be in the future). // Calculate the speed-adjusted position using the timestamp (which may be in the future).
long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames();
long timestampPositionUs = framesToDurationUs(timestampPositionFrames); long timestampPositionUs = sampleCountToDurationUs(timestampPositionFrames, outputSampleRate);
long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs(); long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs();
elapsedSinceTimestampUs = elapsedSinceTimestampUs =
Util.getMediaDurationForPlayoutDuration(elapsedSinceTimestampUs, audioTrackPlaybackSpeed); Util.getMediaDurationForPlayoutDuration(elapsedSinceTimestampUs, audioTrackPlaybackSpeed);
@ -420,7 +425,8 @@ import java.lang.reflect.Method;
* @return Whether the audio track has any pending data to play out. * @return Whether the audio track has any pending data to play out.
*/ */
public boolean hasPendingData(long writtenFrames) { public boolean hasPendingData(long writtenFrames) {
return writtenFrames > durationUsToFrames(getCurrentPositionUs(/* sourceEnded= */ false)) long currentPositionUs = getCurrentPositionUs(/* sourceEnded= */ false);
return writtenFrames > durationUsToSampleCount(currentPositionUs, outputSampleRate)
|| forceHasPendingData(); || forceHasPendingData();
} }
@ -491,23 +497,18 @@ import java.lang.reflect.Method;
} }
// Check the timestamp and accept/reject it. // Check the timestamp and accept/reject it.
long audioTimestampSystemTimeUs = audioTimestampPoller.getTimestampSystemTimeUs(); long timestampSystemTimeUs = audioTimestampPoller.getTimestampSystemTimeUs();
long audioTimestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames();
long playbackPositionUs = getPlaybackHeadPositionUs(); long playbackPositionUs = getPlaybackHeadPositionUs();
if (Math.abs(audioTimestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) { if (Math.abs(timestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) {
listener.onSystemTimeUsMismatch( listener.onSystemTimeUsMismatch(
audioTimestampPositionFrames, timestampPositionFrames, timestampSystemTimeUs, systemTimeUs, playbackPositionUs);
audioTimestampSystemTimeUs,
systemTimeUs,
playbackPositionUs);
audioTimestampPoller.rejectTimestamp(); audioTimestampPoller.rejectTimestamp();
} else if (Math.abs(framesToDurationUs(audioTimestampPositionFrames) - playbackPositionUs) } else if (Math.abs(
sampleCountToDurationUs(timestampPositionFrames, outputSampleRate) - playbackPositionUs)
> MAX_AUDIO_TIMESTAMP_OFFSET_US) { > MAX_AUDIO_TIMESTAMP_OFFSET_US) {
listener.onPositionFramesMismatch( listener.onPositionFramesMismatch(
audioTimestampPositionFrames, timestampPositionFrames, timestampSystemTimeUs, systemTimeUs, playbackPositionUs);
audioTimestampSystemTimeUs,
systemTimeUs,
playbackPositionUs);
audioTimestampPoller.rejectTimestamp(); audioTimestampPoller.rejectTimestamp();
} else { } else {
audioTimestampPoller.acceptTimestamp(); audioTimestampPoller.acceptTimestamp();
@ -539,14 +540,6 @@ import java.lang.reflect.Method;
} }
} }
private long framesToDurationUs(long frameCount) {
return (frameCount * C.MICROS_PER_SECOND) / outputSampleRate;
}
private long durationUsToFrames(long durationUs) {
return (durationUs * outputSampleRate) / C.MICROS_PER_SECOND;
}
private void resetSyncParams() { private void resetSyncParams() {
smoothedPlayheadOffsetUs = 0; smoothedPlayheadOffsetUs = 0;
playheadOffsetCount = 0; playheadOffsetCount = 0;
@ -578,7 +571,7 @@ import java.lang.reflect.Method;
} }
private long getPlaybackHeadPositionUs() { private long getPlaybackHeadPositionUs() {
return framesToDurationUs(getPlaybackHeadPosition()); return sampleCountToDurationUs(getPlaybackHeadPosition(), outputSampleRate);
} }
/** /**
@ -596,7 +589,7 @@ import java.lang.reflect.Method;
long elapsedTimeSinceStopUs = (currentTimeMs * 1000) - stopTimestampUs; long elapsedTimeSinceStopUs = (currentTimeMs * 1000) - stopTimestampUs;
long mediaTimeSinceStopUs = long mediaTimeSinceStopUs =
Util.getMediaDurationForPlayoutDuration(elapsedTimeSinceStopUs, audioTrackPlaybackSpeed); Util.getMediaDurationForPlayoutDuration(elapsedTimeSinceStopUs, audioTrackPlaybackSpeed);
long framesSinceStop = durationUsToFrames(mediaTimeSinceStopUs); long framesSinceStop = durationUsToSampleCount(mediaTimeSinceStopUs, outputSampleRate);
return min(endPlaybackHeadPosition, stopPlaybackHeadPosition + framesSinceStop); return min(endPlaybackHeadPosition, stopPlaybackHeadPosition + framesSinceStop);
} }
if (currentTimeMs - lastRawPlaybackHeadPositionSampleTimeMs if (currentTimeMs - lastRawPlaybackHeadPositionSampleTimeMs

View file

@ -2086,11 +2086,11 @@ public final class DefaultAudioSink implements AudioSink {
} }
public long inputFramesToDurationUs(long frameCount) { public long inputFramesToDurationUs(long frameCount) {
return (frameCount * C.MICROS_PER_SECOND) / inputFormat.sampleRate; return Util.sampleCountToDurationUs(frameCount, inputFormat.sampleRate);
} }
public long framesToDurationUs(long frameCount) { public long framesToDurationUs(long frameCount) {
return (frameCount * C.MICROS_PER_SECOND) / outputSampleRate; return Util.sampleCountToDurationUs(frameCount, outputSampleRate);
} }
public AudioTrack buildAudioTrack( public AudioTrack buildAudioTrack(