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