| /* |
| * Copyright (C) 2015 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 org.drrickorang.loopback; |
| |
| import android.content.Context; |
| import android.media.AudioFormat; |
| import android.media.AudioManager; |
| import android.media.AudioRecord; |
| import android.os.Build; |
| import android.util.Log; |
| |
| /** |
| * This thread records incoming sound samples (uses AudioRecord). |
| */ |
| |
| public class RecorderRunnable implements Runnable { |
| private static final String TAG = "RecorderRunnable"; |
| |
| private AudioRecord mRecorder; |
| private boolean mIsRunning; |
| private boolean mIsRecording = false; |
| private static final Object sRecordingLock = new Object(); |
| |
| private final LoopbackAudioThread mAudioThread; |
| // This is the pipe that connects the player and the recorder in latency test. |
| private final PipeShort mLatencyTestPipeShort; |
| // This is the pipe that is used in buffer test to send data to GlitchDetectionThread |
| private PipeShort mBufferTestPipeShort; |
| |
| private boolean mIsRequestStop = false; |
| private final int mTestType; // latency test or buffer test |
| private final int mSelectedRecordSource; |
| private final int mSamplingRate; |
| |
| private int mChannelConfig = AudioFormat.CHANNEL_IN_MONO; |
| private int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT; |
| private int mMinRecorderBuffSizeInBytes = 0; |
| private int mMinRecorderBuffSizeInSamples = 0; |
| |
| private short[] mAudioShortArray; // this array stores values from mAudioTone in read() |
| private short[] mBufferTestShortArray; |
| private short[] mAudioTone; |
| |
| // for glitch detection (buffer test) |
| private BufferPeriod mRecorderBufferPeriodInRecorder; |
| private final int mBufferTestWavePlotDurationInSeconds; |
| private final int mChannelIndex; |
| private final double mFrequency1; |
| private final double mFrequency2; // not actually used |
| private int[] mAllGlitches; // value = 1 means there's a glitch in that interval |
| private boolean mGlitchingIntervalTooLong; |
| private int mFFTSamplingSize; // the amount of samples used per FFT. |
| private int mFFTOverlapSamples; // overlap half the samples |
| private long mStartTimeMs; |
| private int mBufferTestDurationInSeconds; |
| private long mBufferTestDurationMs; |
| private final CaptureHolder mCaptureHolder; |
| private final Context mContext; |
| private AudioManager mAudioManager; |
| private GlitchDetectionThread mGlitchDetectionThread; |
| |
| // for adjusting sound level in buffer test |
| private double[] mSoundLevelSamples; |
| private int mSoundLevelSamplesIndex = 0; |
| private boolean mIsAdjustingSoundLevel = true; // is true if still adjusting sound level |
| private double mSoundBotLimit = 0.6; // we want to keep the sound level high |
| private double mSoundTopLimit = 0.8; // but we also don't want to be close to saturation |
| private int mAdjustSoundLevelCount = 0; |
| private int mMaxVolume; // max possible volume of the device |
| |
| private double[] mSamples; // samples shown on WavePlotView |
| private int mSamplesIndex; |
| |
| RecorderRunnable(PipeShort latencyPipe, int samplingRate, int channelConfig, int audioFormat, |
| int recorderBufferInBytes, int micSource, LoopbackAudioThread audioThread, |
| BufferPeriod recorderBufferPeriod, int testType, double frequency1, |
| double frequency2, int bufferTestWavePlotDurationInSeconds, |
| Context context, int channelIndex, CaptureHolder captureHolder) { |
| mLatencyTestPipeShort = latencyPipe; |
| mSamplingRate = samplingRate; |
| mChannelConfig = channelConfig; |
| mAudioFormat = audioFormat; |
| mMinRecorderBuffSizeInBytes = recorderBufferInBytes; |
| mSelectedRecordSource = micSource; |
| mAudioThread = audioThread; |
| mRecorderBufferPeriodInRecorder = recorderBufferPeriod; |
| mTestType = testType; |
| mFrequency1 = frequency1; |
| mFrequency2 = frequency2; |
| mBufferTestWavePlotDurationInSeconds = bufferTestWavePlotDurationInSeconds; |
| mContext = context; |
| mChannelIndex = channelIndex; |
| mCaptureHolder = captureHolder; |
| } |
| |
| |
| /** Initialize the recording device for latency test. */ |
| public boolean initRecord() { |
| log("Init Record"); |
| if (mMinRecorderBuffSizeInBytes <= 0) { |
| mMinRecorderBuffSizeInBytes = AudioRecord.getMinBufferSize(mSamplingRate, |
| mChannelConfig, mAudioFormat); |
| log("RecorderRunnable: computing min buff size = " + mMinRecorderBuffSizeInBytes |
| + " bytes"); |
| } else { |
| log("RecorderRunnable: using min buff size = " + mMinRecorderBuffSizeInBytes + |
| " bytes"); |
| } |
| |
| if (mMinRecorderBuffSizeInBytes <= 0) { |
| return false; |
| } |
| |
| mMinRecorderBuffSizeInSamples = mMinRecorderBuffSizeInBytes / Constant.BYTES_PER_FRAME; |
| mAudioShortArray = new short[mMinRecorderBuffSizeInSamples]; |
| |
| try { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
| mRecorder = new AudioRecord.Builder() |
| .setAudioFormat((mChannelIndex < 0 ? |
| new AudioFormat.Builder() |
| .setChannelMask(AudioFormat.CHANNEL_IN_MONO) : |
| new AudioFormat |
| .Builder().setChannelIndexMask(1 << mChannelIndex)) |
| .setSampleRate(mSamplingRate) |
| .setEncoding(mAudioFormat) |
| .build()) |
| .setAudioSource(mSelectedRecordSource) |
| .setBufferSizeInBytes(2 * mMinRecorderBuffSizeInBytes) |
| .build(); |
| } else { |
| mRecorder = new AudioRecord(mSelectedRecordSource, mSamplingRate, |
| mChannelConfig, mAudioFormat, 2 * mMinRecorderBuffSizeInBytes); |
| } |
| } catch (IllegalArgumentException | UnsupportedOperationException e) { |
| e.printStackTrace(); |
| return false; |
| } finally { |
| if (mRecorder == null){ |
| return false; |
| } else if (mRecorder.getState() != AudioRecord.STATE_INITIALIZED) { |
| mRecorder.release(); |
| mRecorder = null; |
| return false; |
| } |
| } |
| |
| //generate sinc wave for use in loopback test |
| ToneGeneration sincTone = new RampedSineTone(mSamplingRate, Constant.LOOPBACK_FREQUENCY); |
| mAudioTone = new short[Constant.LOOPBACK_SAMPLE_FRAMES]; |
| sincTone.generateTone(mAudioTone, Constant.LOOPBACK_SAMPLE_FRAMES); |
| |
| return true; |
| } |
| |
| |
| /** Initialize the recording device for buffer test. */ |
| boolean initBufferRecord() { |
| log("Init Record"); |
| if (mMinRecorderBuffSizeInBytes <= 0) { |
| |
| mMinRecorderBuffSizeInBytes = AudioRecord.getMinBufferSize(mSamplingRate, |
| mChannelConfig, mAudioFormat); |
| log("RecorderRunnable: computing min buff size = " + mMinRecorderBuffSizeInBytes |
| + " bytes"); |
| } else { |
| log("RecorderRunnable: using min buff size = " + mMinRecorderBuffSizeInBytes + |
| " bytes"); |
| } |
| |
| if (mMinRecorderBuffSizeInBytes <= 0) { |
| return false; |
| } |
| |
| mMinRecorderBuffSizeInSamples = mMinRecorderBuffSizeInBytes / Constant.BYTES_PER_FRAME; |
| mBufferTestShortArray = new short[mMinRecorderBuffSizeInSamples]; |
| |
| final int cycles = 100; |
| int soundLevelSamples = (mSamplingRate / (int) mFrequency1) * cycles; |
| mSoundLevelSamples = new double[soundLevelSamples]; |
| mAudioManager = (AudioManager) mContext.getSystemService(mContext.AUDIO_SERVICE); |
| mMaxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); |
| |
| try { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
| mRecorder = new AudioRecord.Builder() |
| .setAudioFormat((mChannelIndex < 0 ? |
| new AudioFormat.Builder() |
| .setChannelMask(AudioFormat.CHANNEL_IN_MONO) : |
| new AudioFormat |
| .Builder().setChannelIndexMask(1 << mChannelIndex)) |
| .setSampleRate(mSamplingRate) |
| .setEncoding(mAudioFormat) |
| .build()) |
| .setAudioSource(mSelectedRecordSource) |
| .setBufferSizeInBytes(2 * mMinRecorderBuffSizeInBytes) |
| .build(); |
| } else { |
| mRecorder = new AudioRecord(mSelectedRecordSource, mSamplingRate, |
| mChannelConfig, mAudioFormat, 2 * mMinRecorderBuffSizeInBytes); |
| } |
| } catch (IllegalArgumentException | UnsupportedOperationException e) { |
| e.printStackTrace(); |
| return false; |
| } finally { |
| if (mRecorder == null){ |
| return false; |
| } else if (mRecorder.getState() != AudioRecord.STATE_INITIALIZED) { |
| mRecorder.release(); |
| mRecorder = null; |
| return false; |
| } |
| } |
| |
| final int targetFFTMs = 20; // we want each FFT to cover 20ms of samples |
| mFFTSamplingSize = targetFFTMs * mSamplingRate / Constant.MILLIS_PER_SECOND; |
| // round to the nearest power of 2 |
| mFFTSamplingSize = (int) Math.pow(2, Math.round(Math.log(mFFTSamplingSize) / Math.log(2))); |
| |
| if (mFFTSamplingSize < 2) { |
| mFFTSamplingSize = 2; // mFFTSamplingSize should be at least 2 |
| } |
| mFFTOverlapSamples = mFFTSamplingSize / 2; // mFFTOverlapSamples is half of mFFTSamplingSize |
| |
| return true; |
| } |
| |
| |
| boolean startRecording() { |
| synchronized (sRecordingLock) { |
| mIsRecording = true; |
| } |
| |
| final int samplesDurationInSecond = 2; |
| int nNewSize = mSamplingRate * samplesDurationInSecond; // 2 seconds! |
| mSamples = new double[nNewSize]; |
| |
| boolean status = initRecord(); |
| if (status) { |
| log("Ready to go."); |
| startRecordingForReal(); |
| } else { |
| log("Recorder initialization error."); |
| synchronized (sRecordingLock) { |
| mIsRecording = false; |
| } |
| } |
| |
| return status; |
| } |
| |
| |
| boolean startBufferRecording() { |
| synchronized (sRecordingLock) { |
| mIsRecording = true; |
| } |
| |
| boolean status = initBufferRecord(); |
| if (status) { |
| log("Ready to go."); |
| startBufferRecordingForReal(); |
| } else { |
| log("Recorder initialization error."); |
| synchronized (sRecordingLock) { |
| mIsRecording = false; |
| } |
| } |
| |
| return status; |
| } |
| |
| |
| void startRecordingForReal() { |
| mLatencyTestPipeShort.flush(); |
| mRecorder.startRecording(); |
| } |
| |
| |
| void startBufferRecordingForReal() { |
| mBufferTestPipeShort = new PipeShort(Constant.MAX_SHORTS); |
| mGlitchDetectionThread = new GlitchDetectionThread(mFrequency1, mFrequency2, mSamplingRate, |
| mFFTSamplingSize, mFFTOverlapSamples, mBufferTestDurationInSeconds, |
| mBufferTestWavePlotDurationInSeconds, mBufferTestPipeShort, mCaptureHolder); |
| mGlitchDetectionThread.start(); |
| mRecorder.startRecording(); |
| } |
| |
| |
| void stopRecording() { |
| log("stop recording A"); |
| synchronized (sRecordingLock) { |
| log("stop recording B"); |
| mIsRecording = false; |
| } |
| stopRecordingForReal(); |
| } |
| |
| |
| void stopRecordingForReal() { |
| log("stop recording for real"); |
| if (mRecorder != null) { |
| mRecorder.stop(); |
| } |
| |
| if (mRecorder != null) { |
| mRecorder.release(); |
| mRecorder = null; |
| } |
| } |
| |
| |
| public void run() { |
| // keeps the total time elapsed since the start of the test. Only used in buffer test. |
| long elapsedTimeMs; |
| mIsRunning = true; |
| while (mIsRunning) { |
| boolean isRecording; |
| |
| synchronized (sRecordingLock) { |
| isRecording = mIsRecording; |
| } |
| |
| if (isRecording && mRecorder != null) { |
| int nSamplesRead; |
| switch (mTestType) { |
| case Constant.LOOPBACK_PLUG_AUDIO_THREAD_TEST_TYPE_LATENCY: |
| nSamplesRead = mRecorder.read(mAudioShortArray, 0, |
| mMinRecorderBuffSizeInSamples); |
| |
| if (nSamplesRead > 0) { |
| mRecorderBufferPeriodInRecorder.collectBufferPeriod(); |
| { // inject the tone that will be looped-back |
| int currentIndex = mSamplesIndex - 100; //offset |
| for (int i = 0; i < nSamplesRead; i++) { |
| if (currentIndex >= 0 && currentIndex < mAudioTone.length) { |
| mAudioShortArray[i] = mAudioTone[currentIndex]; |
| } |
| currentIndex++; |
| } |
| } |
| |
| mLatencyTestPipeShort.write(mAudioShortArray, 0, nSamplesRead); |
| if (isStillRoomToRecord()) { //record to vector |
| for (int i = 0; i < nSamplesRead; i++) { |
| double value = mAudioShortArray[i]; |
| value = value / Short.MAX_VALUE; |
| if (mSamplesIndex < mSamples.length) { |
| mSamples[mSamplesIndex++] = value; |
| } |
| |
| } |
| } else { |
| mIsRunning = false; |
| } |
| } |
| break; |
| case Constant.LOOPBACK_PLUG_AUDIO_THREAD_TEST_TYPE_BUFFER_PERIOD: |
| if (mIsRequestStop) { |
| endBufferTest(); |
| } else { |
| // before we start the test, first adjust sound level |
| if (mIsAdjustingSoundLevel) { |
| nSamplesRead = mRecorder.read(mBufferTestShortArray, 0, |
| mMinRecorderBuffSizeInSamples); |
| if (nSamplesRead > 0) { |
| for (int i = 0; i < nSamplesRead; i++) { |
| double value = mBufferTestShortArray[i]; |
| if (mSoundLevelSamplesIndex < mSoundLevelSamples.length) { |
| mSoundLevelSamples[mSoundLevelSamplesIndex++] = value; |
| } else { |
| // adjust the sound level to appropriate level |
| mIsAdjustingSoundLevel = AdjustSoundLevel(); |
| mAdjustSoundLevelCount++; |
| mSoundLevelSamplesIndex = 0; |
| if (!mIsAdjustingSoundLevel) { |
| // end of sound level adjustment, notify AudioTrack |
| mAudioThread.setIsAdjustingSoundLevel(false); |
| mStartTimeMs = System.currentTimeMillis(); |
| break; |
| } |
| } |
| } |
| } |
| } else { |
| // the end of test is controlled here. Once we've run for the specified |
| // test duration, end the test |
| elapsedTimeMs = System.currentTimeMillis() - mStartTimeMs; |
| if (elapsedTimeMs >= mBufferTestDurationMs) { |
| endBufferTest(); |
| } else { |
| nSamplesRead = mRecorder.read(mBufferTestShortArray, 0, |
| mMinRecorderBuffSizeInSamples); |
| if (nSamplesRead > 0) { |
| mRecorderBufferPeriodInRecorder.collectBufferPeriod(); |
| mBufferTestPipeShort.write(mBufferTestShortArray, 0, |
| nSamplesRead); |
| } |
| } |
| } |
| } |
| break; |
| } |
| } |
| } //synchronized |
| stopRecording(); //close this |
| } |
| |
| |
| /** Someone is requesting to stop the test, will stop the test even if the test is not done. */ |
| public void requestStop() { |
| switch (mTestType) { |
| case Constant.LOOPBACK_PLUG_AUDIO_THREAD_TEST_TYPE_BUFFER_PERIOD: |
| mIsRequestStop = true; |
| break; |
| case Constant.LOOPBACK_PLUG_AUDIO_THREAD_TEST_TYPE_LATENCY: |
| mIsRunning = false; |
| break; |
| } |
| } |
| |
| |
| /** Collect data then clean things up.*/ |
| private void endBufferTest() { |
| mIsRunning = false; |
| mAllGlitches = mGlitchDetectionThread.getGlitches(); |
| mGlitchingIntervalTooLong = mGlitchDetectionThread.getGlitchingIntervalTooLong(); |
| mSamples = mGlitchDetectionThread.getWaveData(); |
| endDetecting(); |
| } |
| |
| |
| /** Clean everything up. */ |
| public void endDetecting() { |
| mBufferTestPipeShort.flush(); |
| mBufferTestPipeShort = null; |
| mGlitchDetectionThread.requestStop(); |
| GlitchDetectionThread tempThread = mGlitchDetectionThread; |
| mGlitchDetectionThread = null; |
| try { |
| tempThread.join(Constant.JOIN_WAIT_TIME_MS); |
| } catch (InterruptedException e) { |
| e.printStackTrace(); |
| } |
| } |
| |
| |
| /** |
| * Adjust the sound level such that the buffer test can run with small noise disturbance. |
| * Return a boolean value to indicate whether or not the sound level has adjusted to an |
| * appropriate level. |
| */ |
| private boolean AdjustSoundLevel() { |
| // if after adjusting 20 times, we still cannot get into the volume we want, increase the |
| // limit range, so it's easier to get into the volume we want. |
| if (mAdjustSoundLevelCount != 0 && mAdjustSoundLevelCount % 20 == 0) { |
| mSoundTopLimit += 0.1; |
| mSoundBotLimit -= 0.1; |
| } |
| |
| double topThreshold = Short.MAX_VALUE * mSoundTopLimit; |
| double botThreshold = Short.MAX_VALUE * mSoundBotLimit; |
| double currentMax = mSoundLevelSamples[0]; |
| int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC); |
| |
| // since it's a sine wave, we are only checking max positive value |
| for (int i = 1; i < mSoundLevelSamples.length; i++) { |
| if (mSoundLevelSamples[i] > topThreshold) { // once a sample exceed, return |
| // adjust sound level down |
| currentVolume--; |
| mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0); |
| return true; |
| } |
| |
| if (mSoundLevelSamples[i] > currentMax) { |
| currentMax = mSoundLevelSamples[i]; |
| } |
| } |
| |
| if (currentMax < botThreshold) { |
| // adjust sound level up |
| if (currentVolume < mMaxVolume) { |
| currentVolume++; |
| mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, |
| currentVolume, 0); |
| return true; |
| } else { |
| return false; |
| } |
| } |
| |
| return false; |
| } |
| |
| |
| /** Check if there's any room left in mSamples. */ |
| public boolean isStillRoomToRecord() { |
| boolean result = false; |
| if (mSamples != null) { |
| if (mSamplesIndex < mSamples.length) { |
| result = true; |
| } |
| } |
| |
| return result; |
| } |
| |
| |
| public void setBufferTestDurationInSeconds(int bufferTestDurationInSeconds) { |
| mBufferTestDurationInSeconds = bufferTestDurationInSeconds; |
| mBufferTestDurationMs = Constant.MILLIS_PER_SECOND * mBufferTestDurationInSeconds; |
| } |
| |
| |
| public int[] getAllGlitches() { |
| return mAllGlitches; |
| } |
| |
| |
| public boolean getGlitchingIntervalTooLong() { |
| return mGlitchingIntervalTooLong; |
| } |
| |
| |
| public double[] getWaveData() { |
| return mSamples; |
| } |
| |
| |
| public int getFFTSamplingSize() { |
| return mFFTSamplingSize; |
| } |
| |
| |
| public int getFFTOverlapSamples() { |
| return mFFTOverlapSamples; |
| } |
| |
| |
| private static void log(String msg) { |
| Log.v(TAG, msg); |
| } |
| |
| } |