/* * Copyright 2014 Mario Guggenberger <mg@protyposis.net> * * 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 net.protyposis.android.mediaplayer; import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; import android.media.MediaFormat; import android.util.Log; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Queue; /** * Wrapper of an AudioTrack for easier management in the playback thread. * * Created by maguggen on 23.09.2014. */ class AudioPlayback { private static final String TAG = AudioPlayback.class.getSimpleName(); public static long PTS_NOT_SET = Long.MIN_VALUE; private MediaFormat mAudioFormat; private AudioTrack mAudioTrack; private byte[] mTransferBuffer; private int mFrameChunkSize; private int mFrameSize; private int mSampleRate; private BufferQueue mBufferQueue; private int mPlaybackBufferSize; private AudioThread mAudioThread; private long mLastPresentationTimeUs; private int mAudioSessionId; private int mAudioStreamType; private float mVolumeLeft = 1, mVolumeRight = 1; /** * Keeps track of the PTS of the moment when playback has started. * It is required to calculate the current PTS because the playback head * is reset to zero when playback is paused. */ private long mPresentationTimeOffsetUs; /** * Hold the previous playback head position time for comparison with the current playback * head position time to detect a position wrap/overflow. */ private long mLastPlaybackHeadPositionUs; public AudioPlayback() { mFrameChunkSize = 4096 * 2; // arbitrary default chunk size mBufferQueue = new BufferQueue(); mAudioSessionId = 0; // AudioSystem.AUDIO_SESSION_ALLOCATE; mAudioStreamType = AudioManager.STREAM_MUSIC; } /** * Initializes or reinitializes the audio track with the supplied format for playback * while keeping the playstate. Keeps the current configuration and skips reinitialization * if the new format is the same as the current format. */ public void init(MediaFormat format) { Log.d(TAG, "init"); boolean playing = false; if(isInitialized()) { if(!checkIfReinitializationRequired(format)) { // Set new format that equals the old one (in case we compare references somewhere) mAudioFormat = format; return; } playing = isPlaying(); pause(); stopAndRelease(false); } else { // deferred creation of the audio thread until its first use mAudioThread = new AudioThread(); mAudioThread.setPaused(true); mAudioThread.start(); } mAudioFormat = format; int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); int bytesPerSample = 2; mFrameSize = bytesPerSample * channelCount; mSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); int channelConfig = AudioFormat.CHANNEL_OUT_DEFAULT; switch(channelCount) { case 1: channelConfig = AudioFormat.CHANNEL_OUT_MONO; break; case 2: channelConfig = AudioFormat.CHANNEL_OUT_STEREO; break; case 4: channelConfig = AudioFormat.CHANNEL_OUT_QUAD; break; case 6: channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; break; case 8: channelConfig = AudioFormat.CHANNEL_OUT_7POINT1; } mPlaybackBufferSize = mFrameChunkSize * channelCount; mAudioTrack = new AudioTrack( mAudioStreamType, mSampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT, mPlaybackBufferSize, // at least twice the size to enable double buffering (according to docs) AudioTrack.MODE_STREAM, mAudioSessionId); if(mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) { stopAndRelease(); throw new IllegalStateException("audio track init failed"); } mAudioSessionId = mAudioTrack.getAudioSessionId(); mAudioStreamType = mAudioTrack.getStreamType(); setStereoVolume(mVolumeLeft, mVolumeRight); mPresentationTimeOffsetUs = PTS_NOT_SET; if(playing) { play(); } } private boolean checkIfReinitializationRequired(MediaFormat newFormat) { return mAudioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) != newFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) || mAudioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) != newFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) || !mAudioFormat.getString(MediaFormat.KEY_MIME).equals(newFormat.getString(MediaFormat.KEY_MIME)); } /** * Can be used to set an audio session ID before calling {@link #init(android.media.MediaFormat)}. */ public void setAudioSessionId(int sessionId) { if(isInitialized()) { throw new IllegalStateException("cannot set session id on an initialized audio track"); } mAudioSessionId = sessionId; } public int getAudioSessionId() { return mAudioSessionId; } public void setAudioStreamType(int streamType) { mAudioStreamType = streamType; } public int getAudioStreamType() { return mAudioStreamType; } public boolean isInitialized() { return mAudioTrack != null && mAudioTrack.getState() == AudioTrack.STATE_INITIALIZED; } public void play() { //Log.d(TAG, "play"); if(isInitialized()) { mAudioTrack.play(); mAudioThread.setPaused(false); } else { throw new IllegalStateException(); } } public void pause(boolean flush) { //Log.d(TAG, "pause(" + flush + ")"); if(isInitialized()) { mAudioThread.setPaused(true); mAudioTrack.pause(); if(flush) { flush(); } } else { throw new IllegalStateException(); } } public void pause() { pause(true); } public void flush() { if(isInitialized()) { boolean playing = isPlaying(); if(playing) { mAudioTrack.pause(); } mAudioTrack.flush(); mBufferQueue.flush(); // Reset offset so it gets updated with the current PTS when playback continues mPresentationTimeOffsetUs = PTS_NOT_SET; if(playing) { mAudioTrack.play(); } } else { throw new IllegalStateException(); } } public void write(ByteBuffer audioData, long presentationTimeUs) { int sizeInBytes = audioData.remaining(); // TODO find a way to determine the audio decoder max output frame size at configuration time if(mFrameChunkSize < sizeInBytes) { Log.d(TAG, "incoming frame chunk size increased to " + sizeInBytes); mFrameChunkSize = sizeInBytes; // re-init the audio track to accommodate buffer to new chunk size init(mAudioFormat); } // Special handling of the first written audio buffer after a flush (pause with flush) if(mPresentationTimeOffsetUs == PTS_NOT_SET) { // Initialize with the PTS of the first audio buffer (which isn't necessarily zero) mPresentationTimeOffsetUs = presentationTimeUs; mLastPlaybackHeadPositionUs = 0; /** Handle playback head reset bug * * affected: Galaxy S2 API 16 * not affected: Nexus 4 API 22 * * Sometimes the playback head does not really reset to zero in a pause. During the * pause, it correctly returns zero (0), but when playback continues it sometimes * continues from the previous playback head position instead of starting from zero. * Since this does not always happen, this looks to be a bug in the Android framework. * * TODO find out if this is a reported bug * * To work around this issue, we subtract the playback head position time from the PTS * offset to adjust the base time by the playback head time. This leads to the * {@link #getCurrentPresentationTimeUs} method returning a correct value. */ long playbackHeadPositionUs = getPlaybackheadPositionUs(); if(playbackHeadPositionUs > 0) { mPresentationTimeOffsetUs -= playbackHeadPositionUs; Log.d(TAG, "playback head not reset"); } } mBufferQueue.put(audioData, presentationTimeUs); // Log.d(TAG, "buffer queue size " + mBufferQueue.bufferQueue.size() // + " data " + mBufferQueue.mQueuedDataSize // + " time " + getQueueBufferTimeUs()); mAudioThread.notifyOfNewBufferInQueue(); } private void stopAndRelease(boolean killThread) { if(killThread && mAudioThread != null) { mAudioThread.interrupt(); } if(mAudioTrack != null) { if(isInitialized()) { mAudioTrack.stop(); } mAudioTrack.release(); } mAudioTrack = null; } public void stopAndRelease() { stopAndRelease(true); } /** * Returns the length of the queued audio, that does not fit into the playback buffer yet. * @return the length of the queued audio in microsecs */ public long getQueueBufferTimeUs() { return (long)((double)(mBufferQueue.mQueuedDataSize / mFrameSize) / mSampleRate * 1000000d); } /** * Returns the length of the playback buffer, without posidering the current playback position * inside the buffer (the remaining audio data that is waiting for playback can be less than * the buffer length). * @return the length of the playback buffer in microsecs */ public long getPlaybackBufferTimeUs() { return (long)((double)(mPlaybackBufferSize / mFrameSize) / mSampleRate * 1000000d); } private long getPlaybackheadPositionUs() { // The playback head position is encoded as a uint in an int long playbackHeadPosition = 0xFFFFFFFFL & mAudioTrack.getPlaybackHeadPosition(); // Convert frames to time return (long)((double)playbackHeadPosition / mSampleRate * 1000000); } /** * Returns the current PTS of the playback head or PTS_NOT_SET if the PTS cannot be reliably * calculated yet. * For this method to return a PTS, audio samples need to be written before ({@link #write(ByteBuffer, long)}. * @return the PTS at the playback head or PTS_NOT_SET if unknown */ public long getCurrentPresentationTimeUs() { // Return the PTS_NOT_SET flag when the PTS has not been initialized yet. At the start of // media playback, returning the playback head alone is reliable, but later on (e.g. after a // seek), a missing PTS offset leads to totally wrong values. if(mPresentationTimeOffsetUs == PTS_NOT_SET) { return PTS_NOT_SET; } long playbackHeadPositionUs = getPlaybackheadPositionUs(); // Handle playback head wrapping if(playbackHeadPositionUs < mLastPlaybackHeadPositionUs) { // playback head position has wrapped around it's 32bit uint value Log.d(TAG, "playback head has wrapped"); // Add the full runtime to the PTS offset to advance it one playback head iteration mPresentationTimeOffsetUs += (long)((double)0xFFFFFFFF / mSampleRate * 1000000); } mLastPlaybackHeadPositionUs = playbackHeadPositionUs; // Return the playback head time, offset by the start offset PTS return mPresentationTimeOffsetUs + playbackHeadPositionUs; } public long getLastPresentationTimeUs() { return mLastPresentationTimeUs; } public void setPlaybackSpeed(float speed) { if(isInitialized()) { mAudioTrack.setPlaybackRate((int)(mSampleRate * speed)); } else { throw new IllegalStateException(); } } public boolean isPlaying() { return mAudioTrack.getPlayState() == AudioTrack.PLAYSTATE_PLAYING; } private void writeToPlaybackBuffer(ByteBuffer audioData, long presentationTimeUs) { int size = audioData.remaining(); if(mTransferBuffer == null || mTransferBuffer.length < size) { mTransferBuffer = new byte[size]; } audioData.get(mTransferBuffer, 0, size); //Log.d(TAG, "audio write / chunk count " + mPlaybackBufferChunkCount); mLastPresentationTimeUs = presentationTimeUs; mAudioTrack.write(mTransferBuffer, 0, size); } /** * @see android.media.AudioTrack#setStereoVolume(float, float) * @deprecated deprecated in API21, prefer use of {@link #setVolume(float)} */ public void setStereoVolume(float leftGain, float rightGain) { mVolumeLeft = leftGain; mVolumeRight = rightGain; if(mAudioTrack != null) { mAudioTrack.setStereoVolume(leftGain, rightGain); } } /** * @see android.media.AudioTrack#setVolume(float) */ public void setVolume(float gain) { //@TargetApi(Build.VERSION_CODES.LOLLIPOP) //mAudioTrack.setVolume(gain); setStereoVolume(gain, gain); } /* * This thread reads buffers from the queue and supplies them to the playback buffer. If the * queue is empty, it waits until a buffer item becomes available. If the playback buffer is * full, it blocks until it empties because of the AudioTrack#write blocking behaviour, and * since this is a separate audio thread, it does not block the video playback thread. * The thread is necessary because the AudioTrack#setPositionNotificationPeriod + listener * combination does only seem top work reliably if the written frame chunk sizes are constant * and the notification period is set to exactly this chunk size, which is impossible when * dealing with variable chunk sizes. Workarounds would be to set the notification period to the * least common multiple and split the written chunk also in pieces of this size (not sure if * very small notifications work though), or to add a transformation layer in the queue that * redistributes the incoming chunks of variable size into chunks of constant size; both * solutions would be more complex than this thread and also add noticeable overhead (many * method calls in the first workaround, many data copy operations in the second). */ private class AudioThread extends Thread { private final Object SYNC = new Object(); private boolean mPaused; AudioThread() { super(TAG); mPaused = true; } void setPaused(boolean paused) { mPaused = paused; synchronized (this) { this.notify(); } } public void notifyOfNewBufferInQueue() { synchronized (SYNC) { SYNC.notify(); } } @Override public void run() { while(!isInterrupted()) { try { synchronized(this) { while(mPaused) { wait(); } } BufferQueue.Item bufferItem = null; synchronized (SYNC) { while ((bufferItem = mBufferQueue.take()) == null) { SYNC.wait(); } } writeToPlaybackBuffer(bufferItem.buffer, bufferItem.presentationTimeUs); mBufferQueue.put(bufferItem); } catch (InterruptedException e) { interrupt(); } } } } /** * Intermediate buffer queue for audio chunks. When an audio chunk is decoded, it is put into * this queue until the audio track periodic notification event gets fired, telling that a certain * amount of the audio playback buffer has been consumed, which then enqueues another chunk to * the playback output buffer. */ private static class BufferQueue { private static class Item { ByteBuffer buffer; long presentationTimeUs; Item(int size) { buffer = ByteBuffer.allocate(size); } } private int bufferSize; private Queue<Item> bufferQueue; private List<Item> emptyBuffers; private int mQueuedDataSize; BufferQueue() { bufferQueue = new LinkedList<Item>(); emptyBuffers = new ArrayList<Item>(); } synchronized void put(ByteBuffer data, long presentationTimeUs) { //Log.d(TAG, "put"); if(data.remaining() > bufferSize) { /* Buffer size has increased, invalidate all empty buffers since they can not be * reused any more. */ emptyBuffers.clear(); bufferSize = data.remaining(); } Item item; if(!emptyBuffers.isEmpty()) { item = emptyBuffers.remove(0); } else { item = new Item(data.remaining()); } item.buffer.limit(data.remaining()); item.buffer.mark(); item.buffer.put(data); item.buffer.reset(); item.presentationTimeUs = presentationTimeUs; bufferQueue.add(item); mQueuedDataSize += item.buffer.remaining(); } /** * Takes a buffer item out of the queue to read the data. Returns NULL if there is no * buffer ready. */ synchronized Item take() { //Log.d(TAG, "take"); Item item = bufferQueue.poll(); if(item != null) { mQueuedDataSize -= item.buffer.remaining(); } return item; } /** * Returns a buffer to the queue for reuse. */ synchronized void put(Item returnItem) { if(returnItem.buffer.capacity() != bufferSize) { /* The buffer size has changed and the returned buffer is not valid any more and * can be discarded. */ return; } returnItem.buffer.rewind(); emptyBuffers.add(returnItem); } /** * Removes all remaining buffers from the queue and returns them to the empty-item store. */ synchronized void flush() { Item item; while((item = bufferQueue.poll()) != null) { put(item); } mQueuedDataSize = 0; } } }