
org.robolectric.shadows.ShadowAudioTrack Maven / Gradle / Ivy
Show all versions of shadows-framework Show documentation
package org.robolectric.shadows;
import static android.media.AudioTrack.ERROR_DEAD_OBJECT;
import static android.os.Build.VERSION_CODES.M;
import static android.os.Build.VERSION_CODES.N;
import static android.os.Build.VERSION_CODES.P;
import static android.os.Build.VERSION_CODES.Q;
import static android.os.Build.VERSION_CODES.R;
import static android.os.Build.VERSION_CODES.S;
import static android.os.Build.VERSION_CODES.TIRAMISU;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.robolectric.shadow.api.Shadow.directlyOn;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresApi;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioRouting.OnRoutingChangedListener;
import android.media.AudioTrack;
import android.media.AudioTrack.WriteMode;
import android.media.PlaybackParams;
import android.os.Build.VERSION;
import android.os.Handler;
import android.os.Parcel;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;
import org.robolectric.annotation.Resetter;
import org.robolectric.versioning.AndroidVersions.U;
/**
* Implementation of a couple methods in {@link AudioTrack}. Only a couple methods are supported,
* other methods are expected run through the real class. The two {@link WriteMode} are treated the
* same.
*/
@Implements(value = AudioTrack.class)
public class ShadowAudioTrack {
/**
* Listeners to be notified when data is written to an {@link AudioTrack} via {@link
* AudioTrack#write(ByteBuffer, int, int)}
*
* Currently, only the data written through AudioTrack.write(ByteBuffer audioData, int
* sizeInBytes, int writeMode) will be reported.
*/
public interface OnAudioDataWrittenListener {
/**
* Called when data is written to {@link ShadowAudioTrack}.
*
* @param audioTrack The {@link ShadowAudioTrack} to which the data is written.
* @param audioData The data that is written to the {@link ShadowAudioTrack}.
* @param format The output format of the {@link ShadowAudioTrack}.
*/
void onAudioDataWritten(ShadowAudioTrack audioTrack, byte[] audioData, AudioFormat format);
}
protected static final int DEFAULT_MIN_BUFFER_SIZE = 1024;
// Copied from native code
// https://cs.android.com/android/platform/superproject/+/android13-release:frameworks/base/core/jni/android_media_AudioTrack.cpp?q=AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED
private static final int AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED = -20;
private static final String TAG = "ShadowAudioTrack";
/** Direct playback support checked from {@link #native_is_direct_output_supported}. */
private static final Multimap directSupportedFormats =
Multimaps.synchronizedMultimap(HashMultimap.create());
/** Non-PCM encodings allowed for creating an AudioTrack instance. */
private static final Set allowedNonPcmEncodings =
Collections.synchronizedSet(new HashSet<>());
private static AudioDeviceInfo routedDevice;
private static final Set onRoutingChangedListeners =
new CopyOnWriteArraySet<>();
private static final List audioDataWrittenListeners =
new CopyOnWriteArrayList<>();
private static int minBufferSize = DEFAULT_MIN_BUFFER_SIZE;
@SuppressWarnings("NonFinalStaticField")
private static boolean illegalStateOnPlayEnabled = false;
private int numBytesReceived;
private PlaybackParams playbackParams;
@RealObject AudioTrack audioTrack;
/**
* In the real class, the minimum buffer size is estimated from audio sample rate and other
* factors. We do not provide such estimation in {@link #native_get_min_buff_size(int, int, int)},
* instead letting users set the minimum for the expected audio sample. Usually higher sample rate
* requires bigger buffer size.
*/
public static void setMinBufferSize(int bufferSize) {
minBufferSize = bufferSize;
}
/**
* Adds support for direct playback for the pair of {@link AudioFormat} and {@link
* AudioAttributes} where the format encoding must be non-PCM. Calling {@link
* AudioTrack#isDirectPlaybackSupported(AudioFormat, AudioAttributes)} will return {@code true}
* for matching {@link AudioFormat} and {@link AudioAttributes}. The matching is performed against
* the format's {@linkplain AudioFormat#getEncoding() encoding}, {@linkplain
* AudioFormat#getSampleRate() sample rate}, {@linkplain AudioFormat#getChannelMask() channel
* mask} and {@linkplain AudioFormat#getChannelIndexMask() channel index mask}, and the
* attribute's {@linkplain AudioAttributes#getContentType() content type}, {@linkplain
* AudioAttributes#getUsage() usage} and {@linkplain AudioAttributes#getFlags() flags}.
*
* @param format The {@link AudioFormat}, which must be of a non-PCM encoding. If the encoding is
* PCM, the method will throw an {@link IllegalArgumentException}.
* @param attr The {@link AudioAttributes}.
*/
public static void addDirectPlaybackSupport(
@NonNull AudioFormat format, @NonNull AudioAttributes attr) {
checkNotNull(format);
checkNotNull(attr);
checkArgument(!isPcm(format.getEncoding()));
directSupportedFormats.put(
new AudioFormatInfo(
format.getEncoding(),
format.getSampleRate(),
format.getChannelMask(),
format.getChannelIndexMask()),
new AudioAttributesInfo(attr.getContentType(), attr.getUsage(), attr.getFlags()));
}
/**
* Clears all encodings that have been added for direct playback support with {@link
* #addDirectPlaybackSupport}.
*/
public static void clearDirectPlaybackSupportedFormats() {
directSupportedFormats.clear();
}
/**
* Add a non-PCM encoding for which {@link AudioTrack} instances are allowed to be created.
*
* @param encoding One of {@link AudioFormat} {@code ENCODING_} constants that represents a
* non-PCM encoding. If {@code encoding} is PCM, this method throws an {@link
* IllegalArgumentException}.
*/
public static void addAllowedNonPcmEncoding(int encoding) {
checkArgument(!isPcm(encoding));
allowedNonPcmEncodings.add(encoding);
}
/** Clears all encodings that have been added with {@link #addAllowedNonPcmEncoding(int)}. */
public static void clearAllowedNonPcmEncodings() {
allowedNonPcmEncodings.clear();
}
/**
* Sets the routed device returned from {@link AudioTrack#getRoutedDevice()} and informs all
* registered {@link OnRoutingChangedListener}.
*
* Note that this affects the routed device for all {@link AudioTrack} instances.
*
* @param routedDevice The route device, or null to reset it to unknown.
*/
@RequiresApi(N)
public static void setRoutedDevice(@Nullable AudioDeviceInfo routedDevice) {
if (Objects.equals(routedDevice, ShadowAudioTrack.routedDevice)) {
return;
}
ShadowAudioTrack.routedDevice = routedDevice;
for (OnRoutingChangedListenerInfo listenerInfo : onRoutingChangedListeners) {
listenerInfo.callListener();
}
}
@Implementation(minSdk = N, maxSdk = P)
protected static int native_get_FCC_8() {
// Return the value hard-coded in native code:
// https://cs.android.com/android/platform/superproject/+/android-7.1.1_r41:system/media/audio/include/system/audio.h;l=42;drc=57a4158dc4c4ce62bc6a2b8a0072ba43305548d4
return 8;
}
@Implementation(minSdk = Q)
protected static boolean native_is_direct_output_supported(
int encoding,
int sampleRate,
int channelMask,
int channelIndexMask,
int contentType,
int usage,
int flags) {
return directSupportedFormats.containsEntry(
new AudioFormatInfo(encoding, sampleRate, channelMask, channelIndexMask),
new AudioAttributesInfo(contentType, usage, flags));
}
/** Returns a predefined or default minimum buffer size. Audio format and config are neglected. */
@Implementation
protected static int native_get_min_buff_size(
int sampleRateInHz, int channelConfig, int audioFormat) {
return minBufferSize;
}
@Implementation(minSdk = P, maxSdk = Q)
protected int native_setup(
Object /*WeakReference*/ audioTrack,
Object /*AudioAttributes*/ attributes,
int[] sampleRate,
int channelMask,
int channelIndexMask,
int audioFormat,
int buffSizeInBytes,
int mode,
int[] sessionId,
long nativeAudioTrack,
boolean offload) {
// If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem.
if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) {
return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED;
}
return AudioTrack.SUCCESS;
}
@Implementation(minSdk = R, maxSdk = R)
protected int native_setup(
Object /*WeakReference*/ audioTrack,
Object /*AudioAttributes*/ attributes,
int[] sampleRate,
int channelMask,
int channelIndexMask,
int audioFormat,
int buffSizeInBytes,
int mode,
int[] sessionId,
long nativeAudioTrack,
boolean offload,
int encapsulationMode,
Object tunerConfiguration) {
// If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem.
if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) {
return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED;
}
return AudioTrack.SUCCESS;
}
@Implementation(minSdk = S, maxSdk = TIRAMISU)
protected int native_setup(
Object /*WeakReference*/ audioTrack,
Object /*AudioAttributes*/ attributes,
int[] sampleRate,
int channelMask,
int channelIndexMask,
int audioFormat,
int buffSizeInBytes,
int mode,
int[] sessionId,
long nativeAudioTrack,
boolean offload,
int encapsulationMode,
Object tunerConfiguration,
String opPackageName) {
// If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem.
if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) {
return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED;
}
return AudioTrack.SUCCESS;
}
@Implementation(minSdk = U.SDK_INT)
protected int native_setup(
Object /*WeakReference*/ audioTrack,
Object /*AudioAttributes*/ attributes,
int[] sampleRate,
int channelMask,
int channelIndexMask,
int audioFormat,
int buffSizeInBytes,
int mode,
int[] sessionId,
@NonNull Parcel attributionSource,
long nativeAudioTrack,
boolean offload,
int encapsulationMode,
Object tunerConfiguration,
@NonNull String opPackageName) {
// If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem.
if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) {
return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED;
}
return AudioTrack.SUCCESS;
}
/**
* Returns the number of bytes to write. This method returns immediately even with {@link
* AudioTrack#WRITE_BLOCKING}. If the {@link AudioTrack} instance was created with a non-PCM
* encoding and the encoding can no longer be played directly, the method will return {@link
* AudioTrack#ERROR_DEAD_OBJECT};
*/
@Implementation(minSdk = M)
protected int native_write_byte(
byte[] audioData, int offsetInBytes, int sizeInBytes, int format, boolean isBlocking) {
byte[] dataToWrite = new byte[sizeInBytes];
System.arraycopy(audioData, offsetInBytes, dataToWrite, /* destPos= */ 0, sizeInBytes);
return maybeWriteBytes(dataToWrite);
}
/**
* @see #native_write_byte(byte[], int, int, int, boolean)
*/
@Implementation(minSdk = M, maxSdk = P)
protected int native_write_native_bytes(
Object audioData, int positionInBytes, int sizeInBytes, int format, boolean blocking) {
return maybeWriteBytes(((ByteBuffer) audioData), sizeInBytes);
}
/**
* @see #native_write_byte(byte[], int, int, int, boolean)
*/
@Implementation(minSdk = Q)
protected int native_write_native_bytes(
ByteBuffer audioData, int positionInBytes, int sizeInBytes, int format, boolean blocking) {
return maybeWriteBytes(audioData, sizeInBytes);
}
private int maybeWriteBytes(ByteBuffer audioData, int sizeInBytes) {
int previousPosition = audioData.position();
byte[] dataToWrite = new byte[sizeInBytes];
audioData.get(dataToWrite);
audioData.position(previousPosition); // Restore the original position
return maybeWriteBytes(dataToWrite);
}
private int maybeWriteBytes(byte[] audioData) {
int encoding = audioTrack.getAudioFormat();
// Assume that offload support does not change during the lifetime of the instance.
if ((VERSION.SDK_INT < 29 || !audioTrack.isOffloadedPlayback())
&& !isPcm(encoding)
&& !allowedNonPcmEncodings.contains(encoding)) {
return ERROR_DEAD_OBJECT;
}
numBytesReceived += audioData.length;
for (OnAudioDataWrittenListener listener : audioDataWrittenListeners) {
listener.onAudioDataWritten(this, audioData, audioTrack.getFormat());
}
return audioData.length;
}
@Implementation(minSdk = N)
protected AudioDeviceInfo getRoutedDevice() {
return routedDevice;
}
@Implementation(minSdk = N)
protected void addOnRoutingChangedListener(
@NonNull OnRoutingChangedListener listener, Handler handler) {
OnRoutingChangedListenerInfo listenerInfo =
new OnRoutingChangedListenerInfo(listener, audioTrack, handler);
onRoutingChangedListeners.add(listenerInfo);
if (routedDevice != null) {
listenerInfo.callListener();
}
}
@Implementation(minSdk = N)
protected void removeOnRoutingChangedListener(@NonNull OnRoutingChangedListener listener) {
onRoutingChangedListeners.removeIf(
registeredListener -> registeredListener.listener.equals(listener));
}
@Implementation(minSdk = M)
public void setPlaybackParams(@NonNull PlaybackParams params) {
playbackParams = checkNotNull(params, "Illegal null params");
}
@Implementation(minSdk = M)
@NonNull
protected PlaybackParams getPlaybackParams() {
return playbackParams;
}
@Implementation
protected int getPlaybackHeadPosition() {
return numBytesReceived / audioTrack.getFormat().getFrameSizeInBytes();
}
@Implementation
protected void play() {
if (illegalStateOnPlayEnabled) {
throw new IllegalStateException("illegalStateOnPlayEnabled == true");
}
//noinspection ResultOfMethodCallIgnored
directlyOn(audioTrack, AudioTrack.class, "play");
}
@Implementation
protected void flush() {
numBytesReceived = 0;
}
/**
* Registers an {@link OnAudioDataWrittenListener} to the {@link ShadowAudioTrack}.
*
* @param listener The {@link OnAudioDataWrittenListener} to be registered.
*/
public static void addAudioDataListener(OnAudioDataWrittenListener listener) {
ShadowAudioTrack.audioDataWrittenListeners.add(listener);
}
/**
* Removes an {@link OnAudioDataWrittenListener} from the {@link ShadowAudioTrack}.
*
* @param listener The {@link OnAudioDataWrittenListener} to be removed.
*/
public static void removeAudioDataListener(OnAudioDataWrittenListener listener) {
ShadowAudioTrack.audioDataWrittenListeners.remove(listener);
}
/** Simulates an {@link AudioTrack} {@link IllegalStateException} while playing. */
public static void enableIllegalStateOnPlay(boolean enabled) {
illegalStateOnPlayEnabled = enabled;
}
@Resetter
public static void resetTest() {
audioDataWrittenListeners.clear();
clearDirectPlaybackSupportedFormats();
clearAllowedNonPcmEncodings();
routedDevice = null;
illegalStateOnPlayEnabled = false;
}
private static boolean isPcm(int encoding) {
switch (encoding) {
case AudioFormat.ENCODING_PCM_8BIT:
case AudioFormat.ENCODING_PCM_16BIT:
case AudioFormat.ENCODING_PCM_24BIT_PACKED:
case AudioFormat.ENCODING_PCM_32BIT:
case AudioFormat.ENCODING_PCM_FLOAT:
return true;
default:
return false;
}
}
/**
* Specific fields from {@link AudioFormat} that are used for detection of direct playback
* support.
*
* @see #native_is_direct_output_supported
*/
private static class AudioFormatInfo {
private final int encoding;
private final int sampleRate;
private final int channelMask;
private final int channelIndexMask;
public AudioFormatInfo(int encoding, int sampleRate, int channelMask, int channelIndexMask) {
this.encoding = encoding;
this.sampleRate = sampleRate;
this.channelMask = channelMask;
this.channelIndexMask = channelIndexMask;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof AudioFormatInfo)) {
return false;
}
AudioFormatInfo other = (AudioFormatInfo) o;
return encoding == other.encoding
&& sampleRate == other.sampleRate
&& channelMask == other.channelMask
&& channelIndexMask == other.channelIndexMask;
}
@Override
public int hashCode() {
int result = encoding;
result = 31 * result + sampleRate;
result = 31 * result + channelMask;
result = 31 * result + channelIndexMask;
return result;
}
}
/**
* Specific fields from {@link AudioAttributes} used for detection of direct playback support.
*
* @see #native_is_direct_output_supported
*/
private static class AudioAttributesInfo {
private final int contentType;
private final int usage;
private final int flags;
public AudioAttributesInfo(int contentType, int usage, int flags) {
this.contentType = contentType;
this.usage = usage;
this.flags = flags;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof AudioAttributesInfo)) {
return false;
}
AudioAttributesInfo other = (AudioAttributesInfo) o;
return contentType == other.contentType && usage == other.usage && flags == other.flags;
}
@Override
public int hashCode() {
int result = contentType;
result = 31 * result + usage;
result = 31 * result + flags;
return result;
}
}
private static final class OnRoutingChangedListenerInfo {
private final OnRoutingChangedListener listener;
private final AudioTrack audioTrack;
private final Handler handler;
public OnRoutingChangedListenerInfo(
OnRoutingChangedListener listener, AudioTrack audioTrack, Handler handler) {
this.listener = listener;
this.audioTrack = audioTrack;
this.handler = handler;
}
public void callListener() {
handler.post(() -> listener.onRoutingChanged(audioTrack));
}
}
}