| /* |
| * Copyright (C) 2021 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 com.android.cts.verifier.audio; |
| |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.util.Log; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.widget.Button; |
| import android.widget.RadioButton; |
| import android.widget.TextView; |
| |
| import com.android.compatibility.common.util.CddTest; |
| import com.android.compatibility.common.util.ResultType; |
| import com.android.compatibility.common.util.ResultUnit; |
| import com.android.cts.verifier.CtsVerifierReportLog; |
| import com.android.cts.verifier.PassFailButtons; |
| import com.android.cts.verifier.R; |
| import com.android.cts.verifier.audio.audiolib.AudioSystemFlags; |
| import com.android.cts.verifier.audio.audiolib.CircularBufferFloat; |
| import com.android.cts.verifier.audio.audiolib.StatUtils; |
| import com.android.cts.verifier.audio.audiolib.TapLatencyAnalyser; |
| import com.android.cts.verifier.audio.audiolib.WaveformView; |
| import com.android.cts.verifier.audio.sources.BlipAudioSourceProvider; |
| |
| import org.hyphonate.megaaudio.common.BuilderBase; |
| import org.hyphonate.megaaudio.duplex.DuplexAudioManager; |
| import org.hyphonate.megaaudio.player.AudioSource; |
| import org.hyphonate.megaaudio.player.AudioSourceProvider; |
| import org.hyphonate.megaaudio.player.JavaSourceProxy; |
| import org.hyphonate.megaaudio.recorder.AudioSinkProvider; |
| import org.hyphonate.megaaudio.recorder.sinks.AppCallback; |
| import org.hyphonate.megaaudio.recorder.sinks.AppCallbackAudioSinkProvider; |
| |
| /** |
| * CtsVerifier test to measure tap-to-tone latency. |
| */ |
| @CddTest(requirement = "5.6") |
| public class AudioTap2ToneActivity |
| extends PassFailButtons.Activity |
| implements View.OnClickListener, AppCallback { |
| private static final String TAG = "AudioTap2ToneActivity"; |
| |
| // JNI load |
| static { |
| try { |
| System.loadLibrary("megaaudio_jni"); |
| } catch (UnsatisfiedLinkError e) { |
| Log.e(TAG, "Error loading MegaAudio JNI library"); |
| Log.e(TAG, "e: " + e); |
| e.printStackTrace(); |
| } |
| |
| /* TODO: gracefully fail/notify if the library can't be loaded */ |
| } |
| |
| private boolean mIsRecording; |
| |
| private int mPlayerType = BuilderBase.TYPE_OBOE | BuilderBase.SUB_TYPE_OBOE_AAUDIO; |
| |
| private DuplexAudioManager mDuplexAudioManager; |
| private AudioSource mBlipSource; |
| |
| private Button mStartBtn; |
| private Button mStopBtn; |
| |
| private TextView mSpecView; |
| private TextView mResultsView; |
| private TextView mStatsView; |
| private TextView mPhaseView; |
| |
| private WaveformView mWaveformView; |
| |
| // Test Constants are from OboeTester.AudioMidiTester |
| private static final float MAX_TOUCH_LATENCY = 0.200f; |
| private static final float MAX_OUTPUT_LATENCY = 0.600f; |
| private static final float ANALYSIS_TIME_MARGIN = 0.250f; |
| |
| private static final float ANALYSIS_TIME_DELAY = MAX_OUTPUT_LATENCY; |
| private static final float ANALYSIS_TIME_TOTAL = MAX_TOUCH_LATENCY + MAX_OUTPUT_LATENCY; |
| private static final float ANALYSIS_TIME_MAX = ANALYSIS_TIME_TOTAL + ANALYSIS_TIME_MARGIN; |
| private static final int ANALYSIS_SAMPLE_RATE = 48000; // need not match output rate |
| |
| private static final int NUM_RECORD_CHANNELS = 1; |
| |
| private CircularBufferFloat mInputBuffer; |
| |
| private Runnable mAnalysisTask; |
| private int mTaskCountdown; |
| |
| private TapLatencyAnalyser mTapLatencyAnalyser; |
| |
| // Stats for latency |
| private double mMaxRequiredLatency; |
| |
| // REQUIRED CDD 5.6/H-1-1 |
| private static final int MAX_TAP_2_TONE_LATENCY_BASIC = 500; // ms |
| // Requirement for "R" and "S" |
| private static final int MAX_TAP_2_TONE_LATENCY_RS = 100; // ms |
| // Requirement for "T" |
| private static final int MAX_TAP_2_TONE_LATENCY_T = 80; // ms |
| // Requirement for any builds declaring "ProAudio" and "LowLatency" |
| private static final int MAX_TAP_2_TONE_LATENCY_PRO = 80; // ms |
| private static final int MAX_TAP_2_TONE_LATENCY_LOW = 80; // ms |
| |
| // Test API (back-end) IDs |
| private static final int NUM_TEST_APIS = 2; |
| private static final int TEST_API_NATIVE = 0; |
| private static final int TEST_API_JAVA = 1; |
| private int mActiveTestAPI = TEST_API_NATIVE; |
| |
| private int[] mNumMeasurements = new int[NUM_TEST_APIS]; |
| private int[] mLatencySumSamples = new int[NUM_TEST_APIS]; |
| private double[] mLatencyMin = new double[NUM_TEST_APIS]; // ms |
| private double[] mLatencyMax = new double[NUM_TEST_APIS]; // ms |
| private double[] mLatencyAve = new double[NUM_TEST_APIS]; // ms |
| |
| // Test State |
| private static final int NUM_TEST_PHASES = 5; |
| private int mTestPhase; |
| private boolean mArmed = true; // OK to fire another beep |
| |
| private double[] mLatencyMillis = new double[NUM_TEST_PHASES]; |
| |
| // ReportLog Schema |
| // Note that each key will be suffixed with the ID of the API tested |
| private static final String KEY_LATENCY_MIN = "latency_min_"; |
| private static final String KEY_LATENCY_MAX = "latency_max_"; |
| private static final String KEY_LATENCY_AVE = "latency_max_"; |
| private static final String KEY_LATENCY_NUM_MEASUREMENTS = "latency_num_measurements_"; |
| |
| @Override |
| protected void onCreate(Bundle savedInstanceState) { |
| setContentView(R.layout.audio_tap2tone_activity); |
| |
| super.onCreate(savedInstanceState); |
| |
| // Setup UI |
| String yesString = getResources().getString(R.string.audio_general_yes); |
| String noString = getResources().getString(R.string.audio_general_no); |
| |
| boolean claimsProAudio = AudioSystemFlags.claimsProAudio(this); |
| boolean claimsLowLatencyAudio = AudioSystemFlags.claimsLowLatencyAudio(this); |
| |
| ((TextView) findViewById(R.id.audio_t2t_pro_audio)) |
| .setText(claimsProAudio ? yesString : noString); |
| ((TextView) findViewById(R.id.audio_t2t_low_latency)) |
| .setText(claimsLowLatencyAudio ? yesString : noString); |
| |
| String mediaPerformanceClassString; |
| if (Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.TIRAMISU) { |
| mediaPerformanceClassString = "T"; |
| } else if (Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.S) { |
| mediaPerformanceClassString = "S"; |
| } else if (Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.R) { |
| mediaPerformanceClassString = "R"; |
| } else { |
| mediaPerformanceClassString = "none"; |
| } |
| ((TextView) findViewById(R.id.audio_t2t_mpc)).setText(mediaPerformanceClassString); |
| |
| // Note: These tests need to be ordered such that we find the LOWEST allowable latency |
| mMaxRequiredLatency = MAX_TAP_2_TONE_LATENCY_BASIC; |
| if (claimsProAudio) { |
| mMaxRequiredLatency = Math.min(mMaxRequiredLatency, MAX_TAP_2_TONE_LATENCY_PRO); |
| } |
| if (claimsLowLatencyAudio) { |
| mMaxRequiredLatency = Math.min(mMaxRequiredLatency, MAX_TAP_2_TONE_LATENCY_LOW); |
| } |
| if (Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.TIRAMISU) { |
| mMaxRequiredLatency = Math.min(mMaxRequiredLatency, MAX_TAP_2_TONE_LATENCY_T); |
| } |
| if (Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.R |
| || Build.VERSION.MEDIA_PERFORMANCE_CLASS == Build.VERSION_CODES.S) { |
| mMaxRequiredLatency = Math.min(mMaxRequiredLatency, MAX_TAP_2_TONE_LATENCY_RS); |
| } |
| |
| ((TextView) findViewById(R.id.audio_t2t_required_latency)) |
| .setText("" + mMaxRequiredLatency + "ms"); |
| |
| mStartBtn = (Button) findViewById(R.id.tap2tone_startBtn); |
| mStartBtn.setOnClickListener(this); |
| mStopBtn = (Button) findViewById(R.id.tap2tone_stopBtn); |
| mStopBtn.setOnClickListener(this); |
| |
| ((RadioButton) findViewById(R.id.audioJavaApiBtn)).setOnClickListener(this); |
| RadioButton nativeApiRB = findViewById(R.id.audioNativeApiBtn); |
| nativeApiRB.setChecked(true); |
| nativeApiRB.setOnClickListener(this); |
| |
| ((Button) findViewById(R.id.tap2tone_clearResults)).setOnClickListener(this); |
| |
| mSpecView = (TextView) findViewById(R.id.tap2tone_specTxt); |
| mResultsView = (TextView) findViewById(R.id.tap2tone_resultTxt); |
| mStatsView = (TextView) findViewById(R.id.tap2tone_statsTxt); |
| mPhaseView = (TextView) findViewById(R.id.tap2tone_phaseInfo); |
| |
| mWaveformView = (WaveformView) findViewById(R.id.tap2tone_waveView); |
| // Start a blip test when the waveform view is tapped. |
| mWaveformView.setOnTouchListener(new View.OnTouchListener() { |
| @Override |
| public boolean onTouch(View view, MotionEvent event) { |
| int action = event.getActionMasked(); |
| switch (action) { |
| case MotionEvent.ACTION_DOWN: |
| case MotionEvent.ACTION_POINTER_DOWN: |
| trigger(); |
| break; |
| case MotionEvent.ACTION_MOVE: |
| break; |
| case MotionEvent.ACTION_UP: |
| case MotionEvent.ACTION_POINTER_UP: |
| break; |
| } |
| // Must return true or we do not get the ACTION_MOVE and |
| // ACTION_UP events. |
| return true; |
| } |
| }); |
| |
| setPassFailButtonClickListeners(); |
| setInfoResources(R.string.audio_tap2tone, R.string.audio_tap2tone_info, -1); |
| |
| enableAudioButtons(); |
| |
| // Setup analysis |
| int numBufferSamples = (int) (ANALYSIS_TIME_MAX * ANALYSIS_SAMPLE_RATE); |
| mInputBuffer = new CircularBufferFloat(numBufferSamples); |
| mTapLatencyAnalyser = new TapLatencyAnalyser(); |
| |
| JavaSourceProxy.initN(); |
| |
| calculateTestPass(); |
| } |
| |
| private void startAudio() { |
| if (mIsRecording) { |
| return; |
| } |
| |
| if (mDuplexAudioManager == null) { |
| AudioSourceProvider sourceProvider = new BlipAudioSourceProvider(); |
| AudioSinkProvider sinkProvider = new AppCallbackAudioSinkProvider(this); |
| mDuplexAudioManager = new DuplexAudioManager(sourceProvider, sinkProvider); |
| mDuplexAudioManager.setNumRecorderChannels(NUM_RECORD_CHANNELS); |
| } |
| |
| mDuplexAudioManager.setupStreams(mPlayerType, BuilderBase.TYPE_JAVA); |
| mDuplexAudioManager.start(); |
| |
| mBlipSource = (AudioSource) mDuplexAudioManager.getAudioSource(); |
| |
| mIsRecording = true; |
| enableAudioButtons(); |
| } |
| |
| private void stopAudio() { |
| if (mIsRecording) { |
| mDuplexAudioManager.stop(); |
| // is there a teardown method here? |
| mIsRecording = false; |
| enableAudioButtons(); |
| } |
| } |
| |
| private void resetStats() { |
| mNumMeasurements[mActiveTestAPI] = 0; |
| mLatencySumSamples[mActiveTestAPI] = 0; |
| mLatencyMin[mActiveTestAPI] = |
| mLatencyMax[mActiveTestAPI] = |
| mLatencyAve[mActiveTestAPI] = 0; |
| |
| java.util.Arrays.fill(mLatencyMillis, 0.0); |
| |
| mTestPhase = 0; |
| } |
| |
| private void clearResults() { |
| resetStats(); |
| mSpecView.setText(""); |
| mResultsView.setText(""); |
| mStatsView.setText(""); |
| } |
| |
| private void enableAudioButtons() { |
| mStartBtn.setEnabled(!mIsRecording); |
| mStopBtn.setEnabled(mIsRecording); |
| } |
| |
| private void calculateTestPass() { |
| boolean testCompleted = mTestPhase >= NUM_TEST_PHASES; |
| if (!testCompleted) { |
| mSpecView.setText(getResources().getString(R.string.audio_general_testnotcompleted)); |
| getPassButton().setEnabled(false); |
| return; |
| } |
| |
| double averageLatency = mLatencyAve[mActiveTestAPI]; |
| boolean pass = averageLatency != 0 && averageLatency <= mMaxRequiredLatency; |
| |
| if (pass) { |
| mSpecView.setText("Average: " + averageLatency + " ms <= " |
| + mMaxRequiredLatency + " ms -- PASS"); |
| } else { |
| mSpecView.setText("Average: " + averageLatency + " ms > " |
| + mMaxRequiredLatency + " ms -- FAIL"); |
| } |
| getPassButton().setEnabled(pass); |
| } |
| |
| private void recordTestStatus() { |
| CtsVerifierReportLog reportLog = getReportLog(); |
| for (int api = TEST_API_NATIVE; api <= TEST_API_JAVA; api++) { |
| reportLog.addValue( |
| KEY_LATENCY_MIN + api, |
| mLatencyMin[api], |
| ResultType.NEUTRAL, |
| ResultUnit.NONE); |
| reportLog.addValue( |
| KEY_LATENCY_MAX + api, |
| mLatencyMax[api], |
| ResultType.NEUTRAL, |
| ResultUnit.NONE); |
| reportLog.addValue( |
| KEY_LATENCY_AVE + api, |
| mLatencyAve[api], |
| ResultType.NEUTRAL, |
| ResultUnit.NONE); |
| reportLog.addValue( |
| KEY_LATENCY_NUM_MEASUREMENTS + api, |
| mNumMeasurements[api], |
| ResultType.NEUTRAL, |
| ResultUnit.NONE); |
| } |
| |
| reportLog.submit(); |
| } |
| |
| private void trigger() { |
| if (mIsRecording) { |
| if (mArmed) { |
| mArmed = false; |
| |
| mBlipSource.trigger(); |
| |
| // schedule an analysis to start in the near future |
| mAnalysisTask = new Runnable() { |
| public void run() { |
| new Thread() { |
| public void run() { |
| analyzeCapturedAudio(); |
| } |
| }.start(); |
| } |
| }; |
| mTaskCountdown = (int) (mDuplexAudioManager.getRecorder().getSampleRate() |
| * ANALYSIS_TIME_DELAY); |
| |
| } |
| } |
| } |
| |
| /** |
| * A holder for analysis results/ |
| */ |
| public static class TestResult { |
| public float[] samples; |
| public float[] filtered; |
| public int frameRate; |
| public TapLatencyAnalyser.TapLatencyEvent[] events; |
| } |
| |
| private void processTest(TestResult result) { |
| if (mTestPhase == NUM_TEST_PHASES) { |
| mTestPhase--; |
| } |
| |
| int[] cursors = new int[2]; |
| cursors[0] = result.events[0].sampleIndex; |
| cursors[1] = result.events[1].sampleIndex; |
| mWaveformView.setCursorData(cursors); |
| |
| int latencySamples = cursors[1] - cursors[0]; |
| mLatencySumSamples[mActiveTestAPI] += latencySamples; |
| mNumMeasurements[mActiveTestAPI]++; |
| |
| double latencyMillis = 1000 * latencySamples / result.frameRate; |
| mLatencyMillis[mTestPhase] = latencyMillis; |
| |
| if (mLatencyMin[mActiveTestAPI] == 0 |
| || mLatencyMin[mActiveTestAPI] > latencyMillis) { |
| mLatencyMin[mActiveTestAPI] = latencyMillis; |
| } |
| if (mLatencyMax[mActiveTestAPI] == 0 |
| || mLatencyMax[mActiveTestAPI] < latencyMillis) { |
| mLatencyMax[mActiveTestAPI] = latencyMillis; |
| } |
| |
| mLatencyAve[mActiveTestAPI] = StatUtils.calculateMean(mLatencyMillis); |
| double meanAbsoluteDeviation = StatUtils.calculateMeanAbsoluteDeviation( |
| mLatencyAve[mActiveTestAPI], mLatencyMillis, mTestPhase + 1); |
| |
| mTestPhase++; |
| |
| mLatencyAve[mActiveTestAPI] = 1000 |
| * (mLatencySumSamples[mActiveTestAPI] / mNumMeasurements[mActiveTestAPI]) |
| / result.frameRate; |
| mResultsView.setText("Phase: " + mTestPhase + " : " + latencyMillis |
| + " ms, Ave: " + mLatencyAve[mActiveTestAPI] + " ms"); |
| mStatsView.setText("Deviation: " + String.format("%.2f",meanAbsoluteDeviation)); |
| |
| mPhaseView.setText("" + mTestPhase + " of " + NUM_TEST_PHASES + " completed."); |
| } |
| |
| private void analyzeCapturedAudio() { |
| if (!mIsRecording) { |
| return; |
| } |
| int sampleRate = mDuplexAudioManager.getRecorder().getSampleRate(); |
| int numSamples = (int) (sampleRate * ANALYSIS_TIME_TOTAL); |
| float[] buffer = new float[numSamples]; |
| |
| int numRead = mInputBuffer.readMostRecent(buffer); |
| |
| TestResult result = new TestResult(); |
| result.samples = buffer; |
| result.frameRate = sampleRate; |
| result.events = mTapLatencyAnalyser.analyze(buffer, 0, numRead); |
| result.filtered = mTapLatencyAnalyser.getFilteredBuffer(); |
| |
| // This will come in on a background thread, so switch to the UI thread to update the UI. |
| runOnUiThread(new Runnable() { |
| public void run() { |
| if (result.events.length < 2) { |
| mResultsView.setText( |
| getResources().getString(R.string.audio_tap2tone_too_few)); |
| mStatsView.setText(""); |
| } else if (result.events.length > 2) { |
| mResultsView.setText( |
| getResources().getString(R.string.audio_tap2tone_too_many)); |
| mStatsView.setText(""); |
| } else { |
| processTest(result); |
| } |
| |
| mWaveformView.setSampleData(result.filtered); |
| mWaveformView.postInvalidate(); |
| |
| mArmed = true; |
| |
| calculateTestPass(); |
| } |
| }); |
| } |
| |
| // |
| // View.OnClickListener overrides |
| // |
| @Override |
| public void onClick(View v) { |
| int id = v.getId(); |
| if (id == R.id.tap2tone_startBtn) { |
| startAudio(); |
| } else if (id == R.id.tap2tone_stopBtn) { |
| stopAudio(); |
| } else if (id == R.id.audioJavaApiBtn) { |
| stopAudio(); |
| clearResults(); |
| mPlayerType = BuilderBase.TYPE_JAVA; |
| mActiveTestAPI = TEST_API_JAVA; |
| } else if (id == R.id.audioNativeApiBtn) { |
| stopAudio(); |
| clearResults(); |
| mPlayerType = BuilderBase.TYPE_OBOE | BuilderBase.SUB_TYPE_OBOE_AAUDIO; |
| mActiveTestAPI = TEST_API_NATIVE; |
| } else if (id == R.id.tap2tone_clearResults) { |
| clearResults(); |
| } |
| } |
| |
| @Override |
| public void setTestResultAndFinish(boolean passed) { |
| stopAudio(); |
| recordTestStatus(); |
| super.setTestResultAndFinish(passed); |
| } |
| |
| // |
| // AppCallback overrides |
| // |
| @Override |
| public void onDataReady(float[] audioData, int numFrames) { |
| mInputBuffer.write(audioData); |
| |
| // Analysis? |
| if (mTaskCountdown > 0) { |
| mTaskCountdown -= numFrames; |
| if (mTaskCountdown <= 0) { |
| mTaskCountdown = 0; |
| new Thread(mAnalysisTask).start(); // run asynchronously with audio thread |
| } |
| } |
| } |
| } |