From fdd568eb25d2211a4702fd69a604f66bd561101f Mon Sep 17 00:00:00 2001 From: alessiob Date: Tue, 1 Aug 2017 04:37:21 -0700 Subject: [PATCH] This CL is a refactoring of the APM QA tool; it includes the following changes: - render stream support, required to assess AEC; - echo path simulation and input mixer, to generate echo and add it to the speech signal; - export engine: improved UI, switch to Pandas DataFrames; - minor design improvements and needed adaptions. BUG=webrtc:7218 Review-Url: https://codereview.webrtc.org/2813883002 Cr-Commit-Position: refs/heads/master@{#19198} --- .../test/py_quality_assessment/BUILD.gn | 5 + .../test/py_quality_assessment/README.md | 22 +- .../apm_quality_assessment.py | 32 +- .../apm_quality_assessment.sh | 19 +- .../apm_quality_assessment_export.py | 218 ++++++--- .../quality_assessment/audioproc_wrapper.py | 22 +- .../quality_assessment/data_access.py | 16 +- .../echo_path_simulation.py | 136 ++++++ .../echo_path_simulation_factory.py | 48 ++ .../echo_path_simulation_unittest.py | 81 +++ .../quality_assessment/eval_scores.py | 56 ++- .../quality_assessment/eval_scores_factory.py | 12 +- .../eval_scores_unittest.py | 1 + .../quality_assessment/exceptions.py | 6 + .../quality_assessment/export.py | 460 ++++++++++-------- .../quality_assessment/input_mixer.py | 93 ++++ .../input_mixer_unittest.py | 149 ++++++ .../quality_assessment/results.css | 84 +--- .../quality_assessment/results.js | 348 +++++++------ .../quality_assessment/signal_processing.py | 104 +++- .../quality_assessment/simulation.py | 234 +++++++-- .../quality_assessment/simulation_unittest.py | 2 +- .../test_data_generation.py | 66 ++- .../test_data_generation_factory.py | 11 +- .../test_data_generation_unittest.py | 9 +- 25 files changed, 1612 insertions(+), 622 deletions(-) create mode 100644 webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation.py create mode 100644 webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_factory.py create mode 100644 webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_unittest.py create mode 100644 webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer.py create mode 100644 webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer_unittest.py diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/BUILD.gn b/webrtc/modules/audio_processing/test/py_quality_assessment/BUILD.gn index 44fbd2ffdb..72ec187b9c 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/BUILD.gn +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/BUILD.gn @@ -54,11 +54,14 @@ copy("lib") { "quality_assessment/__init__.py", "quality_assessment/audioproc_wrapper.py", "quality_assessment/data_access.py", + "quality_assessment/echo_path_simulation.py", + "quality_assessment/echo_path_simulation_factory.py", "quality_assessment/eval_scores.py", "quality_assessment/eval_scores_factory.py", "quality_assessment/evaluation.py", "quality_assessment/exceptions.py", "quality_assessment/export.py", + "quality_assessment/input_mixer.py", "quality_assessment/results.css", "quality_assessment/results.js", "quality_assessment/signal_processing.py", @@ -112,7 +115,9 @@ rtc_executable("fake_polqa") { copy("lib_unit_tests") { testonly = true sources = [ + "quality_assessment/echo_path_simulation_unittest.py", "quality_assessment/eval_scores_unittest.py", + "quality_assessment/input_mixer_unittest.py", "quality_assessment/signal_processing_unittest.py", "quality_assessment/simulation_unittest.py", "quality_assessment/test_data_generation_unittest.py", diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/README.md b/webrtc/modules/audio_processing/test/py_quality_assessment/README.md index 140ed45fd6..981f31555a 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/README.md +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/README.md @@ -11,7 +11,7 @@ reference one used for evaluation. - OS: Linux - Python 2.7 - - Python libraries: numpy, scipy, pydub (0.17.0+) + - Python libraries: numpy, scipy, pydub (0.17.0+), pandas (0.20.1+) - It is recommended that a dedicated Python environment is used - install `virtualenv` - `$ sudo apt-get install python-virtualenv` @@ -20,7 +20,7 @@ reference one used for evaluation. - activate the new Python environment - `$ source ~/my_env/bin/activate` - add dependcies via `pip` - - `(my_env)$ pip install numpy pydub scipy` + - `(my_env)$ pip install numpy pydub scipy pandas` - PolqaOem64 (see http://www.polqa.info/) - Tested with POLQA Library v1.180 / P863 v2.400 - Aachen Impulse Response (AIR) Database @@ -68,22 +68,24 @@ Showing all the results at once can be confusing. You therefore may want to export separate reports. In this case, you can use the `apm_quality_assessment_export.py` script as follows: - - Set --output_dir to the same value used in `apm_quality_assessment.sh` + - Set `--output_dir, -o` to the same value used in `apm_quality_assessment.sh` - Use regular expressions to select/filter out scores by - APM configurations: `--config_names, -c` - - probing signals: `--input_names, -i` + - capture signals: `--capture_names, -i` + - render signals: `--render_names, -r` + - echo simulator: `--echo_simulator_names, -e` - test data generators: `--test_data_generators, -t` - - scores: `--eval_scores, -e` + - scores: `--eval_scores, -s` - Assign a suffix to the report name using `-f ` For instance: ``` $ ./apm_quality_assessment-export.py \ - -o ~/data/apm_quality_assessment \ - -e \(polqa\) \ - -n \(echo\) \ + -o output/ \ -c "(^default$)|(.*AE.*)" \ + -t \(white_noise\) \ + -s \(polqa\) \ -f echo ``` @@ -92,8 +94,8 @@ $ ./apm_quality_assessment-export.py \ The input wav file must be: - sampled at a sample rate that is a multiple of 100 (required by POLQA) - in the 16 bit format (required by `audioproc_f`) - - encoded in the Microsoft WAV signed 16 bit PCM format (Audacity default - when exporting) + - encoded in the Microsoft WAV signed 16 bit PCM format (Audacity default + when exporting) Depending on the license, the POLQA tool may take “breaks” as a way to limit the throughput. When this happens, the APM Quality Assessment tool is slowed down. diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.py b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.py index b495a74f3c..ee08ff7b7f 100755 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.py @@ -23,11 +23,14 @@ import os import sys import quality_assessment.audioproc_wrapper as audioproc_wrapper +import quality_assessment.echo_path_simulation as echo_path_simulation import quality_assessment.eval_scores as eval_scores import quality_assessment.evaluation as evaluation import quality_assessment.test_data_generation as test_data_generation import quality_assessment.simulation as simulation +_ECHO_PATH_SIMULATOR_NAMES = ( + echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES) _TEST_DATA_GENERATOR_CLASSES = ( test_data_generation.TestDataGenerator.REGISTERED_CLASSES) _TEST_DATA_GENERATORS_NAMES = _TEST_DATA_GENERATOR_CLASSES.keys() @@ -53,8 +56,20 @@ def _InstanceArgumentsParser(): 'called'), default=[_DEFAULT_CONFIG_FILE]) - parser.add_argument('-i', '--input_files', nargs='+', required=True, - help='path to the input wav files (one or more)') + parser.add_argument('-i', '--capture_input_files', nargs='+', required=True, + help='path to the capture input wav files (one or more)') + + parser.add_argument('-r', '--render_input_files', nargs='+', required=False, + help=('path to the render input wav files; either ' + 'omitted or one file for each file in ' + '--capture_input_files (files will be paired by ' + 'index)'), default=None) + + parser.add_argument('-p', '--echo_path_simulator', required=False, + help=('custom echo path simulator name; required if ' + '--render_input_files is specified'), + choices=_ECHO_PATH_SIMULATOR_NAMES, + default=echo_path_simulation.NoEchoPathSimulator.NAME) parser.add_argument('-t', '--test_data_generators', nargs='+', required=False, help='custom list of test data generators to use', @@ -87,6 +102,15 @@ def main(): parser = _InstanceArgumentsParser() args = parser.parse_args() + if args.capture_input_files and args.render_input_files and ( + len(args.capture_input_files) != len(args.render_input_files)): + parser.error('--render_input_files and --capture_input_files must be lists ' + 'having the same length') + sys.exit(1) + if args.render_input_files and not args.echo_path_simulator: + parser.error('when --render_input_files is set, --echo_path_simulator is ' + 'also required') + sys.exit(1) simulator = simulation.ApmModuleSimulator( aechen_ir_database_path=args.air_db_path, @@ -95,7 +119,9 @@ def main(): evaluator=evaluation.ApmModuleEvaluator()) simulator.Run( config_filepaths=args.config_files, - input_filepaths=args.input_files, + capture_input_filepaths=args.capture_input_files, + render_input_filepaths=args.render_input_files, + echo_path_simulator_name=args.echo_path_simulator, test_data_generator_names=args.test_data_generators, eval_score_names=args.eval_scores, output_dir=args.output_dir) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.sh b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.sh index 646bd3792b..aa563ee26b 100755 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.sh +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment.sh @@ -32,16 +32,17 @@ else fi # Customize probing signals, test data generators and scores if needed. -PROBING_SIGNALS=(probing_signals/*.wav) +CAPTURE_SIGNALS=(probing_signals/*.wav) TEST_DATA_GENERATORS=( \ "identity" \ "white_noise" \ - "environmental_noise" \ - "reverberation" \ + # "environmental_noise" \ + # "reverberation" \ ) SCORES=( \ - "polqa" \ - "audio_level" \ + # "polqa" \ + "audio_level_peak" \ + "audio_level_mean" \ ) OUTPUT_PATH=output @@ -59,8 +60,8 @@ fi # Start one process for each "probing signal"-"test data source" pair. chmod +x apm_quality_assessment.py -for probing_signal_filepath in "${PROBING_SIGNALS[@]}" ; do - probing_signal_name="$(basename $probing_signal_filepath)" +for capture_signal_filepath in "${CAPTURE_SIGNALS[@]}" ; do + probing_signal_name="$(basename $capture_signal_filepath)" probing_signal_name="${probing_signal_name%.*}" for test_data_gen_name in "${TEST_DATA_GENERATORS[@]}" ; do LOG_FILE="${OUTPUT_PATH}/apm_qa-${probing_signal_name}-"` @@ -70,7 +71,7 @@ for probing_signal_filepath in "${PROBING_SIGNALS[@]}" ; do ./apm_quality_assessment.py \ --polqa_path ${POLQA_PATH}\ --air_db_path ${AECHEN_IR_DATABASE_PATH}\ - -i ${probing_signal_filepath} \ + -i ${capture_signal_filepath} \ -o ${OUTPUT_PATH} \ -t ${test_data_gen_name} \ -c "${APM_CONFIGS[@]}" \ @@ -78,7 +79,7 @@ for probing_signal_filepath in "${PROBING_SIGNALS[@]}" ; do done done -# Join. +# Join Python processes running apm_quality_assessment.py. wait # Export results. diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py index 8d6777a0fb..29618dcb6e 100755 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py @@ -12,22 +12,37 @@ """ import argparse -import collections import logging import glob import os import re import sys -import quality_assessment.audioproc_wrapper as audioproc_wrapper +try: + import pandas as pd +except ImportError: + logging.critical('Cannot import the third-party Python package pandas') + sys.exit(1) + import quality_assessment.data_access as data_access import quality_assessment.export as export +import quality_assessment.simulation as sim -# Regular expressions used to derive score descriptors from file paths. -RE_CONFIG_NAME = re.compile(r'cfg-(.+)') -RE_INPUT_NAME = re.compile(r'input-(.+)') -RE_TEST_DATA_GEN_NAME = re.compile(r'gen-(.+)') -RE_SCORE_NAME = re.compile(r'score-(.+)\.txt') +# Compiled regular expressions used to extract score descriptors. +RE_CONFIG_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixApmConfig() + r'(.+)') +RE_CAPTURE_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixCapture() + r'(.+)') +RE_RENDER_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixRender() + r'(.+)') +RE_ECHO_SIM_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixEchoSimulator() + r'(.+)') +RE_TEST_DATA_GEN_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + r'(.+)') +RE_TEST_DATA_GEN_PARAMS = re.compile( + sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + r'(.+)') +RE_SCORE_NAME = re.compile( + sim.ApmModuleSimulator.GetPrefixScore() + r'(.+)(\..+)') def _InstanceArgumentsParser(): @@ -48,15 +63,23 @@ def _InstanceArgumentsParser(): help=('regular expression to filter the APM configuration' ' names')) - parser.add_argument('-i', '--input_names', type=re.compile, - help=('regular expression to filter the probing signal ' + parser.add_argument('-i', '--capture_names', type=re.compile, + help=('regular expression to filter the capture signal ' + 'names')) + + parser.add_argument('-r', '--render_names', type=re.compile, + help=('regular expression to filter the render signal ' + 'names')) + + parser.add_argument('-e', '--echo_simulator_names', type=re.compile, + help=('regular expression to filter the echo simulator ' 'names')) parser.add_argument('-t', '--test_data_generators', type=re.compile, help=('regular expression to filter the test data ' 'generator names')) - parser.add_argument('-e', '--eval_scores', type=re.compile, + parser.add_argument('-s', '--eval_scores', type=re.compile, help=('regular expression to filter the evaluation score ' 'names')) @@ -70,32 +93,36 @@ def _GetScoreDescriptors(score_filepath): score_filepath: path to the score file. Returns: - A tuple of strings (APM configuration name, input audio track name, - test data generator name, test data generator parameters name, - evaluation score name). + A tuple of strings (APM configuration name, capture audio track name, + render audio track name, echo simulator name, test data generator name, + test data generator parameters as string, evaluation score name). """ - (config_name, input_name, test_data_gen_name, test_data_gen_params, - score_name) = score_filepath.split(os.sep)[-5:] - config_name = RE_CONFIG_NAME.match(config_name).groups(0)[0] - input_name = RE_INPUT_NAME.match(input_name).groups(0)[0] - test_data_gen_name = RE_TEST_DATA_GEN_NAME.match( - test_data_gen_name).groups(0)[0] - score_name = RE_SCORE_NAME.match(score_name).groups(0)[0] - return (config_name, input_name, test_data_gen_name, test_data_gen_params, - score_name) + fields = score_filepath.split(os.sep)[-7:] + extract_name = lambda index, reg_expr: ( + reg_expr.match(fields[index]).groups(0)[0]) + return ( + extract_name(0, RE_CONFIG_NAME), + extract_name(1, RE_CAPTURE_NAME), + extract_name(2, RE_RENDER_NAME), + extract_name(3, RE_ECHO_SIM_NAME), + extract_name(4, RE_TEST_DATA_GEN_NAME), + extract_name(5, RE_TEST_DATA_GEN_PARAMS), + extract_name(6, RE_SCORE_NAME), + ) -def _ExcludeScore(config_name, input_name, test_data_gen_name, score_name, - args): +def _ExcludeScore(config_name, capture_name, render_name, echo_simulator_name, + test_data_gen_name, score_name, args): """Decides whether excluding a score. - Given a score descriptor, encoded in config_name, input_name, - test_data_gen_name and score_name, use the corresponding regular expressions - to determine if the score should be excluded. + A set of optional regular expressions in args is used to determine if the + score should be excluded (depending on its |*_name| descriptors). Args: config_name: APM configuration name. - input_name: input audio track name. + capture_name: capture audio track name. + render_name: render audio track name. + echo_simulator_name: echo simulator name. test_data_gen_name: test data generator name. score_name: evaluation score name. args: parsed arguments. @@ -105,7 +132,9 @@ def _ExcludeScore(config_name, input_name, test_data_gen_name, score_name, """ value_regexpr_pairs = [ (config_name, args.config_names), - (input_name, args.input_names), + (capture_name, args.capture_names), + (render_name, args.render_names), + (echo_simulator_name, args.echo_simulator_names), (test_data_gen_name, args.test_data_generators), (score_name, args.eval_scores), ] @@ -134,53 +163,116 @@ def _BuildOutputFilename(filename_suffix): return 'results-{}.html'.format(filename_suffix) +def _FindScores(src_path, args): + """Given a search path, find scores and return a DataFrame object. + + Args: + src_path: Search path pattern. + args: parsed arguments. + + Returns: + A DataFrame object. + """ + # Get scores. + scores = [] + for score_filepath in glob.iglob(src_path): + # Extract score descriptor fields from the path. + (config_name, + capture_name, + render_name, + echo_simulator_name, + test_data_gen_name, + test_data_gen_params, + score_name) = _GetScoreDescriptors(score_filepath) + + # Ignore the score if required. + if _ExcludeScore( + config_name, + capture_name, + render_name, + echo_simulator_name, + test_data_gen_name, + score_name, + args): + logging.info( + 'ignored score: %s %s %s %s %s %s', + config_name, + capture_name, + render_name, + echo_simulator_name, + test_data_gen_name, + score_name) + continue + + # Read metadata and score. + metadata = data_access.Metadata.LoadAudioTestDataPaths( + os.path.split(score_filepath)[0]) + score = data_access.ScoreFile.Load(score_filepath) + + # Add a score with its descriptor fields. + scores.append(( + metadata['clean_capture_input_filepath'], + metadata['echo_free_capture_filepath'], + metadata['echo_filepath'], + metadata['render_filepath'], + metadata['capture_filepath'], + metadata['apm_output_filepath'], + metadata['apm_reference_filepath'], + config_name, + capture_name, + render_name, + echo_simulator_name, + test_data_gen_name, + test_data_gen_params, + score_name, + score, + )) + + return pd.DataFrame( + data=scores, + columns=( + 'clean_capture_input_filepath', + 'echo_free_capture_filepath', + 'echo_filepath', + 'render_filepath', + 'capture_filepath', + 'apm_output_filepath', + 'apm_reference_filepath', + 'apm_config', + 'capture', + 'render', + 'echo_simulator', + 'test_data_gen', + 'test_data_gen_params', + 'eval_score_name', + 'score', + )) + + def main(): # Init. logging.basicConfig(level=logging.DEBUG) # TODO(alessio): INFO once debugged. parser = _InstanceArgumentsParser() - nested_dict = lambda: collections.defaultdict(nested_dict) - scores = nested_dict() # Organize the scores in a nested dictionary. - - # Parse command line arguments. args = parser.parse_args() - # Find score files in the output path. + # Get the scores. src_path = os.path.join( - args.output_dir, 'cfg-*', 'input-*', 'gen-*', '*', 'score-*.txt') + args.output_dir, + sim.ApmModuleSimulator.GetPrefixApmConfig() + '*', + sim.ApmModuleSimulator.GetPrefixCapture() + '*', + sim.ApmModuleSimulator.GetPrefixRender() + '*', + sim.ApmModuleSimulator.GetPrefixEchoSimulator() + '*', + sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + '*', + sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + '*', + sim.ApmModuleSimulator.GetPrefixScore() + '*') logging.debug(src_path) - for score_filepath in glob.iglob(src_path): - # Extract score descriptors from the path. - (config_name, input_name, test_data_gen_name, test_data_gen_params, - score_name) = _GetScoreDescriptors(score_filepath) - - # Ignore the score if required. - if _ExcludeScore( - config_name, input_name, test_data_gen_name, score_name, args): - logging.info('ignored score: %s %s %s %s', - config_name, input_name, test_data_gen_name, score_name) - continue - - # Get metadata. - score_path, _ = os.path.split(score_filepath) - audio_in_filepath, audio_ref_filepath = ( - data_access.Metadata.LoadAudioTestDataPaths(score_path)) - audio_out_filepath = os.path.abspath(os.path.join( - score_path, audioproc_wrapper.AudioProcWrapper.OUTPUT_FILENAME)) - - # Add the score to the nested dictionary. - scores[score_name][config_name][input_name][test_data_gen_name][ - test_data_gen_params] = { - 'score': data_access.ScoreFile.Load(score_filepath), - 'audio_in_filepath': audio_in_filepath, - 'audio_out_filepath': audio_out_filepath, - 'audio_ref_filepath': audio_ref_filepath, - } + scores_data_frame = _FindScores(src_path, args) # Export. output_filepath = os.path.join(args.output_dir, _BuildOutputFilename( args.filename_suffix)) exporter = export.HtmlExport(output_filepath) - exporter.Export(scores) + exporter.Export(scores_data_frame) logging.info('output file successfully written in %s', output_filepath) sys.exit(0) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/audioproc_wrapper.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/audioproc_wrapper.py index 3c618b4605..d0dd51c34f 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/audioproc_wrapper.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/audioproc_wrapper.py @@ -15,6 +15,7 @@ import os import subprocess from . import data_access +from . import exceptions class AudioProcWrapper(object): @@ -22,11 +23,11 @@ class AudioProcWrapper(object): """ OUTPUT_FILENAME = 'output.wav' - _AUDIOPROC_F_BIN_PATH = os.path.abspath('../audioproc_f') + _AUDIOPROC_F_BIN_PATH = os.path.abspath(os.path.join( + os.pardir, 'audioproc_f')) def __init__(self): self._config = None - self._input_signal_filepath = None self._output_signal_filepath = None # Profiler instance to measure audioproc_f running time. @@ -36,17 +37,20 @@ class AudioProcWrapper(object): def output_filepath(self): return self._output_signal_filepath - def Run(self, config_filepath, input_filepath, output_path): + def Run(self, config_filepath, capture_input_filepath, output_path, + render_input_filepath=None): """Run audioproc_f. Args: config_filepath: path to the configuration file specifing the arguments for audioproc_f. - input_filepath: path to the audio track input file. + capture_input_filepath: path to the capture audio track input file (aka + forward or near-end). output_path: path of the audio track output file. + render_input_filepath: path to the render audio track input file (aka + reverse or far-end). """ # Init. - self._input_signal_filepath = input_filepath self._output_signal_filepath = os.path.join( output_path, self.OUTPUT_FILENAME) profiling_stats_filepath = os.path.join(output_path, 'profiling.stats') @@ -60,8 +64,14 @@ class AudioProcWrapper(object): self._config = data_access.AudioProcConfigFile.Load(config_filepath) # Set remaining parametrs. - self._config['-i'] = self._input_signal_filepath + if not os.path.exists(capture_input_filepath): + raise exceptions.FileNotFoundError('cannot find capture input file') + self._config['-i'] = capture_input_filepath self._config['-o'] = self._output_signal_filepath + if render_input_filepath is not None: + if not os.path.exists(render_input_filepath): + raise exceptions.FileNotFoundError('cannot find render input file') + self._config['-ri'] = render_input_filepath # Build arguments list. args = [self._AUDIOPROC_F_BIN_PATH] diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/data_access.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/data_access.py index c5db04b83a..826a0899ab 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/data_access.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/data_access.py @@ -31,7 +31,7 @@ class Metadata(object): def __init__(self): pass - _AUDIO_TEST_DATA_FILENAME = 'audio_test_data.txt' + _AUDIO_TEST_DATA_FILENAME = 'audio_test_data.json' @classmethod def LoadAudioTestDataPaths(cls, metadata_path): @@ -46,23 +46,21 @@ class Metadata(object): metadata_filepath = os.path.join( metadata_path, cls._AUDIO_TEST_DATA_FILENAME) with open(metadata_filepath) as f: - audio_in_filepath = f.readline().strip() - audio_ref_filepath = f.readline().strip() - return audio_in_filepath, audio_ref_filepath + return json.load(f) @classmethod - def SaveAudioTestDataPaths(cls, output_path, audio_in_filepath, - audio_ref_filepath): + def SaveAudioTestDataPaths(cls, output_path, **filepaths): """Saves the input and the reference audio track paths. Args: output_path: path to the directory containing the metadata file. - audio_in_filepath: path to the input audio track file. - audio_ref_filepath: path to the reference audio track file. + + Keyword Args: + filepaths: collection of audio track file paths to save. """ output_filepath = os.path.join(output_path, cls._AUDIO_TEST_DATA_FILENAME) with open(output_filepath, 'w') as f: - f.write('{}\n{}\n'.format(audio_in_filepath, audio_ref_filepath)) + json.dump(filepaths, f) class AudioProcConfigFile(object): diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation.py new file mode 100644 index 0000000000..a1621966fe --- /dev/null +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation.py @@ -0,0 +1,136 @@ +# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Echo path simulation module. +""" + +import hashlib +import os + +from . import signal_processing + + +class EchoPathSimulator(object): + """Abstract class for the echo path simulators. + + In general, an echo path simulator is a function of the render signal and + simulates the propagation of the latter into the microphone (e.g., due to + mechanical or electrical paths). + """ + + NAME = None + REGISTERED_CLASSES = {} + + def __init__(self): + pass + + def Simulate(self, output_path): + """Creates the echo signal and stores it in an audio file (abstract method). + + Args: + output_path: Path in which any output can be saved. + + Returns: + Path to the generated audio track file or None if no echo is present. + """ + raise NotImplementedError() + + @classmethod + def RegisterClass(cls, class_to_register): + """Registers an EchoPathSimulator implementation. + + Decorator to automatically register the classes that extend + EchoPathSimulator. + Example usage: + + @EchoPathSimulator.RegisterClass + class NoEchoPathSimulator(EchoPathSimulator): + pass + """ + cls.REGISTERED_CLASSES[class_to_register.NAME] = class_to_register + return class_to_register + + +@EchoPathSimulator.RegisterClass +class NoEchoPathSimulator(EchoPathSimulator): + """Simulates absence of echo.""" + + NAME = 'noecho' + + def __init__(self): + EchoPathSimulator.__init__(self) + + def Simulate(self, output_path): + return None + + +@EchoPathSimulator.RegisterClass +class LinearEchoPathSimulator(EchoPathSimulator): + """Simulates linear echo path. + + This class applies a given impulse response to the render input and then it + sums the signal to the capture input signal. + """ + + NAME = 'linear' + + def __init__(self, render_input_filepath, impulse_response): + """ + Args: + render_input_filepath: Render audio track file. + impulse_response: list or numpy vector of float values. + """ + EchoPathSimulator.__init__(self) + self._render_input_filepath = render_input_filepath + self._impulse_response = impulse_response + + def Simulate(self, output_path): + """Simulates linear echo path.""" + # Form the file name with a hash of the impulse response. + impulse_response_hash = hashlib.sha256( + str(self._impulse_response).encode('utf-8', 'ignore')).hexdigest() + echo_filepath = os.path.join(output_path, 'linear_echo_{}.wav'.format( + impulse_response_hash)) + + # If the simulated echo audio track file does not exists, create it. + if not os.path.exists(echo_filepath): + render = signal_processing.SignalProcessingUtils.LoadWav( + self._render_input_filepath) + echo = signal_processing.SignalProcessingUtils.ApplyImpulseResponse( + render, self._impulse_response) + signal_processing.SignalProcessingUtils.SaveWav(echo_filepath, echo) + + return echo_filepath + + +@EchoPathSimulator.RegisterClass +class RecordedEchoPathSimulator(EchoPathSimulator): + """Uses recorded echo. + + This class uses the clean capture input file name to build the file name of + the corresponding recording containing echo (a predefined suffix is used). + Such a file is expected to be already existing. + """ + + NAME = 'recorded' + + _FILE_NAME_SUFFIX = '_echo' + + def __init__(self, render_input_filepath): + EchoPathSimulator.__init__(self) + self._render_input_filepath = render_input_filepath + + def Simulate(self, output_path): + """Uses recorded echo path.""" + path, file_name_ext = os.path.split(self._render_input_filepath) + file_name, file_ext = os.path.splitext(file_name_ext) + echo_filepath = os.path.join(path, '{}{}{}'.format( + file_name, self._FILE_NAME_SUFFIX, file_ext)) + assert os.path.exists(echo_filepath), ( + 'cannot find the echo audio track file {}'.format(echo_filepath)) + return echo_filepath diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_factory.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_factory.py new file mode 100644 index 0000000000..eeffd1d71b --- /dev/null +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_factory.py @@ -0,0 +1,48 @@ +# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Echo path simulation factory module. +""" + +import numpy as np + +from . import echo_path_simulation + + +class EchoPathSimulatorFactory(object): + + # TODO(alessiob): Replace 5 ms delay (at 48 kHz sample rate) with a more + # realistic impulse response. + _LINEAR_ECHO_IMPULSE_RESPONSE = np.array([0.0]*(5 * 48) + [0.15]) + + def __init__(self): + pass + + @classmethod + def GetInstance(cls, echo_path_simulator_class, render_input_filepath): + """Creates an EchoPathSimulator instance given a class object. + + Args: + echo_path_simulator_class: EchoPathSimulator class object (not an + instance). + render_input_filepath: Path to the render audio track file. + + Returns: + An EchoPathSimulator instance. + """ + assert render_input_filepath is not None or ( + echo_path_simulator_class == echo_path_simulation.NoEchoPathSimulator) + + if echo_path_simulator_class == echo_path_simulation.NoEchoPathSimulator: + return echo_path_simulation.NoEchoPathSimulator() + elif echo_path_simulator_class == ( + echo_path_simulation.LinearEchoPathSimulator): + return echo_path_simulation.LinearEchoPathSimulator( + render_input_filepath, cls._LINEAR_ECHO_IMPULSE_RESPONSE) + else: + return echo_path_simulator_class(render_input_filepath) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_unittest.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_unittest.py new file mode 100644 index 0000000000..d9ef2c61e4 --- /dev/null +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/echo_path_simulation_unittest.py @@ -0,0 +1,81 @@ +# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Unit tests for the echo path simulation module. +""" + +import shutil +import os +import tempfile +import unittest + +import pydub + +from . import echo_path_simulation +from . import echo_path_simulation_factory +from . import signal_processing + + +class TestEchoPathSimulators(unittest.TestCase): + """Unit tests for the eval_scores module. + """ + + def setUp(self): + """Creates temporary data.""" + self._tmp_path = tempfile.mkdtemp() + + # Create and save white noise. + silence = pydub.AudioSegment.silent(duration=1000, frame_rate=48000) + white_noise = signal_processing.SignalProcessingUtils.GenerateWhiteNoise( + silence) + self._audio_track_num_samples = ( + signal_processing.SignalProcessingUtils.CountSamples(white_noise)) + self._audio_track_filepath = os.path.join(self._tmp_path, 'white_noise.wav') + signal_processing.SignalProcessingUtils.SaveWav( + self._audio_track_filepath, white_noise) + + # Make a copy the white noise audio track file; it will be used by + # echo_path_simulation.RecordedEchoPathSimulator. + shutil.copy(self._audio_track_filepath, os.path.join( + self._tmp_path, 'white_noise_echo.wav')) + + def tearDown(self): + """Recursively deletes temporary folders.""" + shutil.rmtree(self._tmp_path) + + def testRegisteredClasses(self): + # Check that there is at least one registered echo path simulator. + registered_classes = ( + echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES) + self.assertIsInstance(registered_classes, dict) + self.assertGreater(len(registered_classes), 0) + + # Instance factory. + factory = echo_path_simulation_factory.EchoPathSimulatorFactory() + + # Try each registered echo path simulator. + for echo_path_simulator_name in registered_classes: + simulator = factory.GetInstance( + echo_path_simulator_class=registered_classes[ + echo_path_simulator_name], + render_input_filepath=self._audio_track_filepath) + + echo_filepath = simulator.Simulate(self._tmp_path) + if echo_filepath is None: + self.assertEqual(echo_path_simulation.NoEchoPathSimulator.NAME, + echo_path_simulator_name) + # No other tests in this case. + continue + + # Check that the echo audio track file exists and its length is greater or + # equal to that of the render audio track. + self.assertTrue(os.path.exists(echo_filepath)) + echo = signal_processing.SignalProcessingUtils.LoadWav(echo_filepath) + self.assertGreaterEqual( + signal_processing.SignalProcessingUtils.CountSamples(echo), + self._audio_track_num_samples) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores.py index eec74391ad..78d0c18558 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores.py @@ -9,6 +9,7 @@ """Evaluation score abstract class and implementations. """ +from __future__ import division import logging import os import re @@ -24,7 +25,8 @@ class EvaluationScore(object): NAME = None REGISTERED_CLASSES = {} - def __init__(self): + def __init__(self, score_filename_prefix): + self._score_filename_prefix = score_filename_prefix self._reference_signal = None self._reference_signal_filepath = None self._tested_signal = None @@ -76,8 +78,8 @@ class EvaluationScore(object): Args: output_path: path to the directory where the output is written. """ - self._output_filepath = os.path.join(output_path, 'score-{}.txt'.format( - self.NAME)) + self._output_filepath = os.path.join( + output_path, self._score_filename_prefix + self.NAME + '.txt') try: # If the score has already been computed, load. self._LoadScore() @@ -110,10 +112,10 @@ class EvaluationScore(object): @EvaluationScore.RegisterClass -class AudioLevelScore(EvaluationScore): - """Audio level score. +class AudioLevelPeakScore(EvaluationScore): + """Peak audio level score. - Defined as the difference between the average audio level of the tested and + Defined as the difference between the peak audio level of the tested and the reference signals. Unit: dB @@ -121,10 +123,10 @@ class AudioLevelScore(EvaluationScore): Worst case: +/-inf dB """ - NAME = 'audio_level' + NAME = 'audio_level_peak' - def __init__(self): - EvaluationScore.__init__(self) + def __init__(self, score_filename_prefix): + EvaluationScore.__init__(self, score_filename_prefix) def _Run(self, output_path): self._LoadReferenceSignal() @@ -133,6 +135,38 @@ class AudioLevelScore(EvaluationScore): self._SaveScore() +@EvaluationScore.RegisterClass +class MeanAudioLevelScore(EvaluationScore): + """Mean audio level score. + + Defined as the difference between the mean audio level of the tested and + the reference signals. + + Unit: dB + Ideal: 0 dB + Worst case: +/-inf dB + """ + + NAME = 'audio_level_mean' + + def __init__(self, score_filename_prefix): + EvaluationScore.__init__(self, score_filename_prefix) + + def _Run(self, output_path): + self._LoadReferenceSignal() + self._LoadTestedSignal() + + dbfs_diffs_sum = 0.0 + seconds = min(len(self._tested_signal), len(self._reference_signal)) // 1000 + for t in range(seconds): + t0 = t * seconds + t1 = t0 + seconds + dbfs_diffs_sum += ( + self._tested_signal[t0:t1].dBFS - self._reference_signal[t0:t1].dBFS) + self._score = dbfs_diffs_sum / float(seconds) + self._SaveScore() + + @EvaluationScore.RegisterClass class PolqaScore(EvaluationScore): """POLQA score. @@ -146,8 +180,8 @@ class PolqaScore(EvaluationScore): NAME = 'polqa' - def __init__(self, polqa_bin_filepath): - EvaluationScore.__init__(self) + def __init__(self, score_filename_prefix, polqa_bin_filepath): + EvaluationScore.__init__(self, score_filename_prefix) # POLQA binary file path. self._polqa_bin_filepath = polqa_bin_filepath diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_factory.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_factory.py index 4ce6458cf1..c19e1f9cef 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_factory.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_factory.py @@ -21,7 +21,8 @@ class EvaluationScoreWorkerFactory(object): workers. """ - def __init__(self, polqa_tool_bin_path): + def __init__(self, score_filename_prefix, polqa_tool_bin_path): + self._score_filename_prefix = score_filename_prefix self._polqa_tool_bin_path = polqa_tool_bin_path def GetInstance(self, evaluation_score_class): @@ -29,11 +30,14 @@ class EvaluationScoreWorkerFactory(object): Args: evaluation_score_class: EvaluationScore class object (not an instance). + + Returns: + An EvaluationScore instance. """ logging.debug( 'factory producing a %s evaluation score', evaluation_score_class) if evaluation_score_class == eval_scores.PolqaScore: - return eval_scores.PolqaScore(self._polqa_tool_bin_path) + return eval_scores.PolqaScore( + self._score_filename_prefix, self._polqa_tool_bin_path) else: - # By default, no arguments in the constructor. - return evaluation_score_class() + return evaluation_score_class(self._score_filename_prefix) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_unittest.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_unittest.py index df0ccaef51..b3bd4f9a9e 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_unittest.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/eval_scores_unittest.py @@ -63,6 +63,7 @@ class TestEvalScores(unittest.TestCase): # Instance evaluation score workers factory with fake dependencies. eval_score_workers_factory = ( eval_scores_factory.EvaluationScoreWorkerFactory( + score_filename_prefix='scores-', polqa_tool_bin_path=os.path.join( os.path.dirname(os.path.abspath(__file__)), 'fake_polqa'))) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/exceptions.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/exceptions.py index 0e9116cb6b..943f2143b8 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/exceptions.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/exceptions.py @@ -20,3 +20,9 @@ class SignalProcessingException(Exception): """Signal processing exeception. """ pass + + +class InputMixerException(Exception): + """Input mixer exeception. + """ + pass diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py index b0efa13e2b..720cb9b4cc 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py @@ -6,55 +6,39 @@ # in the file PATENTS. All contributing project authors may # be found in the AUTHORS file in the root of the source tree. -import logging +import hashlib import os import re class HtmlExport(object): - """HTML exporter class for APM quality scores. - """ - - # Path to CSS and JS files. - _PATH = os.path.dirname(os.path.realpath(__file__)) - - # CSS file parameters. - _CSS_FILEPATH = os.path.join(_PATH, 'results.css') - _INLINE_CSS = False - - # JS file parameters. - _JS_FILEPATH = os.path.join(_PATH, 'results.js') - _INLINE_JS = False + """HTML exporter class for APM quality scores.""" _NEW_LINE = '\n' + # CSS and JS file paths. + _PATH = os.path.dirname(os.path.realpath(__file__)) + _CSS_FILEPATH = os.path.join(_PATH, 'results.css') + _JS_FILEPATH = os.path.join(_PATH, 'results.js') + def __init__(self, output_filepath): - self._test_data_generator_names = None - self._test_data_generator_params = None + self._scores_data_frame = None self._output_filepath = output_filepath - def Export(self, scores): - """Exports the scores into an HTML file. + def Export(self, scores_data_frame): + """Exports scores into an HTML file. Args: - scores: nested dictionary containing the scores. + scores_data_frame: DataFrame instance. """ - # Generate one table for each evaluation score. - tables = [] - for score_name in sorted(scores.keys()): - tables.append(self._BuildScoreTable(score_name, scores[score_name])) - - # Create the html file. - html = ( - '' + - self._BuildHeader() + - '' + - '

