diff --git a/extensions/flac/src/androidTest/assets/bear-flac.mka b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka similarity index 100% rename from extensions/flac/src/androidTest/assets/bear-flac.mka rename to extensions/flac/src/androidTest/assets/bear-flac-16bit.mka diff --git a/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump new file mode 100644 index 0000000000..9615ff0a6e --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump @@ -0,0 +1,91 @@ +config: + encoding = 2 (16 bit) + channel count = 2 + sample rate = 48000 +buffer: + time = 1000 + data = 1217833679 +buffer: + time = 97000 + data = 558614672 +buffer: + time = 193000 + data = -709714787 +buffer: + time = 289000 + data = 1367870571 +buffer: + time = 385000 + data = -141229457 +buffer: + time = 481000 + data = 1287758361 +buffer: + time = 577000 + data = 1125289147 +buffer: + time = 673000 + data = -1677383475 +buffer: + time = 769000 + data = 2130742861 +buffer: + time = 865000 + data = -1292320253 +buffer: + time = 961000 + data = -456587163 +buffer: + time = 1057000 + data = 748981534 +buffer: + time = 1153000 + data = 1550456016 +buffer: + time = 1249000 + data = 1657906039 +buffer: + time = 1345000 + data = -762677083 +buffer: + time = 1441000 + data = -1343810763 +buffer: + time = 1537000 + data = 1137318783 +buffer: + time = 1633000 + data = -1891318229 +buffer: + time = 1729000 + data = -472068495 +buffer: + time = 1825000 + data = 832315001 +buffer: + time = 1921000 + data = 2054935175 +buffer: + time = 2017000 + data = 57921641 +buffer: + time = 2113000 + data = 2132759067 +buffer: + time = 2209000 + data = -1742540521 +buffer: + time = 2305000 + data = 1657024301 +buffer: + time = 2401000 + data = -585080145 +buffer: + time = 2497000 + data = 427271397 +buffer: + time = 2593000 + data = -364201340 +buffer: + time = 2689000 + data = -627965287 diff --git a/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka new file mode 100644 index 0000000000..e6d124e0ce Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka differ diff --git a/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump new file mode 100644 index 0000000000..efc3e0e9d0 --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump @@ -0,0 +1,91 @@ +config: + encoding = 536870912 (24 bit) + channel count = 2 + sample rate = 48000 +buffer: + time = 0 + data = 225023649 +buffer: + time = 96000 + data = 455106306 +buffer: + time = 192000 + data = 2025727297 +buffer: + time = 288000 + data = 758514657 +buffer: + time = 384000 + data = 1044986473 +buffer: + time = 480000 + data = -2030029695 +buffer: + time = 576000 + data = 1907053281 +buffer: + time = 672000 + data = -1974954431 +buffer: + time = 768000 + data = -206248383 +buffer: + time = 864000 + data = 1484984417 +buffer: + time = 960000 + data = -1306117439 +buffer: + time = 1056000 + data = 692829792 +buffer: + time = 1152000 + data = 1070563058 +buffer: + time = 1248000 + data = -1444096479 +buffer: + time = 1344000 + data = 1753016419 +buffer: + time = 1440000 + data = 1947797953 +buffer: + time = 1536000 + data = 266121411 +buffer: + time = 1632000 + data = 1275494369 +buffer: + time = 1728000 + data = 372077825 +buffer: + time = 1824000 + data = -993079679 +buffer: + time = 1920000 + data = 177307937 +buffer: + time = 2016000 + data = 2037083009 +buffer: + time = 2112000 + data = -435776287 +buffer: + time = 2208000 + data = 1867447329 +buffer: + time = 2304000 + data = 1884495937 +buffer: + time = 2400000 + data = -804673375 +buffer: + time = 2496000 + data = -588531007 +buffer: + time = 2592000 + data = -1064642970 +buffer: + time = 2688000 + data = -1771406207 diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index bf96442f61..47e33fce38 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -25,9 +25,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.audio.AudioProcessor; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.testutil.CapturingAudioSink; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.junit.Before; import org.junit.Test; @@ -37,7 +41,8 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class FlacPlaybackTest { - private static final String BEAR_FLAC_URI = "asset:///bear-flac.mka"; + private static final String BEAR_FLAC_16BIT = "bear-flac-16bit.mka"; + private static final String BEAR_FLAC_24BIT = "bear-flac-24bit.mka"; @Before public void setUp() { @@ -47,38 +52,56 @@ public class FlacPlaybackTest { } @Test - public void testBasicPlayback() throws Exception { - playUri(BEAR_FLAC_URI); + public void test16BitPlayback() throws Exception { + playAndAssertAudioSinkInput(BEAR_FLAC_16BIT); } - private void playUri(String uri) throws Exception { + @Test + public void test24BitPlayback() throws Exception { + playAndAssertAudioSinkInput(BEAR_FLAC_24BIT); + } + + private static void playAndAssertAudioSinkInput(String fileName) throws Exception { + CapturingAudioSink audioSink = + new CapturingAudioSink( + new DefaultAudioSink(/* audioCapabilities= */ null, new AudioProcessor[0])); + TestPlaybackRunnable testPlaybackRunnable = - new TestPlaybackRunnable(Uri.parse(uri), ApplicationProvider.getApplicationContext()); + new TestPlaybackRunnable( + Uri.parse("asset:///" + fileName), + ApplicationProvider.getApplicationContext(), + audioSink); Thread thread = new Thread(testPlaybackRunnable); thread.start(); thread.join(); if (testPlaybackRunnable.playbackException != null) { throw testPlaybackRunnable.playbackException; } + + audioSink.assertOutput( + ApplicationProvider.getApplicationContext(), fileName + ".audiosink.dump"); } private static class TestPlaybackRunnable implements Player.EventListener, Runnable { private final Context context; private final Uri uri; + private final AudioSink audioSink; private ExoPlayer player; private ExoPlaybackException playbackException; - public TestPlaybackRunnable(Uri uri, Context context) { + public TestPlaybackRunnable(Uri uri, Context context, AudioSink audioSink) { this.uri = uri; this.context = context; + this.audioSink = audioSink; } @Override public void run() { Looper.prepare(); - LibflacAudioRenderer audioRenderer = new LibflacAudioRenderer(); + LibflacAudioRenderer audioRenderer = + new LibflacAudioRenderer(/* eventHandler= */ null, /* eventListener= */ null, audioSink); player = new ExoPlayer.Builder(context, audioRenderer).build(); player.addListener(this); MediaSource mediaSource = @@ -105,5 +128,4 @@ public class FlacPlaybackTest { } } } - } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 7d9f6253e3..e4d8e4a250 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -21,6 +21,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; @@ -55,6 +56,24 @@ public final class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { super(eventHandler, eventListener, audioProcessors); } + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + */ + public LibflacAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + super( + eventHandler, + eventListener, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + audioSink); + } + @Override @FormatSupport protected int supportsFormatInternal( @@ -68,8 +87,8 @@ public final class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { if (format.initializationData.isEmpty()) { // The initialization data might not be set if the format was obtained from a manifest (e.g. // for DASH playbacks) rather than directly from the media. In this case we assume - // ENCODING_PCM_16BIT. If the actual encoding is different, playback will still succeed as - // long as the AudioSink supports it (which will always be true when using DefaultAudioSink). + // ENCODING_PCM_16BIT. If the actual encoding is different then playback will still succeed as + // long as the AudioSink supports it, which will always be true when using DefaultAudioSink. pcmEncoding = C.ENCODING_PCM_16BIT; } else { int streamMetadataOffset = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java new file mode 100644 index 0000000000..704bd11cc2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.PlaybackParameters; +import java.nio.ByteBuffer; + +/** An overridable {@link AudioSink} implementation forwarding all methods to another sink. */ +public class ForwardingAudioSink implements AudioSink { + + private final AudioSink sink; + + public ForwardingAudioSink(AudioSink sink) { + this.sink = sink; + } + + @Override + public void setListener(Listener listener) { + sink.setListener(listener); + } + + @Override + public boolean supportsOutput(int channelCount, int encoding) { + return sink.supportsOutput(channelCount, encoding); + } + + @Override + public long getCurrentPositionUs(boolean sourceEnded) { + return sink.getCurrentPositionUs(sourceEnded); + } + + @Override + public void configure( + int inputEncoding, + int inputChannelCount, + int inputSampleRate, + int specifiedBufferSize, + @Nullable int[] outputChannels, + int trimStartFrames, + int trimEndFrames) + throws ConfigurationException { + sink.configure( + inputEncoding, + inputChannelCount, + inputSampleRate, + specifiedBufferSize, + outputChannels, + trimStartFrames, + trimEndFrames); + } + + @Override + public void play() { + sink.play(); + } + + @Override + public void handleDiscontinuity() { + sink.handleDiscontinuity(); + } + + @Override + public boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException { + return sink.handleBuffer(buffer, presentationTimeUs); + } + + @Override + public void playToEndOfStream() throws WriteException { + sink.playToEndOfStream(); + } + + @Override + public boolean isEnded() { + return sink.isEnded(); + } + + @Override + public boolean hasPendingData() { + return sink.hasPendingData(); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + sink.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return sink.getPlaybackParameters(); + } + + @Override + public void setAudioAttributes(AudioAttributes audioAttributes) { + sink.setAudioAttributes(audioAttributes); + } + + @Override + public void setAudioSessionId(int audioSessionId) { + sink.setAudioSessionId(audioSessionId); + } + + @Override + public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) { + sink.setAuxEffectInfo(auxEffectInfo); + } + + @Override + public void enableTunnelingV21(int tunnelingAudioSessionId) { + sink.enableTunnelingV21(tunnelingAudioSessionId); + } + + @Override + public void disableTunneling() { + sink.disableTunneling(); + } + + @Override + public void setVolume(float volume) { + sink.setVolume(volume); + } + + @Override + public void pause() { + sink.pause(); + } + + @Override + public void flush() { + sink.flush(); + } + + @Override + public void reset() { + sink.reset(); + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java new file mode 100644 index 0000000000..7a766e225f --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CapturingAudioSink.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertWithMessage; + +import android.content.Context; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.ForwardingAudioSink; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** A {@link ForwardingAudioSink} that captures configuration, discontinuity and buffer events. */ +public final class CapturingAudioSink extends ForwardingAudioSink implements Dumper.Dumpable { + + /** + * If true, makes {@link #assertOutput(Context, String)} method write the output to a file, rather + * than validating that the output matches the dump file. + * + *

The output file is written to the test apk's external storage directory, which is typically: + * {@code /sdcard/Android/data/${package-under-test}.test/files/}. + */ + private static final boolean WRITE_DUMP = false; + + private final List interceptedData; + @Nullable private ByteBuffer currentBuffer; + + public CapturingAudioSink(AudioSink sink) { + super(sink); + interceptedData = new ArrayList<>(); + } + + @Override + public void configure( + int inputEncoding, + int inputChannelCount, + int inputSampleRate, + int specifiedBufferSize, + @Nullable int[] outputChannels, + int trimStartFrames, + int trimEndFrames) + throws ConfigurationException { + interceptedData.add( + new DumpableConfiguration(inputEncoding, inputChannelCount, inputSampleRate)); + super.configure( + inputEncoding, + inputChannelCount, + inputSampleRate, + specifiedBufferSize, + outputChannels, + trimStartFrames, + trimEndFrames); + } + + @Override + public void handleDiscontinuity() { + interceptedData.add(new DumpableDiscontinuity()); + super.handleDiscontinuity(); + } + + @Override + @SuppressWarnings("ReferenceEquality") + public boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException { + // handleBuffer is called repeatedly with the same buffer until it's been fully consumed by the + // sink. We only want to dump each buffer once, and we need to do so before the sink being + // forwarded to has a chance to modify its position. + if (buffer != currentBuffer) { + interceptedData.add(new DumpableBuffer(buffer, presentationTimeUs)); + currentBuffer = buffer; + } + boolean fullyConsumed = super.handleBuffer(buffer, presentationTimeUs); + if (fullyConsumed) { + currentBuffer = null; + } + return fullyConsumed; + } + + @Override + public void flush() { + currentBuffer = null; + super.flush(); + } + + @Override + public void reset() { + currentBuffer = null; + super.reset(); + } + + /** + * Asserts that dump of this sink is equal to expected dump which is read from {@code dumpFile}. + * + *

If assertion fails because of an intended change in the output or a new dump file needs to + * be created, set {@link #WRITE_DUMP} flag to true and run the test again. Instead of assertion, + * actual dump will be written to {@code dumpFile}. This new dump file needs to be copied to the + * project, {@code library/src/androidTest/assets} folder manually. + */ + public void assertOutput(Context context, String dumpFile) throws IOException { + String actual = new Dumper().add(this).toString(); + + if (WRITE_DUMP) { + File directory = context.getExternalFilesDir(null); + File file = new File(directory, dumpFile); + file.getParentFile().mkdirs(); + PrintWriter out = new PrintWriter(file); + out.print(actual); + out.close(); + } else { + String expected = TestUtil.getString(context, dumpFile); + assertWithMessage(dumpFile).that(actual).isEqualTo(expected); + } + } + + @Override + public void dump(Dumper dumper) { + for (int i = 0; i < interceptedData.size(); i++) { + interceptedData.get(i).dump(dumper); + } + } + + private static final class DumpableConfiguration implements Dumper.Dumpable { + + private final int inputEncoding; + private final int inputChannelCount; + private final int inputSampleRate; + + public DumpableConfiguration(int inputEncoding, int inputChannelCount, int inputSampleRate) { + this.inputEncoding = inputEncoding; + this.inputChannelCount = inputChannelCount; + this.inputSampleRate = inputSampleRate; + } + + @Override + public void dump(Dumper dumper) { + int bitDepth = (Util.getPcmFrameSize(inputEncoding, /* channelCount= */ 1) * 8); + dumper + .startBlock("config") + .add("encoding", inputEncoding + " (" + bitDepth + " bit)") + .add("channel count", inputChannelCount) + .add("sample rate", inputSampleRate) + .endBlock(); + } + } + + private static final class DumpableBuffer implements Dumper.Dumpable { + + private final long presentationTimeUs; + private final int dataHashcode; + + public DumpableBuffer(ByteBuffer buffer, long presentationTimeUs) { + this.presentationTimeUs = presentationTimeUs; + // Compute a hash of the buffer data without changing its position. + int initialPosition = buffer.position(); + byte[] data = new byte[buffer.remaining()]; + buffer.get(data); + buffer.position(initialPosition); + this.dataHashcode = Arrays.hashCode(data); + } + + @Override + public void dump(Dumper dumper) { + dumper + .startBlock("buffer") + .add("time", presentationTimeUs) + .add("data", dataHashcode) + .endBlock(); + } + } + + private static final class DumpableDiscontinuity implements Dumper.Dumpable { + + @Override + public void dump(Dumper dumper) { + dumper.startBlock("discontinuity").endBlock(); + } + } +}