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
This commit is contained in:
ivanbuper 2025-05-01 05:18:24 -07:00 committed by Copybara-Service
parent 8bcef5df6d
commit e06e9b4cc9
3 changed files with 104 additions and 3 deletions

View File

@ -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

View File

@ -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()

View File

@ -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) {