From e06e9b4cc9d270d2fbcf549d17fdaaaa14126b7f Mon Sep 17 00:00:00 2001 From: ivanbuper Date: Thu, 1 May 2025 05:18:24 -0700 Subject: [PATCH] Detect underruns with AudioTrack#getUnderrunCount() in DefaultAudioSink Prior to this change, DefaultAudioSink (via AudioTrackPositionTracker) would use best-effort logic to infer underruns in the underlying AudioTrack. This logic would miss underrun events (e.g. newly added test fails if run without any changes to AudioTrackPositionTracker). This change should help more accurately detect regressions in the audio pipeline. PiperOrigin-RevId: 753550187 --- RELEASENOTES.md | 3 + .../exoplayer/audio/DefaultAudioSinkTest.java | 72 +++++++++++++++++++ .../audio/AudioTrackPositionTracker.java | 32 ++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) 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) {