Closed-loop audio feedback client implementation.

This is an implementation of a feedback client for interactive audio
testing that is based on a closed-loop connection between the DUT's
audio-out/audio-in jacks.  This includes logic for both playback and
recording queries.

BUG=b:26162596
TEST=None

Change-Id: I930630a9beedbdbc1d9c879aeb50c27285682116
Reviewed-on: https://chromium-review.googlesource.com/319198
Commit-Ready: Gilad Arnold <garnold@chromium.org>
Tested-by: Gilad Arnold <garnold@chromium.org>
Reviewed-by: Ralph Nathan <ralphnathan@chromium.org>
diff --git a/client/common_lib/site_utils.py b/client/common_lib/site_utils.py
index d55f0ff..2c5926e 100644
--- a/client/common_lib/site_utils.py
+++ b/client/common_lib/site_utils.py
@@ -12,6 +12,7 @@
 import time
 import urllib2
 import uuid
+import wave
 
 from autotest_lib.client.common_lib import base_utils
 from autotest_lib.client.common_lib import error
@@ -617,3 +618,37 @@
     """
     branch, target, build_id = build_name.split('/')
     return branch, target, build_id
+
+
+def check_wav_file(filename, num_channels=None, sample_rate=None,
+                   sample_width=None):
+    """Checks a WAV file and returns its peak PCM value.
+
+    @param filename: Input WAV file to analyze.
+    @param num_channels: Number of channels to expect (None to not check).
+    @param sample_rate: Sample rate to expect (None to not check).
+    @param sample_width: Sample width to expect (None to not check).
+
+    @return The absolute maximum PCM value in the WAV file.
+
+    @raise ValueError: Failed to process the WAV file or validate an attribute.
+    """
+    chk_file = None
+    try:
+        chk_file = wave.open(filename, 'r')
+        if num_channels is not None and chk_file.getnchannels() != num_channels:
+            raise ValueError('Incorrect number of channels')
+        if sample_rate is not None and chk_file.getframerate() != sample_rate:
+            raise ValueError('Incorrect sample rate')
+        if sample_width is not None and chk_file.getsampwidth() != sample_width:
+            raise ValueError('Incorrect sample width')
+        num_frames = chk_file.getnframes()
+        frames = struct.unpack('%dh' % num_frames,
+                               chk_file.readframes(num_frames))
+    except wave.Error as e:
+        raise ValueError('Error processing WAV file: %s' % e)
+    finally:
+        if chk_file is not None:
+            chk_file.close()
+
+    return max(map(abs, frames))
diff --git a/server/brillo/__init__.py b/server/brillo/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/brillo/__init__.py
diff --git a/server/brillo/feedback/__init__.py b/server/brillo/feedback/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/brillo/feedback/__init__.py
diff --git a/server/brillo/feedback/closed_loop_audio_client.py b/server/brillo/feedback/closed_loop_audio_client.py
new file mode 100644
index 0000000..64b78e6
--- /dev/null
+++ b/server/brillo/feedback/closed_loop_audio_client.py
@@ -0,0 +1,342 @@
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Feedback implementation for audio with closed-loop cable."""
+
+import logging
+import os
+import tempfile
+
+import common
+from autotest_lib.client.common_lib import error
+from autotest_lib.client.common_lib import site_utils
+from autotest_lib.client.common_lib.feedback import client
+from autotest_lib.server.brillo import host_utils
+
+
+def _max_volume(sample_width):
+    """Returns the maximum possible volume.
+
+    This is the highest absolute value of a signed integer of a given width.
+
+    @param sample_width: The sample width in bytes.
+    """
+    return (1 << (sample_width * 8 - 1))
+
+
+# Constants used for updating the audio policy.
+#
+_DUT_AUDIO_POLICY_PATH = 'system/etc/audio_policy.conf'
+_AUDIO_POLICY_DEFAULT_OUTPUT_DEVICE = 'default_output_device'
+_AUDIO_POLICY_ATTACHED_OUTPUT_DEVICES = 'attached_output_devices'
+_WIRED_HEADSET = 'AUDIO_DEVICE_OUT_WIRED_HEADSET'
+
+# Constants used when recording playback.
+#
+_REC_FILENAME = 'rec_file.wav'
+_REC_DURATION = 10
+# Number of channels to record.
+_NUM_CHANNELS = 1
+# Recording sample rate (48kHz).
+_SAMPLE_RATE = 48000
+# Recording sample format is signed 16-bit PCM (two bytes).
+_SAMPLE_WIDTH = 2
+# The peak when recording silence is 5% of the max volume.
+_SILENCE_MAX = _max_volume(_SAMPLE_WIDTH) / 20
+
+
+class Client(client.Client):
+    """Audio closed-loop feedback implementation.
+
+    This class (and the queries it instantiates) perform playback and recording
+    of audio on the DUT itself, with the assumption that the audio in/out
+    connections are cross-wired with a cable. It provides some shared logic
+    that queries can use for handling the DUT as well as maintaining shared
+    state between queries (such as an audible volume threshold).
+    """
+
+    def __init__(self):
+        """Construct the client library."""
+        super(Client, self).__init__()
+        self.host = None
+        self.dut_tmp_dir = None
+        self.tmp_dir = None
+        self.orig_policy = None
+        # By default, the audible threshold is equivalent to the silence cap.
+        self.audible_threshold = _SILENCE_MAX
+
+
+    def set_audible_threshold(self, threshold):
+        """Sets the audible volume threshold.
+
+        @param threshold: New threshold value.
+        """
+        self.audible_threshold = threshold
+
+
+    def _patch_audio_policy(self):
+        """Updates the audio_policy.conf file to use the headphone jack.
+
+        Currently, there's no way to update the audio routing if a headset is
+        plugged in. This function manually changes the audio routing to play
+        through the headset.
+        TODO(ralphnathan): Remove this once b/25188354 is resolved.
+        """
+        # Fetch the DUT's original audio policy.
+        _, self.orig_policy = tempfile.mkstemp(dir=self.tmp_dir)
+        self.host.get_file(_DUT_AUDIO_POLICY_PATH, self.orig_policy,
+                           delete_dest=True)
+
+        # Patch the policy to route audio to a headset.
+        _, test_policy = tempfile.mkstemp(dir=self.tmp_dir)
+        policy_changed = False
+        with open(self.orig_policy) as orig_file:
+            with open(test_policy, 'w') as test_file:
+                for line in orig_file:
+                    if _WIRED_HEADSET not in line:
+                        if _AUDIO_POLICY_ATTACHED_OUTPUT_DEVICES in line:
+                            line = '%s|%s\n' % (line.rstrip(), _WIRED_HEADSET)
+                            policy_changed = True
+                        elif _AUDIO_POLICY_DEFAULT_OUTPUT_DEVICE in line:
+                            line = '%s %s\n' % (line.rstrip().rsplit(' ', 1)[0],
+                                                _WIRED_HEADSET)
+                            policy_changed = True
+
+                    test_file.write(line)
+
+        # Update the DUT's audio policy if changed.
+        if policy_changed:
+            logging.info('Updating audio policy to route audio to headset')
+            self.host.remount()
+            self.host.send_file(test_policy, _DUT_AUDIO_POLICY_PATH,
+                                delete_dest=True)
+            self.host.reboot()
+        else:
+            os.remove(self.orig_policy)
+            self.orig_policy = None
+
+        os.remove(test_policy)
+
+
+    # Interface overrides.
+    #
+    def _initialize_impl(self, test, host):
+        """Initializes the feedback object.
+
+        @param test: An object representing the test case.
+        @param host: An object representing the DUT.
+        """
+        self.host = host
+        self.tmp_dir = test.tmpdir
+        self.dut_tmp_dir = host.get_tmp_dir()
+        self._patch_audio_policy()
+
+
+    def _finalize_impl(self):
+        """Finalizes the feedback object."""
+        if self.orig_policy:
+            logging.info('Restoring DUT audio policy')
+            self.host.remount()
+            self.host.send_file(self.orig_policy, _DUT_AUDIO_POLICY_PATH,
+                                delete_dest=True)
+            os.remove(self.orig_policy)
+            self.orig_policy = None
+
+
+    def _new_query_impl(self, query_id):
+        """Instantiates a new query.
+
+        @param query_id: A query identifier.
+
+        @return A query object.
+
+        @raise error.TestError: Query is not supported.
+        """
+        if query_id == client.QUERY_AUDIO_PLAYBACK_SILENT:
+            return SilentPlaybackAudioQuery(self)
+        elif query_id == client.QUERY_AUDIO_PLAYBACK_AUDIBLE:
+            return AudiblePlaybackAudioQuery(self)
+        elif query_id == client.QUERY_AUDIO_RECORDING:
+            return RecordingAudioQuery(self)
+        else:
+            raise error.TestError('Unsupported query (%s)' % query_id)
+
+
+class _PlaybackAudioQuery(client.OutputQuery):
+    """Playback query base class."""
+
+    def __init__(self, client):
+        """Constructor.
+
+        @param client: The instantiating client object.
+        """
+        super(_PlaybackAudioQuery, self).__init__()
+        self.client = client
+        self.dut_rec_filename = None
+        self.local_tmp_dir = None
+        self.recording_pid = None
+
+
+    def _process_recording(self):
+        """Waits for recording to finish and processes the result.
+
+        @return The highest recorded peak value.
+
+        @raise error.TestError: Error while validating the recording.
+        @raise error.TestFail: Recording file failed to validate.
+        """
+        # Wait for recording to finish.
+        timeout = _REC_DURATION + 5
+        if not host_utils.wait_for_process(self.client.host,
+                                           self.recording_pid, timeout):
+            raise error.TestError(
+                    'Recording did not terminate within %d seconds' % timeout)
+
+        _, local_rec_filename = tempfile.mkstemp(
+                prefix='recording-', suffix='.wav', dir=self.local_tmp_dir)
+        try:
+            self.client.host.get_file(self.dut_rec_filename,
+                                      local_rec_filename, delete_dest=True)
+            return site_utils.check_wav_file(local_rec_filename,
+                                             num_channels=_NUM_CHANNELS,
+                                             sample_rate=_SAMPLE_RATE,
+                                             sample_width=_SAMPLE_WIDTH)
+        except ValueError as e:
+            raise error.TestFail('Invalid file attributes: %s' % e)
+
+
+    # Implementation overrides.
+    #
+    def _prepare_impl(self):
+        """Implementation of query preparation logic."""
+        self.dut_rec_filename = os.path.join(self.client.dut_tmp_dir,
+                                             _REC_FILENAME)
+        self.local_tmp_dir = tempfile.mkdtemp(dir=self.client.tmp_dir)
+
+        # Trigger recording in the background.
+        # TODO(garnold) Remove 'su root' once b/25663983 is resolved.
+        cmd = ('su root slesTest_recBuffQueue -d%d %s' %
+               (_REC_DURATION, self.dut_rec_filename))
+        self.recording_pid = host_utils.run_in_background(self.client.host, cmd)
+
+
+class SilentPlaybackAudioQuery(_PlaybackAudioQuery):
+    """Implementation of a silent playback query."""
+
+    def __init__(self, client):
+        super(SilentPlaybackAudioQuery, self).__init__(client)
+
+
+    # Implementation overrides.
+    #
+    def _validate_impl(self):
+        """Implementation of query validation logic."""
+        silence_peak = self._process_recording()
+
+        # Fail if the silence peak volume exceeds the maximum allowed.
+        if silence_peak > _SILENCE_MAX:
+            logging.error(
+                    'Silence peak level (%d) exceeds the max allowed (%d)',
+                    silence_peak, _SILENCE_MAX)
+            raise error.TestFail('Environment is too noisy')
+
+        # Update the client audible threshold, if so instructed.
+        audible_threshold = silence_peak * 15
+        logging.info('Silent peak level (%d) is below the max allowed (%d); '
+                     'setting audible threshold to %d',
+                     silence_peak, _SILENCE_MAX, audible_threshold)
+        self.client.set_audible_threshold(audible_threshold)
+
+
+class AudiblePlaybackAudioQuery(_PlaybackAudioQuery):
+    """Implementation of an audible playback query."""
+
+    def __init__(self, client):
+        super(AudiblePlaybackAudioQuery, self).__init__(client)
+
+
+    # Implementation overrides.
+    #
+    def _validate_impl(self, audio_file=None):
+        """Implementation of query validation logic."""
+        # TODO(garnold) This currently ignores the audio_file argument entirely
+        # and just ensures that peak levels look reasonable. We should probably
+        # compare actual audio content.
+
+        # Ensure that peak recording volume exceeds the threshold.
+        audible_peak = self._process_recording()
+        if audible_peak < self.client.audible_threshold:
+            logging.error(
+                    'Audible peak level (%d) is less than expected (%d)',
+                    audible_peak, self.client.audible_threshold)
+            raise error.TestFail(
+                    'The played audio peak level is below the expected '
+                    'threshold. Either playback did not work, or the volume '
+                    'level is too low. Check the audio connections and '
+                    'settings on the DUT.')
+
+        logging.info('Audible peak level (%d) exceeds the threshold (%d)',
+                     audible_peak, self.client.audible_threshold)
+
+
+class RecordingAudioQuery(client.InputQuery):
+    """Implementation of a recording query."""
+
+    def __init__(self, client):
+        super(RecordingAudioQuery, self).__init__()
+        self.client = client
+
+
+    def _prepare_impl(self):
+        """Implementation of query preparation logic (no-op)."""
+        pass
+
+
+    def _emit_impl(self):
+        """Implementation of query emission logic."""
+        self.client.host.run('slesTest_sawtoothBufferQueue')
+
+
+    def _validate_impl(self, captured_audio_file, sample_width,
+                       sample_rate=None, num_channels=None,
+                       peak_percent_min=1, peak_percent_max=100):
+        """Implementation of query validation logic.
+
+        @param captured_audio_file: Path to the recorded WAV file.
+        @peak_percent_min: Lower bound on peak recorded volume as percentage of
+            max molume (0-100). Default is 1%.
+        @peak_percent_max: Upper bound on peak recorded volume as percentage of
+            max molume (0-100). Default is 100% (no limit).
+        """
+        # TODO(garnold) Currently, we just test whether anything audible was
+        # recorded. We should compare the captured audio to the one produced.
+        try:
+            recorded_peak = site_utils.check_wav_file(
+                    captured_audio_file, num_channels=num_channels,
+                    sample_rate=sample_rate, sample_width=sample_width)
+        except ValueError as e:
+            raise error.TestFail('Recorded audio file is invalid: %s' % e)
+
+        max_volume = _max_volume(sample_width)
+        peak_min = max_volume * peak_percent_min / 100
+        peak_max = max_volume * peak_percent_max / 100
+        if recorded_peak < peak_min:
+            logging.error(
+                    'Recorded audio peak level (%d) is less than expected (%d)',
+                    recorded_peak, peak_min)
+            raise error.TestFail(
+                    'The recorded audio peak level is below the expected '
+                    'threshold. Either recording did not capture the produced '
+                    'audio, or the recording level is too low. Check the audio '
+                    'connections and settings on the DUT.')
+
+        if recorded_peak > peak_max:
+            logging.error(
+                    'Recorded audio peak level (%d) is more than expected (%d)',
+                    recorded_peak, peak_max)
+            raise error.TestFail(
+                    'The recorded audio peak level exceeds the expected '
+                    'maximum. Either recording captured much background noise, '
+                    'or the recording level is too high. Check the audio '
+                    'connections and settings on the DUT.')
diff --git a/server/brillo/host_utils.py b/server/brillo/host_utils.py
new file mode 100644
index 0000000..7ac30bf
--- /dev/null
+++ b/server/brillo/host_utils.py
@@ -0,0 +1,42 @@
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Utilities used with Brillo hosts."""
+
+_RUN_BACKGROUND_TEMPLATE = '( %(cmd)s ) </dev/null >/dev/null 2>&1 & echo -n $!'
+
+_WAIT_CMD_TEMPLATE = """\
+to=%(timeout)d; \
+while test ${to} -ne 0; do \
+  test $(ps %(pid)d | wc -l) -gt 1 || break; \
+  sleep 1; \
+  to=$((to - 1)); \
+done; \
+test ${to} -ne 0 -o $(ps %(pid)d | wc -l) -eq 1 \
+"""
+
+
+def run_in_background(host, cmd):
+    """Runs a command in the background on the DUT.
+
+    @param host: A host object representing the DUT.
+    @param cmd: The command to run.
+
+    @return The background process ID (integer).
+    """
+    background_cmd = _RUN_BACKGROUND_TEMPLATE % {'cmd': cmd}
+    return int(host.run_output(background_cmd).strip())
+
+
+def wait_for_process(host, pid, timeout=-1):
+    """Waits for a process on the DUT to terminate.
+
+    @param host: A host object representing the DUT.
+    @param pid: The process ID (integer).
+    @param timeout: Number of seconds to wait; default is wait forever.
+
+    @return True if process terminated within the alotted time, False otherwise.
+    """
+    wait_cmd = _WAIT_CMD_TEMPLATE % {'pid': pid, 'timeout': timeout}
+    return host.run(wait_cmd, ignore_status=True).exit_status == 0