diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 57774c8b01..4a65501887 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,6 +35,9 @@ * Make `ChannelMappingAudioProcessor`, `TrimmingAudioProcessor` and `ToFloatPcmAudioProcessor` public ([#2339](https://github.com/androidx/media/issues/2339)). + * Use `AudioTrack#getUnderrunCount()` in `AudioTrackPositionTracker` to + detect underruns in `DefaultAudioSink` instead of best-effort + estimation. * Video: * Add experimental `ExoPlayer` API to include the `MediaCodec.BUFFER_FLAG_DECODE_ONLY` flag when queuing decode-only input diff --git a/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java b/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java index f72d07b17b..3025de70ec 100644 --- a/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java +++ b/libraries/exoplayer/src/androidTest/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java @@ -15,8 +15,11 @@ */ package androidx.media3.exoplayer.audio; +import static androidx.media3.common.util.Util.sampleCountToDurationUs; +import static androidx.media3.common.util.Util.usToMs; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.google.common.collect.Iterables.getLast; +import static com.google.common.truth.Truth.assertThat; import android.content.Context; import androidx.media3.common.C; @@ -28,6 +31,7 @@ import androidx.test.filters.SdkSuppress; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; import org.junit.runner.RunWith; @@ -73,6 +77,74 @@ public class DefaultAudioSinkTest { }); } + @Test + @SdkSuppress(minSdkVersion = 24) // The test depends on AudioTrack#getUnderrunCount() (API 24+). + public void audioTrackUnderruns_callsOnUnderrun() throws InterruptedException { + AtomicInteger underrunCount = new AtomicInteger(); + DefaultAudioSink sink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + sink.setListener( + new AudioSink.Listener() { + @Override + public void onPositionDiscontinuity() {} + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + underrunCount.addAndGet(1); + } + + @Override + public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) {} + }); + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(1) + .setSampleRate(44_100) + .build(); + // Create a big buffer to prime the sink's AudioTrack (~113ms). + long bigBufferDurationUs = + sampleCountToDurationUs(/* sampleCount= */ 5000, /* sampleRate= */ 44_100); + ByteBuffer bigBuffer = ByteBuffer.allocateDirect(5000 * 2).order(ByteOrder.nativeOrder()); + + // Create a buffer smaller than sink buffer size to eventually cause an underrun (~567us). + long smallBufferDurationUs = + sampleCountToDurationUs(/* sampleCount= */ 25, /* sampleRate= */ 44_100); + ByteBuffer smallBuffer = ByteBuffer.allocateDirect(50).order(ByteOrder.nativeOrder()); + + getInstrumentation() + .runOnMainSync( + () -> { + try { + // Set buffer size of ~1.1ms. The tiny size helps cause an underrun. + sink.configure(format, /* specifiedBufferSize= */ 100, /* outputChannels= */ null); + + // Prime AudioTrack with buffer larger than start threshold. Otherwise, AudioTrack + // won't start playing. + sink.handleBuffer( + bigBuffer, /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); + sink.play(); + // Sleep until AudioTrack starts running out of queued samples. + Thread.sleep(usToMs(bigBufferDurationUs)); + for (int i = 0; i < 5; i++) { + smallBuffer.rewind(); + // Queue small buffer so that sink buffer is never filled up. + sink.handleBuffer( + smallBuffer, + /* presentationTimeUs= */ bigBufferDurationUs + smallBufferDurationUs * i, + /* encodedAccessUnitCount= */ 1); + // Add additional latency so loop can never fill up sink buffer quickly enough. + Thread.sleep(20); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + assertThat(underrunCount.get()).isGreaterThan(0); + } + private void configureAudioSinkAndFeedData(DefaultAudioSink audioSink) throws Exception { Format format = new Format.Builder() diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java index 455ef2a7ab..45507c79b5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioTrackPositionTracker.java @@ -29,6 +29,7 @@ import android.media.AudioTimestamp; import android.media.AudioTrack; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.media3.common.C; import androidx.media3.common.util.Clock; import androidx.media3.common.util.Util; @@ -175,6 +176,7 @@ import java.lang.reflect.Method; private long bufferSizeUs; private float audioTrackPlaybackSpeed; private boolean notifiedPositionIncreasing; + private int lastUnderrunCount; private long smoothedPlayheadOffsetUs; private long lastPlayheadSampleTimeUs; @@ -273,6 +275,7 @@ import java.lang.reflect.Method; lastLatencySampleTimeUs = 0; latencyUs = 0; audioTrackPlaybackSpeed = 1f; + lastUnderrunCount = 0; } public void setAudioTrackPlaybackSpeed(float audioTrackPlaybackSpeed) { @@ -408,9 +411,17 @@ import java.lang.reflect.Method; } } - boolean hadData = hasData; - hasData = hasPendingData(writtenFrames); - if (hadData && !hasData && playState != PLAYSTATE_STOPPED) { + boolean emitUnderrun; + if (SDK_INT >= 24) { + emitUnderrun = hasPendingAudioTrackUnderruns(); + } else { + boolean hadData = hasData; + hasData = hasPendingData(writtenFrames); + // For API 23- AudioTrack has no underrun API so we need to infer underruns heuristically. + emitUnderrun = hadData && !hasData && playState != PLAYSTATE_STOPPED; + } + + if (emitUnderrun) { listener.onUnderrun(bufferSize, Util.usToMs(bufferSizeUs)); } @@ -510,6 +521,21 @@ import java.lang.reflect.Method; this.clock = clock; } + /** + * Returns whether {@link #audioTrack} has reported one or more underruns since the last call to + * this method. + */ + @RequiresApi(24) + private boolean hasPendingAudioTrackUnderruns() { + int underrunCount = checkNotNull(audioTrack).getUnderrunCount(); + boolean result = underrunCount > lastUnderrunCount; + + // If the AudioTrack unexpectedly resets the underrun count, we should update it silently. + lastUnderrunCount = underrunCount; + + return result; + } + private void maybeSampleSyncParams() { long systemTimeUs = clock.nanoTime() / 1000; if (systemTimeUs - lastPlayheadSampleTimeUs >= MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US) {