358 lines
14 KiB
Python
358 lines
14 KiB
Python
# 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.feedback import client
|
|
from autotest_lib.server.brillo import audio_utils
|
|
from autotest_lib.server.brillo import host_utils
|
|
|
|
|
|
# Constants used when recording playback.
|
|
#
|
|
_REC_FILENAME = 'rec_file.wav'
|
|
_REC_DURATION = 10
|
|
|
|
# Number of channels to record.
|
|
_DEFAULT_NUM_CHANNELS = 1
|
|
# Recording sample rate (48kHz).
|
|
_DEFAULT_SAMPLE_RATE = 48000
|
|
# Recording sample format is signed 16-bit PCM (two bytes).
|
|
_DEFAULT_SAMPLE_WIDTH = 2
|
|
# Default frequency to generate audio at (used for recording).
|
|
_DEFAULT_FREQUENCY = 440
|
|
|
|
# The peak when recording silence is 5% of the max volume.
|
|
_SILENCE_THRESHOLD = 0.05
|
|
|
|
|
|
def _max_volume(sample_width):
|
|
"""Returns the maximum possible volume.
|
|
|
|
This is the highest absolute value of an integer of a given width.
|
|
If the sample width is one, then we assume an unsigned intger. For all other
|
|
sample sizes, we assume that the format is signed.
|
|
|
|
@param sample_width: The sample width in bytes.
|
|
"""
|
|
return (1 << 8) if sample_width == 1 else (1 << (sample_width * 8 - 1))
|
|
|
|
|
|
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
|
|
|
|
|
|
def set_audible_threshold(self, threshold):
|
|
"""Sets the audible volume threshold.
|
|
|
|
@param threshold: New threshold value.
|
|
"""
|
|
self.audible_threshold = threshold
|
|
|
|
|
|
# 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()
|
|
|
|
|
|
def _finalize_impl(self):
|
|
"""Finalizes the feedback object."""
|
|
pass
|
|
|
|
|
|
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 _get_local_rec_filename(self):
|
|
"""Waits for recording to finish and copies the file to the host.
|
|
|
|
@return A string of the local filename containing the recorded audio.
|
|
|
|
@raise error.TestError: Error while validating the recording.
|
|
"""
|
|
# 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)
|
|
self.client.host.get_file(self.dut_rec_filename,
|
|
local_rec_filename, delete_dest=True)
|
|
return local_rec_filename
|
|
|
|
|
|
# Implementation overrides.
|
|
#
|
|
def _prepare_impl(self,
|
|
sample_width=_DEFAULT_SAMPLE_WIDTH,
|
|
sample_rate=_DEFAULT_SAMPLE_RATE,
|
|
num_channels=_DEFAULT_NUM_CHANNELS,
|
|
duration_secs=_REC_DURATION):
|
|
"""Implementation of query preparation logic.
|
|
|
|
@sample_width: Sample width to record at.
|
|
@sample_rate: Sample rate to record at.
|
|
@num_channels: Number of channels to record at.
|
|
@duration_secs: Duration (in seconds) to record for.
|
|
"""
|
|
self.num_channels = num_channels
|
|
self.sample_rate = sample_rate
|
|
self.sample_width = sample_width
|
|
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.
|
|
cmd = ('slesTest_recBuffQueue -c%d -d%d -r%d -%d %s' %
|
|
(num_channels, duration_secs, sample_rate, sample_width,
|
|
self.dut_rec_filename))
|
|
logging.info("Recording cmd: %s", cmd)
|
|
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."""
|
|
local_rec_filename = self._get_local_rec_filename()
|
|
try:
|
|
silence_peaks = audio_utils.check_wav_file(
|
|
local_rec_filename,
|
|
num_channels=self.num_channels,
|
|
sample_rate=self.sample_rate,
|
|
sample_width=self.sample_width)
|
|
except ValueError as e:
|
|
raise error.TestFail('Invalid file attributes: %s' % e)
|
|
|
|
silence_peak = max(silence_peaks)
|
|
# Fail if the silence peak volume exceeds the maximum allowed.
|
|
max_vol = _max_volume(self.sample_width) * _SILENCE_THRESHOLD
|
|
if silence_peak > max_vol:
|
|
logging.error('Silence peak level (%d) exceeds the max allowed '
|
|
'(%d)', silence_peak, max_vol)
|
|
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, max_vol, 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)
|
|
|
|
|
|
def _check_peaks(self):
|
|
"""Ensure that peak recording volume exceeds the threshold."""
|
|
local_rec_filename = self._get_local_rec_filename()
|
|
try:
|
|
audible_peaks = audio_utils.check_wav_file(
|
|
local_rec_filename,
|
|
num_channels=self.num_channels,
|
|
sample_rate=self.sample_rate,
|
|
sample_width=self.sample_width)
|
|
except ValueError as e:
|
|
raise error.TestFail('Invalid file attributes: %s' % e)
|
|
|
|
min_channel, min_audible_peak = min(enumerate(audible_peaks),
|
|
key=lambda p: p[1])
|
|
if min_audible_peak < self.client.audible_threshold:
|
|
logging.error(
|
|
'Audible peak level (%d) is less than expected (%d) for '
|
|
'channel %d', min_audible_peak,
|
|
self.client.audible_threshold, min_channel)
|
|
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)',
|
|
min_audible_peak, self.client.audible_threshold)
|
|
|
|
|
|
# Implementation overrides.
|
|
#
|
|
def _validate_impl(self, audio_file=None):
|
|
"""Implementation of query validation logic.
|
|
|
|
@audio_file: File to compare recorded audio to.
|
|
"""
|
|
self._check_peaks()
|
|
# If the reference audio file is available, then perform an additional
|
|
# check.
|
|
if audio_file:
|
|
local_rec_filename = self._get_local_rec_filename()
|
|
audio_utils.compare_file(reference_audio_filename=audio_file,
|
|
test_audio_filename=local_rec_filename)
|
|
|
|
|
|
class RecordingAudioQuery(client.InputQuery):
|
|
"""Implementation of a recording query."""
|
|
|
|
def __init__(self, client):
|
|
super(RecordingAudioQuery, self).__init__()
|
|
self.client = client
|
|
|
|
|
|
def _prepare_impl(self, use_file=False,
|
|
sample_width=_DEFAULT_SAMPLE_WIDTH,
|
|
sample_rate=_DEFAULT_SAMPLE_RATE,
|
|
num_channels=_DEFAULT_NUM_CHANNELS,
|
|
duration_secs=_REC_DURATION,
|
|
frequency=_DEFAULT_FREQUENCY):
|
|
"""Implementation of query preparation logic.
|
|
|
|
@param use_file: A bool to indicate whether a file should be used for
|
|
playback. The other arguments are only valid if
|
|
use_file is True.
|
|
@param sample_width: Size of samples in bytes.
|
|
@param sample_rate: Recording sample rate in hertz.
|
|
@param num_channels: Number of channels to use for playback.
|
|
@param duration_secs: Number of seconds to play audio for.
|
|
@param frequency: Frequency of sine wave to generate.
|
|
"""
|
|
self.use_file = use_file
|
|
self.sample_rate = sample_rate
|
|
self.sample_width = sample_width
|
|
self.num_channels = num_channels
|
|
self.duration_secs = duration_secs
|
|
self.frequency = frequency
|
|
|
|
|
|
def _emit_impl(self):
|
|
"""Implementation of query emission logic."""
|
|
if self.use_file:
|
|
self.reference_filename, dut_play_file = \
|
|
audio_utils.generate_sine_file(
|
|
self.client.host, self.num_channels,
|
|
self.sample_rate, self.sample_width,
|
|
self.duration_secs, self.frequency,
|
|
self.client.tmp_dir)
|
|
playback_cmd = 'slesTest_playFdPath %s 0' % dut_play_file
|
|
self.client.host.run(playback_cmd)
|
|
else:
|
|
self.client.host.run('slesTest_sawtoothBufferQueue')
|
|
|
|
|
|
def _validate_impl(self, captured_audio_file,
|
|
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).
|
|
"""
|
|
try:
|
|
recorded_peaks = audio_utils.check_wav_file(
|
|
captured_audio_file, num_channels=self.num_channels,
|
|
sample_rate=self.sample_rate,
|
|
sample_width=self.sample_width)
|
|
except ValueError as e:
|
|
raise error.TestFail('Recorded audio file is invalid: %s' % e)
|
|
|
|
max_volume = _max_volume(self.sample_width)
|
|
peak_min = max_volume * peak_percent_min / 100
|
|
peak_max = max_volume * peak_percent_max / 100
|
|
for channel, recorded_peak in enumerate(recorded_peaks):
|
|
if recorded_peak < peak_min:
|
|
logging.error(
|
|
'Recorded audio peak level (%d) is less than expected '
|
|
'(%d) for channel %d', recorded_peak, peak_min, channel)
|
|
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) for channel %d', recorded_peak, peak_max, channel)
|
|
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.')
|
|
if self.use_file:
|
|
audio_utils.compare_file(
|
|
reference_audio_filename=self.reference_filename,
|
|
test_audio_filename=captured_audio_file)
|