Results from {}

'.format(self._output_filepath) + - self._NEW_LINE.join(tables) + - '' + - '') - - self._Save(self._output_filepath, html) + self._scores_data_frame = scores_data_frame + html = ['', + self._BuildHeader(), + '', + self._BuildBody(), + '', + ''] + self._Save(self._output_filepath, self._NEW_LINE.join(html)) def _BuildHeader(self): """Builds the section of the HTML file. @@ -67,197 +51,289 @@ class HtmlExport(object): """ html = ['', 'Results'] - # Function to append the lines of a text file to html. + # Add Material Design hosted libs. + html.append('') + html.append('') + html.append('') + html.append('') + + # Embed custom JavaScript and CSS files. def EmbedFile(filepath): with open(filepath) as f: for l in f: - html.append(l.strip()) - - # CSS. - if self._INLINE_CSS: - # Embed. - html.append('') - else: - # Link. - html.append(''.format(self._CSS_FILEPATH)) - - # Javascript. - if self._INLINE_JS: - # Embed. - html.append('') - else: - # Link. - html.append(''.format( - self._JS_FILEPATH)) + html.append(l.rstrip()) + html.append('') + html.append('') html.append('') return self._NEW_LINE.join(html) - def _BuildScoreTable(self, score_name, scores): - """Builds a table for a specific evaluation score (e.g., POLQA). + def _BuildBody(self): + """Builds the content of the section.""" + score_names = self._scores_data_frame.eval_score_name.unique().tolist() - Args: - score_name: name of the score. - scores: nested dictionary of scores. + html = [ + ('
'), + '
', + '
', + 'APM QA results ({})'.format( + self._output_filepath), + '
', + ] - Returns: - A string with ...
HTML. - """ - config_names = sorted(scores.keys()) - input_names = sorted(scores[config_names[0]].keys()) - rows = [self._BuildTableRow( - score_name, config_name, scores[config_name], input_names) for ( - config_name) in config_names] + # Tab selectors. + html.append('
') + for tab_index, score_name in enumerate(score_names): + is_active = tab_index == 0 + html.append('' + '{}'.format(tab_index, + ' is-active' if is_active else '', + self._FormatName(score_name))) + html.append('
') - html = ( - '' + - '{}'.format( - self._BuildTableHeader(score_name, input_names)) + - '' + - '' + ''.join(rows) + '' + - '' + - '
' + self._BuildLegend()) + html.append('
') + html.append('
') - return html + # Tabs content. + for tab_index, score_name in enumerate(score_names): + html.append('
'.format( + ' is-active' if is_active else '', tab_index)) + html.append('
') + html.append(self._BuildScoreTab(score_name)) + html.append('
') + html.append('
') - def _BuildTableHeader(self, score_name, input_names): - """Builds the cells of a table header. + html.append('
') + html.append('
') - A table header starts with a cell containing the name of the evaluation - score, and then it includes one column for each probing signal. + return self._NEW_LINE.join(html) - Args: - score_name: name of the score. - input_names: list of probing signal names. + def _BuildScoreTab(self, score_name): + """Builds the content of a tab.""" + # Find unique values. + scores = self._scores_data_frame[ + self._scores_data_frame.eval_score_name == score_name] + apm_configs = sorted(self._FindUniqueTuples(scores, ['apm_config'])) + test_data_gen_configs = sorted(self._FindUniqueTuples( + scores, ['test_data_gen', 'test_data_gen_params'])) - Returns: - A string with a list of ... HTML elements. - """ - html = ( - '{}'.format(self._FormatName(score_name)) + - '' + ''.join( - [self._FormatName(name) for name in input_names]) + '') - return html + html = [ + '
', + '
', + '
', + (''), + ] - def _BuildTableRow(self, score_name, config_name, scores, input_names): - """Builds the cells of a table row. + # Header. + html.append('') + for test_data_gen_info in test_data_gen_configs: + html.append(''.format( + self._FormatName(test_data_gen_info[0]), test_data_gen_info[1])) + html.append('') - A table row starts with the name of the APM configuration file, and then it - includes one column for each probing singal. + # Body. + html.append('') + for apm_config in apm_configs: + html.append('') + for test_data_gen_info in test_data_gen_configs: + onclick_handler = 'openScoreStatsInspector(\'{}\')'.format( + self._ScoreStatsInspectorDialogId(score_name, apm_config[0], + test_data_gen_info[0], + test_data_gen_info[1])) + html.append(''.format( + onclick_handler, self._BuildScoreTableCell( + score_name, test_data_gen_info[0], test_data_gen_info[1], + apm_config[0]))) + html.append('') + html.append('') - Args: - score_name: name of the score. - config_name: name of the APM configuration. - scores: nested dictionary of scores. - input_names: list of probing signal names. + html.append('
APM config / Test data generator{} {}
' + self._FormatName(apm_config[0]) + '{}
') - Returns: - A string with a list of ... HTML elements. - """ - cells = [self._BuildTableCell( - scores[input_name], score_name, config_name, input_name) for ( - input_name) in input_names] - html = ('{}'.format(self._FormatName(config_name)) + - '' + ''.join(cells) + '') - return html + html.append(self._BuildScoreStatsInspectorDialogs( + score_name, apm_configs, test_data_gen_configs)) - def _BuildTableCell(self, scores, score_name, config_name, input_name): - """Builds the inner content of a table cell. + return self._NEW_LINE.join(html) - A table cell includes all the scores computed for a specific evaluation - score (e.g., POLQA), APM configuration (e.g., default), and probing signal. + def _BuildScoreTableCell(self, score_name, test_data_gen, + test_data_gen_params, apm_config): + """Builds the content of a table cell for a score table.""" + scores = self._SliceDataForScoreTableCell( + score_name, apm_config, test_data_gen, test_data_gen_params) + stats = self._ComputeScoreStats(scores) - Args: - scores: dictionary of score data. - score_name: name of the score. - config_name: name of the APM configuration. - input_name: name of the probing signal. + html = [] + items_id_prefix = ( + score_name + test_data_gen + test_data_gen_params + apm_config) + if stats['count'] == 1: + # Show the only available score. + item_id = hashlib.md5(items_id_prefix).hexdigest() + html.append('
{1:f}
'.format( + item_id, scores['score'].mean())) + html.append('
{}' + '
'.format(item_id, 'single value')) + else: + # Show stats. + for stat_name in ['min', 'max', 'mean', 'std dev']: + item_id = hashlib.md5(items_id_prefix + stat_name).hexdigest() + html.append('
{1:f}
'.format( + item_id, stats[stat_name])) + html.append('
{}' + '
'.format(item_id, stat_name)) - Returns: - A string with the HTML of a table body cell. - """ - # Init test data generator names and parameters cache (if not done). - if self._test_data_generator_names is None: - self._test_data_generator_names = sorted(scores.keys()) - self._test_data_generator_params = {test_data_generator_name: sorted( - scores[test_data_generator_name].keys()) for ( - test_data_generator_name) in self._test_data_generator_names} + return self._NEW_LINE.join(html) - # For each noisy input (that is a pair of test data generator and - # generator parameters), add an item with the score and its metadata. - items = [] - for name_index, test_data_generator_name in enumerate( - self._test_data_generator_names): - for params_index, test_data_generator_params in enumerate( - self._test_data_generator_params[test_data_generator_name]): + def _BuildScoreStatsInspectorDialogs( + self, score_name, apm_configs, test_data_gen_configs): + """Builds a set of score stats inspector dialogs.""" + html = [] + for apm_config in apm_configs: + for test_data_gen_info in test_data_gen_configs: + dialog_id = self._ScoreStatsInspectorDialogId( + score_name, apm_config[0], + test_data_gen_info[0], test_data_gen_info[1]) - # Init. - score_value = '?' - metadata = '' + html.append(''.format(dialog_id)) - # Extract score value and its metadata. - try: - data = scores[test_data_generator_name][test_data_generator_params] - score_value = '{0:f}'.format(data['score']) - metadata = ( - '' - '' - '' - '' - '' - ).format( - test_data_generator_name, - test_data_generator_params, - data['audio_in_filepath'], - data['audio_out_filepath'], - data['audio_ref_filepath']) - except TypeError: - logging.warning( - 'missing score found: ' - ' ', score_name, config_name, input_name, - test_data_generator_name, test_data_generator_params) + # Content. + html.append('
') + html.append('
APM config preset: {}
' + 'Test data generator: {} ({})
'.format( + self._FormatName(apm_config[0]), + self._FormatName(test_data_gen_info[0]), + test_data_gen_info[1])) + html.append(self._BuildScoreStatsInspectorDialog( + score_name, apm_config[0], test_data_gen_info[0], + test_data_gen_info[1])) + html.append('
') - # Add the score. - items.append( - '
[{0:d}, {1:d}]{2}
' - '
{3}
'.format( - name_index, params_index, metadata, score_value)) + # Actions. + html.append('
') + html.append('') + html.append('
') - html = ( - '
' + - '
'.join(items) + - '
') + html.append('
') - return html + return self._NEW_LINE.join(html) - def _BuildLegend(self): - """Builds the legend. + def _BuildScoreStatsInspectorDialog( + self, score_name, apm_config, test_data_gen, test_data_gen_params): + """Builds one score stats inspector dialog.""" + scores = self._SliceDataForScoreTableCell( + score_name, apm_config, test_data_gen, test_data_gen_params) - The legend details test data generator name and parameter pairs. + capture_render_pairs = sorted(self._FindUniqueTuples( + scores, ['capture', 'render'])) + echo_simulators = sorted(self._FindUniqueTuples(scores, ['echo_simulator'])) - Returns: - A string with a
...
HTML element. - """ - items = [] - for name_index, test_data_generator_name in enumerate( - self._test_data_generator_names): - for params_index, test_data_generator_params in enumerate( - self._test_data_generator_params[test_data_generator_name]): - items.append( - '
[{0:d}, {1:d}]
: {2}, ' - '{3}'.format(name_index, params_index, test_data_generator_name, - test_data_generator_params)) - html = ( - '
' + - '
'.join(items) + '
') + html = [''] - return html + # Header. + html.append('') + for echo_simulator in echo_simulators: + html.append('') + html.append('') + + # Body. + html.append('') + for capture, render in capture_render_pairs: + html.append(''.format( + capture, render)) + for echo_simulator in echo_simulators: + score_tuple = self._SliceDataForScoreStatsTableCell( + scores, capture, render, echo_simulator[0]) + html.append(''.format( + self._BuildScoreStatsInspectorTableCell(score_tuple))) + html.append('') + html.append('') + + html.append('
Capture-Render / Echo simulator' + self._FormatName(echo_simulator[0]) +'
{}
{}
{}
') + + # Placeholder for the audio inspector. + html.append('
') + + return self._NEW_LINE.join(html) + + def _BuildScoreStatsInspectorTableCell(self, score_tuple): + """Builds the content of a cell of a score stats inspector.""" + html = ['
{}
'.format(score_tuple.score)] + + # Add all the available file paths as hidden data. + for field_name in score_tuple.keys(): + if field_name.endswith('_filepath'): + html.append(''.format( + field_name, score_tuple[field_name])) + + return self._NEW_LINE.join(html) + + def _SliceDataForScoreTableCell( + self, score_name, apm_config, test_data_gen, test_data_gen_params): + """Slices |self._scores_data_frame| to extract the data for a tab.""" + masks = [] + masks.append(self._scores_data_frame.eval_score_name == score_name) + masks.append(self._scores_data_frame.apm_config == apm_config) + masks.append(self._scores_data_frame.test_data_gen == test_data_gen) + masks.append( + self._scores_data_frame.test_data_gen_params == test_data_gen_params) + mask = reduce((lambda i1, i2: i1 & i2), masks) + del masks + return self._scores_data_frame[mask] + + @classmethod + def _SliceDataForScoreStatsTableCell( + cls, scores, capture, render, echo_simulator): + """Slices |scores| to extract the data for a tab.""" + masks = [] + + masks.append(scores.capture == capture) + masks.append(scores.render == render) + masks.append(scores.echo_simulator == echo_simulator) + mask = reduce((lambda i1, i2: i1 & i2), masks) + del masks + + sliced_data = scores[mask] + assert len(sliced_data) == 1, 'single score is expected' + return sliced_data.iloc[0] + + @classmethod + def _FindUniqueTuples(cls, data_frame, fields): + """Slices |data_frame| to a list of fields and finds unique tuples.""" + return data_frame[fields].drop_duplicates().values.tolist() + + @classmethod + def _ComputeScoreStats(cls, data_frame): + """Computes score stats.""" + scores = data_frame['score'] + return { + 'count': scores.count(), + 'min': scores.min(), + 'max': scores.max(), + 'mean': scores.mean(), + 'std dev': scores.std(), + } + + @classmethod + def _ScoreStatsInspectorDialogId(cls, score_name, apm_config, test_data_gen, + test_data_gen_params): + """Assigns a unique name to a dialog.""" + return 'score-stats-dialog-' + hashlib.md5( + 'score-stats-inspector-{}-{}-{}-{}'.format( + score_name, apm_config, test_data_gen, + test_data_gen_params)).hexdigest() @classmethod def _Save(cls, output_filepath, html): diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer.py new file mode 100644 index 0000000000..8f9e5422a7 --- /dev/null +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer.py @@ -0,0 +1,93 @@ +# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Input mixer module. +""" + +import logging +import os + +from . import exceptions +from . import signal_processing + + +class ApmInputMixer(object): + """Class to mix a set of audio segments down to the APM input.""" + + _HARD_CLIPPING_LOG_MSG = 'hard clipping detected in the mixed signal' + + def __init__(self): + pass + + @classmethod + def HardClippingLogMessage(cls): + """Returns the log message used when hard clipping is detected in the mix. + + This method is mainly intended to be used by the unit tests. + """ + return cls._HARD_CLIPPING_LOG_MSG + + @classmethod + def Mix(cls, output_path, capture_input_filepath, echo_filepath): + """Mixes capture and echo. + + Creates the overall capture input for APM by mixing the "echo-free" capture + signal with the echo signal (e.g., echo simulated via the + echo_path_simulation module). + + The echo signal cannot be shorter than the capture signal and the generated + mix will have the same duration of the capture signal. The latter property + is enforced in order to let the input of APM and the reference signal + created by TestDataGenerator have the same length (required for the + evaluation step). + + Hard-clipping may occur in the mix; a warning is raised when this happens. + + If |echo_filepath| is None, nothing is done and |capture_input_filepath| is + returned. + + Args: + speech: AudioSegment instance. + echo_path: AudioSegment instance or None. + + Returns: + Path to the mix audio track file. + """ + if echo_filepath is None: + return capture_input_filepath + + # Build the mix output file name as a function of the echo file name. + # This ensures that if the internal parameters of the echo path simulator + # change, no erroneous cache hit occurs. + echo_file_name, _ = os.path.splitext(os.path.split(echo_filepath)[1]) + mix_filepath = os.path.join(output_path, 'mix_capture_{}.wav'.format( + echo_file_name)) + + # Create the mix if not done yet. + mix = None + if not os.path.exists(mix_filepath): + echo_free_capture = signal_processing.SignalProcessingUtils.LoadWav( + capture_input_filepath) + echo = signal_processing.SignalProcessingUtils.LoadWav(echo_filepath) + + if signal_processing.SignalProcessingUtils.CountSamples(echo) < ( + signal_processing.SignalProcessingUtils.CountSamples( + echo_free_capture)): + raise exceptions.InputMixerException( + 'echo cannot be shorter than capture') + + mix = echo_free_capture.overlay(echo) + signal_processing.SignalProcessingUtils.SaveWav(mix_filepath, mix) + + # Check if hard clipping occurs. + if mix is None: + mix = signal_processing.SignalProcessingUtils.LoadWav(mix_filepath) + if signal_processing.SignalProcessingUtils.DetectHardClipping(mix): + logging.warning(cls._HARD_CLIPPING_LOG_MSG) + + return mix_filepath diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer_unittest.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer_unittest.py new file mode 100644 index 0000000000..b212614199 --- /dev/null +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/input_mixer_unittest.py @@ -0,0 +1,149 @@ +# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE file in the root of the source +# tree. An additional intellectual property rights grant can be found +# in the file PATENTS. All contributing project authors may +# be found in the AUTHORS file in the root of the source tree. + +"""Unit tests for the input mixer module. +""" + +import logging +import os +import shutil +import sys +import tempfile +import unittest + +SRC = os.path.abspath(os.path.join( + os.path.dirname((__file__)), os.pardir, os.pardir, os.pardir, os.pardir)) +sys.path.append(os.path.join(SRC, 'third_party', 'pymock')) + +import mock + +from . import exceptions +from . import input_mixer +from . import signal_processing + + +class TestApmInputMixer(unittest.TestCase): + """Unit tests for the ApmInputMixer class. + """ + + # Audio track file names created in setUp(). + _FILENAMES = ['capture', 'echo_1', 'echo_2', 'shorter', 'longer'] + + # Target peak power level (dBFS) of each audio track file created in setUp(). + # These values are hand-crafted in order to make saturation happen when + # capture and echo_2 are mixed and the contrary for capture and echo_1. + # None means that the power is not changed. + _MAX_PEAK_POWER_LEVELS = [-10.0, -5.0, 0.0, None, None] + + # Audio track file durations in milliseconds. + _DURATIONS = [1000, 1000, 1000, 800, 1200] + + _SAMPLE_RATE = 48000 + + def setUp(self): + """Creates temporary data.""" + self._tmp_path = tempfile.mkdtemp() + + # Create audio track files. + self._audio_tracks = {} + for filename, peak_power, duration in zip( + self._FILENAMES, self._MAX_PEAK_POWER_LEVELS, self._DURATIONS): + audio_track_filepath = os.path.join(self._tmp_path, '{}.wav'.format( + filename)) + + # Create a pure tone with the target peak power level. + template = signal_processing.SignalProcessingUtils.GenerateSilence( + duration=duration, sample_rate=self._SAMPLE_RATE) + signal = signal_processing.SignalProcessingUtils.GeneratePureTone( + template) + if peak_power is not None: + signal = signal.apply_gain(-signal.max_dBFS + peak_power) + + signal_processing.SignalProcessingUtils.SaveWav( + audio_track_filepath, signal) + self._audio_tracks[filename] = { + 'filepath': audio_track_filepath, + 'num_samples': signal_processing.SignalProcessingUtils.CountSamples( + signal) + } + + def tearDown(self): + """Recursively deletes temporary folders.""" + shutil.rmtree(self._tmp_path) + + def testCheckMixSameDuration(self): + """Checks the duration when mixing capture and echo with same duration.""" + mix_filepath = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['echo_1']['filepath']) + self.assertTrue(os.path.exists(mix_filepath)) + + mix = signal_processing.SignalProcessingUtils.LoadWav(mix_filepath) + self.assertEqual(self._audio_tracks['capture']['num_samples'], + signal_processing.SignalProcessingUtils.CountSamples(mix)) + + def testRejectShorterEcho(self): + """Rejects echo signals that are shorter than the capture signal.""" + try: + _ = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['shorter']['filepath']) + self.fail('no exception raised') + except exceptions.InputMixerException: + pass + + def testCheckMixDurationWithLongerEcho(self): + """Checks the duration when mixing an echo longer than the capture.""" + mix_filepath = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['longer']['filepath']) + self.assertTrue(os.path.exists(mix_filepath)) + + mix = signal_processing.SignalProcessingUtils.LoadWav(mix_filepath) + self.assertEqual(self._audio_tracks['capture']['num_samples'], + signal_processing.SignalProcessingUtils.CountSamples(mix)) + + def testCheckOutputFileNamesConflict(self): + """Checks that different echo files lead to different output file names.""" + mix1_filepath = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['echo_1']['filepath']) + self.assertTrue(os.path.exists(mix1_filepath)) + + mix2_filepath = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['echo_2']['filepath']) + self.assertTrue(os.path.exists(mix2_filepath)) + + self.assertNotEqual(mix1_filepath, mix2_filepath) + + def testHardClippingLogExpected(self): + """Checks that hard clipping warning is raised when occurring.""" + logging.warning = mock.MagicMock(name='warning') + _ = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['echo_2']['filepath']) + logging.warning.assert_called_once_with( + input_mixer.ApmInputMixer.HardClippingLogMessage()) + + def testHardClippingLogNotExpected(self): + """Checks that hard clipping warning is not raised when not occurring.""" + logging.warning = mock.MagicMock(name='warning') + _ = input_mixer.ApmInputMixer.Mix( + self._tmp_path, + self._audio_tracks['capture']['filepath'], + self._audio_tracks['echo_1']['filepath']) + self.assertNotIn( + mock.call(input_mixer.ApmInputMixer.HardClippingLogMessage()), + logging.warning.call_args_list) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.css b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.css index ee76fbcfaf..8fd07519d9 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.css +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.css @@ -7,84 +7,22 @@ * be found in the AUTHORS file in the root of the source tree. */ -body{ - font-family: Arial; - font-size: 75%; +td.selected-score { + background-color: #DDD; } -table{ - border-top: 1px solid #000; - border-right: 1px solid #000; - margin: 1em 0 0.2em 0; -} - -table thead tr th{ - font-size: 0.9em; +.audio-inspector { text-align: center; - border-bottom: 1px solid #000; - border-left: 1px solid #000; - min-width: 8em; } -table thead tr th:first-child{ - text-transform: uppercase; +.audio-inspector div{ + margin-bottom: 0; + padding-bottom: 0; + padding-top: 0; } -table tbody tr td{ - font-size: 0.8em; - text-align: center; - border-bottom: 1px solid #000; - border-left: 1px solid #000; -} - -table tbody tr td:first-child{ - font-weight: bold; -} - -table tbody tr td .selected{ - background-color: #EE9; -} - -table tbody tr td .value{ - display: inline-block; -} - -.test-data-gen-desc{ - display: inline-block; - margin-right: 0.3em; - border: 1px solid #555; - color: #555; - background-color: #EEE; - padding: 1px; - font-size: 0.8em; -} - -.inspector{ - background-color: #FFF; - border: 3px solid #000; - display: block; - padding: 0.5em; - position: fixed; - right: 1em; - top: 1em; -} - -.inspector .property{ - margin-bottom: 1em; -} - -.inspector .property .name{ - font-weight: bold; -} - -.inspector .property .value{ - padding-left: 0.5em; -} - -.inspector .buttons{ - margin-top: 1em; -} - -.inspector .buttons button{ - margin: 0 0.25em; +.audio-inspector div div{ + margin-bottom: 0; + padding-bottom: 0; + padding-top: 0; } diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.js b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.js index e343530e59..c0272afd9d 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.js +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/results.js @@ -6,59 +6,229 @@ // in the file PATENTS. All contributing project authors may // be found in the AUTHORS file in the root of the source tree. +var inspector = null; + /** - * Inspector UI class. + * Opens the score stats inspector dialog. + * @param {String} dialogId: identifier of the dialog to show. + */ +function openScoreStatsInspector(dialogId) { + var dialog = document.getElementById(dialogId); + dialog.showModal(); +} + +/** + * Closes the score stats inspector dialog. + * @param {String} dialogId: identifier of the dialog to close. + */ +function closeScoreStatsInspector(dialogId) { + var dialog = document.getElementById(dialogId); + dialog.close(); + if (inspector != null) { + inspector.stopAudio(); + } +} + +/** + * Instance and initialize the audio inspector. + */ +function initialize() { + inspector = new AudioInspector(); + inspector.init(); +} + +/** + * Audio inspector class. * @constructor */ -function Inspector() { +function AudioInspector() { this.audioPlayer_ = new Audio(); - this.inspectorNode_ = document.createElement('div'); - this.divTestDataGeneratorName_ = document.createElement('div'); - this.divTestDataGenParameters_ = document.createElement('div'); - this.buttonPlayAudioIn_ = document.createElement('button'); - this.buttonPlayAudioOut_ = document.createElement('button'); - this.buttonPlayAudioRef_ = document.createElement('button'); - this.buttonStopAudio_ = document.createElement('button'); - - this.selectedItem_ = null; - this.audioInUrl_ = null; - this.audioOutUrl_ = null; - this.audioRefUrl_ = null; + this.metadata_ = {}; + this.currentScore_ = null; + this.audioInspector_ = null; } /** * Initialize. */ -Inspector.prototype.init = function() { +AudioInspector.prototype.init = function() { window.event.stopPropagation(); + this.createAudioInspector_(); + this.initializeEventHandlers_(); +}; - // Create inspector UI. - this.buildInspector_(); - var body = document.getElementsByTagName('body')[0]; - body.appendChild(this.inspectorNode_); +/** + * Set up the inspector for a new score. + * @param {DOMElement} element: Element linked to the selected score. + */ +AudioInspector.prototype.selectedScoreChange = function(element) { + if (this.currentScore_ == element) { return; } + if (this.currentScore_ != null) { + this.currentScore_.classList.remove('selected-score'); + } + this.currentScore_ = element; + this.currentScore_.classList.add('selected-score'); + this.stopAudio(); - // Bind click handler. - var self = this; - var items = document.getElementsByClassName('score'); - for (var index = 0; index < items.length; index++) { - items[index].onclick = function() { - self.openInspector(this); - }; + // Read metadata. + var matches = element.querySelectorAll('input[type=hidden]'); + this.metadata_ = {}; + for (var index = 0; index < matches.length; ++index) { + this.metadata_[matches[index].name] = matches[index].value; } - // Bind pressed key handlers. + // Show the audio inspector interface. + var container = element.parentNode.parentNode.parentNode.parentNode; + var audioInspectorPlaceholder = container.querySelector( + '.audio-inspector-placeholder'); + this.moveInspector_(audioInspectorPlaceholder); +}; + +/** + * Stop playing audio. + */ +AudioInspector.prototype.stopAudio = function() { + this.audioPlayer_.pause(); +}; + +/** + * Move the audio inspector DOM node into the given parent. + * @param {DOMElement} newParentNode: New parent for the inspector. + */ +AudioInspector.prototype.moveInspector_ = function(newParentNode) { + newParentNode.appendChild(this.audioInspector_); +}; + +/** + * Play audio file from url. + * @param {string} metadataFieldName: Metadata field name. + */ +AudioInspector.prototype.playAudio = function(metadataFieldName) { + if (this.metadata_[metadataFieldName] == undefined) { return; } + if (this.metadata_[metadataFieldName] == 'None') { + alert('The selected stream was not used during the experiment.'); + return; + } + this.stopAudio(); + this.audioPlayer_.src = this.metadata_[metadataFieldName]; + this.audioPlayer_.play(); +}; + +/** + * Initialize event handlers. + */ +AudioInspector.prototype.createAudioInspector_ = function() { + var buttonIndex = 0; + function getButtonHtml(icon, toolTipText, caption, metadataFieldName) { + var buttonId = 'audioInspectorButton' + buttonIndex++; + html = caption == null ? '' : caption; + html += '' + + return html; + } + + this.audioInspector_ = document.createElement('div'); + this.audioInspector_.classList.add('audio-inspector'); + this.audioInspector_.innerHTML = + '
' + + '
' + + '
' + + getButtonHtml('play_arrow', 'Simulated echo', 'Ein', + 'echo_filepath') + + '
' + + '
' + + getButtonHtml('stop', 'Stop playing [S]', null, '__stop__') + + '
' + + '
' + + getButtonHtml('play_arrow', 'Render stream', 'Rin', + 'render_filepath') + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + getButtonHtml('play_arrow', 'Capture stream (APM input) [1]', + 'Y\'in', 'capture_filepath') + + '
' + + '
APM
' + + '
' + + getButtonHtml('play_arrow', 'APM output [2]', 'Yout', + 'apm_output_filepath') + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + getButtonHtml('play_arrow', 'Echo-free capture stream', + 'Yin', 'echo_free_capture_filepath') + + '
' + + '
' + + getButtonHtml('play_arrow', 'Clean capture stream', + 'Yclean', 'clean_capture_input_filepath') + + '
' + + '
' + + getButtonHtml('play_arrow', 'APM reference [3]', 'Yref', + 'apm_reference_filepath') + + '
' + + '
' + + '
'; + + // Add an invisible node as initial container for the audio inspector. + var parent = document.createElement('div'); + parent.style.display = 'none'; + this.moveInspector_(parent); + document.body.appendChild(parent); +}; + +/** + * Initialize event handlers. + */ +AudioInspector.prototype.initializeEventHandlers_ = function() { var self = this; + + // Score cells. + document.querySelectorAll('td.single-score-cell').forEach(function(element) { + element.onclick = function() { + self.selectedScoreChange(this); + } + }); + + // Audio inspector buttons. + this.audioInspector_.querySelectorAll('button').forEach(function(element) { + var target = element.querySelector('input[type=hidden]'); + if (target == null) { return; } + element.onclick = function() { + if (target.value == '__stop__') { + self.stopAudio(); + } else { + self.playAudio(target.value); + } + }; + }); + + // Keyboard shortcuts. window.onkeyup = function(e) { var key = e.keyCode ? e.keyCode : e.which; switch (key) { case 49: // 1. - self.playAudioIn(); + self.playAudio('capture_filepath'); break; case 50: // 2. - self.playAudioOut(); + self.playAudio('apm_output_filepath'); break; case 51: // 3. - self.playAudioRef(); + self.playAudio('apm_reference_filepath'); break; case 83: // S. case 115: // s. @@ -67,121 +237,3 @@ Inspector.prototype.init = function() { } }; }; - -/** - * Open the inspector. - * @param {DOMElement} target: score element that has been clicked. - */ -Inspector.prototype.openInspector = function(target) { - if (this.selectedItem_ != null) { - this.selectedItem_.classList.remove('selected'); - } - this.selectedItem_ = target; - this.selectedItem_.classList.add('selected'); - - var target = this.selectedItem_.querySelector('.test-data-gen-desc'); - var testDataGenName = target.querySelector('input[name=gen_name]').value; - var testDataGenParams = target.querySelector('input[name=gen_params]').value; - var audioIn = target.querySelector('input[name=audio_in]').value; - var audioOut = target.querySelector('input[name=audio_out]').value; - var audioRef = target.querySelector('input[name=audio_ref]').value; - - this.divTestDataGeneratorName_.innerHTML = testDataGenName; - this.divTestDataGenParameters_.innerHTML = testDataGenParams; - - this.audioInUrl_ = audioIn; - this.audioOutUrl_ = audioOut; - this.audioRefUrl_ = audioRef; -}; - -/** - * Play APM audio input signal. - */ -Inspector.prototype.playAudioIn = function() { - this.play_(this.audioInUrl_); -}; - -/** - * Play APM audio output signal. - */ -Inspector.prototype.playAudioOut = function() { - this.play_(this.audioOutUrl_); -}; - -/** - * Play APM audio reference signal. - */ -Inspector.prototype.playAudioRef = function() { - this.play_(this.audioRefUrl_); -}; - -/** - * Stop playing audio. - */ -Inspector.prototype.stopAudio = function() { - this.audioPlayer_.pause(); -}; - -/** - * Play audio file from url. - * @param {string} url - */ -Inspector.prototype.play_ = function(url) { - if (url == null) { - alert('Select a score first.'); - return; - } - - this.audioPlayer_.src = url; - this.audioPlayer_.play(); -}; - -/** - * Build inspector. - */ -Inspector.prototype.buildInspector_ = function() { - var self = this; - - this.inspectorNode_.setAttribute('class', 'inspector'); - this.inspectorNode_.innerHTML = - '
' + - '
test data generator
' + - '
' + - '
' + - '
parameters
' + - '
' + - '
'; - - // Add value nodes. - function addValueNode(node, parent_selector) { - node.setAttribute('class', 'value'); - node.innerHTML = '-'; - var parentNode = self.inspectorNode_.querySelector(parent_selector); - parentNode.appendChild(node); - } - addValueNode(this.divTestDataGeneratorName_, 'div.test-data-gen-name'); - addValueNode(this.divTestDataGenParameters_, 'div.test-data-gen-parmas'); - - // Add buttons. - var buttonsNode = this.inspectorNode_.querySelector('div.buttons'); - function addButton(node, caption, callback) { - node.innerHTML = caption; - buttonsNode.appendChild(node); - node.onclick = callback.bind(self); - } - addButton(this.buttonPlayAudioIn_, 'A_in (1)', - this.playAudioIn); - addButton(this.buttonPlayAudioOut_, 'A_out (2)', - this.playAudioOut); - addButton(this.buttonPlayAudioRef_, 'A_ref (3)', - this.playAudioRef); - addButton(this.buttonStopAudio_, 'Stop', this.stopAudio); -}; - -/** - * Instance and initialize the inspector. - */ -function initialize() { - var inspector = new Inspector(); - inspector.init(); -} diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/signal_processing.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/signal_processing.py index 1b58833eb8..9a1f27978b 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/signal_processing.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/signal_processing.py @@ -86,25 +86,113 @@ class SignalProcessingUtils(object): return number_of_samples / signal.channels @classmethod - def GenerateWhiteNoise(cls, signal): - """Generates white noise. + def GenerateSilence(cls, duration=1000, sample_rate=48000): + """Generates silence. - White noise is generated with the same duration and in the same format as a - given signal. + This method can also be used to create a template AudioSegment instance. + A template can then be used with other Generate*() methods accepting an + AudioSegment instance as argument. Args: - signal: AudioSegment instance. + duration: duration in ms. + sample_rate: sample rate. + + Returns: + AudioSegment instance. + """ + return pydub.AudioSegment.silent(duration, sample_rate) + + @classmethod + def GeneratePureTone(cls, template, frequency=440.0): + """Generates a pure tone. + + The pure tone is generated with the same duration and in the same format of + the given template signal. + + Args: + template: AudioSegment instance. + frequency: Frequency of the pure tone in Hz. + + Return: + AudioSegment instance. + """ + if frequency > template.frame_rate >> 1: + raise exceptions.SignalProcessingException('Invalid frequency') + + generator = pydub.generators.Sine( + sample_rate=template.frame_rate, + bit_depth=template.sample_width * 8, + freq=frequency) + + return generator.to_audio_segment( + duration=len(template), + volume=0.0) + + @classmethod + def GenerateWhiteNoise(cls, template): + """Generates white noise. + + The white noise is generated with the same duration and in the same format + of the given template signal. + + Args: + template: AudioSegment instance. Return: AudioSegment instance. """ generator = pydub.generators.WhiteNoise( - sample_rate=signal.frame_rate, - bit_depth=signal.sample_width * 8) + sample_rate=template.frame_rate, + bit_depth=template.sample_width * 8) return generator.to_audio_segment( - duration=len(signal), + duration=len(template), volume=0.0) + @classmethod + def DetectHardClipping(cls, signal, threshold=2): + """Detects hard clipping. + + Hard clipping is simply detected by counting samples that touch either the + lower or upper bound too many times in a row (according to |threshold|). + The presence of a single sequence of samples meeting such property is enough + to label the signal as hard clipped. + + Args: + signal: AudioSegment instance. + threshold: minimum number of samples at full-scale in a row. + + Returns: + True if hard clipping is detect, False otherwise. + """ + if signal.channels != 1: + raise NotImplementedError('mutliple-channel clipping not implemented') + if signal.sample_width != 2: # Note that signal.sample_width is in bytes. + raise exceptions.SignalProcessingException( + 'hard-clipping detection only supported for 16 bit samples') + + # Get raw samples, check type, cast. + samples = signal.get_array_of_samples() + if samples.typecode != 'h': + raise exceptions.SignalProcessingException( + 'hard-clipping detection only supported for 16 bit samples') + samples = np.array(signal.get_array_of_samples(), np.int16) + + # Detect adjacent clipped samples. + samples_type_info = np.iinfo(samples.dtype) + mask_min = samples == samples_type_info.min + mask_max = samples == samples_type_info.max + + def HasLongSequence(vector, min_legth=threshold): + """Returns True if there are one or more long sequences of True flags.""" + seq_length = 0 + for b in vector: + seq_length = seq_length + 1 if b else 0 + if seq_length >= min_legth: + return True + return False + + return HasLongSequence(mask_min) or HasLongSequence(mask_max) + @classmethod def ApplyImpulseResponse(cls, signal, impulse_response): """Applies an impulse response to a signal. diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation.py index a3bf9cceee..7023b6a8c5 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation.py @@ -13,20 +13,31 @@ import logging import os from . import data_access +from . import echo_path_simulation +from . import echo_path_simulation_factory from . import eval_scores from . import eval_scores_factory +from . import input_mixer from . import test_data_generation from . import test_data_generation_factory class ApmModuleSimulator(object): - """APM module simulator class. + """Audio processing module (APM) simulator class. """ _TEST_DATA_GENERATOR_CLASSES = ( test_data_generation.TestDataGenerator.REGISTERED_CLASSES) _EVAL_SCORE_WORKER_CLASSES = eval_scores.EvaluationScore.REGISTERED_CLASSES + _PREFIX_APM_CONFIG = 'apmcfg-' + _PREFIX_CAPTURE = 'capture-' + _PREFIX_RENDER = 'render-' + _PREFIX_ECHO_SIMULATOR = 'echosim-' + _PREFIX_TEST_DATA_GEN = 'datagen-' + _PREFIX_TEST_DATA_GEN_PARAMS = 'datagen_params-' + _PREFIX_SCORE = 'score-' + def __init__(self, aechen_ir_database_path, polqa_tool_bin_path, ap_wrapper, evaluator): # Init. @@ -36,9 +47,11 @@ class ApmModuleSimulator(object): # Instance factory objects. self._test_data_generator_factory = ( test_data_generation_factory.TestDataGeneratorFactory( + output_directory_prefix=self._PREFIX_TEST_DATA_GEN_PARAMS, aechen_ir_database_path=aechen_ir_database_path)) self._evaluation_score_factory = ( eval_scores_factory.EvaluationScoreWorkerFactory( + score_filename_prefix=self._PREFIX_SCORE, polqa_tool_bin_path=polqa_tool_bin_path)) # Properties for each run. @@ -46,21 +59,65 @@ class ApmModuleSimulator(object): self._test_data_generators = None self._evaluation_score_workers = None self._config_filepaths = None - self._input_filepaths = None + self._capture_input_filepaths = None + self._render_input_filepaths = None + self._echo_path_simulator_class = None - def Run(self, config_filepaths, input_filepaths, test_data_generator_names, - eval_score_names, output_dir): + @classmethod + def GetPrefixApmConfig(cls): + return cls._PREFIX_APM_CONFIG + + @classmethod + def GetPrefixCapture(cls): + return cls._PREFIX_CAPTURE + + @classmethod + def GetPrefixRender(cls): + return cls._PREFIX_RENDER + + @classmethod + def GetPrefixEchoSimulator(cls): + return cls._PREFIX_ECHO_SIMULATOR + + @classmethod + def GetPrefixTestDataGenerator(cls): + return cls._PREFIX_TEST_DATA_GEN + + @classmethod + def GetPrefixTestDataGeneratorParameters(cls): + return cls._PREFIX_TEST_DATA_GEN_PARAMS + + @classmethod + def GetPrefixScore(cls): + return cls._PREFIX_SCORE + + def Run(self, config_filepaths, capture_input_filepaths, + test_data_generator_names, eval_score_names, output_dir, + render_input_filepaths=None, echo_path_simulator_name=( + echo_path_simulation.NoEchoPathSimulator.NAME)): """Runs the APM simulation. Initializes paths and required instances, then runs all the simulations. + The render input can be optionally added. If added, the number of capture + input audio tracks and the number of render input audio tracks have to be + equal. The two lists are used to form pairs of capture and render input. Args: config_filepaths: set of APM configuration files to test. - input_filepaths: set of input audio track files to test. + capture_input_filepaths: set of capture input audio track files to test. test_data_generator_names: set of test data generator names to test. eval_score_names: set of evaluation score names to test. output_dir: base path to the output directory for wav files and outcomes. + render_input_filepaths: set of render input audio track files to test. + echo_path_simulator_name: name of the echo path simulator to use when + render input is provided. """ + assert render_input_filepaths is None or ( + len(capture_input_filepaths) == len(render_input_filepaths)), ( + 'render input set size not matching input set size') + assert render_input_filepaths is None or echo_path_simulator_name in ( + echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES), ( + 'invalid echo path simulator') self._base_output_path = os.path.abspath(output_dir) # Instance test data generators. @@ -79,7 +136,20 @@ class ApmModuleSimulator(object): self._config_filepaths = self._CreatePathsCollection(config_filepaths) # Set probing signal file paths. - self._input_filepaths = self._CreatePathsCollection(input_filepaths) + if render_input_filepaths is None: + # Capture input only. + self._capture_input_filepaths = self._CreatePathsCollection( + capture_input_filepaths) + self._render_input_filepaths = None + else: + # Set both capture and render input signals. + self._SetTestInputSignalFilePaths( + capture_input_filepaths, render_input_filepaths) + + # Set the echo path simulator class. + self._echo_path_simulator_class = ( + echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES[ + echo_path_simulator_name]) self._SimulateAll() @@ -87,44 +157,73 @@ class ApmModuleSimulator(object): """Runs all the simulations. Iterates over the combinations of APM configurations, probing signals, and - test data generators. + test data generators. This method is mainly responsible for the creation of + the cache and output directories required in order to call _Simulate(). """ + without_render_input = self._render_input_filepaths is None + # Try different APM config files. for config_name in self._config_filepaths: config_filepath = self._config_filepaths[config_name] - # Try different probing signal files. - for input_name in self._input_filepaths: - input_filepath = self._input_filepaths[input_name] + # Try different capture-render pairs. + for capture_input_name in self._capture_input_filepaths: + capture_input_filepath = self._capture_input_filepaths[ + capture_input_name] + render_input_filepath = None if without_render_input else ( + self._render_input_filepaths[capture_input_name]) + render_input_name = '(none)' if without_render_input else ( + self._ExtractFileName(render_input_filepath)) + + # Instance echo path simulator (if needed). + echo_path_simulator = ( + echo_path_simulation_factory.EchoPathSimulatorFactory.GetInstance( + self._echo_path_simulator_class, render_input_filepath)) # Try different test data generators. for test_data_generators in self._test_data_generators: - logging.info('config: <%s>, input: <%s>, noise: <%s>', - config_name, input_name, test_data_generators.NAME) + logging.info('APM config preset: <%s>, capture: <%s>, render: <%s>,' + 'test data generator: <%s>, echo simulator: <%s>', + config_name, capture_input_name, render_input_name, + test_data_generators.NAME, echo_path_simulator.NAME) - # Output path for the input-noise pairs. It is used to cache the noisy - # copies of the probing signals (shared across some simulations). - input_noise_cache_path = os.path.join( - self._base_output_path, - '_cache', - 'input_{}-noise_{}'.format(input_name, test_data_generators.NAME)) - data_access.MakeDirectory(input_noise_cache_path) - logging.debug('input-noise cache path: <%s>', input_noise_cache_path) + # Output path for the generated test data. + # The path is used to cache the signals shared across simulations. + test_data_cache_path = os.path.join( + self._base_output_path, '_cache', + self._PREFIX_CAPTURE + capture_input_name, + self._PREFIX_TEST_DATA_GEN + test_data_generators.NAME) + data_access.MakeDirectory(test_data_cache_path) + logging.debug('test data cache path: <%s>', test_data_cache_path) + + # Output path for the echo simulator and APM input mixer output. + echo_test_data_cache_path = os.path.join( + test_data_cache_path, 'echosim-{}'.format( + echo_path_simulator.NAME)) + data_access.MakeDirectory(echo_test_data_cache_path) + logging.debug('echo test data cache path: <%s>', + echo_test_data_cache_path) # Full output path. output_path = os.path.join( self._base_output_path, - 'cfg-{}'.format(config_name), - 'input-{}'.format(input_name), - 'gen-{}'.format(test_data_generators.NAME)) + self._PREFIX_APM_CONFIG + config_name, + self._PREFIX_CAPTURE + capture_input_name, + self._PREFIX_RENDER + render_input_name, + self._PREFIX_ECHO_SIMULATOR + echo_path_simulator.NAME, + self._PREFIX_TEST_DATA_GEN + test_data_generators.NAME) data_access.MakeDirectory(output_path) logging.debug('output path: <%s>', output_path) - self._Simulate(test_data_generators, input_filepath, - input_noise_cache_path, output_path, config_filepath) + self._Simulate(test_data_generators, capture_input_filepath, + render_input_filepath, test_data_cache_path, + echo_test_data_cache_path, output_path, + config_filepath, echo_path_simulator) - def _Simulate(self, test_data_generators, input_filepath, - input_noise_cache_path, output_path, config_filepath): + def _Simulate(self, test_data_generators, clean_capture_input_filepath, + render_input_filepath, test_data_cache_path, + echo_test_data_cache_path, output_path, config_filepath, + echo_path_simulator): """Runs a single set of simulation. Simulates a given combination of APM configuration, probing signal, and @@ -133,38 +232,52 @@ class ApmModuleSimulator(object): Args: test_data_generators: TestDataGenerator instance. - input_filepath: input audio track file to test. - input_noise_cache_path: path for the noisy audio track files. + clean_capture_input_filepath: capture input audio track file to be + processed by a test data generator and + not affected by echo. + render_input_filepath: render input audio track file to test. + test_data_cache_path: path for the generated test audio track files. + echo_test_data_cache_path: path for the echo simulator. output_path: base output path for the test data generator. config_filepath: APM configuration file to test. + echo_path_simulator: EchoPathSimulator instance. """ # Generate pairs of noisy input and reference signal files. test_data_generators.Generate( - input_signal_filepath=input_filepath, - input_noise_cache_path=input_noise_cache_path, + input_signal_filepath=clean_capture_input_filepath, + test_data_cache_path=test_data_cache_path, base_output_path=output_path) # For each test data pair, simulate a call and evaluate. for config_name in test_data_generators.config_names: logging.info(' - test data generator config: <%s>', config_name) - # APM input and output signal paths. - noisy_signal_filepath = test_data_generators.noisy_signal_filepaths[ - config_name] + # Paths to the test data generator output. + # Note that the reference signal does not depend on the render input + # which is optional. + noisy_capture_input_filepath = ( + test_data_generators.noisy_signal_filepaths[config_name]) + reference_signal_filepath = ( + test_data_generators.reference_signal_filepaths[config_name]) + + # Output path for the evaluation (e.g., APM output file). evaluation_output_path = test_data_generators.apm_output_paths[ config_name] - # Simulate a call using the audio processing module. + # Paths to the APM input signals. + echo_path_filepath = echo_path_simulator.Simulate( + echo_test_data_cache_path) + apm_input_filepath = input_mixer.ApmInputMixer.Mix( + echo_test_data_cache_path, noisy_capture_input_filepath, + echo_path_filepath) + + # Simulate a call using APM. self._audioproc_wrapper.Run( config_filepath=config_filepath, - input_filepath=noisy_signal_filepath, + capture_input_filepath=apm_input_filepath, + render_input_filepath=render_input_filepath, output_path=evaluation_output_path) - # Reference signal path for the evaluation step. - reference_signal_filepath = ( - test_data_generators.reference_signal_filepaths[ - config_name]) - # Evaluate. self._evaluator.Run( evaluation_score_workers=self._evaluation_score_workers, @@ -172,6 +285,39 @@ class ApmModuleSimulator(object): reference_input_filepath=reference_signal_filepath, output_path=evaluation_output_path) + # Save simulation metadata. + data_access.Metadata.SaveAudioTestDataPaths( + output_path=evaluation_output_path, + clean_capture_input_filepath=clean_capture_input_filepath, + echo_free_capture_filepath=noisy_capture_input_filepath, + echo_filepath=echo_path_filepath, + render_filepath=render_input_filepath, + capture_filepath=apm_input_filepath, + apm_output_filepath=self._audioproc_wrapper.output_filepath, + apm_reference_filepath=reference_signal_filepath) + + def _SetTestInputSignalFilePaths(self, capture_input_filepaths, + render_input_filepaths): + """Sets input and render input file paths collections. + + Pairs the input and render input files by storing the file paths into two + collections. The key is the file name of the input file. + + Args: + capture_input_filepaths: list of file paths. + render_input_filepaths: list of file paths. + """ + self._capture_input_filepaths = {} + self._render_input_filepaths = {} + assert len(capture_input_filepaths) == len(render_input_filepaths) + for capture_input_filepath, render_input_filepath in zip( + capture_input_filepaths, render_input_filepaths): + name = self._ExtractFileName(capture_input_filepath) + self._capture_input_filepaths[name] = os.path.abspath( + capture_input_filepath) + self._render_input_filepaths[name] = os.path.abspath( + render_input_filepath) + @classmethod def _CreatePathsCollection(cls, filepaths): """Creates a collection of file paths. @@ -188,6 +334,10 @@ class ApmModuleSimulator(object): """ filepaths_collection = {} for filepath in filepaths: - name = os.path.splitext(os.path.split(filepath)[1])[0] + name = cls._ExtractFileName(filepath) filepaths_collection[name] = os.path.abspath(filepath) return filepaths_collection + + @classmethod + def _ExtractFileName(cls, filepath): + return os.path.splitext(os.path.split(filepath)[-1])[0] diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation_unittest.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation_unittest.py index 1f5f2d400e..1d4789d6e9 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation_unittest.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/simulation_unittest.py @@ -71,7 +71,7 @@ class TestApmModuleSimulator(unittest.TestCase): # Run all simulations. simulator.Run( config_filepaths=config_files, - input_filepaths=input_files, + capture_input_filepaths=input_files, test_data_generator_names=test_data_generators, eval_score_names=eval_scores, output_dir=self._output_path) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation.py index f42944cedd..2fa49da6e3 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation.py @@ -43,7 +43,7 @@ class TestDataGenerator(object): reference. The former is the clean signal deteriorated by the noise source, the latter goes through the same deterioration process, but more "gently". Noisy signal and reference are produced so that the reference is the signal - expected at the output of the APM module when the latter is fed with the nosiy + expected at the output of the APM module when the latter is fed with the noisy signal. An test data generator generates one or more pairs. @@ -52,7 +52,8 @@ class TestDataGenerator(object): NAME = None REGISTERED_CLASSES = {} - def __init__(self): + def __init__(self, output_directory_prefix): + self._output_directory_prefix = output_directory_prefix # Init dictionaries with one entry for each test data generator # configuration (e.g., different SNRs). # Noisy audio track files (stored separately in a cache folder). @@ -65,7 +66,7 @@ class TestDataGenerator(object): @classmethod def RegisterClass(cls, class_to_register): - """Registers an TestDataGenerator implementation. + """Registers a TestDataGenerator implementation. Decorator to automatically register the classes that extend TestDataGenerator. @@ -95,7 +96,7 @@ class TestDataGenerator(object): return self._reference_signal_filepaths def Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): """Generates a set of noisy input and reference audiotrack file pairs. This method initializes an empty set of pairs and calls the _Generate() @@ -103,12 +104,13 @@ class TestDataGenerator(object): Args: input_signal_filepath: path to the clean input audio track file. - input_noise_cache_path: path to the cache of noisy audio track files. + test_data_cache_path: path to the cache of the generated audio track + files. base_output_path: base path where output is written. """ self.Clear() self._Generate( - input_signal_filepath, input_noise_cache_path, base_output_path) + input_signal_filepath, test_data_cache_path, base_output_path) def Clear(self): """Clears the generated output path dictionaries. @@ -118,7 +120,7 @@ class TestDataGenerator(object): self._reference_signal_filepaths = {} def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): """Abstract method to be implemented in each concrete class. """ raise NotImplementedError() @@ -163,16 +165,10 @@ class TestDataGenerator(object): self._reference_signal_filepaths[config_name] = os.path.abspath( reference_signal_filepath) - # Save noisy and reference file paths. - data_access.Metadata.SaveAudioTestDataPaths( - output_path=output_path, - audio_in_filepath=self._noisy_signal_filepaths[config_name], - audio_ref_filepath=self._reference_signal_filepaths[config_name]) - - @classmethod - def _MakeDir(cls, base_output_path, test_data_generator_config_name): + def _MakeDir(self, base_output_path, test_data_generator_config_name): output_path = os.path.join( - base_output_path, test_data_generator_config_name) + base_output_path, + self._output_directory_prefix + test_data_generator_config_name) data_access.MakeDirectory(output_path) return output_path @@ -186,11 +182,11 @@ class IdentityTestDataGenerator(TestDataGenerator): NAME = 'identity' - def __init__(self): - TestDataGenerator.__init__(self) + def __init__(self, output_directory_prefix): + TestDataGenerator.__init__(self, output_directory_prefix) def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): config_name = 'default' output_path = self._MakeDir(base_output_path, config_name) self._AddNoiseReferenceFilesPair( @@ -219,11 +215,11 @@ class WhiteNoiseTestDataGenerator(TestDataGenerator): _NOISY_SIGNAL_FILENAME_TEMPLATE = 'noise_{0:d}_SNR.wav' - def __init__(self): - TestDataGenerator.__init__(self) + def __init__(self, output_directory_prefix): + TestDataGenerator.__init__(self, output_directory_prefix) def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): # Load the input signal. input_signal = signal_processing.SignalProcessingUtils.LoadWav( input_signal_filepath) @@ -241,7 +237,7 @@ class WhiteNoiseTestDataGenerator(TestDataGenerator): snr_values = set([snr for pair in self._SNR_VALUE_PAIRS for snr in pair]) for snr in snr_values: noisy_signal_filepath = os.path.join( - input_noise_cache_path, + test_data_cache_path, self._NOISY_SIGNAL_FILENAME_TEMPLATE.format(snr)) # Create and save if not done. @@ -276,11 +272,11 @@ class NarrowBandNoiseTestDataGenerator(TestDataGenerator): NAME = 'narrow_band_noise' - def __init__(self): - TestDataGenerator.__init__(self) + def __init__(self, output_directory_prefix): + TestDataGenerator.__init__(self, output_directory_prefix) def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): # TODO(alessiob): implement. pass @@ -316,11 +312,11 @@ class EnvironmentalNoiseTestDataGenerator(TestDataGenerator): [0, 10], # Largest noise. ] - def __init__(self): - TestDataGenerator.__init__(self) + def __init__(self, output_directory_prefix): + TestDataGenerator.__init__(self, output_directory_prefix) def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): """Generates test data pairs using environmental noise. For each noise track and pair of SNR values, the following two audio tracks @@ -356,7 +352,7 @@ class EnvironmentalNoiseTestDataGenerator(TestDataGenerator): noisy_mix_filepaths[noise_track_name] = {} for snr in snr_values: noisy_signal_filepath = os.path.join( - input_noise_cache_path, + test_data_cache_path, self._NOISY_SIGNAL_FILENAME_TEMPLATE.format(noise_track_name, snr)) # Create and save if not done. @@ -405,12 +401,12 @@ class ReverberationTestDataGenerator(TestDataGenerator): _NOISE_TRACK_FILENAME_TEMPLATE = '{0}.wav' _NOISY_SIGNAL_FILENAME_TEMPLATE = '{0}_{1:d}_SNR.wav' - def __init__(self, aechen_ir_database_path): - TestDataGenerator.__init__(self) + def __init__(self, output_directory_prefix, aechen_ir_database_path): + TestDataGenerator.__init__(self, output_directory_prefix) self._aechen_ir_database_path = aechen_ir_database_path def _Generate( - self, input_signal_filepath, input_noise_cache_path, base_output_path): + self, input_signal_filepath, test_data_cache_path, base_output_path): """Generates test data pairs using reverberation noise. For each impulse response, one noise track is created. For each impulse @@ -431,7 +427,7 @@ class ReverberationTestDataGenerator(TestDataGenerator): noise_track_filename = self._NOISE_TRACK_FILENAME_TEMPLATE.format( impulse_response_name) noise_track_filepath = os.path.join( - input_noise_cache_path, noise_track_filename) + test_data_cache_path, noise_track_filename) noise_signal = None try: # Load noise track. @@ -450,7 +446,7 @@ class ReverberationTestDataGenerator(TestDataGenerator): noisy_mix_filepaths[impulse_response_name] = {} for snr in snr_values: noisy_signal_filepath = os.path.join( - input_noise_cache_path, + test_data_cache_path, self._NOISY_SIGNAL_FILENAME_TEMPLATE.format( impulse_response_name, snr)) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_factory.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_factory.py index b5193f2cc5..b42d3af273 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_factory.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_factory.py @@ -21,7 +21,8 @@ class TestDataGeneratorFactory(object): generators will be produced. """ - def __init__(self, aechen_ir_database_path): + def __init__(self, output_directory_prefix, aechen_ir_database_path): + self._output_directory_prefix = output_directory_prefix self._aechen_ir_database_path = aechen_ir_database_path def GetInstance(self, test_data_generators_class): @@ -30,12 +31,14 @@ class TestDataGeneratorFactory(object): Args: test_data_generators_class: TestDataGenerator class object (not an instance). + + Returns: + TestDataGenerator instance. """ logging.debug('factory producing %s', test_data_generators_class) if test_data_generators_class == ( test_data_generation.ReverberationTestDataGenerator): return test_data_generation.ReverberationTestDataGenerator( - self._aechen_ir_database_path) + self._output_directory_prefix, self._aechen_ir_database_path) else: - # By default, no arguments in the constructor. - return test_data_generators_class() + return test_data_generators_class(self._output_directory_prefix) diff --git a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_unittest.py b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_unittest.py index 909d7bad39..0bf7e1ab98 100644 --- a/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_unittest.py +++ b/webrtc/modules/audio_processing/test/py_quality_assessment/quality_assessment/test_data_generation_unittest.py @@ -29,7 +29,7 @@ class TestTestDataGenerators(unittest.TestCase): def setUp(self): """Create temporary folders.""" self._base_output_path = tempfile.mkdtemp() - self._input_noise_cache_path = tempfile.mkdtemp() + self._test_data_cache_path = tempfile.mkdtemp() self._fake_air_db_path = tempfile.mkdtemp() # Fake AIR DB impulse responses. @@ -49,13 +49,13 @@ class TestTestDataGenerators(unittest.TestCase): def tearDown(self): """Recursively delete temporary folders.""" shutil.rmtree(self._base_output_path) - shutil.rmtree(self._input_noise_cache_path) + shutil.rmtree(self._test_data_cache_path) shutil.rmtree(self._fake_air_db_path) def testTestDataGenerators(self): # Preliminary check. self.assertTrue(os.path.exists(self._base_output_path)) - self.assertTrue(os.path.exists(self._input_noise_cache_path)) + self.assertTrue(os.path.exists(self._test_data_cache_path)) # Check that there is at least one registered test data generator. registered_classes = ( @@ -66,6 +66,7 @@ class TestTestDataGenerators(unittest.TestCase): # Instance generators factory. generators_factory = ( test_data_generation_factory.TestDataGeneratorFactory( + output_directory_prefix='datagen-', aechen_ir_database_path=self._fake_air_db_path)) # Use a sample input file as clean input signal. @@ -86,7 +87,7 @@ class TestTestDataGenerators(unittest.TestCase): # Generate the noisy input - reference pairs. generator.Generate( input_signal_filepath=input_signal_filepath, - input_noise_cache_path=self._input_noise_cache_path, + test_data_cache_path=self._test_data_cache_path, base_output_path=self._base_output_path) # Perform checks.