| /* |
| * Copyright (C) 2023 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 android.media; |
| |
| import static android.media.LoudnessCodecInfo.CodecMetadataType.CODEC_METADATA_TYPE_MPEG_4; |
| import static android.media.LoudnessCodecInfo.CodecMetadataType.CODEC_METADATA_TYPE_MPEG_D; |
| import static android.media.audio.Flags.FLAG_LOUDNESS_CONFIGURATOR_API; |
| |
| import android.annotation.CallbackExecutor; |
| import android.annotation.FlaggedApi; |
| import android.media.permission.SafeCloseable; |
| import android.os.Bundle; |
| import android.util.Log; |
| |
| import androidx.annotation.GuardedBy; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Map.Entry; |
| import java.util.Objects; |
| import java.util.Set; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.function.Consumer; |
| |
| /** |
| * Class for getting recommended loudness parameter updates for audio decoders as they are used |
| * to play back media content according to the encoded format and current audio routing. These |
| * audio decoder updates leverage loudness metadata present in compressed audio streams. They |
| * ensure the loudness and dynamic range of the content is optimized to the physical |
| * characteristics of the audio output device (e.g. phone microspeakers vs headphones vs TV |
| * speakers).Those updates can be automatically applied to the {@link MediaCodec} instance(s), or |
| * be provided to the user. The codec loudness management parameter updates are computed in |
| * accordance to the CTA-2075 standard. |
| * <p>A new object should be instantiated for each audio session |
| * (see {@link AudioManager#generateAudioSessionId()}) using creator methods {@link #create(int)} or |
| * {@link #create(int, Executor, OnLoudnessCodecUpdateListener)}. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public class LoudnessCodecController implements SafeCloseable { |
| private static final String TAG = "LoudnessCodecController"; |
| |
| /** |
| * Listener used for receiving asynchronous loudness metadata updates. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public interface OnLoudnessCodecUpdateListener { |
| /** |
| * Contains the MediaCodec key/values that can be set directly to |
| * configure the loudness of the handle's corresponding decoder (see |
| * {@link MediaCodec#setParameters(Bundle)}). |
| * |
| * @param mediaCodec the mediaCodec that will receive the new parameters |
| * @param codecValues contains loudness key/value pairs that can be set |
| * directly on the mediaCodec. The listener can modify |
| * these values with their own edits which will be |
| * returned for the mediaCodec configuration |
| * |
| * @return a Bundle which contains the original computed codecValues |
| * aggregated with user edits. The platform will configure the associated |
| * MediaCodecs with the returned Bundle params. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| @NonNull |
| default Bundle onLoudnessCodecUpdate(@NonNull MediaCodec mediaCodec, |
| @NonNull Bundle codecValues) { |
| return codecValues; |
| } |
| } |
| |
| @NonNull |
| private final LoudnessCodecDispatcher mLcDispatcher; |
| |
| private final Object mControllerLock = new Object(); |
| |
| private final int mSessionId; |
| |
| @GuardedBy("mControllerLock") |
| private final HashMap<LoudnessCodecInfo, Set<MediaCodec>> mMediaCodecs = new HashMap<>(); |
| |
| /** |
| * Creates a new instance of {@link LoudnessCodecController} |
| * |
| * <p>This method should be used when the client does not need to alter the |
| * codec loudness parameters before they are applied to the audio decoders. |
| * Otherwise, use {@link #create(int, Executor, OnLoudnessCodecUpdateListener)}. |
| * |
| * @param sessionId the session ID of the track that will receive data |
| * from the added {@link MediaCodec}'s |
| * |
| * @return the {@link LoudnessCodecController} instance |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public static @NonNull LoudnessCodecController create(int sessionId) { |
| final LoudnessCodecDispatcher dispatcher = new LoudnessCodecDispatcher( |
| AudioManager.getService()); |
| final LoudnessCodecController controller = new LoudnessCodecController(dispatcher, |
| sessionId); |
| dispatcher.addLoudnessCodecListener(controller, Executors.newSingleThreadExecutor(), |
| new OnLoudnessCodecUpdateListener() {}); |
| dispatcher.startLoudnessCodecUpdates(sessionId); |
| return controller; |
| } |
| |
| /** |
| * Creates a new instance of {@link LoudnessCodecController} |
| * |
| * <p>This method should be used when the client wants to alter the codec |
| * loudness parameters before they are applied to the audio decoders. |
| * Otherwise, use {@link #create( int)}. |
| * |
| * @param sessionId the session ID of the track that will receive data |
| * from the added {@link MediaCodec}'s |
| * @param executor {@link Executor} to handle the callbacks |
| * @param listener used for receiving updates |
| * |
| * @return the {@link LoudnessCodecController} instance |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public static @NonNull LoudnessCodecController create( |
| int sessionId, |
| @NonNull @CallbackExecutor Executor executor, |
| @NonNull OnLoudnessCodecUpdateListener listener) { |
| Objects.requireNonNull(executor, "Executor cannot be null"); |
| Objects.requireNonNull(listener, "OnLoudnessCodecUpdateListener cannot be null"); |
| |
| final LoudnessCodecDispatcher dispatcher = new LoudnessCodecDispatcher( |
| AudioManager.getService()); |
| final LoudnessCodecController controller = new LoudnessCodecController(dispatcher, |
| sessionId); |
| dispatcher.addLoudnessCodecListener(controller, executor, listener); |
| dispatcher.startLoudnessCodecUpdates(sessionId); |
| return controller; |
| } |
| |
| /** |
| * Creates a new instance of {@link LoudnessCodecController} |
| * |
| * <p>This method should be used only in testing |
| * |
| * @param sessionId the session ID of the track that will receive data |
| * from the added {@link MediaCodec}'s |
| * @param executor {@link Executor} to handle the callbacks |
| * @param listener used for receiving updates |
| * @param service interface for communicating with AudioService |
| * |
| * @return the {@link LoudnessCodecController} instance |
| * @hide |
| */ |
| public static @NonNull LoudnessCodecController createForTesting( |
| int sessionId, |
| @NonNull @CallbackExecutor Executor executor, |
| @NonNull OnLoudnessCodecUpdateListener listener, |
| @NonNull IAudioService service) { |
| Objects.requireNonNull(service, "IAudioService cannot be null"); |
| Objects.requireNonNull(executor, "Executor cannot be null"); |
| Objects.requireNonNull(listener, "OnLoudnessCodecUpdateListener cannot be null"); |
| |
| final LoudnessCodecDispatcher dispatcher = new LoudnessCodecDispatcher(service); |
| final LoudnessCodecController controller = new LoudnessCodecController(dispatcher, |
| sessionId); |
| dispatcher.addLoudnessCodecListener(controller, executor, listener); |
| dispatcher.startLoudnessCodecUpdates(sessionId); |
| return controller; |
| } |
| |
| /** @hide */ |
| private LoudnessCodecController(@NonNull LoudnessCodecDispatcher lcDispatcher, int sessionId) { |
| mLcDispatcher = Objects.requireNonNull(lcDispatcher, "Dispatcher cannot be null"); |
| mSessionId = sessionId; |
| } |
| |
| /** |
| * Adds a new {@link MediaCodec} that will stream data to a player |
| * which uses {@link #mSessionId}. |
| * |
| * <p>No new element will be added if the passed {@code mediaCodec} was |
| * previously added. |
| * |
| * @param mediaCodec the codec to start receiving asynchronous loudness |
| * updates. The codec has to be in a configured or started |
| * state in order to add it for loudness updates. |
| * @return {@code false} if the {@code mediaCodec} was not configured or does |
| * not contain loudness metadata, {@code true} otherwise. |
| * @throws IllegalArgumentException if the same {@code mediaCodec} was already |
| * added before. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public boolean addMediaCodec(@NonNull MediaCodec mediaCodec) { |
| final MediaCodec mc = Objects.requireNonNull(mediaCodec, |
| "MediaCodec for addMediaCodec cannot be null"); |
| final LoudnessCodecInfo mcInfo = getCodecInfo(mc); |
| |
| if (mcInfo == null) { |
| Log.v(TAG, "Could not extract codec loudness information"); |
| return false; |
| } |
| synchronized (mControllerLock) { |
| final AtomicBoolean containsCodec = new AtomicBoolean(false); |
| Set<MediaCodec> newSet = mMediaCodecs.computeIfPresent(mcInfo, (info, codecSet) -> { |
| containsCodec.set(!codecSet.add(mc)); |
| return codecSet; |
| }); |
| if (newSet == null) { |
| newSet = new HashSet<>(); |
| newSet.add(mc); |
| mMediaCodecs.put(mcInfo, newSet); |
| } |
| if (containsCodec.get()) { |
| throw new IllegalArgumentException( |
| "Loudness controller already added " + mediaCodec); |
| } |
| } |
| |
| mLcDispatcher.addLoudnessCodecInfo(mSessionId, mediaCodec.hashCode(), |
| mcInfo); |
| |
| return true; |
| } |
| |
| /** |
| * Removes the {@link MediaCodec} from receiving loudness updates. |
| * |
| * <p>This method can be called while asynchronous updates are live. |
| * |
| * <p>No elements will be removed if the passed mediaCodec was not added before. |
| * |
| * @param mediaCodec the element to remove for receiving asynchronous updates |
| * @throws IllegalArgumentException if the {@code mediaCodec} was not configured, |
| * does not contain loudness metadata or if it |
| * was not added before |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| public void removeMediaCodec(@NonNull MediaCodec mediaCodec) { |
| LoudnessCodecInfo mcInfo; |
| AtomicBoolean removedMc = new AtomicBoolean(false); |
| AtomicBoolean removeInfo = new AtomicBoolean(false); |
| |
| mcInfo = getCodecInfo(Objects.requireNonNull(mediaCodec, |
| "MediaCodec for removeMediaCodec cannot be null")); |
| |
| if (mcInfo == null) { |
| throw new IllegalArgumentException("Could not extract codec loudness information"); |
| } |
| synchronized (mControllerLock) { |
| mMediaCodecs.computeIfPresent(mcInfo, (format, mcs) -> { |
| removedMc.set(mcs.remove(mediaCodec)); |
| if (mcs.isEmpty()) { |
| // remove the entry |
| removeInfo.set(true); |
| return null; |
| } |
| return mcs; |
| }); |
| if (!removedMc.get()) { |
| throw new IllegalArgumentException( |
| "Loudness controller does not contain " + mediaCodec); |
| } |
| } |
| |
| if (removeInfo.get()) { |
| mLcDispatcher.removeLoudnessCodecInfo(mSessionId, mcInfo); |
| } |
| } |
| |
| /** |
| * Returns the loudness parameters of the registered audio decoders |
| * |
| * <p>Those parameters may have been automatically applied if the |
| * {@code LoudnessCodecController} was created with {@link #create(int)}, or they are the |
| * parameters that have been sent to the {@link OnLoudnessCodecUpdateListener} if using a |
| * codec update listener. |
| * |
| * @param mediaCodec codec that decodes loudness annotated data. Has to be added |
| * with {@link #addMediaCodec(MediaCodec)} before calling this |
| * method |
| * @throws IllegalArgumentException if the passed {@link MediaCodec} was not |
| * added before calling this method |
| * |
| * @return the {@link Bundle} containing the current loudness parameters. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| @NonNull |
| public Bundle getLoudnessCodecParams(@NonNull MediaCodec mediaCodec) { |
| Objects.requireNonNull(mediaCodec, "MediaCodec cannot be null"); |
| |
| LoudnessCodecInfo codecInfo = getCodecInfo(mediaCodec); |
| if (codecInfo == null) { |
| throw new IllegalArgumentException("MediaCodec does not have valid codec information"); |
| } |
| |
| synchronized (mControllerLock) { |
| final Set<MediaCodec> codecs = mMediaCodecs.get(codecInfo); |
| if (codecs == null || !codecs.contains(mediaCodec)) { |
| throw new IllegalArgumentException( |
| "MediaCodec was not added for loudness annotation"); |
| } |
| } |
| |
| return mLcDispatcher.getLoudnessCodecParams(codecInfo); |
| } |
| |
| /** |
| * Stops any loudness updates and frees up the resources. |
| */ |
| @FlaggedApi(FLAG_LOUDNESS_CONFIGURATOR_API) |
| @Override |
| public void close() { |
| synchronized (mControllerLock) { |
| mMediaCodecs.clear(); |
| } |
| mLcDispatcher.stopLoudnessCodecUpdates(mSessionId); |
| } |
| |
| /** @hide */ |
| /*package*/ int getSessionId() { |
| return mSessionId; |
| } |
| |
| /** @hide */ |
| /*package*/ void mediaCodecsConsume( |
| Consumer<Entry<LoudnessCodecInfo, Set<MediaCodec>>> consumer) { |
| synchronized (mControllerLock) { |
| for (Entry<LoudnessCodecInfo, Set<MediaCodec>> entry : mMediaCodecs.entrySet()) { |
| consumer.accept(entry); |
| } |
| } |
| } |
| |
| @Nullable |
| private static LoudnessCodecInfo getCodecInfo(@NonNull MediaCodec mediaCodec) { |
| LoudnessCodecInfo lci = new LoudnessCodecInfo(); |
| final MediaCodecInfo codecInfo = mediaCodec.getCodecInfo(); |
| if (codecInfo.isEncoder()) { |
| // loudness info only for decoders |
| Log.w(TAG, "MediaCodec used for encoding does not support loudness annotation"); |
| return null; |
| } |
| |
| try { |
| final MediaFormat inputFormat = mediaCodec.getInputFormat(); |
| final String mimeType = inputFormat.getString(MediaFormat.KEY_MIME); |
| if (MediaFormat.MIMETYPE_AUDIO_AAC.equalsIgnoreCase(mimeType)) { |
| // check both KEY_AAC_PROFILE and KEY_PROFILE as some codecs may only recognize |
| // one of these two keys |
| int aacProfile = -1; |
| int profile = -1; |
| try { |
| aacProfile = inputFormat.getInteger(MediaFormat.KEY_AAC_PROFILE); |
| } catch (NullPointerException e) { |
| // does not contain KEY_AAC_PROFILE. do nothing |
| } |
| try { |
| profile = inputFormat.getInteger(MediaFormat.KEY_PROFILE); |
| } catch (NullPointerException e) { |
| // does not contain KEY_PROFILE. do nothing |
| } |
| if (aacProfile == MediaCodecInfo.CodecProfileLevel.AACObjectXHE |
| || profile == MediaCodecInfo.CodecProfileLevel.AACObjectXHE) { |
| lci.metadataType = CODEC_METADATA_TYPE_MPEG_D; |
| } else { |
| lci.metadataType = CODEC_METADATA_TYPE_MPEG_4; |
| } |
| } else { |
| Log.w(TAG, "MediaCodec mime type not supported for loudness annotation"); |
| return null; |
| } |
| |
| final MediaFormat outputFormat = mediaCodec.getOutputFormat(); |
| lci.isDownmixing = outputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) |
| < inputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); |
| } catch (IllegalStateException e) { |
| Log.e(TAG, "MediaCodec is not configured", e); |
| return null; |
| } |
| |
| return lci; |
| } |
| } |