• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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