Add Clear Key test.
bug: 12035506
Change-Id: I22d54e8d0361d0419710aac3659f71f558923dee
diff --git a/tests/tests/media/src/android/media/cts/ClearKeySystemTest.java b/tests/tests/media/src/android/media/cts/ClearKeySystemTest.java
new file mode 100644
index 0000000..c05a605
--- /dev/null
+++ b/tests/tests/media/src/android/media/cts/ClearKeySystemTest.java
@@ -0,0 +1,454 @@
+/*
+ * Copyright (C) 2014 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.cts;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCodecList;
+import android.media.MediaDrm;
+import android.media.MediaDrmException;
+import android.media.CamcorderProfile;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.Looper;
+import android.test.ActivityInstrumentationTestCase2;
+import android.util.Base64;
+import android.util.Log;
+import android.view.SurfaceHolder;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.Vector;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Tests of MediaPlayer streaming capabilities.
+ */
+public class ClearKeySystemTest extends MediaPlayerTestBase {
+ private static final String TAG = ClearKeySystemTest.class.getSimpleName();
+
+ // Add additional keys here if the content has more keys.
+ private static final byte[] CLEAR_KEY =
+ { 0x1a, (byte)0x8a, 0x20, (byte)0x95, (byte)0xe4, (byte)0xde, (byte)0xb2, (byte)0xd2,
+ (byte)0x9e, (byte)0xc8, 0x16, (byte)0xac, 0x7b, (byte)0xae, 0x20, (byte)0x82 };
+
+ private static final int SLEEP_TIME_MS = 1000;
+ private static final int VIDEO_WIDTH = 1280;
+ private static final int VIDEO_HEIGHT = 720;
+ private static final long PLAY_TIME_MS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES);
+ private static final String MIME_VIDEO_AVC = "video/avc";
+
+ private static final Uri AUDIO_URL = Uri.parse(
+ "http://yt-dash-mse-test.commondatastorage.googleapis.com/media/car_cenc-20120827-8c.mp4");
+ private static final Uri VIDEO_URL = Uri.parse(
+ "http://yt-dash-mse-test.commondatastorage.googleapis.com/media/car_cenc-20120827-88.mp4");
+
+ private static final UUID CLEARKEY_SCHEME_UUID =
+ new UUID(0x1077efecc0b24d02L, 0xace33c1e52e2fb4bL);
+
+ private byte[] mDrmInitData;
+ private byte[] mSessionId;
+ private Context mContext;
+ private final List<byte[]> mClearKeys = new ArrayList<byte[]>() {
+ {
+ add(CLEAR_KEY);
+ // add additional keys here
+ }
+ };
+ private Looper mLooper;
+ private MediaCodecCencPlayer mMediaCodecPlayer;
+ private MediaDrm mDrm;
+ private Object mLock = new Object();
+ private SurfaceHolder mSurfaceHolder;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ if (false == deviceHasMediaDrm()) {
+ tearDown();
+ }
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ private boolean deviceHasMediaDrm() {
+ // ClearKey is introduced after KitKat.
+ if (android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.KITKAT) {
+ Log.i(TAG, "This test is designed to work after Android KitKat.");
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Extracts key ids from the pssh blob returned by getKeyRequest() and
+ * places it in keyIds.
+ * keyRequestBlob format (section 5.1.3.1):
+ * https://dvcs.w3.org/hg/html-media/raw-file/default/encrypted-media/encrypted-media.html#clear-key
+ *
+ * @return size of keyIds vector that contains the key ids, 0 for error
+ */
+ private int getKeyIds(byte[] keyRequestBlob, Vector<String> keyIds) {
+ if (0 == keyRequestBlob.length || keyIds == null)
+ return 0;
+
+ String jsonLicenseRequest = new String(keyRequestBlob);
+ keyIds.clear();
+
+ try {
+ JSONObject license = new JSONObject(jsonLicenseRequest);
+ final JSONArray ids = license.getJSONArray("kids");
+ for (int i = 0; i < ids.length(); ++i) {
+ keyIds.add(ids.getString(i));
+ }
+ } catch (JSONException e) {
+ Log.e(TAG, "Invalid JSON license = " + jsonLicenseRequest);
+ return 0;
+ }
+ return keyIds.size();
+ }
+
+ /**
+ * Creates the JSON Web Key string.
+ *
+ * @return JSON Web Key string.
+ */
+ private String createJsonWebKeySet(Vector<String> keyIds, Vector<String> keys) {
+ String jwkSet = "{\"keys\":[";
+ for (int i = 0; i < keyIds.size(); ++i) {
+ String id = new String(keyIds.get(i).getBytes(Charset.forName("UTF-8")));
+ String key = new String(keys.get(i).getBytes(Charset.forName("UTF-8")));
+
+ jwkSet += "{\"kty\":\"oct\",\"kid\":\"" + id +
+ "\",\"k\":\"" + key + "\"}";
+ }
+ jwkSet += "]}";
+ return jwkSet;
+ }
+
+ /**
+ * Retrieves clear key ids from getKeyRequest(), create JSON Web Key
+ * set and send it to the CDM via provideKeyResponse().
+ */
+ private void getKeys(MediaDrm drm, byte[] sessionId, byte[] drmInitData) {
+ MediaDrm.KeyRequest drmRequest = null;;
+ try {
+ drmRequest = drm.getKeyRequest(sessionId, drmInitData, "cenc",
+ MediaDrm.KEY_TYPE_STREAMING, null);
+ } catch (Exception e) {
+ e.printStackTrace();
+ Log.i(TAG, "Failed to get key request: " + e.toString());
+ }
+ if (drmRequest == null) {
+ Log.e(TAG, "Failed getKeyRequest");
+ return;
+ }
+
+ Vector<String> keyIds = new Vector<String>();
+ if (0 == getKeyIds(drmRequest.getData(), keyIds)) {
+ Log.e(TAG, "No key ids found in initData");
+ return;
+ }
+
+ if (mClearKeys.size() != keyIds.size()) {
+ Log.e(TAG, "Mismatch number of key ids and keys: ids=" +
+ keyIds.size() + ", keys=" + mClearKeys.size());
+ return;
+ }
+
+ // Base64 encodes clearkeys. Keys are known to the application.
+ Vector<String> keys = new Vector<String>();
+ for (int i = 0; i < mClearKeys.size(); ++i) {
+ String clearKey = Base64.encodeToString(mClearKeys.get(i),
+ Base64.NO_PADDING | Base64.NO_WRAP);
+ keys.add(clearKey);
+ }
+
+ String jwkSet = createJsonWebKeySet(keyIds, keys);
+ byte[] jsonResponse = jwkSet.getBytes(Charset.forName("UTF-8"));
+
+ try {
+ try {
+ drm.provideKeyResponse(sessionId, jsonResponse);
+ } catch (IllegalStateException e) {
+ Log.e(TAG, "Failed to provide key response: " + e.toString());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ Log.e(TAG, "Failed to provide key response: " + e.toString());
+ }
+ }
+
+ private MediaDrm startDrm() {
+ new Thread() {
+ @Override
+ public void run() {
+ // Set up a looper to handle events
+ Looper.prepare();
+
+ // Save the looper so that we can terminate this thread
+ // after we are done with it.
+ mLooper = Looper.myLooper();
+
+ try {
+ mDrm = new MediaDrm(CLEARKEY_SCHEME_UUID);
+ } catch (MediaDrmException e) {
+ Log.e(TAG, "Failed to create MediaDrm: " + e.getMessage());
+ return;
+ }
+
+ synchronized(mLock) {
+ mDrm.setOnEventListener(new MediaDrm.OnEventListener() {
+ @Override
+ public void onEvent(MediaDrm md, byte[] sessionId, int event,
+ int extra, byte[] data) {
+ if (event == MediaDrm.EVENT_KEY_REQUIRED) {
+ Log.i(TAG, "MediaDrm event: Key required");
+ getKeys(mDrm, mSessionId, mDrmInitData);
+ } else if (event == MediaDrm.EVENT_KEY_EXPIRED) {
+ Log.i(TAG, "MediaDrm event: Key expired");
+ getKeys(mDrm, mSessionId, mDrmInitData);
+ } else {
+ Log.e(TAG, "Events not supported" + event);
+ }
+ }
+ });
+ mLock.notify();
+ }
+ Looper.loop(); // Blocks forever until Looper.quit() is called.
+ }
+ }.start();
+
+ // wait for mDrm to be created
+ synchronized(mLock) {
+ try {
+ mLock.wait(1000);
+ } catch (Exception e) {
+ }
+ }
+ return mDrm;
+ }
+
+ private void stopDrm(MediaDrm drm) {
+ if (drm != mDrm) {
+ Log.e(TAG, "invalid drm specified in stopDrm");
+ }
+ mLooper.quit();
+ }
+
+ private byte[] openSession(MediaDrm drm) {
+ byte[] mSessionId = null;
+ boolean mRetryOpen;
+ do {
+ try {
+ mRetryOpen = false;
+ mSessionId = drm.openSession();
+ } catch (Exception e) {
+ mRetryOpen = true;
+ }
+ } while (mRetryOpen);
+ return mSessionId;
+ }
+
+ private void closeSession(MediaDrm drm, byte[] sessionId) {
+ drm.closeSession(sessionId);
+ }
+
+ public boolean isResolutionSupported(int videoWidth, int videoHeight) {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.JELLY_BEAN) {
+ if (videoHeight <= 144) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_QCIF);
+ } else if (videoHeight <= 240) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_QVGA);
+ } else if (videoHeight <= 288) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_CIF);
+ } else if (videoHeight <= 480) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_480P);
+ } else if (videoHeight <= 720) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_720P);
+ } else if (videoHeight <= 1080) {
+ return CamcorderProfile.hasProfile(CamcorderProfile.QUALITY_1080P);
+ } else {
+ return false;
+ }
+ }
+
+ CodecCapabilities cap;
+ int highestProfileLevel = 0;
+ MediaCodecInfo codecInfo;
+
+ for (int i = 0; i < MediaCodecList.getCodecCount(); i++) {
+ codecInfo = MediaCodecList.getCodecInfoAt(i);
+ if (codecInfo.isEncoder()) {
+ continue;
+ }
+
+ String[] types = codecInfo.getSupportedTypes();
+ for (int j = 0; j < types.length; ++j) {
+ if (!types[j].equalsIgnoreCase(MIME_VIDEO_AVC)) {
+ continue;
+ }
+
+ Log.d(TAG, "codec: " + codecInfo.getName() + "types: " + types[j]);
+ cap = codecInfo.getCapabilitiesForType(types[j]);
+ for (CodecProfileLevel profileLevel : cap.profileLevels) {
+ Log.i(TAG, "codec " + codecInfo.getName() + ", level " + profileLevel.level);
+ if (profileLevel.level > highestProfileLevel) {
+ highestProfileLevel = profileLevel.level;
+ }
+ }
+ Log.i(TAG, "codec " + codecInfo.getName() + ", highest level " + highestProfileLevel);
+ }
+ }
+
+ // AVCLevel and its resolution is taken from http://en.wikipedia.org/wiki/H.264/MPEG-4_AVC
+ switch(highestProfileLevel) {
+ case CodecProfileLevel.AVCLevel1:
+ case CodecProfileLevel.AVCLevel1b:
+ return (videoWidth <= 176 && videoHeight <= 144);
+ case CodecProfileLevel.AVCLevel11:
+ case CodecProfileLevel.AVCLevel12:
+ case CodecProfileLevel.AVCLevel13:
+ case CodecProfileLevel.AVCLevel2:
+ return (videoWidth <= 352 && videoHeight <= 288);
+ case CodecProfileLevel.AVCLevel21:
+ return (videoWidth <= 352 && videoHeight <= 576);
+ case CodecProfileLevel.AVCLevel22:
+ case CodecProfileLevel.AVCLevel3:
+ return (videoWidth <= 720 && videoHeight <= 576);
+ case CodecProfileLevel.AVCLevel31:
+ return (videoWidth <= 1280 && videoHeight <= 720);
+ case CodecProfileLevel.AVCLevel32:
+ return (videoWidth <= 1280 && videoHeight <= 1024);
+ case CodecProfileLevel.AVCLevel4:
+ case CodecProfileLevel.AVCLevel41:
+ // 1280 x 720
+ // 1920 x 1080
+ // 2048 x 1024
+ if (videoWidth <= 1920) {
+ return (videoHeight <= 1080);
+ } else if (videoWidth <= 2048) {
+ return (videoHeight <= 1024);
+ } else {
+ return false;
+ }
+ case CodecProfileLevel.AVCLevel42:
+ return (videoWidth <= 2048 && videoHeight <= 1080);
+ case CodecProfileLevel.AVCLevel5:
+ // 1920 x 1080
+ // 2048 x 1024
+ // 2048 x 1080
+ // 2560 x 1920
+ // 3672 x 1536
+ if (videoWidth <= 1920) {
+ return (videoHeight <= 1080);
+ } else if (videoWidth <= 2048) {
+ return (videoHeight <= 1080);
+ } else if (videoWidth <= 2560) {
+ return (videoHeight <= 1920);
+ } else if (videoWidth <= 3672) {
+ return (videoHeight <= 1536);
+ } else {
+ return false;
+ }
+ case CodecProfileLevel.AVCLevel51:
+ default: // any future extension will cap at level 5.1
+ // 1920 x 1080
+ // 2560 x 1920
+ // 3840 x 2160
+ // 4096 x 2048
+ // 4096 x 2160
+ // 4096 x 2304
+ if (videoWidth <= 1920) {
+ return (videoHeight <= 1080);
+ } else if (videoWidth <= 2560) {
+ return (videoHeight <= 1920);
+ } else if (videoWidth <= 3840) {
+ return (videoHeight <= 2160);
+ } else if (videoWidth <= 4096) {
+ return (videoHeight <= 2304);
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Tests clear key system playback.
+ */
+ public void testClearKeyPlayback() throws Exception {
+ MediaDrm drm = startDrm();
+ if (null == drm) {
+ throw new Error("Failed to create drm.");
+ }
+
+ if (!drm.isCryptoSchemeSupported(CLEARKEY_SCHEME_UUID)) {
+ stopDrm(drm);
+ throw new Error("Crypto scheme is not supported.");
+ }
+
+ if (!isResolutionSupported(VIDEO_WIDTH, VIDEO_HEIGHT)) {
+ Log.i(TAG, "Device does not support " +
+ VIDEO_WIDTH + "x" + VIDEO_HEIGHT + "resolution.");
+ return;
+ }
+
+ mSessionId = openSession(drm);
+ mMediaCodecPlayer = new MediaCodecCencPlayer(
+ getActivity().getSurfaceHolder(), mSessionId);
+
+ mMediaCodecPlayer.setAudioDataSource(AUDIO_URL, null, false);
+ mMediaCodecPlayer.setVideoDataSource(VIDEO_URL, null, true);
+ mMediaCodecPlayer.start();
+ mMediaCodecPlayer.prepare();
+ mDrmInitData = mMediaCodecPlayer.getPsshInfo().get(CLEARKEY_SCHEME_UUID);
+
+ getKeys(mDrm, mSessionId, mDrmInitData);
+ // starts video playback
+ mMediaCodecPlayer.startThread();
+
+ long timeOut = System.currentTimeMillis() + PLAY_TIME_MS * 4;
+ while (timeOut > System.currentTimeMillis() && !mMediaCodecPlayer.isEnded()) {
+ Thread.sleep(SLEEP_TIME_MS);
+ if (mMediaCodecPlayer.getCurrentPosition() >= mMediaCodecPlayer.getDuration() ) {
+ Log.d(TAG, "current pos = " + mMediaCodecPlayer.getCurrentPosition() +
+ ">= duration = " + mMediaCodecPlayer.getDuration());
+ break;
+ }
+ }
+
+ Log.d(TAG, "playVideo player.reset()");
+ mMediaCodecPlayer.reset();
+ closeSession(drm, mSessionId);
+ stopDrm(drm);
+ }
+}
diff --git a/tests/tests/media/src/android/media/cts/CodecState.java b/tests/tests/media/src/android/media/cts/CodecState.java
new file mode 100644
index 0000000..cd6b68f
--- /dev/null
+++ b/tests/tests/media/src/android/media/cts/CodecState.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright (C) 2014 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.cts;
+
+import android.media.AudioTrack;
+import android.media.MediaCodec;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.util.Log;
+
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+
+/**
+ * Class for directly managing both audio and video playback by
+ * using {@link MediaCodec} and {@link AudioTrack}.
+ */
+public class CodecState {
+ private static final String TAG = CodecState.class.getSimpleName();
+
+ private boolean mSawInputEOS, mSawOutputEOS;
+ private boolean mLimitQueueDepth;
+ private ByteBuffer[] mCodecInputBuffers;
+ private ByteBuffer[] mCodecOutputBuffers;
+ private int mTrackIndex;
+ private LinkedList<Integer> mAvailableInputBufferIndices;
+ private LinkedList<Integer> mAvailableOutputBufferIndices;
+ private LinkedList<MediaCodec.BufferInfo> mAvailableOutputBufferInfos;
+ private long mPresentationTimeUs;
+ private MediaCodec mCodec;
+ private MediaCodecCencPlayer mMediaCodecPlayer;
+ private MediaExtractor mExtractor;
+ private MediaFormat mFormat;
+ private MediaFormat mOutputFormat;
+ private NonBlockingAudioTrack mAudioTrack;
+
+ /**
+ * Manages audio and video playback using MediaCodec and AudioTrack.
+ */
+ public CodecState(
+ MediaCodecCencPlayer mediaCodecPlayer,
+ MediaExtractor extractor,
+ int trackIndex,
+ MediaFormat format,
+ MediaCodec codec,
+ boolean limitQueueDepth) {
+
+ mMediaCodecPlayer = mediaCodecPlayer;
+ mExtractor = extractor;
+ mTrackIndex = trackIndex;
+ mFormat = format;
+ mSawInputEOS = mSawOutputEOS = false;
+ mLimitQueueDepth = limitQueueDepth;
+
+ mCodec = codec;
+
+ mAvailableInputBufferIndices = new LinkedList<Integer>();
+ mAvailableOutputBufferIndices = new LinkedList<Integer>();
+ mAvailableOutputBufferInfos = new LinkedList<MediaCodec.BufferInfo>();
+
+ mPresentationTimeUs = 0;
+ }
+
+ public void release() {
+ mCodec.stop();
+ mCodecInputBuffers = null;
+ mCodecOutputBuffers = null;
+ mOutputFormat = null;
+
+ mAvailableInputBufferIndices.clear();
+ mAvailableOutputBufferIndices.clear();
+ mAvailableOutputBufferInfos.clear();
+
+ mAvailableInputBufferIndices = null;
+ mAvailableOutputBufferIndices = null;
+ mAvailableOutputBufferInfos = null;
+
+ mCodec.release();
+ mCodec = null;
+
+ if (mAudioTrack != null) {
+ mAudioTrack.release();
+ mAudioTrack = null;
+ }
+ }
+
+ public void start() {
+ mCodec.start();
+ mCodecInputBuffers = mCodec.getInputBuffers();
+ mCodecOutputBuffers = mCodec.getOutputBuffers();
+
+ if (mAudioTrack != null) {
+ mAudioTrack.play();
+ }
+ }
+
+ public void pause() {
+ if (mAudioTrack != null) {
+ mAudioTrack.pause();
+ }
+ }
+
+ public long getCurrentPositionUs() {
+ return mPresentationTimeUs;
+ }
+
+ public void flush() {
+ mAvailableInputBufferIndices.clear();
+ mAvailableOutputBufferIndices.clear();
+ mAvailableOutputBufferInfos.clear();
+
+ mSawInputEOS = false;
+ mSawOutputEOS = false;
+
+ if (mAudioTrack != null
+ && mAudioTrack.getPlayState() == AudioTrack.PLAYSTATE_STOPPED) {
+ mAudioTrack.play();
+ }
+
+ mCodec.flush();
+ }
+
+ public boolean isEnded() {
+ return mSawInputEOS && mSawOutputEOS;
+ }
+
+ /**
+ * doSomeWork() is the worker function that does all buffer handling and decoding works.
+ * It first reads data from {@link MediaExtractor} and pushes it into {@link MediaCodec};
+ * it then dequeues buffer from {@link MediaCodec}, consumes it and pushes back to its own
+ * buffer queue for next round reading data from {@link MediaExtractor}.
+ */
+ public void doSomeWork() {
+ int indexInput = mCodec.dequeueInputBuffer(0 /* timeoutUs */);
+
+ if (indexInput != MediaCodec.INFO_TRY_AGAIN_LATER) {
+ mAvailableInputBufferIndices.add(indexInput);
+ }
+
+ while (feedInputBuffer()) {
+ }
+
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ int indexOutput = mCodec.dequeueOutputBuffer(info, 0 /* timeoutUs */);
+
+ if (indexOutput == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ mOutputFormat = mCodec.getOutputFormat();
+ onOutputFormatChanged();
+ } else if (indexOutput == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ mCodecOutputBuffers = mCodec.getOutputBuffers();
+ } else if (indexOutput != MediaCodec.INFO_TRY_AGAIN_LATER) {
+ mAvailableOutputBufferIndices.add(indexOutput);
+ mAvailableOutputBufferInfos.add(info);
+ }
+
+ while (drainOutputBuffer()) {
+ }
+ }
+
+ /** Returns true if more input data could be fed. */
+ private boolean feedInputBuffer() throws MediaCodec.CryptoException, IllegalStateException {
+ if (mSawInputEOS || mAvailableInputBufferIndices.isEmpty()) {
+ return false;
+ }
+
+ // stalls read if audio queue is larger than 2MB full so we will not occupy too much heap
+ if (mLimitQueueDepth && mAudioTrack != null &&
+ mAudioTrack.getNumBytesQueued() > 2 * 1024 * 1024) {
+ return false;
+ }
+
+ int index = mAvailableInputBufferIndices.peekFirst().intValue();
+
+ ByteBuffer codecData = mCodecInputBuffers[index];
+
+ int trackIndex = mExtractor.getSampleTrackIndex();
+
+ if (trackIndex == mTrackIndex) {
+ int sampleSize =
+ mExtractor.readSampleData(codecData, 0 /* offset */);
+
+ long sampleTime = mExtractor.getSampleTime();
+
+ int sampleFlags = mExtractor.getSampleFlags();
+
+ if (sampleSize <= 0) {
+ Log.d(TAG, "sampleSize: " + sampleSize + " trackIndex:" + trackIndex +
+ " sampleTime:" + sampleTime + " sampleFlags:" + sampleFlags);
+ mSawInputEOS = true;
+ return false;
+ }
+
+ if ((sampleFlags & MediaExtractor.SAMPLE_FLAG_ENCRYPTED) != 0) {
+ MediaCodec.CryptoInfo info = new MediaCodec.CryptoInfo();
+ mExtractor.getSampleCryptoInfo(info);
+
+ mCodec.queueSecureInputBuffer(
+ index, 0 /* offset */, info, sampleTime, 0 /* flags */);
+ } else {
+ mCodec.queueInputBuffer(
+ index, 0 /* offset */, sampleSize, sampleTime, 0 /* flags */);
+ }
+
+ mAvailableInputBufferIndices.removeFirst();
+ mExtractor.advance();
+
+ return true;
+ } else if (trackIndex < 0) {
+ Log.d(TAG, "saw input EOS on track " + mTrackIndex);
+
+ mSawInputEOS = true;
+
+ mCodec.queueInputBuffer(
+ index, 0 /* offset */, 0 /* sampleSize */,
+ 0 /* sampleTime */, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+
+ mAvailableInputBufferIndices.removeFirst();
+ }
+
+ return false;
+ }
+
+ private void onOutputFormatChanged() {
+ String mime = mOutputFormat.getString(MediaFormat.KEY_MIME);
+ // b/9250789
+ Log.d(TAG, "CodecState::onOutputFormatChanged " + mime);
+
+ if (mime.startsWith("audio/")) {
+ int sampleRate =
+ mOutputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+
+ int channelCount =
+ mOutputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+
+ Log.d(TAG, "CodecState::onOutputFormatChanged Audio" +
+ " sampleRate:" + sampleRate + " channels:" + channelCount);
+ // We do sanity check here after we receive data from MediaExtractor and before
+ // we pass them down to AudioTrack. If MediaExtractor works properly, this
+ // sanity-check is not necessary, however, in our tests, we found that there
+ // are a few cases where ch=0 and samplerate=0 were returned by MediaExtractor.
+ if (channelCount < 1 || channelCount > 8 ||
+ sampleRate < 8000 || sampleRate > 128000) {
+ return;
+ }
+ mAudioTrack = new NonBlockingAudioTrack(sampleRate, channelCount);
+ mAudioTrack.play();
+ }
+
+ if (mime.startsWith("video/")) {
+ int width = mOutputFormat.getInteger(MediaFormat.KEY_WIDTH);
+ int height = mOutputFormat.getInteger(MediaFormat.KEY_HEIGHT);
+ Log.d(TAG, "CodecState::onOutputFormatChanged Video" +
+ " width:" + width + " height:" + height);
+ }
+ }
+
+ /** Returns true if more output data could be drained. */
+ private boolean drainOutputBuffer() {
+ if (mSawOutputEOS || mAvailableOutputBufferIndices.isEmpty()) {
+ return false;
+ }
+
+ int index = mAvailableOutputBufferIndices.peekFirst().intValue();
+ MediaCodec.BufferInfo info = mAvailableOutputBufferInfos.peekFirst();
+
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ Log.d(TAG, "saw output EOS on track " + mTrackIndex);
+
+ mSawOutputEOS = true;
+
+ return false;
+ }
+
+ long realTimeUs =
+ mMediaCodecPlayer.getRealTimeUsForMediaTime(info.presentationTimeUs);
+
+ long nowUs = mMediaCodecPlayer.getNowUs();
+
+ long lateUs = nowUs - realTimeUs;
+
+ if (mAudioTrack != null) {
+ ByteBuffer buffer = mCodecOutputBuffers[index];
+ buffer.clear();
+ buffer.position(0 /* offset */);
+
+ byte[] audioCopy = new byte[info.size];
+ buffer.get(audioCopy, 0, info.size);
+
+ mAudioTrack.write(audioCopy, info.size);
+
+ mCodec.releaseOutputBuffer(index, false /* render */);
+
+ mPresentationTimeUs = info.presentationTimeUs;
+
+ mAvailableOutputBufferIndices.removeFirst();
+ mAvailableOutputBufferInfos.removeFirst();
+ return true;
+ } else {
+ // video
+ boolean render;
+
+ if (lateUs < -45000) {
+ // too early;
+ return false;
+ } else if (lateUs > 30000) {
+ Log.d(TAG, "video late by " + lateUs + " us.");
+ render = false;
+ } else {
+ render = true;
+ mPresentationTimeUs = info.presentationTimeUs;
+ }
+
+ mCodec.releaseOutputBuffer(index, render);
+
+ mAvailableOutputBufferIndices.removeFirst();
+ mAvailableOutputBufferInfos.removeFirst();
+ return true;
+ }
+ }
+
+ public long getAudioTimeUs() {
+ if (mAudioTrack == null) {
+ return 0;
+ }
+
+ return mAudioTrack.getAudioTimeUs();
+ }
+
+ public void process() {
+ if (mAudioTrack != null) {
+ mAudioTrack.process();
+ }
+ }
+}
diff --git a/tests/tests/media/src/android/media/cts/MediaCodecCencPlayer.java b/tests/tests/media/src/android/media/cts/MediaCodecCencPlayer.java
new file mode 100644
index 0000000..90696ff
--- /dev/null
+++ b/tests/tests/media/src/android/media/cts/MediaCodecCencPlayer.java
@@ -0,0 +1,532 @@
+/*
+ * Copyright (C) 2014 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.cts;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaCrypto;
+import android.media.MediaCryptoException;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.net.Uri;
+import android.util.Log;
+import android.view.SurfaceHolder;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * JB(API 16) introduces {@link MediaCodec} API. It allows apps have more control over
+ * media playback, pushes individual frames to decoder and supports decryption via
+ * {@link MediaCrypto} API.
+ *
+ * {@link MediaDrm} can be used to obtain keys for decrypting protected media streams,
+ * in conjunction with MediaCrypto.
+ */
+public class MediaCodecCencPlayer {
+ private static final String TAG = MediaCodecCencPlayer.class.getSimpleName();
+
+ private static final int STATE_IDLE = 1;
+ private static final int STATE_PREPARING = 2;
+ private static final int STATE_PLAYING = 3;
+ private static final int STATE_PAUSED = 4;
+
+ private static final UUID CLEARKEY_SCHEME_UUID =
+ new UUID(0x1077efecc0b24d02L, 0xace33c1e52e2fb4bL);
+
+ private boolean mEncryptedAudio;
+ private boolean mEncryptedVideo;
+ private boolean mThreadStarted = false;
+ private byte[] mSessionId;
+ private CodecState mAudioTrackState;
+ private int mMediaFormatHeight;
+ private int mMediaFormatWidth;
+ private int mState;
+ private long mDeltaTimeUs;
+ private long mDurationUs;
+ private Map<Integer, CodecState> mAudioCodecStates;
+ private Map<Integer, CodecState> mVideoCodecStates;
+ private Map<String, String> mAudioHeaders;
+ private Map<String, String> mVideoHeaders;
+ private Map<UUID, byte[]> mPsshInitData;
+ private MediaCrypto mCrypto;
+ private MediaExtractor mAudioExtractor;
+ private MediaExtractor mVideoExtractor;
+ private SurfaceHolder mSurfaceHolder;
+ private Thread mThread;
+ private Uri mAudioUri;
+ private Uri mVideoUri;
+
+ private static final byte[] PSSH = hexStringToByteArray(
+ "0000003470737368" + // BMFF box header (4 bytes size + 'pssh')
+ "01000000" + // Full box header (version = 1 flags = 0)
+ "1077efecc0b24d02" + // SystemID
+ "ace33c1e52e2fb4b" +
+ "00000001" + // Number of key ids
+ "60061e017e477e87" + // Key id
+ "7e57d00d1ed00d1e" +
+ "00000000" // Size of Data, must be zero
+ );
+
+ /**
+ * Convert a hex string into byte array.
+ */
+ private static byte[] hexStringToByteArray(String s) {
+ int len = s.length();
+ byte[] data = new byte[len / 2];
+ for (int i = 0; i < len; i += 2) {
+ data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ + Character.digit(s.charAt(i + 1), 16));
+ }
+ return data;
+ }
+
+ /*
+ * Media player class to stream CENC content using MediaCodec class.
+ */
+ public MediaCodecCencPlayer(SurfaceHolder holder, byte[] sessionId) {
+ mSessionId = sessionId;
+ mSurfaceHolder = holder;
+ mState = STATE_IDLE;
+ mThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ while (mThreadStarted == true) {
+ doSomeWork();
+ if (mAudioTrackState != null) {
+ mAudioTrackState.process();
+ }
+ try {
+ Thread.sleep(5);
+ } catch (InterruptedException ex) {
+ Log.d(TAG, "Thread interrupted");
+ }
+ }
+ }
+ });
+ }
+
+ public void setAudioDataSource(Uri uri, Map<String, String> headers, boolean encrypted) {
+ mAudioUri = uri;
+ mAudioHeaders = headers;
+ mEncryptedAudio = encrypted;
+ }
+
+ public void setVideoDataSource(Uri uri, Map<String, String> headers, boolean encrypted) {
+ mVideoUri = uri;
+ mVideoHeaders = headers;
+ mEncryptedVideo = encrypted;
+ }
+
+ public final int getMediaFormatHeight() {
+ return mMediaFormatHeight;
+ }
+
+ public final int getMediaFormatWidth() {
+ return mMediaFormatWidth;
+ }
+
+ public final Map<UUID, byte[]> getPsshInfo() {
+ // TODO (edwinwong@)
+ // Remove the if statement when we get content that has the clear key system id.
+ if (mPsshInitData == null ||
+ (mPsshInitData != null && !mPsshInitData.containsKey(CLEARKEY_SCHEME_UUID))) {
+ mPsshInitData = new HashMap<UUID, byte[]>();
+ mPsshInitData.put(CLEARKEY_SCHEME_UUID, PSSH);
+ }
+ return mPsshInitData;
+ }
+
+ private void prepareAudio() throws IOException {
+ boolean hasAudio = false;
+ for (int i = mAudioExtractor.getTrackCount(); i-- > 0;) {
+ MediaFormat format = mAudioExtractor.getTrackFormat(i);
+ String mime = format.getString(MediaFormat.KEY_MIME);
+
+ Log.d(TAG, "audio track #" + i + " " + format + " " + mime +
+ " Is ADTS:" + getMediaFormatInteger(format, MediaFormat.KEY_IS_ADTS) +
+ " Sample rate:" + getMediaFormatInteger(format, MediaFormat.KEY_SAMPLE_RATE) +
+ " Channel count:" +
+ getMediaFormatInteger(format, MediaFormat.KEY_CHANNEL_COUNT));
+
+ if (!hasAudio) {
+ mAudioExtractor.selectTrack(i);
+ addTrack(i, format, mEncryptedAudio);
+ hasAudio = true;
+
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
+ long durationUs = format.getLong(MediaFormat.KEY_DURATION);
+
+ if (durationUs > mDurationUs) {
+ mDurationUs = durationUs;
+ }
+ Log.d(TAG, "audio track format #" + i +
+ " Duration:" + mDurationUs + " microseconds");
+ }
+
+ if (hasAudio) {
+ break;
+ }
+ }
+ }
+ }
+
+ private void prepareVideo() throws IOException {
+ boolean hasVideo = false;
+
+ for (int i = mVideoExtractor.getTrackCount(); i-- > 0;) {
+ MediaFormat format = mVideoExtractor.getTrackFormat(i);
+ String mime = format.getString(MediaFormat.KEY_MIME);
+
+ mMediaFormatHeight = getMediaFormatInteger(format, MediaFormat.KEY_HEIGHT);
+ mMediaFormatWidth = getMediaFormatInteger(format, MediaFormat.KEY_WIDTH);
+ Log.d(TAG, "video track #" + i + " " + format + " " + mime +
+ " Width:" + mMediaFormatWidth + ", Height:" + mMediaFormatHeight);
+
+ if (!hasVideo) {
+ mVideoExtractor.selectTrack(i);
+ addTrack(i, format, mEncryptedVideo);
+
+ hasVideo = true;
+
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
+ long durationUs = format.getLong(MediaFormat.KEY_DURATION);
+
+ if (durationUs > mDurationUs) {
+ mDurationUs = durationUs;
+ }
+ Log.d(TAG, "track format #" + i + " Duration:" +
+ mDurationUs + " microseconds");
+ }
+
+ if (hasVideo) {
+ break;
+ }
+ }
+ }
+ return;
+ }
+
+ public void prepare() throws IOException, MediaCryptoException {
+ if (null == mAudioExtractor) {
+ mAudioExtractor = new MediaExtractor();
+ if (null == mAudioExtractor) {
+ Log.e(TAG, "Cannot create Audio extractor.");
+ return;
+ }
+ }
+
+ if (null == mVideoExtractor){
+ mVideoExtractor = new MediaExtractor();
+ if (null == mVideoExtractor) {
+ Log.e(TAG, "Cannot create Video extractor.");
+ return;
+ }
+ }
+
+ mAudioExtractor.setDataSource(mAudioUri.toString(), mAudioHeaders);
+ mVideoExtractor.setDataSource(mVideoUri.toString(), mVideoHeaders);
+ mPsshInitData = mVideoExtractor.getPsshInfo();
+
+ if (null == mCrypto && (mEncryptedVideo || mEncryptedAudio)) {
+ try {
+ mCrypto = new MediaCrypto(CLEARKEY_SCHEME_UUID, mSessionId);
+ } catch (MediaCryptoException e) {
+ reset();
+ Log.e(TAG, "Failed to create MediaCrypto instance.");
+ throw e;
+ }
+ } else {
+ reset();
+ mCrypto.release();
+ mCrypto = null;
+ }
+
+ if (null == mVideoCodecStates) {
+ mVideoCodecStates = new HashMap<Integer, CodecState>();
+ } else {
+ mVideoCodecStates.clear();
+ }
+
+ if (null == mAudioCodecStates) {
+ mAudioCodecStates = new HashMap<Integer, CodecState>();
+ } else {
+ mAudioCodecStates.clear();
+ }
+
+ prepareVideo();
+ prepareAudio();
+
+ mState = STATE_PAUSED;
+ }
+
+ private void addTrack(int trackIndex, MediaFormat format,
+ boolean encrypted) throws IOException {
+ String mime = format.getString(MediaFormat.KEY_MIME);
+ boolean isVideo = mime.startsWith("video/");
+ boolean isAudio = mime.startsWith("audio/");
+
+ MediaCodec codec;
+
+ if (encrypted && mCrypto.requiresSecureDecoderComponent(mime)) {
+ codec = MediaCodec.createByCodecName(
+ getSecureDecoderNameForMime(mime));
+ } else {
+ codec = MediaCodec.createDecoderByType(mime);
+ }
+
+ codec.configure(
+ format,
+ isVideo ? mSurfaceHolder.getSurface() : null,
+ mCrypto,
+ 0);
+
+ CodecState state;
+ if (isVideo) {
+ state = new CodecState(this, mVideoExtractor, trackIndex, format, codec, true);
+ mVideoCodecStates.put(Integer.valueOf(trackIndex), state);
+ } else {
+ state = new CodecState(this, mAudioExtractor, trackIndex, format, codec, true);
+ mAudioCodecStates.put(Integer.valueOf(trackIndex), state);
+ }
+
+ if (isAudio) {
+ mAudioTrackState = state;
+ }
+ }
+
+ protected int getMediaFormatInteger(MediaFormat format, String key) {
+ return format.containsKey(key) ? format.getInteger(key) : 0;
+ }
+
+ protected String getSecureDecoderNameForMime(String mime) {
+ int n = MediaCodecList.getCodecCount();
+ for (int i = 0; i < n; ++i) {
+ MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+
+ if (info.isEncoder()) {
+ continue;
+ }
+
+ String[] supportedTypes = info.getSupportedTypes();
+
+ for (int j = 0; j < supportedTypes.length; ++j) {
+ if (supportedTypes[j].equalsIgnoreCase(mime)) {
+ return info.getName() + ".secure";
+ }
+ }
+ }
+ return null;
+ }
+
+ public void start() {
+ Log.d(TAG, "start");
+
+ if (mState == STATE_PLAYING || mState == STATE_PREPARING) {
+ return;
+ } else if (mState == STATE_IDLE) {
+ mState = STATE_PREPARING;
+ return;
+ } else if (mState != STATE_PAUSED) {
+ throw new IllegalStateException();
+ }
+
+ for (CodecState state : mVideoCodecStates.values()) {
+ state.start();
+ }
+
+ for (CodecState state : mAudioCodecStates.values()) {
+ state.start();
+ }
+
+ mDeltaTimeUs = -1;
+ mState = STATE_PLAYING;
+ }
+
+ public void startWork() throws IOException, MediaCryptoException, Exception {
+ try {
+ // Just change state from STATE_IDLE to STATE_PREPARING.
+ start();
+ // Extract media information from uri asset, and change state to STATE_PAUSED.
+ prepare();
+ // Start CodecState, and change from STATE_PAUSED to STATE_PLAYING.
+ start();
+ } catch (IOException e) {
+ throw e;
+ } catch (MediaCryptoException e) {
+ throw e;
+ }
+
+ mThreadStarted = true;
+ mThread.start();
+ }
+
+ public void startThread() {
+ start();
+ mThreadStarted = true;
+ mThread.start();
+ }
+
+ public void pause() {
+ Log.d(TAG, "pause");
+
+ if (mState == STATE_PAUSED) {
+ return;
+ } else if (mState != STATE_PLAYING) {
+ throw new IllegalStateException();
+ }
+
+ for (CodecState state : mVideoCodecStates.values()) {
+ state.pause();
+ }
+
+ for (CodecState state : mAudioCodecStates.values()) {
+ state.pause();
+ }
+
+ mState = STATE_PAUSED;
+ }
+
+ public void reset() {
+ if (mState == STATE_PLAYING) {
+ mThreadStarted = false;
+
+ try {
+ mThread.join();
+ } catch (InterruptedException ex) {
+ Log.d(TAG, "mThread.join " + ex);
+ }
+
+ pause();
+ }
+
+ if (mVideoCodecStates != null) {
+ for (CodecState state : mVideoCodecStates.values()) {
+ state.release();
+ }
+ mVideoCodecStates = null;
+ }
+
+ if (mAudioCodecStates != null) {
+ for (CodecState state : mAudioCodecStates.values()) {
+ state.release();
+ }
+ mAudioCodecStates = null;
+ }
+
+ if (mAudioExtractor != null) {
+ mAudioExtractor.release();
+ mAudioExtractor = null;
+ }
+
+ if (mVideoExtractor != null) {
+ mVideoExtractor.release();
+ mVideoExtractor = null;
+ }
+
+ if (mCrypto != null) {
+ mCrypto.release();
+ mCrypto = null;
+ }
+
+ mDurationUs = -1;
+ mState = STATE_IDLE;
+ }
+
+ public boolean isEnded() {
+ for (CodecState state : mVideoCodecStates.values()) {
+ if (!state.isEnded()) {
+ return false;
+ }
+ }
+
+ for (CodecState state : mAudioCodecStates.values()) {
+ if (!state.isEnded()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void doSomeWork() {
+ try {
+ for (CodecState state : mVideoCodecStates.values()) {
+ state.doSomeWork();
+ }
+ } catch (MediaCodec.CryptoException e) {
+ throw new Error("Video CryptoException w/ errorCode "
+ + e.getErrorCode() + ", '" + e.getMessage() + "'");
+ } catch (IllegalStateException e) {
+ throw new Error("Video CodecState.feedInputBuffer IllegalStateException " + e);
+ }
+
+ try {
+ for (CodecState state : mAudioCodecStates.values()) {
+ state.doSomeWork();
+ }
+ } catch (MediaCodec.CryptoException e) {
+ throw new Error("Audio CryptoException w/ errorCode "
+ + e.getErrorCode() + ", '" + e.getMessage() + "'");
+ } catch (IllegalStateException e) {
+ throw new Error("Aduio CodecState.feedInputBuffer IllegalStateException " + e);
+ }
+
+ }
+
+ public long getNowUs() {
+ if (mAudioTrackState == null) {
+ return System.currentTimeMillis() * 1000;
+ }
+
+ return mAudioTrackState.getAudioTimeUs();
+ }
+
+ public long getRealTimeUsForMediaTime(long mediaTimeUs) {
+ if (mDeltaTimeUs == -1) {
+ long nowUs = getNowUs();
+ mDeltaTimeUs = nowUs - mediaTimeUs;
+ }
+
+ return mDeltaTimeUs + mediaTimeUs;
+ }
+
+ public int getDuration() {
+ return (int)((mDurationUs + 500) / 1000);
+ }
+
+ public int getCurrentPosition() {
+ if (mVideoCodecStates == null) {
+ return 0;
+ }
+
+ long positionUs = 0;
+
+ for (CodecState state : mVideoCodecStates.values()) {
+ long trackPositionUs = state.getCurrentPositionUs();
+
+ if (trackPositionUs > positionUs) {
+ positionUs = trackPositionUs;
+ }
+ }
+ return (int)((positionUs + 500) / 1000);
+ }
+
+}
diff --git a/tests/tests/media/src/android/media/cts/NonBlockingAudioTrack.java b/tests/tests/media/src/android/media/cts/NonBlockingAudioTrack.java
new file mode 100644
index 0000000..3ba1ce8
--- /dev/null
+++ b/tests/tests/media/src/android/media/cts/NonBlockingAudioTrack.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2014 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.cts;
+
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.util.Log;
+
+import java.util.LinkedList;
+
+/**
+ * Class for playing audio by using audio track.
+ * {@link #write(byte[], int, int)} and {@link #write(short[], int, int)} methods will
+ * block until all data has been written to system. In order to avoid blocking, this class
+ * caculates available buffer size first then writes to audio sink.
+ */
+public class NonBlockingAudioTrack {
+ private static final String TAG = NonBlockingAudioTrack.class.getSimpleName();
+
+ class QueueElem {
+ byte[] data;
+ int offset;
+ int size;
+ }
+
+ private AudioTrack mAudioTrack;
+ private boolean mWriteMorePending = false;
+ private int mSampleRate;
+ private int mFrameSize;
+ private int mBufferSizeInFrames;
+ private int mNumFramesSubmitted = 0;
+ private int mNumBytesQueued = 0;
+ private LinkedList<QueueElem> mQueue = new LinkedList<QueueElem>();
+
+ public NonBlockingAudioTrack(int sampleRate, int channelCount) {
+ int channelConfig;
+ switch (channelCount) {
+ case 1:
+ channelConfig = AudioFormat.CHANNEL_OUT_MONO;
+ break;
+ case 2:
+ channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+ break;
+ case 6:
+ channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+ break;
+ default:
+ throw new IllegalArgumentException();
+ }
+
+ int minBufferSize =
+ AudioTrack.getMinBufferSize(
+ sampleRate,
+ channelConfig,
+ AudioFormat.ENCODING_PCM_16BIT);
+
+ int bufferSize = 2 * minBufferSize;
+
+ mAudioTrack = new AudioTrack(
+ AudioManager.STREAM_MUSIC,
+ sampleRate,
+ channelConfig,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize,
+ AudioTrack.MODE_STREAM);
+
+ mSampleRate = sampleRate;
+ mFrameSize = 2 * channelCount;
+ mBufferSizeInFrames = bufferSize / mFrameSize;
+ }
+
+ public long getAudioTimeUs() {
+ int numFramesPlayed = mAudioTrack.getPlaybackHeadPosition();
+
+ return (numFramesPlayed * 1000000L) / mSampleRate;
+ }
+
+ public int getNumBytesQueued() {
+ return mNumBytesQueued;
+ }
+
+ public void play() {
+ mAudioTrack.play();
+ }
+
+ public void stop() {
+ cancelWriteMore();
+
+ mAudioTrack.stop();
+
+ mNumFramesSubmitted = 0;
+ mQueue.clear();
+ mNumBytesQueued = 0;
+ }
+
+ public void pause() {
+ cancelWriteMore();
+
+ mAudioTrack.pause();
+ }
+
+ public void release() {
+ cancelWriteMore();
+
+ mAudioTrack.release();
+ mAudioTrack = null;
+ }
+
+ public void process() {
+ mWriteMorePending = false;
+ writeMore();
+ }
+
+ public int getPlayState() {
+ return mAudioTrack.getPlayState();
+ }
+
+ private void writeMore() {
+ if (mQueue.isEmpty()) {
+ return;
+ }
+
+ int numFramesPlayed = mAudioTrack.getPlaybackHeadPosition();
+ int numFramesPending = mNumFramesSubmitted - numFramesPlayed;
+ int numFramesAvailableToWrite = mBufferSizeInFrames - numFramesPending;
+ int numBytesAvailableToWrite = numFramesAvailableToWrite * mFrameSize;
+
+ while (numBytesAvailableToWrite > 0) {
+ QueueElem elem = mQueue.peekFirst();
+
+ int numBytes = elem.size;
+ if (numBytes > numBytesAvailableToWrite) {
+ numBytes = numBytesAvailableToWrite;
+ }
+
+ int written = mAudioTrack.write(elem.data, elem.offset, numBytes);
+ assert(written == numBytes);
+
+ mNumFramesSubmitted += written / mFrameSize;
+
+ elem.size -= numBytes;
+ numBytesAvailableToWrite -= numBytes;
+ mNumBytesQueued -= numBytes;
+
+ if (elem.size == 0) {
+ mQueue.removeFirst();
+
+ if (mQueue.isEmpty()) {
+ break;
+ }
+ } else {
+ elem.offset += numBytes;
+ }
+ }
+
+ if (!mQueue.isEmpty()) {
+ scheduleWriteMore();
+ }
+ }
+
+ private void scheduleWriteMore() {
+ if (mWriteMorePending) {
+ return;
+ }
+
+ int numFramesPlayed = mAudioTrack.getPlaybackHeadPosition();
+ int numFramesPending = mNumFramesSubmitted - numFramesPlayed;
+ int pendingDurationMs = 1000 * numFramesPending / mSampleRate;
+
+ mWriteMorePending = true;
+ }
+
+ private void cancelWriteMore() {
+ mWriteMorePending = false;
+ }
+
+ public void write(byte[] data, int size) {
+ QueueElem elem = new QueueElem();
+ elem.data = data;
+ elem.offset = 0;
+ elem.size = size;
+
+ // accumulate size written to queue
+ mNumBytesQueued += size;
+ mQueue.add(elem);
+ }
+}
+