Skip to content

Commit

Permalink
Map VORBIS channel layout to Android layout
Browse files Browse the repository at this point in the history
Both the extension OPUS decoder and the OMX/C2 MediaCodec
implementations for OPUS and VORBIS decode into the channel
layout defined by VORBIS. See
https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-140001.2.3

While this is technically correct for a stand-alone OPUS or VORBIS
decoder, it doesn't match the channel layout expected by Android.
See https://developer.android.com/reference/android/media/AudioFormat#channelMask

The fix is to apply the channel mapping after decoding if needed.
Also add e2e tests with audio dumps for the extension renderer,
including a new 5.1 channel test file.

Issue: google/ExoPlayer#8396

PiperOrigin-RevId: 588004832
(cherry picked from commit b1541b0)
  • Loading branch information
tonihei authored and microkatz committed Jan 9, 2024
1 parent 483426a commit 57187aa
Show file tree
Hide file tree
Showing 12 changed files with 1,383 additions and 20 deletions.
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* Fix issue where manual seeks outside of the
`LiveConfiguration.min/maxOffset` range keep adjusting the offset back
to `min/maxOffset`.
* Fix issue that OPUS and VORBIS channel layouts are wrong for 3, 5, 6, 7
and 8 channels
([#8396](https://github.com/google/ExoPlayer/issues/8396)).
* Transformer:
* Work around an issue where the encoder would throw at configuration time
due to setting a high operating rate.
Expand Down
1 change: 1 addition & 0 deletions libraries/decoder_opus/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
testImplementation project(modulePrefix + 'test-utils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
androidTestImplementation project(modulePrefix + 'test-utils')
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
package androidx.media3.decoder.opus;

import static org.junit.Assert.fail;
import static com.google.common.truth.Truth.assertWithMessage;

import android.content.Context;
import android.net.Uri;
Expand All @@ -28,9 +28,13 @@
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.audio.DefaultAudioSink;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
import androidx.media3.extractor.mkv.MatroskaExtractor;
import androidx.media3.test.utils.CapturingAudioSink;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Before;
Expand All @@ -41,49 +45,69 @@
@RunWith(AndroidJUnit4.class)
public class OpusPlaybackTest {

private static final String BEAR_OPUS_URI = "asset:///media/mka/bear-opus.mka";
private static final String BEAR_OPUS_NEGATIVE_GAIN_URI =
"asset:///media/mka/bear-opus-negative-gain.mka";
private static final String BEAR_OPUS = "mka/bear-opus.mka";
private static final String BEAR_OPUS_NEGATIVE_GAIN = "mka/bear-opus-negative-gain.mka";
private static final String OPUS_5POINT1 = "mka/opus-5.1.mka";

@Before
public void setUp() {
if (!OpusLibrary.isAvailable()) {
fail("Opus library not available.");
}
assertWithMessage("Opus library not available").that(OpusLibrary.isAvailable()).isTrue();
assertWithMessage("Dump files were generated for x86_64")
.that(System.getProperty("os.arch"))
.isEqualTo("x86_64");
}

@Test
public void playBasicOpus() throws Exception {
playUri(BEAR_OPUS);
}

@Test
public void basicPlayback() throws Exception {
playUri(BEAR_OPUS_URI);
public void playWithNegativeGain() throws Exception {
playUri(BEAR_OPUS_NEGATIVE_GAIN);
}

@Test
public void basicPlaybackNegativeGain() throws Exception {
playUri(BEAR_OPUS_NEGATIVE_GAIN_URI);
public void play5Point1() throws Exception {
playUri(OPUS_5POINT1);
}

private void playUri(String uri) throws Exception {
private void playUri(String fileName) throws Exception {
CapturingAudioSink audioSink =
new CapturingAudioSink(
new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build());

TestPlaybackRunnable testPlaybackRunnable =
new TestPlaybackRunnable(Uri.parse(uri), ApplicationProvider.getApplicationContext());
new TestPlaybackRunnable(
Uri.parse("asset:///media/" + fileName),
ApplicationProvider.getApplicationContext(),
audioSink);
Thread thread = new Thread(testPlaybackRunnable);
thread.start();
thread.join();

if (testPlaybackRunnable.playbackException != null) {
throw testPlaybackRunnable.playbackException;
}
DumpFileAsserts.assertOutput(
ApplicationProvider.getApplicationContext(),
audioSink,
"audiosinkdumps/" + fileName + ".audiosink.dump");
}

private static class TestPlaybackRunnable implements Player.Listener, Runnable {

private final Context context;
private final Uri uri;
private final AudioSink audioSink;

@Nullable private ExoPlayer player;
@Nullable private PlaybackException 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
Expand All @@ -95,7 +119,9 @@ public void run() {
audioRendererEventListener,
textRendererOutput,
metadataRendererOutput) ->
new Renderer[] {new LibopusAudioRenderer(eventHandler, audioRendererEventListener)};
new Renderer[] {
new LibopusAudioRenderer(eventHandler, audioRendererEventListener, audioSink)
};
player = new ExoPlayer.Builder(context, renderersFactory).build();
player.addListener(this);
MediaSource mediaSource =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
import androidx.media3.extractor.VorbisUtil;

/** Decodes and renders audio using the native Opus decoder. */
@UnstableApi
Expand Down Expand Up @@ -140,6 +141,12 @@ protected final Format getOutputFormat(OpusDecoder decoder) {
return Util.getPcmFormat(pcmEncoding, decoder.channelCount, OpusDecoder.SAMPLE_RATE);
}

@Nullable
@Override
protected int[] getChannelMapping(OpusDecoder decoder) {
return VorbisUtil.getVorbisToAndroidChannelLayoutMapping(decoder.channelCount);
}

/**
* Returns true if support for padding removal from the end of decoder output buffer should be
* enabled.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,18 @@ protected abstract T createDecoder(Format format, @Nullable CryptoConfig cryptoC
@ForOverride
protected abstract Format getOutputFormat(T decoder);

/**
* Returns the channel layout mapping that should be applied when sending this data to the output,
* or null to not change the channel layout.
*
* @param decoder The decoder.
*/
@ForOverride
@Nullable
protected int[] getChannelMapping(T decoder) {
return null;
}

/**
* Evaluates whether the existing decoder can be reused for a new {@link Format}.
*
Expand Down Expand Up @@ -442,7 +454,7 @@ private boolean drainOutputBuffer()
.setSelectionFlags(inputFormat.selectionFlags)
.setRoleFlags(inputFormat.roleFlags)
.build();
audioSink.configure(outputFormat, /* specifiedBufferSize= */ 0, /* outputChannels= */ null);
audioSink.configure(outputFormat, /* specifiedBufferSize= */ 0, getChannelMapping(decoder));
audioTrackNeedsConfigure = false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil.DecoderQueryException;
import androidx.media3.extractor.VorbisUtil;
import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
Expand Down Expand Up @@ -108,6 +109,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media

private int codecMaxInputSize;
private boolean codecNeedsDiscardChannelsWorkaround;
private boolean codecNeedsVorbisToAndroidChannelMappingWorkaround;
@Nullable private Format inputFormat;

/** Codec used for DRM decryption only in passthrough and offload. */
Expand Down Expand Up @@ -434,6 +436,8 @@ protected MediaCodecAdapter.Configuration getMediaCodecConfiguration(
float codecOperatingRate) {
codecMaxInputSize = getCodecMaxInputSize(codecInfo, format, getStreamFormats());
codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name);
codecNeedsVorbisToAndroidChannelMappingWorkaround =
codecNeedsVorbisToAndroidChannelMappingWorkaround(codecInfo.name);
MediaFormat mediaFormat =
getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate);
// Store the input MIME type if we're only using the codec for decryption.
Expand Down Expand Up @@ -566,6 +570,9 @@ protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaF
for (int i = 0; i < format.channelCount; i++) {
channelMap[i] = i;
}
} else if (codecNeedsVorbisToAndroidChannelMappingWorkaround) {
channelMap =
VorbisUtil.getVorbisToAndroidChannelLayoutMapping(audioSinkInputFormat.channelCount);
}
}
try {
Expand Down Expand Up @@ -962,6 +969,19 @@ private static boolean codecNeedsDiscardChannelsWorkaround(String codecName) {
|| Util.DEVICE.startsWith("heroqlte"));
}

/**
* Returns whether the decoder is known to output PCM samples in VORBIS order, which does not
* match the channel layout required by AudioTrack.
*
* <p>See https://github.com/google/ExoPlayer/issues/8396#issuecomment-1833867901.
*/
private static boolean codecNeedsVorbisToAndroidChannelMappingWorkaround(String codecName) {
return codecName.equals("OMX.google.opus.decoder")
|| codecName.equals("c2.android.opus.decoder")
|| codecName.equals("OMX.google.vorbis.decoder")
|| codecName.equals("c2.android.vorbis.decoder");
}

private final class AudioSinkListener implements AudioSink.Listener {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ public Mode(boolean blockFlag, int windowType, int transformType, int mapping) {

private static final String TAG = "VorbisUtil";

/**
* Returns the mapping from VORBIS channel layout to the channel layout expected by Android, or
* null if the mapping is unchanged.
*
* <p>See https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-140001.2.3 and
* https://developer.android.com/reference/android/media/AudioFormat#channelMask.
*/
@Nullable
public static int[] getVorbisToAndroidChannelLayoutMapping(int channelCount) {
switch (channelCount) {
case 3:
return new int[] {0, 2, 1};
case 5:
return new int[] {0, 2, 1, 3, 4};
case 6:
return new int[] {0, 2, 1, 5, 3, 4};
case 7:
return new int[] {0, 2, 1, 6, 5, 3, 4};
case 8:
return new int[] {0, 2, 1, 7, 5, 6, 3, 4};
default:
return null;
}
}

/**
* Returns ilog(x), which is the index of the highest set bit in {@code x}.
*
Expand Down
Loading

0 comments on commit 57187aa

Please sign in to comment.