1# Copyright 2016 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""Feedback implementation for audio with closed-loop cable.""" 6 7import logging 8import os 9import tempfile 10 11import common 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.common_lib.feedback import client 14from autotest_lib.server.brillo import audio_utils 15from autotest_lib.server.brillo import host_utils 16 17 18# Constants used when recording playback. 19# 20_REC_FILENAME = 'rec_file.wav' 21_REC_DURATION = 10 22 23# Number of channels to record. 24_DEFAULT_NUM_CHANNELS = 1 25# Recording sample rate (48kHz). 26_DEFAULT_SAMPLE_RATE = 48000 27# Recording sample format is signed 16-bit PCM (two bytes). 28_DEFAULT_SAMPLE_WIDTH = 2 29# Default frequency to generate audio at (used for recording). 30_DEFAULT_FREQUENCY = 440 31 32# The peak when recording silence is 5% of the max volume. 33_SILENCE_THRESHOLD = 0.05 34 35 36def _max_volume(sample_width): 37 """Returns the maximum possible volume. 38 39 This is the highest absolute value of an integer of a given width. 40 If the sample width is one, then we assume an unsigned intger. For all other 41 sample sizes, we assume that the format is signed. 42 43 @param sample_width: The sample width in bytes. 44 """ 45 return (1 << 8) if sample_width == 1 else (1 << (sample_width * 8 - 1)) 46 47 48class Client(client.Client): 49 """Audio closed-loop feedback implementation. 50 51 This class (and the queries it instantiates) perform playback and recording 52 of audio on the DUT itself, with the assumption that the audio in/out 53 connections are cross-wired with a cable. It provides some shared logic 54 that queries can use for handling the DUT as well as maintaining shared 55 state between queries (such as an audible volume threshold). 56 """ 57 58 def __init__(self): 59 """Construct the client library.""" 60 super(Client, self).__init__() 61 self.host = None 62 self.dut_tmp_dir = None 63 self.tmp_dir = None 64 65 66 def set_audible_threshold(self, threshold): 67 """Sets the audible volume threshold. 68 69 @param threshold: New threshold value. 70 """ 71 self.audible_threshold = threshold 72 73 74 # Interface overrides. 75 # 76 def _initialize_impl(self, test, host): 77 """Initializes the feedback object. 78 79 @param test: An object representing the test case. 80 @param host: An object representing the DUT. 81 """ 82 self.host = host 83 self.tmp_dir = test.tmpdir 84 self.dut_tmp_dir = host.get_tmp_dir() 85 86 87 def _finalize_impl(self): 88 """Finalizes the feedback object.""" 89 pass 90 91 92 def _new_query_impl(self, query_id): 93 """Instantiates a new query. 94 95 @param query_id: A query identifier. 96 97 @return A query object. 98 99 @raise error.TestError: Query is not supported. 100 """ 101 if query_id == client.QUERY_AUDIO_PLAYBACK_SILENT: 102 return SilentPlaybackAudioQuery(self) 103 elif query_id == client.QUERY_AUDIO_PLAYBACK_AUDIBLE: 104 return AudiblePlaybackAudioQuery(self) 105 elif query_id == client.QUERY_AUDIO_RECORDING: 106 return RecordingAudioQuery(self) 107 else: 108 raise error.TestError('Unsupported query (%s)' % query_id) 109 110 111class _PlaybackAudioQuery(client.OutputQuery): 112 """Playback query base class.""" 113 114 def __init__(self, client): 115 """Constructor. 116 117 @param client: The instantiating client object. 118 """ 119 super(_PlaybackAudioQuery, self).__init__() 120 self.client = client 121 self.dut_rec_filename = None 122 self.local_tmp_dir = None 123 self.recording_pid = None 124 125 126 def _get_local_rec_filename(self): 127 """Waits for recording to finish and copies the file to the host. 128 129 @return A string of the local filename containing the recorded audio. 130 131 @raise error.TestError: Error while validating the recording. 132 """ 133 # Wait for recording to finish. 134 timeout = _REC_DURATION + 5 135 if not host_utils.wait_for_process(self.client.host, 136 self.recording_pid, timeout): 137 raise error.TestError( 138 'Recording did not terminate within %d seconds' % timeout) 139 140 _, local_rec_filename = tempfile.mkstemp( 141 prefix='recording-', suffix='.wav', dir=self.local_tmp_dir) 142 self.client.host.get_file(self.dut_rec_filename, 143 local_rec_filename, delete_dest=True) 144 return local_rec_filename 145 146 147 # Implementation overrides. 148 # 149 def _prepare_impl(self, 150 sample_width=_DEFAULT_SAMPLE_WIDTH, 151 sample_rate=_DEFAULT_SAMPLE_RATE, 152 num_channels=_DEFAULT_NUM_CHANNELS, 153 duration_secs=_REC_DURATION): 154 """Implementation of query preparation logic. 155 156 @sample_width: Sample width to record at. 157 @sample_rate: Sample rate to record at. 158 @num_channels: Number of channels to record at. 159 @duration_secs: Duration (in seconds) to record for. 160 """ 161 self.num_channels = num_channels 162 self.sample_rate = sample_rate 163 self.sample_width = sample_width 164 self.dut_rec_filename = os.path.join(self.client.dut_tmp_dir, 165 _REC_FILENAME) 166 self.local_tmp_dir = tempfile.mkdtemp(dir=self.client.tmp_dir) 167 168 # Trigger recording in the background. 169 cmd = ('slesTest_recBuffQueue -c%d -d%d -r%d -%d %s' % 170 (num_channels, duration_secs, sample_rate, sample_width, 171 self.dut_rec_filename)) 172 logging.info("Recording cmd: %s", cmd) 173 self.recording_pid = host_utils.run_in_background(self.client.host, cmd) 174 175 176class SilentPlaybackAudioQuery(_PlaybackAudioQuery): 177 """Implementation of a silent playback query.""" 178 179 def __init__(self, client): 180 super(SilentPlaybackAudioQuery, self).__init__(client) 181 182 183 # Implementation overrides. 184 # 185 def _validate_impl(self): 186 """Implementation of query validation logic.""" 187 local_rec_filename = self._get_local_rec_filename() 188 try: 189 silence_peaks = audio_utils.check_wav_file( 190 local_rec_filename, 191 num_channels=self.num_channels, 192 sample_rate=self.sample_rate, 193 sample_width=self.sample_width) 194 except ValueError as e: 195 raise error.TestFail('Invalid file attributes: %s' % e) 196 197 silence_peak = max(silence_peaks) 198 # Fail if the silence peak volume exceeds the maximum allowed. 199 max_vol = _max_volume(self.sample_width) * _SILENCE_THRESHOLD 200 if silence_peak > max_vol: 201 logging.error('Silence peak level (%d) exceeds the max allowed ' 202 '(%d)', silence_peak, max_vol) 203 raise error.TestFail('Environment is too noisy') 204 205 # Update the client audible threshold, if so instructed. 206 audible_threshold = silence_peak * 15 207 logging.info('Silent peak level (%d) is below the max allowed (%d); ' 208 'setting audible threshold to %d', 209 silence_peak, max_vol, audible_threshold) 210 self.client.set_audible_threshold(audible_threshold) 211 212 213class AudiblePlaybackAudioQuery(_PlaybackAudioQuery): 214 """Implementation of an audible playback query.""" 215 216 def __init__(self, client): 217 super(AudiblePlaybackAudioQuery, self).__init__(client) 218 219 220 def _check_peaks(self): 221 """Ensure that peak recording volume exceeds the threshold.""" 222 local_rec_filename = self._get_local_rec_filename() 223 try: 224 audible_peaks = audio_utils.check_wav_file( 225 local_rec_filename, 226 num_channels=self.num_channels, 227 sample_rate=self.sample_rate, 228 sample_width=self.sample_width) 229 except ValueError as e: 230 raise error.TestFail('Invalid file attributes: %s' % e) 231 232 min_channel, min_audible_peak = min(enumerate(audible_peaks), 233 key=lambda p: p[1]) 234 if min_audible_peak < self.client.audible_threshold: 235 logging.error( 236 'Audible peak level (%d) is less than expected (%d) for ' 237 'channel %d', min_audible_peak, 238 self.client.audible_threshold, min_channel) 239 raise error.TestFail( 240 'The played audio peak level is below the expected ' 241 'threshold. Either playback did not work, or the volume ' 242 'level is too low. Check the audio connections and ' 243 'settings on the DUT.') 244 245 logging.info('Audible peak level (%d) exceeds the threshold (%d)', 246 min_audible_peak, self.client.audible_threshold) 247 248 249 # Implementation overrides. 250 # 251 def _validate_impl(self, audio_file=None): 252 """Implementation of query validation logic. 253 254 @audio_file: File to compare recorded audio to. 255 """ 256 self._check_peaks() 257 # If the reference audio file is available, then perform an additional 258 # check. 259 if audio_file: 260 local_rec_filename = self._get_local_rec_filename() 261 audio_utils.compare_file(reference_audio_filename=audio_file, 262 test_audio_filename=local_rec_filename) 263 264 265class RecordingAudioQuery(client.InputQuery): 266 """Implementation of a recording query.""" 267 268 def __init__(self, client): 269 super(RecordingAudioQuery, self).__init__() 270 self.client = client 271 272 273 def _prepare_impl(self, use_file=False, 274 sample_width=_DEFAULT_SAMPLE_WIDTH, 275 sample_rate=_DEFAULT_SAMPLE_RATE, 276 num_channels=_DEFAULT_NUM_CHANNELS, 277 duration_secs=_REC_DURATION, 278 frequency=_DEFAULT_FREQUENCY): 279 """Implementation of query preparation logic. 280 281 @param use_file: A bool to indicate whether a file should be used for 282 playback. The other arguments are only valid if 283 use_file is True. 284 @param sample_width: Size of samples in bytes. 285 @param sample_rate: Recording sample rate in hertz. 286 @param num_channels: Number of channels to use for playback. 287 @param duration_secs: Number of seconds to play audio for. 288 @param frequency: Frequency of sine wave to generate. 289 """ 290 self.use_file = use_file 291 self.sample_rate = sample_rate 292 self.sample_width = sample_width 293 self.num_channels = num_channels 294 self.duration_secs = duration_secs 295 self.frequency = frequency 296 297 298 def _emit_impl(self): 299 """Implementation of query emission logic.""" 300 if self.use_file: 301 self.reference_filename, dut_play_file = \ 302 audio_utils.generate_sine_file( 303 self.client.host, self.num_channels, 304 self.sample_rate, self.sample_width, 305 self.duration_secs, self.frequency, 306 self.client.tmp_dir) 307 playback_cmd = 'slesTest_playFdPath %s 0' % dut_play_file 308 self.client.host.run(playback_cmd) 309 else: 310 self.client.host.run('slesTest_sawtoothBufferQueue') 311 312 313 def _validate_impl(self, captured_audio_file, 314 peak_percent_min=1, peak_percent_max=100): 315 """Implementation of query validation logic. 316 317 @param captured_audio_file: Path to the recorded WAV file. 318 @peak_percent_min: Lower bound on peak recorded volume as percentage of 319 max molume (0-100). Default is 1%. 320 @peak_percent_max: Upper bound on peak recorded volume as percentage of 321 max molume (0-100). Default is 100% (no limit). 322 """ 323 try: 324 recorded_peaks = audio_utils.check_wav_file( 325 captured_audio_file, num_channels=self.num_channels, 326 sample_rate=self.sample_rate, 327 sample_width=self.sample_width) 328 except ValueError as e: 329 raise error.TestFail('Recorded audio file is invalid: %s' % e) 330 331 max_volume = _max_volume(self.sample_width) 332 peak_min = max_volume * peak_percent_min / 100 333 peak_max = max_volume * peak_percent_max / 100 334 for channel, recorded_peak in enumerate(recorded_peaks): 335 if recorded_peak < peak_min: 336 logging.error( 337 'Recorded audio peak level (%d) is less than expected ' 338 '(%d) for channel %d', recorded_peak, peak_min, channel) 339 raise error.TestFail( 340 'The recorded audio peak level is below the expected ' 341 'threshold. Either recording did not capture the ' 342 'produced audio, or the recording level is too low. ' 343 'Check the audio connections and settings on the DUT.') 344 345 if recorded_peak > peak_max: 346 logging.error( 347 'Recorded audio peak level (%d) is more than expected ' 348 '(%d) for channel %d', recorded_peak, peak_max, channel) 349 raise error.TestFail( 350 'The recorded audio peak level exceeds the expected ' 351 'maximum. Either recording captured much background ' 352 'noise, or the recording level is too high. Check the ' 353 'audio connections and settings on the DUT.') 354 if self.use_file: 355 audio_utils.compare_file( 356 reference_audio_filename=self.reference_filename, 357 test_audio_filename=captured_audio_file) 358