1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.cts.verifier.audio; 18 19 import android.os.Bundle; 20 import android.util.Log; 21 import android.view.MotionEvent; 22 import android.view.View; 23 import android.widget.Button; 24 import android.widget.RadioButton; 25 import android.widget.TextView; 26 27 import com.android.compatibility.common.util.ResultType; 28 import com.android.compatibility.common.util.ResultUnit; 29 import com.android.cts.verifier.audio.audiolib.StatUtils; 30 import com.android.cts.verifier.CtsVerifierReportLog; 31 import com.android.cts.verifier.PassFailButtons; 32 import com.android.cts.verifier.R; 33 import com.android.cts.verifier.audio.audiolib.CircularBufferFloat; 34 import com.android.cts.verifier.audio.audiolib.TapLatencyAnalyser; 35 import com.android.cts.verifier.audio.audiolib.WaveformView; 36 import com.android.cts.verifier.audio.sources.BlipAudioSourceProvider; 37 38 import org.hyphonate.megaaudio.common.BuilderBase; 39 import org.hyphonate.megaaudio.duplex.DuplexAudioManager; 40 import org.hyphonate.megaaudio.player.AudioSource; 41 import org.hyphonate.megaaudio.player.AudioSourceProvider; 42 import org.hyphonate.megaaudio.player.JavaSourceProxy; 43 import org.hyphonate.megaaudio.recorder.AudioSinkProvider; 44 import org.hyphonate.megaaudio.recorder.sinks.AppCallback; 45 import org.hyphonate.megaaudio.recorder.sinks.AppCallbackAudioSinkProvider; 46 47 /** 48 * CtsVerifier test to measure tap-to-tone latency. 49 */ 50 public class AudioTap2ToneActivity 51 extends PassFailButtons.Activity 52 implements View.OnClickListener, AppCallback { 53 private static final String TAG = "AudioTap2ToneActivity"; 54 55 // JNI load 56 static { 57 try { 58 System.loadLibrary("megaaudio_jni"); 59 } catch (UnsatisfiedLinkError e) { 60 Log.e(TAG, "Error loading MegaAudio JNI library"); 61 Log.e(TAG, "e: " + e); 62 e.printStackTrace(); 63 } 64 65 /* TODO: gracefully fail/notify if the library can't be loaded */ 66 } 67 68 private boolean mIsRecording; 69 70 private int mPlayerType = BuilderBase.TYPE_OBOE | BuilderBase.SUB_TYPE_OBOE_AAUDIO; 71 72 private DuplexAudioManager mDuplexAudioManager; 73 private AudioSource mBlipSource; 74 75 private Button mStartBtn; 76 private Button mStopBtn; 77 78 private TextView mSpecView; 79 private TextView mResultsView; 80 private TextView mStatsView; 81 private TextView mPhaseView; 82 83 private WaveformView mWaveformView; 84 85 // Test Constants are from OboeTester.AudioMidiTester 86 private static final float MAX_TOUCH_LATENCY = 0.200f; 87 private static final float MAX_OUTPUT_LATENCY = 0.600f; 88 private static final float ANALYSIS_TIME_MARGIN = 0.250f; 89 90 private static final float ANALYSIS_TIME_DELAY = MAX_OUTPUT_LATENCY; 91 private static final float ANALYSIS_TIME_TOTAL = MAX_TOUCH_LATENCY + MAX_OUTPUT_LATENCY; 92 private static final float ANALYSIS_TIME_MAX = ANALYSIS_TIME_TOTAL + ANALYSIS_TIME_MARGIN; 93 private static final int ANALYSIS_SAMPLE_RATE = 48000; // need not match output rate 94 95 private static final int NUM_RECORD_CHANNELS = 1; 96 97 private CircularBufferFloat mInputBuffer; 98 99 private Runnable mAnalysisTask; 100 private int mTaskCountdown; 101 102 private TapLatencyAnalyser mTapLatencyAnalyser; 103 104 // Stats for latency 105 // STRONGLY RECOMMENDED in CDD 5.6 106 private static final int MAX_TAP_2_TONE_LATENCY = 80; // ms 107 108 // Test API (back-end) IDs 109 private static final int NUM_TEST_APIS = 2; 110 private static final int TEST_API_NATIVE = 0; 111 private static final int TEST_API_JAVA = 1; 112 private int mActiveTestAPI = TEST_API_NATIVE; 113 114 private int[] mNumMeasurements = new int[NUM_TEST_APIS]; // ms 115 private int[] mLatencySumSamples = new int[NUM_TEST_APIS]; // ms 116 private double[] mLatencyMin = new double[NUM_TEST_APIS]; // ms 117 private double[] mLatencyMax = new double[NUM_TEST_APIS]; // ms 118 private double[] mLatencyAve = new double[NUM_TEST_APIS]; // ms 119 120 private static final int NUM_TEST_PHASES = 5; 121 private int mTestPhase; 122 123 private double[] mLatencyMillis = new double[NUM_TEST_PHASES]; 124 125 // ReportLog Schema 126 // Note that each key will be suffixed with the ID of the API tested 127 private static final String KEY_LATENCY_MIN = "latency_min_"; 128 private static final String KEY_LATENCY_MAX = "latency_max_"; 129 private static final String KEY_LATENCY_AVE = "latency_max_"; 130 private static final String KEY_LATENCY_NUM_MEASUREMENTS = "latency_num_measurements_"; 131 132 @Override onCreate(Bundle savedInstanceState)133 protected void onCreate(Bundle savedInstanceState) { 134 setContentView(R.layout.audio_tap2tone_activity); 135 136 super.onCreate(savedInstanceState); 137 138 // Setup UI 139 mStartBtn = (Button) findViewById(R.id.tap2tone_startBtn); 140 mStartBtn.setOnClickListener(this); 141 mStopBtn = (Button) findViewById(R.id.tap2tone_stopBtn); 142 mStopBtn.setOnClickListener(this); 143 144 ((RadioButton) findViewById(R.id.audioJavaApiBtn)).setOnClickListener(this); 145 RadioButton nativeApiRB = findViewById(R.id.audioNativeApiBtn); 146 nativeApiRB.setChecked(true); 147 nativeApiRB.setOnClickListener(this); 148 149 ((Button) findViewById(R.id.tap2tone_clearResults)).setOnClickListener(this); 150 151 mSpecView = (TextView) findViewById(R.id.tap2tone_specTxt); 152 mResultsView = (TextView) findViewById(R.id.tap2tone_resultTxt); 153 mStatsView = (TextView) findViewById(R.id.tap2tone_statsTxt); 154 mPhaseView = (TextView) findViewById(R.id.tap2tone_phaseInfo); 155 156 mWaveformView = (WaveformView) findViewById(R.id.tap2tone_waveView); 157 // Start a blip test when the waveform view is tapped. 158 mWaveformView.setOnTouchListener(new View.OnTouchListener() { 159 @Override 160 public boolean onTouch(View view, MotionEvent event) { 161 int action = event.getActionMasked(); 162 switch (action) { 163 case MotionEvent.ACTION_DOWN: 164 case MotionEvent.ACTION_POINTER_DOWN: 165 trigger(); 166 break; 167 case MotionEvent.ACTION_MOVE: 168 break; 169 case MotionEvent.ACTION_UP: 170 case MotionEvent.ACTION_POINTER_UP: 171 break; 172 } 173 // Must return true or we do not get the ACTION_MOVE and 174 // ACTION_UP events. 175 return true; 176 } 177 }); 178 179 setPassFailButtonClickListeners(); 180 setInfoResources(R.string.audio_tap2tone, R.string.audio_tap2tone_info, -1); 181 182 enableAudioButtons(); 183 184 // Setup analysis 185 int numBufferSamples = (int) (ANALYSIS_TIME_MAX * ANALYSIS_SAMPLE_RATE); 186 mInputBuffer = new CircularBufferFloat(numBufferSamples); 187 mTapLatencyAnalyser = new TapLatencyAnalyser(); 188 189 JavaSourceProxy.initN(); 190 191 calculateTestPass(); 192 } 193 startAudio()194 private void startAudio() { 195 if (mIsRecording) { 196 return; 197 } 198 199 if (mDuplexAudioManager == null) { 200 AudioSourceProvider sourceProvider = new BlipAudioSourceProvider(); 201 AudioSinkProvider sinkProvider = new AppCallbackAudioSinkProvider(this); 202 mDuplexAudioManager = new DuplexAudioManager(sourceProvider, sinkProvider); 203 mDuplexAudioManager.setNumRecorderChannels(NUM_RECORD_CHANNELS); 204 } 205 206 mDuplexAudioManager.setupStreams(mPlayerType, BuilderBase.TYPE_JAVA); 207 mDuplexAudioManager.start(); 208 209 mBlipSource = (AudioSource) mDuplexAudioManager.getAudioSource(); 210 211 mIsRecording = true; 212 enableAudioButtons(); 213 } 214 stopAudio()215 private void stopAudio() { 216 if (mIsRecording) { 217 mDuplexAudioManager.stop(); 218 // is there a teardown method here? 219 mIsRecording = false; 220 enableAudioButtons(); 221 } 222 } 223 resetStats()224 private void resetStats() { 225 mNumMeasurements[mActiveTestAPI] = 0; 226 mLatencySumSamples[mActiveTestAPI] = 0; 227 mLatencyMin[mActiveTestAPI] = 228 mLatencyMax[mActiveTestAPI] = 229 mLatencyAve[mActiveTestAPI] = 0; 230 231 java.util.Arrays.fill(mLatencyMillis, 0.0); 232 233 mTestPhase = 0; 234 } 235 clearResults()236 private void clearResults() { 237 resetStats(); 238 mSpecView.setText(getResources().getString(R.string.audio_tap2tone_spec)); 239 mResultsView.setText(""); 240 mStatsView.setText(""); 241 } 242 enableAudioButtons()243 private void enableAudioButtons() { 244 mStartBtn.setEnabled(!mIsRecording); 245 mStopBtn.setEnabled(mIsRecording); 246 } 247 calculateTestPass()248 private void calculateTestPass() { 249 // 80ms is currently STRONGLY RECOMMENDED, so pass the test as long as they have run it. 250 boolean testCompleted = mTestPhase >= NUM_TEST_PHASES; 251 boolean pass = mLatencyAve[mActiveTestAPI] != 0 252 && mLatencyAve[mActiveTestAPI] <= MAX_TAP_2_TONE_LATENCY; 253 254 if (testCompleted) { 255 if (pass) { 256 mSpecView.setText("Ave: " + mLatencyAve[mActiveTestAPI] + " ms <= " 257 + MAX_TAP_2_TONE_LATENCY + " ms -- PASS"); 258 } else { 259 mSpecView.setText("Ave: " + mLatencyAve[mActiveTestAPI] + " ms > " 260 + MAX_TAP_2_TONE_LATENCY + " ms -- DOES NOT MEET STRONGLY RECOMMENDED"); 261 } 262 } 263 getPassButton().setEnabled(testCompleted); 264 } 265 recordTestStatus()266 private void recordTestStatus() { 267 CtsVerifierReportLog reportLog = getReportLog(); 268 for (int api = TEST_API_NATIVE; api <= TEST_API_JAVA; api++) { 269 reportLog.addValue( 270 KEY_LATENCY_MIN + api, 271 mLatencyMin[api], 272 ResultType.NEUTRAL, 273 ResultUnit.NONE); 274 reportLog.addValue( 275 KEY_LATENCY_MAX + api, 276 mLatencyMax[api], 277 ResultType.NEUTRAL, 278 ResultUnit.NONE); 279 reportLog.addValue( 280 KEY_LATENCY_AVE + api, 281 mLatencyAve[api], 282 ResultType.NEUTRAL, 283 ResultUnit.NONE); 284 reportLog.addValue( 285 KEY_LATENCY_NUM_MEASUREMENTS + api, 286 mNumMeasurements[api], 287 ResultType.NEUTRAL, 288 ResultUnit.NONE); 289 } 290 291 reportLog.submit(); 292 } 293 trigger()294 private void trigger() { 295 if (mIsRecording) { 296 mBlipSource.trigger(); 297 298 // schedule an analysis to start in the near future 299 mAnalysisTask = new Runnable() { 300 public void run() { 301 new Thread() { 302 public void run() { 303 analyzeCapturedAudio(); 304 } 305 }.start(); 306 } 307 }; 308 mTaskCountdown = 309 (int) (mDuplexAudioManager.getRecorder().getSampleRate() * ANALYSIS_TIME_DELAY); 310 } 311 } 312 313 /** 314 * A holder for analysis results/ 315 */ 316 public static class TestResult { 317 public float[] samples; 318 public float[] filtered; 319 public int frameRate; 320 public TapLatencyAnalyser.TapLatencyEvent[] events; 321 } 322 processTest(TestResult result)323 private void processTest(TestResult result) { 324 if (mTestPhase == NUM_TEST_PHASES) { 325 mTestPhase--; 326 } 327 328 int[] cursors = new int[2]; 329 cursors[0] = result.events[0].sampleIndex; 330 cursors[1] = result.events[1].sampleIndex; 331 mWaveformView.setCursorData(cursors); 332 333 int latencySamples = cursors[1] - cursors[0]; 334 mLatencySumSamples[mActiveTestAPI] += latencySamples; 335 mNumMeasurements[mActiveTestAPI]++; 336 337 double latencyMillis = 1000 * latencySamples / result.frameRate; 338 mLatencyMillis[mTestPhase] = latencyMillis; 339 340 if (mLatencyMin[mActiveTestAPI] == 0 341 || mLatencyMin[mActiveTestAPI] > latencyMillis) { 342 mLatencyMin[mActiveTestAPI] = latencyMillis; 343 } 344 if (mLatencyMax[mActiveTestAPI] == 0 345 || mLatencyMax[mActiveTestAPI] < latencyMillis) { 346 mLatencyMax[mActiveTestAPI] = latencyMillis; 347 } 348 349 mLatencyAve[mActiveTestAPI] = StatUtils.calculateMean(mLatencyMillis); 350 double meanAbsoluteDeviation = StatUtils.calculateMeanAbsoluteDeviation( 351 mLatencyAve[mActiveTestAPI], mLatencyMillis); 352 353 mTestPhase++; 354 355 mLatencyAve[mActiveTestAPI] = 1000 356 * (mLatencySumSamples[mActiveTestAPI] / mNumMeasurements[mActiveTestAPI]) 357 / result.frameRate; 358 mResultsView.setText("Phase: " + mTestPhase + " : " + latencyMillis 359 + " ms, Ave: " + mLatencyAve[mActiveTestAPI] + " ms"); 360 mStatsView.setText("Deviation: " + String.format("%.2f",meanAbsoluteDeviation)); 361 362 mPhaseView.setText("" + mTestPhase + " of " + NUM_TEST_PHASES + " completed."); 363 } 364 analyzeCapturedAudio()365 private void analyzeCapturedAudio() { 366 if (!mIsRecording) { 367 return; 368 } 369 int sampleRate = mDuplexAudioManager.getRecorder().getSampleRate(); 370 int numSamples = (int) (sampleRate * ANALYSIS_TIME_TOTAL); 371 float[] buffer = new float[numSamples]; 372 373 int numRead = mInputBuffer.readMostRecent(buffer); 374 375 TestResult result = new TestResult(); 376 result.samples = buffer; 377 result.frameRate = sampleRate; 378 result.events = mTapLatencyAnalyser.analyze(buffer, 0, numRead); 379 result.filtered = mTapLatencyAnalyser.getFilteredBuffer(); 380 381 // This will come in on a background thread, so switch to the UI thread to update the UI. 382 runOnUiThread(new Runnable() { 383 public void run() { 384 if (result.events.length < 2) { 385 mResultsView.setText( 386 getResources().getString(R.string.audio_tap2tone_too_few)); 387 mStatsView.setText(""); 388 } else if (result.events.length > 2) { 389 mResultsView.setText( 390 getResources().getString(R.string.audio_tap2tone_too_many)); 391 mStatsView.setText(""); 392 } else { 393 processTest(result); 394 } 395 396 mWaveformView.setSampleData(result.filtered); 397 mWaveformView.postInvalidate(); 398 399 calculateTestPass(); 400 } 401 }); 402 } 403 404 // 405 // View.OnClickListener overrides 406 // 407 @Override onClick(View v)408 public void onClick(View v) { 409 switch (v.getId()) { 410 case R.id.tap2tone_startBtn: 411 startAudio(); 412 break; 413 414 case R.id.tap2tone_stopBtn: 415 stopAudio(); 416 break; 417 418 case R.id.audioJavaApiBtn: 419 stopAudio(); 420 clearResults(); 421 mPlayerType = BuilderBase.TYPE_JAVA; 422 mActiveTestAPI = TEST_API_JAVA; 423 break; 424 425 case R.id.audioNativeApiBtn: 426 stopAudio(); 427 clearResults(); 428 mPlayerType = BuilderBase.TYPE_OBOE | BuilderBase.SUB_TYPE_OBOE_AAUDIO; 429 mActiveTestAPI = TEST_API_NATIVE; 430 break; 431 432 case R.id.tap2tone_clearResults: 433 clearResults(); 434 break; 435 } 436 } 437 438 @Override setTestResultAndFinish(boolean passed)439 public void setTestResultAndFinish(boolean passed) { 440 stopAudio(); 441 recordTestStatus(); 442 super.setTestResultAndFinish(passed); 443 } 444 445 // 446 // AppCallback overrides 447 // 448 @Override onDataReady(float[] audioData, int numFrames)449 public void onDataReady(float[] audioData, int numFrames) { 450 mInputBuffer.write(audioData); 451 452 // Analysis? 453 if (mTaskCountdown > 0) { 454 mTaskCountdown -= numFrames; 455 if (mTaskCountdown <= 0) { 456 mTaskCountdown = 0; 457 new Thread(mAnalysisTask).start(); // run asynchronously with audio thread 458 } 459 } 460 } 461 } 462