diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java index a7bd3d98ea..7b30a63a13 100644 --- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java +++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java @@ -34,6 +34,7 @@ import android.content.Context; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.media.Image; +import android.media.MediaCodecInfo; import android.media.MediaFormat; import android.opengl.EGLContext; import android.opengl.EGLDisplay; @@ -1215,6 +1216,28 @@ public final class AndroidTestUtil { throw new AssumptionViolatedException(skipReason); } + /** + * Assumes that the device supports encoding with the given MIME type and profile. + * + * @param mimeType The {@linkplain MimeTypes MIME type}. + * @param profile The {@linkplain MediaCodecInfo.CodecProfileLevel codec profile}. + * @throws AssumptionViolatedException If the device does have required encoder or profile. + */ + public static void assumeCanEncodeWithProfile(String mimeType, int profile) { + ImmutableList supportedEncoders = EncoderUtil.getSupportedEncoders(mimeType); + if (supportedEncoders.isEmpty()) { + throw new AssumptionViolatedException("No supported encoders"); + } + + for (int i = 0; i < supportedEncoders.size(); i++) { + if (EncoderUtil.findSupportedEncodingProfiles(supportedEncoders.get(i), mimeType) + .contains(profile)) { + return; + } + } + throw new AssumptionViolatedException("Profile not supported"); + } + /** Returns a {@link Muxer.Factory} depending upon the API level. */ public static Muxer.Factory getMuxerFactoryBasedOnApi() { // MediaMuxer supports B-frame from API > 24. diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java index 4970bfea21..9ef45ad23a 100644 --- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java +++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java @@ -15,7 +15,10 @@ */ package androidx.media3.transformer; +import static android.media.MediaCodecInfo.CodecProfileLevel.AACObjectHE; import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.common.util.MediaFormatUtil.createFormatFromMediaFormat; import static androidx.media3.common.util.Util.isRunningOnEmulator; import static androidx.media3.test.utils.TestUtil.retrieveTrackFormat; import static androidx.media3.transformer.AndroidTestUtil.JPG_ASSET; @@ -31,6 +34,7 @@ import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION_ import static androidx.media3.transformer.AndroidTestUtil.PNG_ASSET; import static androidx.media3.transformer.AndroidTestUtil.WAV_ASSET; import static androidx.media3.transformer.AndroidTestUtil.WEBP_LARGE; +import static androidx.media3.transformer.AndroidTestUtil.assumeCanEncodeWithProfile; import static androidx.media3.transformer.AndroidTestUtil.assumeFormatsSupported; import static androidx.media3.transformer.AndroidTestUtil.createFrameCountingEffect; import static androidx.media3.transformer.AndroidTestUtil.createOpenGlObjects; @@ -47,10 +51,12 @@ import static androidx.media3.transformer.ExportResult.OPTIMIZATION_FAILED_FORMA import static androidx.media3.transformer.ExportResult.OPTIMIZATION_SUCCEEDED; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeFalse; import static org.junit.Assume.assumeTrue; import android.content.Context; import android.graphics.Bitmap; +import android.media.MediaFormat; import android.net.Uri; import android.opengl.EGLContext; import android.os.Handler; @@ -71,6 +77,7 @@ import androidx.media3.common.audio.ChannelMixingAudioProcessor; import androidx.media3.common.audio.ChannelMixingMatrix; import androidx.media3.common.audio.SonicAudioProcessor; import androidx.media3.common.audio.SpeedProvider; +import androidx.media3.common.util.CodecSpecificDataUtil; import androidx.media3.common.util.GlUtil; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSourceBitmapLoader; @@ -85,6 +92,7 @@ import androidx.media3.effect.RgbFilter; import androidx.media3.effect.ScaleAndRotateTransformation; import androidx.media3.effect.SpeedChangeEffect; import androidx.media3.effect.TimestampWrapper; +import androidx.media3.exoplayer.MediaExtractorCompat; import androidx.media3.exoplayer.audio.TeeAudioProcessor; import androidx.media3.extractor.mp4.Mp4Extractor; import androidx.media3.extractor.text.DefaultSubtitleParserFactory; @@ -96,6 +104,7 @@ import androidx.media3.transformer.AndroidTestUtil.FrameCountingByteBufferProces import androidx.media3.transformer.AssetLoader.CompositionSettings; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import java.io.File; @@ -2167,6 +2176,80 @@ public class TransformerEndToEndTest { assertThat(new File(result.filePath).length()).isGreaterThan(0); } + @Test + public void export_setAudioEncodingProfile_changesProfile() throws Exception { + assumeFalse(shouldSkipDeviceForAacObjectHeProfileEncoding()); + assumeCanEncodeWithProfile(MimeTypes.AUDIO_AAC, AACObjectHE); + Context context = ApplicationProvider.getApplicationContext(); + Transformer transformer = + new Transformer.Builder(context) + .setEncoderFactory( + new AndroidTestUtil.ForceEncodeEncoderFactory( + new DefaultEncoderFactory.Builder(context) + .setRequestedAudioEncoderSettings( + new AudioEncoderSettings.Builder().setProfile(AACObjectHE).build()) + .build())) + .build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(MP4_ASSET.uri).build(); + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder(mediaItem).setRemoveVideo(true).build(); + + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + MediaExtractorCompat mediaExtractor = new MediaExtractorCompat(context); + mediaExtractor.setDataSource(Uri.parse(result.filePath), /* offset= */ 0); + checkState(mediaExtractor.getTrackCount() == 1); + MediaFormat mediaFormat = mediaExtractor.getTrackFormat(/* trackIndex= */ 0); + Format format = createFormatFromMediaFormat(mediaFormat); + Pair profileAndLevel = CodecSpecificDataUtil.getCodecProfileAndLevel(format); + assertThat(profileAndLevel.first).isEqualTo(AACObjectHE); + } + + @Test + public void export_setAudioEncodingBitrate_configuresEncoderWithRequestedBitrate() + throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + int requestedBitrate = 60_000; + // The MediaMuxer is not writing the bitrate hence use the InAppMuxer. + Transformer transformer = + new Transformer.Builder(context) + .setMuxerFactory(new InAppMuxer.Factory.Builder().build()) + .setEncoderFactory( + new AndroidTestUtil.ForceEncodeEncoderFactory( + new DefaultEncoderFactory.Builder(context) + .setRequestedAudioEncoderSettings( + new AudioEncoderSettings.Builder().setBitrate(requestedBitrate).build()) + .build())) + .build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(MP4_ASSET.uri).build(); + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder(mediaItem).setRemoveVideo(true).build(); + + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + MediaExtractorCompat mediaExtractor = new MediaExtractorCompat(context); + mediaExtractor.setDataSource(Uri.parse(result.filePath), /* offset= */ 0); + checkState(mediaExtractor.getTrackCount() == 1); + MediaFormat mediaFormat = mediaExtractor.getTrackFormat(/* trackIndex= */ 0); + Format format = createFormatFromMediaFormat(mediaFormat); + // The format contains the requested bitrate but the actual bitrate is generally different. + assertThat(format.bitrate).isEqualTo(requestedBitrate); + } + + private static boolean shouldSkipDeviceForAacObjectHeProfileEncoding() { + // These devices claims to have the AACObjectHE profile but the profile never gets applied. + return (Util.SDK_INT == 27 && Ascii.equalsIgnoreCase(Util.MODEL, "cph1803")) + || (Util.SDK_INT == 27 && Ascii.equalsIgnoreCase(Util.MODEL, "cph1909")) + || (Util.SDK_INT == 27 && Ascii.equalsIgnoreCase(Util.MODEL, "redmi note 5")) + || (Util.SDK_INT == 26 && isRunningOnEmulator()); + } + private static AudioProcessor createSonic(float pitch) { SonicAudioProcessor sonic = new SonicAudioProcessor(); sonic.setPitch(pitch); diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/AudioEncoderSettings.java b/libraries/transformer/src/main/java/androidx/media3/transformer/AudioEncoderSettings.java new file mode 100644 index 0000000000..17b1b7eb77 --- /dev/null +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/AudioEncoderSettings.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 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 androidx.media3.transformer; + +import android.media.MediaCodecInfo; +import androidx.media3.common.Format; +import androidx.media3.common.util.UnstableApi; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** Represents the audio encoder settings. */ +@UnstableApi +public final class AudioEncoderSettings { + /** Builds {@link AudioEncoderSettings} instances. */ + public static final class Builder { + private int profile; + private int bitrate; + + /** Creates a new instance. */ + public Builder() { + profile = NO_VALUE; + bitrate = NO_VALUE; + } + + /** + * Sets the {@link AudioEncoderSettings#profile}. + * + *

The default value is {@link #NO_VALUE} and the appropriate profile will be used + * automatically. + * + *

The requested profile must be one of the values defined in {@link + * MediaCodecInfo.CodecProfileLevel} and must be compatible with the requested {@linkplain + * Transformer.Builder#setAudioMimeType(String) audio MIME type}. When using the {@link + * DefaultEncoderFactory}, if the encoder does not support the requested profile, then it will + * be ignored to avoid any encoder configuration failures. + * + * @param profile The profile. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setProfile(int profile) { + this.profile = profile; + return this; + } + + /** + * Sets the {@link AudioEncoderSettings#bitrate}. + * + *

The default value is {@link #NO_VALUE}. + * + *

The encoder may ignore the requested bitrate to improve the encoding quality. + * + * @param bitrate The bitrate in bits per second. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setBitrate(int bitrate) { + this.bitrate = bitrate; + return this; + } + + /** Builds the instance. */ + public AudioEncoderSettings build() { + return new AudioEncoderSettings(profile, bitrate); + } + } + + /** A value for various fields to indicate that the field's value is unknown or not applicable. */ + public static final int NO_VALUE = Format.NO_VALUE; + + /** A default {@link AudioEncoderSettings}. */ + public static final AudioEncoderSettings DEFAULT = new Builder().build(); + + /** The encoding profile. */ + public final int profile; + + /** The encoding bitrate in bits per second. */ + public final int bitrate; + + private AudioEncoderSettings(int profile, int bitrate) { + this.profile = profile; + this.bitrate = bitrate; + } +} diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultEncoderFactory.java b/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultEncoderFactory.java index 587ed3a1be..3aa50d1e62 100644 --- a/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultEncoderFactory.java +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultEncoderFactory.java @@ -64,6 +64,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { private EncoderSelector videoEncoderSelector; private VideoEncoderSettings requestedVideoEncoderSettings; + private AudioEncoderSettings requestedAudioEncoderSettings; private boolean enableFallback; private @C.Priority int codecPriority; @@ -72,6 +73,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { this.context = context.getApplicationContext(); videoEncoderSelector = EncoderSelector.DEFAULT; requestedVideoEncoderSettings = VideoEncoderSettings.DEFAULT; + requestedAudioEncoderSettings = AudioEncoderSettings.DEFAULT; enableFallback = true; codecPriority = C.PRIORITY_PROCESSING_FOREGROUND; } @@ -111,6 +113,20 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { return this; } + /** + * Sets the requested {@link AudioEncoderSettings}. + * + *

The default value is {@link AudioEncoderSettings#DEFAULT}. + * + *

Values in {@code requestedAudioEncoderSettings} may be ignored to reduce failures. + */ + @CanIgnoreReturnValue + public Builder setRequestedAudioEncoderSettings( + AudioEncoderSettings requestedAudioEncoderSettings) { + this.requestedAudioEncoderSettings = requestedAudioEncoderSettings; + return this; + } + /** * Sets whether the encoder can fallback. * @@ -160,6 +176,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { private final Context context; private final EncoderSelector videoEncoderSelector; private final VideoEncoderSettings requestedVideoEncoderSettings; + private final AudioEncoderSettings requestedAudioEncoderSettings; private final boolean enableFallback; private final @C.Priority int codecPriority; @@ -203,6 +220,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { this.context = builder.context; this.videoEncoderSelector = builder.videoEncoderSelector; this.requestedVideoEncoderSettings = builder.requestedVideoEncoderSettings; + this.requestedAudioEncoderSettings = builder.requestedAudioEncoderSettings; this.enableFallback = builder.enableFallback; this.codecPriority = builder.codecPriority; } @@ -221,11 +239,35 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { if (mediaCodecInfos.isEmpty()) { throw createExportException(format, "No audio media codec found"); } + + MediaCodecInfo selectedEncoder = mediaCodecInfos.get(0); + + if (requestedAudioEncoderSettings.profile != AudioEncoderSettings.NO_VALUE) { + for (int i = 0; i < mediaCodecInfos.size(); i++) { + MediaCodecInfo encoderInfo = mediaCodecInfos.get(i); + if (EncoderUtil.findSupportedEncodingProfiles(encoderInfo, format.sampleMimeType) + .contains(requestedAudioEncoderSettings.profile)) { + selectedEncoder = encoderInfo; + if (format.sampleMimeType.equals(MimeTypes.AUDIO_AAC)) { + mediaFormat.setInteger( + MediaFormat.KEY_AAC_PROFILE, requestedAudioEncoderSettings.profile); + } + // On some devices setting only KEY_AAC_PROFILE for AAC does not work. + mediaFormat.setInteger(MediaFormat.KEY_PROFILE, requestedAudioEncoderSettings.profile); + break; + } + } + } + + if (requestedAudioEncoderSettings.bitrate != AudioEncoderSettings.NO_VALUE) { + mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, requestedAudioEncoderSettings.bitrate); + } + return new DefaultCodec( context, format, mediaFormat, - mediaCodecInfos.get(0).getName(), + selectedEncoder.getName(), /* isDecoder= */ false, /* outputSurface= */ null); }