| # Lint as: python2, python3 |
| # Copyright 2020 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. |
| |
| """Server side Bluetooth audio tests.""" |
| |
| from __future__ import absolute_import |
| from __future__ import division |
| from __future__ import print_function |
| |
| import logging |
| import os |
| import re |
| import subprocess |
| import time |
| |
| import common |
| from autotest_lib.client.bin import utils |
| from autotest_lib.client.common_lib import error |
| from autotest_lib.client.cros.bluetooth.bluetooth_audio_test_data import ( |
| A2DP, HFP_NBS, HFP_WBS, AUDIO_DATA_TARBALL_PATH, VISQOL_BUFFER_LENGTH, |
| DATA_DIR, VISQOL_PATH, VISQOL_SIMILARITY_MODEL, VISQOL_TEST_DIR, |
| AUDIO_RECORD_DIR, audio_test_data, get_audio_test_data, |
| get_visqol_binary) |
| from autotest_lib.server.cros.bluetooth.bluetooth_adapter_tests import ( |
| BluetoothAdapterTests, test_retry_and_log) |
| from six.moves import range |
| |
| |
| class BluetoothAdapterAudioTests(BluetoothAdapterTests): |
| """Server side Bluetooth adapter audio test class.""" |
| |
| DEVICE_TYPE = 'BLUETOOTH_AUDIO' |
| FREQUENCY_TOLERANCE_RATIO = 0.01 |
| WAIT_DAEMONS_READY_SECS = 1 |
| DEFAULT_CHUNK_IN_SECS = 1 |
| IGNORE_LAST_FEW_CHUNKS = 2 |
| |
| # Useful constant for upsampling NBS files for compatibility with ViSQOL |
| MIN_VISQOL_SAMPLE_RATE = 16000 |
| |
| # The node types of the bluetooth output nodes in cras are the same for both |
| # A2DP and HFP. |
| CRAS_BLUETOOTH_OUTPUT_NODE_TYPE = 'BLUETOOTH' |
| # The node types of the bluetooth input nodes in cras are different for WBS |
| # and NBS. |
| CRAS_HFP_BLUETOOTH_INPUT_NODE_TYPE = {HFP_WBS: 'BLUETOOTH', |
| HFP_NBS: 'BLUETOOTH_NB_MIC'} |
| |
| def _get_pulseaudio_bluez_source(self, get_source_method, device, |
| test_profile): |
| """Get the specified bluez device number in the pulseaudio source list. |
| |
| @param get_source_method: the method to get distinct bluez source |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| @returns: True if the specified bluez source is derived |
| """ |
| sources = device.ListSources(test_profile) |
| logging.debug('ListSources()\n%s', sources) |
| self.bluez_source = get_source_method(test_profile) |
| result = bool(self.bluez_source) |
| if result: |
| logging.debug('bluez_source device number: %s', self.bluez_source) |
| else: |
| logging.debug('waiting for bluez_source ready in pulseaudio...') |
| return result |
| |
| |
| def _get_pulseaudio_bluez_sink(self, get_sink_method, device, test_profile): |
| """Get the specified bluez device number in the pulseaudio sink list. |
| |
| @param get_sink_method: the method to get distinct bluez sink |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| @returns: True if the specified bluez sink is derived |
| """ |
| sinks = device.ListSinks(test_profile) |
| logging.debug('ListSinks()\n%s', sinks) |
| self.bluez_sink = get_sink_method(test_profile) |
| result = bool(self.bluez_sink) |
| if result: |
| logging.debug('bluez_sink device number: %s', self.bluez_sink) |
| else: |
| logging.debug('waiting for bluez_sink ready in pulseaudio...') |
| return result |
| |
| |
| def _get_pulseaudio_bluez_source_a2dp(self, device, test_profile): |
| """Get the a2dp bluez source device number. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| @returns: True if the specified a2dp bluez source is derived |
| """ |
| return self._get_pulseaudio_bluez_source( |
| device.GetBluezSourceA2DPDevice, device, test_profile) |
| |
| |
| def _get_pulseaudio_bluez_source_hfp(self, device, test_profile): |
| """Get the hfp bluez source device number. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| @returns: True if the specified hfp bluez source is derived |
| """ |
| return self._get_pulseaudio_bluez_source( |
| device.GetBluezSourceHFPDevice, device, test_profile) |
| |
| |
| def _get_pulseaudio_bluez_sink_hfp(self, device, test_profile): |
| """Get the hfp bluez sink device number. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| @returns: True if the specified hfp bluez sink is derived |
| """ |
| return self._get_pulseaudio_bluez_sink( |
| device.GetBluezSinkHFPDevice, device, test_profile) |
| |
| |
| def _check_audio_frames_legitimacy(self, audio_test_data, recording_device, |
| recorded_file=None): |
| """Check if audio frames in the recorded file are legitimate. |
| |
| For a wav file, a simple check is to make sure the recorded audio file |
| is not empty. |
| |
| For a raw file, a simple check is to make sure the recorded audio file |
| are not all zeros. |
| |
| @param audio_test_data: a dictionary about the audio test data |
| defined in client/cros/bluetooth/bluetooth_audio_test_data.py |
| @param recording_device: which device recorded the audio, |
| possible values are 'recorded_by_dut' or 'recorded_by_peer' |
| @param recorded_file: the recorded file name |
| |
| @returns: True if audio frames are legitimate. |
| """ |
| result = self.bluetooth_facade.check_audio_frames_legitimacy( |
| audio_test_data, recording_device, recorded_file) |
| if not result: |
| self.results = {'audio_frames_legitimacy': 'empty or all zeros'} |
| logging.error('The recorded audio file is empty or all zeros.') |
| return result |
| |
| |
| def _check_frequency(self, test_profile, recorded_freq, expected_freq): |
| """Check if the recorded frequency is within tolerance. |
| |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| @param recorded_freq: the frequency of recorded audio |
| @param expected_freq: the expected frequency |
| |
| @returns: True if the recoreded frequency falls within the tolerance of |
| the expected frequency |
| """ |
| tolerance = expected_freq * self.FREQUENCY_TOLERANCE_RATIO |
| return abs(expected_freq - recorded_freq) <= tolerance |
| |
| |
| def _check_primary_frequencies(self, test_profile, audio_test_data, |
| recording_device, recorded_file=None): |
| """Check if the recorded frequencies meet expectation. |
| |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| @param audio_test_data: a dictionary about the audio test data |
| defined in client/cros/bluetooth/bluetooth_audio_test_data.py |
| @param recording_device: which device recorded the audio, |
| possible values are 'recorded_by_dut' or 'recorded_by_peer' |
| @param recorded_file: the recorded file name |
| |
| @returns: True if the recorded frequencies of all channels fall within |
| the tolerance of expected frequencies |
| """ |
| recorded_frequencies = self.bluetooth_facade.get_primary_frequencies( |
| audio_test_data, recording_device, recorded_file) |
| expected_frequencies = audio_test_data['frequencies'] |
| final_result = True |
| self.results = dict() |
| |
| if len(recorded_frequencies) < len(expected_frequencies): |
| logging.error('recorded_frequencies: %s, expected_frequencies: %s', |
| str(recorded_frequencies), str(expected_frequencies)) |
| final_result = False |
| else: |
| for channel, expected_freq in enumerate(expected_frequencies): |
| recorded_freq = recorded_frequencies[channel] |
| ret_val = self._check_frequency( |
| test_profile, recorded_freq, expected_freq) |
| pass_fail_str = 'pass' if ret_val else 'fail' |
| result = ('primary frequency %d (expected %d): %s' % |
| (recorded_freq, expected_freq, pass_fail_str)) |
| self.results['Channel %d' % channel] = result |
| logging.info('Channel %d: %s', channel, result) |
| |
| if not ret_val: |
| final_result = False |
| |
| logging.debug(str(self.results)) |
| if not final_result: |
| logging.error('Failure at checking primary frequencies') |
| return final_result |
| |
| |
| def _poll_for_condition(self, condition, timeout=20, sleep_interval=1, |
| desc='waiting for condition'): |
| try: |
| utils.poll_for_condition(condition=condition, |
| timeout=timeout, |
| sleep_interval=sleep_interval, |
| desc=desc) |
| except Exception as e: |
| raise error.TestError('Exception occurred when %s (%s)' % (desc, e)) |
| |
| |
| def initialize_bluetooth_audio(self, device, test_profile): |
| """Initialize the Bluetooth audio task. |
| |
| Note: pulseaudio is not stable. Need to restart it in the beginning. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| """ |
| if not self.bluetooth_facade.create_audio_record_directory( |
| AUDIO_RECORD_DIR): |
| raise error.TestError('Failed to create %s on the DUT' % |
| AUDIO_RECORD_DIR) |
| |
| if not device.StartPulseaudio(test_profile): |
| raise error.TestError('Failed to start pulseaudio.') |
| logging.debug('pulseaudio is started.') |
| |
| if test_profile in (HFP_WBS, HFP_NBS): |
| if device.StartOfono(): |
| logging.debug('ofono is started.') |
| else: |
| raise error.TestError('Failed to start ofono.') |
| elif device.StopOfono(): |
| logging.debug('ofono is stopped.') |
| else: |
| logging.warn('Failed to stop ofono. Ignored.') |
| |
| # Need time to complete starting services. |
| time.sleep(self.WAIT_DAEMONS_READY_SECS) |
| |
| |
| def cleanup_bluetooth_audio(self, device, test_profile): |
| """Cleanup for Bluetooth audio. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, A2DP, HFP_WBS or HFP_NBS |
| |
| """ |
| if device.StopPulseaudio(): |
| logging.debug('pulseaudio is stopped.') |
| else: |
| logging.warn('Failed to stop pulseaudio. Ignored.') |
| |
| if device.StopOfono(): |
| logging.debug('ofono is stopped.') |
| else: |
| logging.warn('Failed to stop ofono. Ignored.') |
| |
| |
| def initialize_bluetooth_player(self, device): |
| """Initialize the Bluetooth media player. |
| |
| @param device: the Bluetooth peer device. |
| |
| """ |
| if not device.ExportMediaPlayer(): |
| raise error.TestError('Failed to export media player.') |
| logging.debug('mpris-proxy is started.') |
| |
| # Wait for player to show up and observed by playerctl. |
| desc='waiting for media player' |
| self._poll_for_condition( |
| lambda: bool(device.GetExportedMediaPlayer()), desc=desc) |
| |
| |
| def cleanup_bluetooth_player(self, device): |
| """Cleanup for Bluetooth media player. |
| |
| @param device: the bluetooth peer device. |
| |
| """ |
| device.UnexportMediaPlayer() |
| |
| |
| def select_audio_output_node(self): |
| """Select the audio output node through cras. |
| |
| @raises: error.TestError if failed. |
| """ |
| def bluetooth_type_selected(node_type): |
| """Check if the bluetooth node type is selected.""" |
| selected = self.bluetooth_facade.get_selected_output_device_type() |
| logging.debug('active output node type: %s, expected %s', |
| selected, node_type) |
| return selected == node_type |
| |
| node_type = self.CRAS_BLUETOOTH_OUTPUT_NODE_TYPE |
| if not self.bluetooth_facade.select_output_node(node_type): |
| raise error.TestError('select_output_node failed') |
| |
| desc='waiting for %s as active cras audio output node type' % node_type |
| logging.debug(desc) |
| self._poll_for_condition(lambda: bluetooth_type_selected(node_type), |
| desc=desc) |
| |
| |
| def initialize_hfp(self, device, test_profile, test_data, |
| recording_device, bluez_function): |
| """Initial set up for hfp tests. |
| |
| Setup that is required for all hfp tests where |
| dut is either source or sink. Selects input device, starts recording, |
| and lastly it waits for pulseaudio bluez source/sink. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, HFP_WBS or HFP_NBS |
| @param test_data: a dictionary about the audio test data defined in |
| client/cros/bluetooth/bluetooth_audio_test_data.py |
| @param recording_device: which device recorded the audio, possible |
| values are 'recorded_by_dut' or 'recorded_by_peer' |
| @param bluez_function: the appropriate bluez hfp function either |
| _get_pulseaudio_bluez_source_hfp or |
| _get_pulseaudio_bluez_sink_hfp depending on the role of the dut |
| """ |
| device_type = 'DUT' if recording_device == 'recorded_by_dut' else 'Peer' |
| dut_role = 'sink' if recording_device == 'recorded_by_dut' else 'source' |
| |
| # Select audio input device. |
| desc = 'waiting for cras to select audio input device' |
| logging.debug(desc) |
| self._poll_for_condition( |
| lambda: self.bluetooth_facade.select_input_device(device.name), |
| desc=desc) |
| |
| # Select audio output node so that we do not rely on chrome to do it. |
| self.select_audio_output_node() |
| |
| # Enable HFP profile. |
| logging.debug('Start recording audio on {}'.format(device_type)) |
| if not self.bluetooth_facade.start_capturing_audio_subprocess( |
| test_data, recording_device): |
| desc = '{} failed to start capturing audio.'.format(device_type) |
| raise error.TestError(desc) |
| |
| # Wait for pulseaudio bluez hfp source/sink |
| desc = 'waiting for pulseaudio bluez hfp {}'.format(dut_role) |
| logging.debug(desc) |
| self._poll_for_condition(lambda: bluez_function(device, test_profile), |
| desc=desc) |
| |
| |
| def hfp_record_on_dut(self, device, test_profile, test_data): |
| """Play audio from test_data dictionary from peer device to dut. |
| |
| Play file described in test_data dictionary from peer device to dut |
| using test_profile, either HFP_WBS or HFP_NBS and record on dut. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the test profile used, HFP_WBS or HFP_NBS |
| @param test_data: a dictionary about the audio test data defined in |
| client/cros/bluetooth/bluetooth_audio_test_data.py |
| |
| @returns: True if the recorded audio frames are legitimate, False |
| if they are not, ie. it did not record. |
| """ |
| # Select audio input device. |
| logging.debug('Select input device') |
| if not self.bluetooth_facade.select_input_device(device.name): |
| raise error.TestError('DUT failed to select audio input device.') |
| |
| # Start playing audio on chameleon. |
| logging.debug('Start playing audio on Pi') |
| if not device.StartPlayingAudioSubprocess(test_profile, test_data): |
| err = 'Failed to start playing audio file on the peer device' |
| raise error.TestError(err) |
| |
| time.sleep(test_data['duration']) |
| |
| # Stop playing audio on chameleon. |
| logging.debug('Stop playing audio on Pi') |
| if not device.StopPlayingAudioSubprocess(): |
| err = 'Failed to stop playing audio on the peer device' |
| raise error.TestError(err) |
| |
| # Disable HFP profile. |
| logging.debug('Stop recording audio on DUT') |
| if not self.bluetooth_facade.stop_capturing_audio_subprocess(): |
| raise error.TestError('DUT failed to stop capturing audio.') |
| |
| # Check if the audio frames in the recorded file are legitimate. |
| return self._check_audio_frames_legitimacy(test_data, 'recorded_by_dut') |
| |
| |
| def hfp_record_on_peer(self, device, test_profile, test_data): |
| """Play audio from test_data dictionary from dut to peer device. |
| |
| Play file described in test_data dictionary from dut to peer device |
| using test_profile, either HFP_WBS or HFP_NBS and record on peer. |
| |
| @param device: The bluetooth peer device. |
| @param test_profile: The test profile used, HFP_WBS or HFP_NBS. |
| @param test_data: A dictionary about the audio test data defined in |
| client/cros/bluetooth/bluetooth_audio_test_data.py. |
| |
| @returns: True if the recorded audio frames are legitimate, False |
| if they are not, ie. it did not record. |
| """ |
| logging.debug('Start recording audio on Pi') |
| # Start recording audio on the peer Bluetooth audio device. |
| if not device.StartRecordingAudioSubprocess(test_profile, test_data): |
| raise error.TestError( |
| 'Failed to record on the peer Bluetooth audio device.') |
| |
| # Play audio on the DUT in a non-blocked way. |
| # If there are issues, cras_test_client playing back might be blocked |
| # forever. We would like to avoid the testing procedure from that. |
| logging.debug('Start playing audio') |
| if not self.bluetooth_facade.start_playing_audio_subprocess(test_data): |
| raise error.TestError('DUT failed to play audio.') |
| |
| time.sleep(test_data['duration']) |
| |
| logging.debug('Stop recording audio on Pi') |
| # Stop recording audio on the peer Bluetooth audio device. |
| if not device.StopRecordingingAudioSubprocess(): |
| msg = 'Failed to stop recording on the peer Bluetooth audio device' |
| logging.error(msg) |
| |
| # Disable HFP profile. |
| logging.debug('Stop recording audio on DUT') |
| if not self.bluetooth_facade.stop_capturing_audio_subprocess(): |
| raise error.TestError('DUT failed to stop capturing audio.') |
| |
| # Stop playing audio on DUT. |
| logging.debug('Stop playing audio on DUT') |
| if not self.bluetooth_facade.stop_playing_audio_subprocess(): |
| raise error.TestError('DUT failed to stop playing audio.') |
| |
| # Copy the recorded audio file to the DUT for spectrum analysis. |
| logging.debug('Scp to DUT') |
| recorded_file = test_data['recorded_by_peer'] |
| device.ScpToDut(recorded_file, recorded_file, self.host.ip) |
| |
| # Check if the audio frames in the recorded file are legitimate. |
| return self._check_audio_frames_legitimacy(test_data, |
| 'recorded_by_peer') |
| |
| |
| def parse_visqol_output(self, stdout, stderr): |
| """ |
| Parse stdout and stderr string from VISQOL output and parse into |
| a float score. |
| |
| On error, stderr will contain the error message, otherwise will be None. |
| On success, stdout will be a string, first line will be |
| VISQOL version, followed by indication of speech mode. Followed by |
| paths to reference and degraded file, and a float MOS-LQO score, which |
| is what we're interested in. Followed by more detailed charts about |
| specific scoring by segments of the files. Stdout is None on error. |
| |
| @param stdout: The stdout bytes from commandline output of VISQOL. |
| @param stderr: The stderr bytes from commandline output of VISQOL. |
| |
| @returns: A tuple of a float score and string representation of the |
| srderr or None if there was no error. |
| """ |
| string_out = stdout or '' |
| |
| # Log verbose VISQOL output: |
| log_file = os.path.join(VISQOL_TEST_DIR, 'VISQOL_LOG.txt') |
| with open(log_file, 'w+') as f: |
| f.write('String Error:\n{}\n'.format(stderr)) |
| f.write('String Out:\n{}\n'.format(stdout)) |
| |
| # pattern matches first float or int after 'MOS-LQO:' in stdout, |
| # e.g. it would match the line 'MOS-LQO 2.3' in the stdout |
| score_pattern = re.compile(r'.*MOS-LQO:\s*(\d+.?\d*)') |
| score_search = re.search(score_pattern, string_out) |
| |
| # re.search returns None if no pattern match found, otherwise the score |
| # would be in the match object's group 1 matches just the float score |
| score = float(score_search.group(1)) if score_search else -1.0 |
| return stderr, score |
| |
| |
| def get_visqol_score(self, ref_file, deg_file, speech_mode=True, |
| verbose=True): |
| """ |
| Runs VISQOL using the subprocess library on the provided reference file |
| and degraded file and returns the VISQOL score. |
| |
| @param ref_file: File path to the reference wav file. |
| @param deg_file: File path to the degraded wav file. |
| @param speech_mode: [Optional] Defaults to True, accepts 16k sample |
| rate files and ignores frequencies > 8kHz for scoring. |
| @param verbose: [Optional] Defaults to True, outputs more details. |
| |
| @returns: A float score for the tested file. |
| """ |
| visqol_cmd = [VISQOL_PATH] |
| visqol_cmd += ['--reference_file', ref_file] |
| visqol_cmd += ['--degraded_file', deg_file] |
| visqol_cmd += ['--similarity_to_quality_model', VISQOL_SIMILARITY_MODEL] |
| |
| if speech_mode: |
| visqol_cmd.append('--use_speech_mode') |
| if verbose: |
| visqol_cmd.append('--verbose') |
| |
| visqol_process = subprocess.Popen(visqol_cmd, stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE) |
| stdout, stderr = visqol_process.communicate() |
| |
| err, score = self.parse_visqol_output(stdout, stderr) |
| |
| if err: |
| raise error.TestError(err) |
| elif score < 0.0: |
| raise error.TestError('Failed to parse score, got {}'.format(score)) |
| |
| return score |
| |
| |
| def get_ref_and_deg_files(self, trimmed_file, test_profile, test_data): |
| """Return path for reference and degraded files to run visqol on. |
| |
| @param trimmed_file: Path to the trimmed audio file on DUT. |
| @param test_profile: The test profile used HFP_WBS or HFP_NBS. |
| @param test_data: A dictionary about the audio test data defined in |
| client/cros/bluetooth/bluetooth_audio_test_data.py. |
| |
| @returns: A tuple of path to the reference file and degraded file if |
| they exist, otherwise False for the files that aren't available. |
| """ |
| # Path in autotest server in ViSQOL folder to store degraded file from |
| # retrieved from the DUT |
| deg_file = os.path.join(VISQOL_TEST_DIR, os.path.split(trimmed_file)[1]) |
| played_file = test_data['file'] |
| # If profile is WBS, no resampling required |
| if test_profile == HFP_WBS: |
| self.host.get_file(trimmed_file, deg_file) |
| return played_file, deg_file |
| |
| # On NBS, degraded and reference files need to be resampled to 16 kHz |
| # Build path for the upsampled (us) reference (ref) file on DUT |
| ref_file = '{}_us_ref{}'.format(*os.path.splitext(played_file)) |
| # If resampled ref file already exists, don't need to do it again |
| if not os.path.isfile(ref_file): |
| if not self.bluetooth_facade.convert_audio_sample_rate( |
| played_file, ref_file, test_data, |
| self.MIN_VISQOL_SAMPLE_RATE): |
| return False, False |
| # Move upsampled reference file to autotest server |
| self.host.get_file(ref_file, ref_file) |
| |
| # Build path for resampled degraded file on DUT |
| deg_on_dut = '{}_us{}'.format(*os.path.splitext(trimmed_file)) |
| # Resample degraded file to 16 kHz and move to autotest server |
| if not self.bluetooth_facade.convert_audio_sample_rate( |
| trimmed_file, deg_on_dut, test_data, |
| self.MIN_VISQOL_SAMPLE_RATE): |
| return ref_file, False |
| |
| self.host.get_file(deg_on_dut, deg_file) |
| |
| return ref_file, deg_file |
| |
| |
| def format_recorded_file(self, test_data, test_profile, recording_device): |
| """Format recorded files to be compatible with ViSQOL. |
| |
| Convert raw files to wav if recorded file is a raw file, trim file to |
| duration, if required, resample the file, then lastly return the paths |
| for the reference file and degraded file on the autotest server. |
| |
| @param test_data: A dictionary about the audio test data defined in |
| client/cros/bluetooth/bluetooth_audio_test_data.py. |
| @param test_profile: The test profile used, HFP_WBS or HFP_NBS. |
| @param recording_device: Which device recorded the audio, either |
| 'recorded_by_dut' or 'recorded_by_peer'. |
| |
| @returns: A tuple of path to the reference file and degraded file if |
| they exist, otherwise False for the files that aren't available. |
| """ |
| # Path to recorded file either on DUT or BT peer |
| recorded_file = test_data[recording_device] |
| untrimmed_file = recorded_file |
| if recorded_file.endswith('.raw'): |
| # build path for file converted from raw to wav, i.e. change the ext |
| untrimmed_file = os.path.splitext(recorded_file)[0] + '.wav' |
| if not self.bluetooth_facade.convert_raw_to_wav( |
| recorded_file, untrimmed_file, test_data): |
| raise error.TestError('Could not convert raw file to wav') |
| |
| # Compute the duration of played file without added buffer |
| new_duration = test_data['duration'] - VISQOL_BUFFER_LENGTH |
| # build path for file resulting from trimming to desired duration |
| trimmed_file = '{}_t{}'.format(*os.path.splitext(untrimmed_file)) |
| if not self.bluetooth_facade.trim_wav_file( |
| untrimmed_file, trimmed_file, new_duration, test_data): |
| raise error.TestError('Failed to trim recorded file') |
| |
| return self.get_ref_and_deg_files(trimmed_file, test_profile, test_data) |
| |
| |
| def handle_chunks(self, device, test_profile, test_data, duration): |
| """Handle chunks of recorded streams and verify the primary frequencies. |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the a2dp test profile; |
| choices are A2DP and A2DP_LONG |
| @param test_data: the test data of the test profile |
| @param duration: the duration of the audio file to test |
| |
| @returns: True if all chunks pass the frequencies check. |
| """ |
| chunk_in_secs = test_data['chunk_in_secs'] |
| if not bool(chunk_in_secs): |
| chunk_in_secs = self.DEFAULT_CHUNK_IN_SECS |
| nchunks = duration // chunk_in_secs |
| logging.info('Number of chunks: %d', nchunks) |
| |
| all_chunks_test_result = True |
| for i in range(nchunks): |
| logging.info('Handle chunk %d', i) |
| recorded_file = device.HandleOneChunk(chunk_in_secs, i, |
| test_profile, self.host.ip) |
| if recorded_file is None: |
| raise error.TestError('Failed to handle chunk %d' % i) |
| |
| # Check if the audio frames in the recorded file are legitimate. |
| if not self._check_audio_frames_legitimacy( |
| test_data, 'recorded_by_peer', recorded_file=recorded_file): |
| if (i > self.IGNORE_LAST_FEW_CHUNKS and |
| i >= nchunks - self.IGNORE_LAST_FEW_CHUNKS): |
| logging.info('empty chunk %d ignored for last %d chunks', |
| i, self.IGNORE_LAST_FEW_CHUNKS) |
| else: |
| all_chunks_test_result = False |
| break |
| |
| # Check if the primary frequencies of the recorded file |
| # meet expectation. |
| if not self._check_primary_frequencies(A2DP, test_data, |
| 'recorded_by_peer', |
| recorded_file=recorded_file): |
| if (i > self.IGNORE_LAST_FEW_CHUNKS and |
| i >= nchunks - self.IGNORE_LAST_FEW_CHUNKS): |
| msg = 'partially filled chunk %d ignored for last %d chunks' |
| logging.info(msg, i, self.IGNORE_LAST_FEW_CHUNKS) |
| else: |
| all_chunks_test_result = False |
| break |
| |
| return all_chunks_test_result |
| |
| |
| # --------------------------------------------------------------- |
| # Definitions of all bluetooth audio test cases |
| # --------------------------------------------------------------- |
| |
| |
| @test_retry_and_log(False) |
| def test_hfp_dut_as_source_visqol_score(self, device, test_profile): |
| """Test Case: hfp test files streaming from peer device to dut |
| |
| @param device: the bluetooth peer device |
| @param test_profile: which test profile is used, HFP_WBS or HFP_NBS |
| |
| @returns: True if the all the test files score at or above their |
| source_passing_score value as defined in |
| bluetooth_audio_test_data.py |
| """ |
| # list of test wav files |
| hfp_test_data = audio_test_data[test_profile] |
| test_files = hfp_test_data['visqol_test_files'] |
| |
| get_visqol_binary() |
| get_audio_test_data() |
| |
| # Download test data to DUT |
| self.host.send_file(AUDIO_DATA_TARBALL_PATH, AUDIO_DATA_TARBALL_PATH) |
| if not self.bluetooth_facade.unzip_audio_test_data( |
| AUDIO_DATA_TARBALL_PATH, DATA_DIR): |
| logging.error('Audio data directory not found in DUT') |
| raise error.TestError('Failed to unzip audio test data to DUT') |
| |
| # Result of visqol test on all files |
| visqol_results = dict() |
| |
| for test_file in test_files: |
| filename = os.path.split(test_file['file'])[1] |
| logging.debug('Testing file: {}'.format(filename)) |
| |
| # Set up hfp test to record on peer |
| self.initialize_hfp(device, test_profile, test_file, |
| 'recorded_by_peer', |
| self._get_pulseaudio_bluez_source_hfp) |
| logging.debug('Initialized HFP') |
| |
| if not self.hfp_record_on_peer(device, test_profile, test_file): |
| return False |
| logging.debug('Recorded {} successfully'.format(filename)) |
| |
| ref_file, deg_file = self.format_recorded_file(test_file, |
| test_profile, |
| 'recorded_by_peer') |
| if not ref_file or not deg_file: |
| desc = 'Failed to get ref and deg file: ref {}, deg {}'.format( |
| ref_file, deg_file) |
| raise error.TestError(desc) |
| |
| score = self.get_visqol_score(ref_file, deg_file, |
| speech_mode=test_file['speech_mode']) |
| |
| logging.info('{} scored {}, min passing score: {}'.format( |
| filename, score, test_file['source_passing_score'])) |
| passed = score >= test_file['source_passing_score'] |
| visqol_results[filename] = passed |
| |
| if not passed: |
| logging.warning('Failed: {}'.format(filename)) |
| |
| return all(visqol_results.values()) |
| |
| |
| @test_retry_and_log(False) |
| def test_hfp_dut_as_sink_visqol_score(self, device, test_profile): |
| """Test Case: hfp test files streaming from peer device to dut |
| |
| @param device: the bluetooth peer device |
| @param test_profile: which test profile is used, HFP_WBS or HFP_NBS |
| |
| @returns: True if the all the test files score at or above their |
| sink_passing_score value as defined in |
| bluetooth_audio_test_data.py |
| """ |
| # list of test wav files |
| hfp_test_data = audio_test_data[test_profile] |
| test_files = hfp_test_data['visqol_test_files'] |
| |
| get_visqol_binary() |
| get_audio_test_data() |
| self.host.send_file(AUDIO_DATA_TARBALL_PATH, AUDIO_DATA_TARBALL_PATH) |
| if not self.bluetooth_facade.unzip_audio_test_data( |
| AUDIO_DATA_TARBALL_PATH, DATA_DIR): |
| logging.error('Audio data directory not found in DUT') |
| raise error.TestError('Failed to unzip audio test data to DUT') |
| |
| # Result of visqol test on all files |
| visqol_results = dict() |
| |
| for test_file in test_files: |
| filename = os.path.split(test_file['file'])[1] |
| logging.debug('Testing file: {}'.format(filename)) |
| |
| # Set up hfp test to record on dut |
| self.initialize_hfp(device, test_profile, test_file, |
| 'recorded_by_dut', |
| self._get_pulseaudio_bluez_sink_hfp) |
| logging.debug('Initialized HFP') |
| # Record audio on dut played from pi, returns true if anything |
| # was successfully recorded, false otherwise |
| if not self.hfp_record_on_dut(device, test_profile, test_file): |
| return False |
| logging.debug('Recorded {} successfully'.format(filename)) |
| |
| ref_file, deg_file = self.format_recorded_file(test_file, |
| test_profile, |
| 'recorded_by_dut') |
| if not ref_file or not deg_file: |
| desc = 'Failed to get ref and deg file: ref {}, deg {}'.format( |
| ref_file, deg_file) |
| raise error.TestError(desc) |
| |
| score = self.get_visqol_score(ref_file, deg_file, |
| speech_mode=test_file['speech_mode']) |
| |
| logging.info('{} scored {}, min passing score: {}'.format( |
| filename, score, test_file['sink_passing_score'])) |
| passed = score >= test_file['sink_passing_score'] |
| visqol_results[filename] = passed |
| |
| if not passed: |
| logging.warning('Failed: {}'.format(filename)) |
| |
| return all(visqol_results.values()) |
| |
| @test_retry_and_log(False) |
| def test_device_a2dp_connected(self, device, timeout=15): |
| """ Tests a2dp profile is connected on device. """ |
| self.results = {} |
| check_connection = lambda: self._get_pulseaudio_bluez_source_a2dp( |
| device, A2DP) |
| is_connected = self._wait_for_condition(check_connection, |
| 'test_device_a2dp_connected', |
| timeout=timeout) |
| self.results['peer a2dp connected'] = is_connected |
| |
| return all(self.results.values()) |
| |
| @test_retry_and_log(False) |
| def test_a2dp_sinewaves(self, device, test_profile, duration): |
| """Test Case: a2dp sinewaves |
| |
| @param device: the bluetooth peer device |
| @param test_profile: the a2dp test profile; |
| choices are A2DP and A2DP_LONG |
| @param duration: the duration of the audio file to test |
| 0 means to use the default value in the test profile |
| |
| @returns: True if the recorded primary frequency is within the |
| tolerance of the playback sine wave frequency. |
| |
| """ |
| # Make a copy since the test_data may be formatted with distinct |
| # arguments in the follow-up tests. |
| test_data = audio_test_data[test_profile].copy() |
| if bool(duration): |
| test_data['duration'] = duration |
| else: |
| duration = test_data['duration'] |
| |
| test_data['file'] %= duration |
| logging.info('%s test for %d seconds.', test_profile, duration) |
| |
| # Wait for pulseaudio a2dp bluez source |
| desc = 'waiting for pulseaudio a2dp bluez source' |
| logging.debug(desc) |
| self._poll_for_condition( |
| lambda: self._get_pulseaudio_bluez_source_a2dp(device, |
| test_profile), |
| desc=desc) |
| |
| # Select audio output node so that we do not rely on chrome to do it. |
| self.select_audio_output_node() |
| |
| # Start recording audio on the peer Bluetooth audio device. |
| logging.debug('Start recording a2dp') |
| if not device.StartRecordingAudioSubprocess(test_profile, test_data): |
| raise error.TestError( |
| 'Failed to record on the peer Bluetooth audio device.') |
| |
| # Play audio on the DUT in a non-blocked way and check the recorded |
| # audio stream in a real-time manner. |
| logging.debug('Start playing audio') |
| if not self.bluetooth_facade.start_playing_audio_subprocess(test_data): |
| raise error.TestError('DUT failed to play audio.') |
| |
| # Handle chunks of recorded streams and verify the primary frequencies. |
| # This is a blocking call until all chunks are completed. |
| all_chunks_test_result = self.handle_chunks(device, test_profile, |
| test_data, duration) |
| |
| # Stop recording audio on the peer Bluetooth audio device. |
| logging.debug('Stop recording a2dp') |
| if not device.StopRecordingingAudioSubprocess(): |
| msg = 'Failed to stop recording on the peer Bluetooth audio device' |
| logging.error(msg) |
| |
| # Stop playing audio on DUT. |
| logging.debug('Stop playing audio on DUT') |
| if not self.bluetooth_facade.stop_playing_audio_subprocess(): |
| raise error.TestError('DUT failed to stop playing audio.') |
| |
| return all_chunks_test_result |
| |
| @test_retry_and_log(False) |
| def test_hfp_dut_as_source(self, device, test_profile): |
| """Test Case: hfp sinewave streaming from dut to peer device |
| |
| @param device: the bluetooth peer device |
| @param test_profile: which test profile is used, HFP_WBS or HFP_NBS |
| |
| @returns: True if the recorded primary frequency is within the |
| tolerance of the playback sine wave frequency. |
| """ |
| hfp_test_data = audio_test_data[test_profile] |
| |
| self.initialize_hfp(device, test_profile, hfp_test_data, |
| 'recorded_by_peer', |
| self._get_pulseaudio_bluez_source_hfp) |
| |
| if not self.hfp_record_on_peer(device, test_profile, hfp_test_data): |
| return False |
| |
| # Check if the primary frequencies of recorded file meet expectation. |
| check_freq_result = self._check_primary_frequencies( |
| test_profile, hfp_test_data, 'recorded_by_peer') |
| return check_freq_result |
| |
| |
| @test_retry_and_log(False) |
| def test_hfp_dut_as_sink(self, device, test_profile): |
| """Test Case: hfp sinewave streaming from peer device to dut |
| |
| @param device: the bluetooth peer device |
| @param test_profile: which test profile is used, HFP_WBS or HFP_NBS |
| |
| @returns: True if the recorded primary frequency is within the |
| tolerance of the playback sine wave frequency. |
| |
| """ |
| hfp_test_data = audio_test_data[test_profile] |
| |
| # Set up hfp test to record on dut |
| self.initialize_hfp(device, test_profile, hfp_test_data, |
| 'recorded_by_dut', |
| self._get_pulseaudio_bluez_sink_hfp) |
| |
| # Record audio on dut play from pi, returns true if anything recorded |
| if not self.hfp_record_on_dut(device, test_profile, hfp_test_data): |
| return False |
| |
| # Check if the primary frequencies of recorded file meet expectation. |
| check_freq_result = self._check_primary_frequencies( |
| test_profile, hfp_test_data, 'recorded_by_dut') |
| return check_freq_result |
| |
| |
| @test_retry_and_log(False) |
| def test_avrcp_commands(self, device): |
| """Test Case: Test AVRCP commands issued by peer can be received at DUT |
| |
| The very first AVRCP command (Linux evdev event) the DUT receives |
| contains extra information than just the AVRCP event, e.g. EV_REP |
| report used to specify delay settings. Send the first command before |
| the actual test starts to avoid dealing with them during test. |
| |
| The peer device name is required to monitor the event reception on the |
| DUT. However, as the peer device itself already registered with the |
| kernel as an udev input device. The AVRCP profile will register as an |
| separate input device with the name pattern: name + (AVRCP), e.g. |
| RASPI_AUDIO (AVRCP). Using 'AVRCP' as device name to help search for |
| the device. |
| |
| @param device: the Bluetooth peer device |
| |
| @returns: True if the all AVRCP commands received by DUT, false |
| otherwise |
| |
| """ |
| device.SendMediaPlayerCommand('play') |
| |
| name = device.name |
| device.name = 'AVRCP' |
| |
| result_pause = self.test_avrcp_event(device, |
| device.SendMediaPlayerCommand, 'pause') |
| result_play = self.test_avrcp_event(device, |
| device.SendMediaPlayerCommand, 'play') |
| result_stop = self.test_avrcp_event(device, |
| device.SendMediaPlayerCommand, 'stop') |
| result_next = self.test_avrcp_event(device, |
| device.SendMediaPlayerCommand, 'next') |
| result_previous = self.test_avrcp_event(device, |
| device.SendMediaPlayerCommand, 'previous') |
| |
| device.name = name |
| self.results = {'pause': result_pause, 'play': result_play, |
| 'stop': result_stop, 'next': result_next, |
| 'previous': result_previous} |
| return all(self.results.values()) |
| |
| |
| @test_retry_and_log(False) |
| def test_avrcp_media_info(self, device): |
| """Test Case: Test AVRCP media info sent by DUT can be received by peer |
| |
| The test update all media information twice to prevent previous |
| leftover data affect the current iteration of test. Then compare the |
| expected results against the information received on the peer device. |
| |
| This test verifies media information including: playback status, |
| length, title, artist, and album. Position of the media is not |
| currently support as playerctl on the peer side cannot correctly |
| retrieve such information. |
| |
| Length and position information are transmitted in the unit of |
| microsecond. However, BlueZ process those time data in the resolution |
| of millisecond. Discard microsecond detail when comparing those media |
| information. |
| |
| @param device: the Bluetooth peer device |
| |
| @returns: True if the all AVRCP media info received by DUT, false |
| otherwise |
| |
| """ |
| # First round of updating media information to overwrite all leftovers. |
| init_status = 'stopped' |
| init_length = 20200414 |
| init_position = 8686868 |
| init_metadata = {'album': 'metadata_album_init', |
| 'artist': 'metadata_artist_init', |
| 'title': 'metadata_title_init'} |
| self.bluetooth_facade.set_player_playback_status(init_status) |
| self.bluetooth_facade.set_player_length(init_length) |
| self.bluetooth_facade.set_player_position(init_position) |
| self.bluetooth_facade.set_player_metadata(init_metadata) |
| |
| # Second round of updating for actual testing. |
| expected_status = 'playing' |
| expected_length = 68686868 |
| expected_position = 20200414 |
| expected_metadata = {'album': 'metadata_album_expected', |
| 'artist': 'metadata_artist_expected', |
| 'title': 'metadata_title_expected'} |
| self.bluetooth_facade.set_player_playback_status(expected_status) |
| self.bluetooth_facade.set_player_length(expected_length) |
| self.bluetooth_facade.set_player_position(expected_position) |
| self.bluetooth_facade.set_player_metadata(expected_metadata) |
| |
| received_media_info = device.GetMediaPlayerMediaInfo() |
| logging.debug(received_media_info) |
| |
| try: |
| actual_length = int(received_media_info.get('length')) |
| except: |
| actual_length = 0 |
| |
| result_status = bool(expected_status == |
| received_media_info.get('status').lower()) |
| result_album = bool(expected_metadata['album'] == |
| received_media_info.get('album')) |
| result_artist = bool(expected_metadata['artist'] == |
| received_media_info.get('artist')) |
| result_title = bool(expected_metadata['title'] == |
| received_media_info.get('title')) |
| # The AVRCP time information is in the unit of microseconds but with |
| # milliseconds resolution. Convert both send and received length into |
| # milliseconds for comparison. |
| result_length = bool(expected_length // 1000 == actual_length // 1000) |
| |
| self.results = {'status': result_status, 'album': result_album, |
| 'artist': result_artist, 'title': result_title, |
| 'length': result_length} |
| return all(self.results.values()) |