1# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved.
2#
3# Use of this source code is governed by a BSD-style license
4# that can be found in the LICENSE file in the root of the source
5# tree. An additional intellectual property rights grant can be found
6# in the file PATENTS.  All contributing project authors may
7# be found in the AUTHORS file in the root of the source tree.
8
9"""APM module simulator.
10"""
11
12import logging
13import os
14
15from . import annotations
16from . import data_access
17from . import echo_path_simulation
18from . import echo_path_simulation_factory
19from . import eval_scores
20from . import exceptions
21from . import input_mixer
22from . import input_signal_creator
23from . import signal_processing
24from . import test_data_generation
25
26
27class ApmModuleSimulator(object):
28  """Audio processing module (APM) simulator class.
29  """
30
31  _TEST_DATA_GENERATOR_CLASSES = (
32      test_data_generation.TestDataGenerator.REGISTERED_CLASSES)
33  _EVAL_SCORE_WORKER_CLASSES = eval_scores.EvaluationScore.REGISTERED_CLASSES
34
35  _PREFIX_APM_CONFIG = 'apmcfg-'
36  _PREFIX_CAPTURE = 'capture-'
37  _PREFIX_RENDER = 'render-'
38  _PREFIX_ECHO_SIMULATOR = 'echosim-'
39  _PREFIX_TEST_DATA_GEN = 'datagen-'
40  _PREFIX_TEST_DATA_GEN_PARAMS = 'datagen_params-'
41  _PREFIX_SCORE = 'score-'
42
43  def __init__(self, test_data_generator_factory, evaluation_score_factory,
44               ap_wrapper, evaluator, external_vads=None):
45    if external_vads is None:
46      external_vads = {}
47    self._test_data_generator_factory = test_data_generator_factory
48    self._evaluation_score_factory = evaluation_score_factory
49    self._audioproc_wrapper = ap_wrapper
50    self._evaluator = evaluator
51    self._annotator = annotations.AudioAnnotationsExtractor(
52        annotations.AudioAnnotationsExtractor.VadType.ENERGY_THRESHOLD |
53        annotations.AudioAnnotationsExtractor.VadType.WEBRTC_COMMON_AUDIO |
54        annotations.AudioAnnotationsExtractor.VadType.WEBRTC_APM,
55        external_vads
56    )
57
58    # Init.
59    self._test_data_generator_factory.SetOutputDirectoryPrefix(
60        self._PREFIX_TEST_DATA_GEN_PARAMS)
61    self._evaluation_score_factory.SetScoreFilenamePrefix(
62        self._PREFIX_SCORE)
63
64    # Properties for each run.
65    self._base_output_path = None
66    self._output_cache_path = None
67    self._test_data_generators = None
68    self._evaluation_score_workers = None
69    self._config_filepaths = None
70    self._capture_input_filepaths = None
71    self._render_input_filepaths = None
72    self._echo_path_simulator_class = None
73
74  @classmethod
75  def GetPrefixApmConfig(cls):
76    return cls._PREFIX_APM_CONFIG
77
78  @classmethod
79  def GetPrefixCapture(cls):
80    return cls._PREFIX_CAPTURE
81
82  @classmethod
83  def GetPrefixRender(cls):
84    return cls._PREFIX_RENDER
85
86  @classmethod
87  def GetPrefixEchoSimulator(cls):
88    return cls._PREFIX_ECHO_SIMULATOR
89
90  @classmethod
91  def GetPrefixTestDataGenerator(cls):
92    return cls._PREFIX_TEST_DATA_GEN
93
94  @classmethod
95  def GetPrefixTestDataGeneratorParameters(cls):
96    return cls._PREFIX_TEST_DATA_GEN_PARAMS
97
98  @classmethod
99  def GetPrefixScore(cls):
100    return cls._PREFIX_SCORE
101
102  def Run(self, config_filepaths, capture_input_filepaths,
103          test_data_generator_names, eval_score_names, output_dir,
104          render_input_filepaths=None, echo_path_simulator_name=(
105              echo_path_simulation.NoEchoPathSimulator.NAME)):
106    """Runs the APM simulation.
107
108    Initializes paths and required instances, then runs all the simulations.
109    The render input can be optionally added. If added, the number of capture
110    input audio tracks and the number of render input audio tracks have to be
111    equal. The two lists are used to form pairs of capture and render input.
112
113    Args:
114      config_filepaths: set of APM configuration files to test.
115      capture_input_filepaths: set of capture input audio track files to test.
116      test_data_generator_names: set of test data generator names to test.
117      eval_score_names: set of evaluation score names to test.
118      output_dir: base path to the output directory for wav files and outcomes.
119      render_input_filepaths: set of render input audio track files to test.
120      echo_path_simulator_name: name of the echo path simulator to use when
121                                render input is provided.
122    """
123    assert render_input_filepaths is None or (
124        len(capture_input_filepaths) == len(render_input_filepaths)), (
125            'render input set size not matching input set size')
126    assert render_input_filepaths is None or echo_path_simulator_name in (
127        echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES), (
128            'invalid echo path simulator')
129    self._base_output_path = os.path.abspath(output_dir)
130
131    # Output path used to cache the data shared across simulations.
132    self._output_cache_path = os.path.join(self._base_output_path, '_cache')
133
134    # Instance test data generators.
135    self._test_data_generators = [self._test_data_generator_factory.GetInstance(
136        test_data_generators_class=(
137            self._TEST_DATA_GENERATOR_CLASSES[name])) for name in (
138                test_data_generator_names)]
139
140    # Instance evaluation score workers.
141    self._evaluation_score_workers = [
142        self._evaluation_score_factory.GetInstance(
143            evaluation_score_class=self._EVAL_SCORE_WORKER_CLASSES[name]) for (
144                name) in eval_score_names]
145
146    # Set APM configuration file paths.
147    self._config_filepaths = self._CreatePathsCollection(config_filepaths)
148
149    # Set probing signal file paths.
150    if render_input_filepaths is None:
151      # Capture input only.
152      self._capture_input_filepaths = self._CreatePathsCollection(
153          capture_input_filepaths)
154      self._render_input_filepaths = None
155    else:
156      # Set both capture and render input signals.
157      self._SetTestInputSignalFilePaths(
158          capture_input_filepaths, render_input_filepaths)
159
160    # Set the echo path simulator class.
161    self._echo_path_simulator_class = (
162        echo_path_simulation.EchoPathSimulator.REGISTERED_CLASSES[
163            echo_path_simulator_name])
164
165    self._SimulateAll()
166
167  def _SimulateAll(self):
168    """Runs all the simulations.
169
170    Iterates over the combinations of APM configurations, probing signals, and
171    test data generators. This method is mainly responsible for the creation of
172    the cache and output directories required in order to call _Simulate().
173    """
174    without_render_input = self._render_input_filepaths is None
175
176    # Try different APM config files.
177    for config_name in self._config_filepaths:
178      config_filepath = self._config_filepaths[config_name]
179
180      # Try different capture-render pairs.
181      for capture_input_name in self._capture_input_filepaths:
182        # Output path for the capture signal annotations.
183        capture_annotations_cache_path = os.path.join(
184            self._output_cache_path,
185            self._PREFIX_CAPTURE + capture_input_name)
186        data_access.MakeDirectory(capture_annotations_cache_path)
187
188        # Capture.
189        capture_input_filepath = self._capture_input_filepaths[
190            capture_input_name]
191        if not os.path.exists(capture_input_filepath):
192          # If the input signal file does not exist, try to create using the
193          # available input signal creators.
194          self._CreateInputSignal(capture_input_filepath)
195        assert os.path.exists(capture_input_filepath)
196        self._ExtractCaptureAnnotations(
197            capture_input_filepath, capture_annotations_cache_path)
198
199        # Render and simulated echo path (optional).
200        render_input_filepath = None if without_render_input else (
201            self._render_input_filepaths[capture_input_name])
202        render_input_name = '(none)' if without_render_input else (
203            self._ExtractFileName(render_input_filepath))
204        echo_path_simulator = (
205            echo_path_simulation_factory.EchoPathSimulatorFactory.GetInstance(
206                self._echo_path_simulator_class, render_input_filepath))
207
208        # Try different test data generators.
209        for test_data_generators in self._test_data_generators:
210          logging.info('APM config preset: <%s>, capture: <%s>, render: <%s>,'
211                       'test data generator: <%s>,  echo simulator: <%s>',
212                       config_name, capture_input_name, render_input_name,
213                       test_data_generators.NAME, echo_path_simulator.NAME)
214
215          # Output path for the generated test data.
216          test_data_cache_path = os.path.join(
217              capture_annotations_cache_path,
218              self._PREFIX_TEST_DATA_GEN + test_data_generators.NAME)
219          data_access.MakeDirectory(test_data_cache_path)
220          logging.debug('test data cache path: <%s>', test_data_cache_path)
221
222          # Output path for the echo simulator and APM input mixer output.
223          echo_test_data_cache_path = os.path.join(
224              test_data_cache_path, 'echosim-{}'.format(
225                  echo_path_simulator.NAME))
226          data_access.MakeDirectory(echo_test_data_cache_path)
227          logging.debug('echo test data cache path: <%s>',
228                        echo_test_data_cache_path)
229
230          # Full output path.
231          output_path = os.path.join(
232              self._base_output_path,
233              self._PREFIX_APM_CONFIG + config_name,
234              self._PREFIX_CAPTURE + capture_input_name,
235              self._PREFIX_RENDER + render_input_name,
236              self._PREFIX_ECHO_SIMULATOR + echo_path_simulator.NAME,
237              self._PREFIX_TEST_DATA_GEN + test_data_generators.NAME)
238          data_access.MakeDirectory(output_path)
239          logging.debug('output path: <%s>', output_path)
240
241          self._Simulate(test_data_generators, capture_input_filepath,
242                         render_input_filepath, test_data_cache_path,
243                         echo_test_data_cache_path, output_path,
244                         config_filepath, echo_path_simulator)
245
246  @staticmethod
247  def _CreateInputSignal(input_signal_filepath):
248    """Creates a missing input signal file.
249
250    The file name is parsed to extract input signal creator and params. If a
251    creator is matched and the parameters are valid, a new signal is generated
252    and written in |input_signal_filepath|.
253
254    Args:
255      input_signal_filepath: Path to the input signal audio file to write.
256
257    Raises:
258      InputSignalCreatorException
259    """
260    filename = os.path.splitext(os.path.split(input_signal_filepath)[-1])[0]
261    filename_parts = filename.split('-')
262
263    if len(filename_parts) < 2:
264      raise exceptions.InputSignalCreatorException(
265          'Cannot parse input signal file name')
266
267    signal, metadata = input_signal_creator.InputSignalCreator.Create(
268        filename_parts[0], filename_parts[1].split('_'))
269
270    signal_processing.SignalProcessingUtils.SaveWav(
271        input_signal_filepath, signal)
272    data_access.Metadata.SaveFileMetadata(input_signal_filepath, metadata)
273
274  def _ExtractCaptureAnnotations(self, input_filepath, output_path,
275                                 annotation_name=""):
276    self._annotator.Extract(input_filepath)
277    self._annotator.Save(output_path, annotation_name)
278
279  def _Simulate(self, test_data_generators, clean_capture_input_filepath,
280                render_input_filepath, test_data_cache_path,
281                echo_test_data_cache_path, output_path, config_filepath,
282                echo_path_simulator):
283    """Runs a single set of simulation.
284
285    Simulates a given combination of APM configuration, probing signal, and
286    test data generator. It iterates over the test data generator
287    internal configurations.
288
289    Args:
290      test_data_generators: TestDataGenerator instance.
291      clean_capture_input_filepath: capture input audio track file to be
292                                    processed by a test data generator and
293                                    not affected by echo.
294      render_input_filepath: render input audio track file to test.
295      test_data_cache_path: path for the generated test audio track files.
296      echo_test_data_cache_path: path for the echo simulator.
297      output_path: base output path for the test data generator.
298      config_filepath: APM configuration file to test.
299      echo_path_simulator: EchoPathSimulator instance.
300    """
301    # Generate pairs of noisy input and reference signal files.
302    test_data_generators.Generate(
303        input_signal_filepath=clean_capture_input_filepath,
304        test_data_cache_path=test_data_cache_path,
305        base_output_path=output_path)
306
307    # Extract metadata linked to the clean input file (if any).
308    apm_input_metadata = None
309    try:
310      apm_input_metadata = data_access.Metadata.LoadFileMetadata(
311          clean_capture_input_filepath)
312    except IOError as e:
313      apm_input_metadata = {}
314    apm_input_metadata['test_data_gen_name'] = test_data_generators.NAME
315    apm_input_metadata['test_data_gen_config'] = None
316
317    # For each test data pair, simulate a call and evaluate.
318    for config_name in test_data_generators.config_names:
319      logging.info(' - test data generator config: <%s>', config_name)
320      apm_input_metadata['test_data_gen_config'] = config_name
321
322      # Paths to the test data generator output.
323      # Note that the reference signal does not depend on the render input
324      # which is optional.
325      noisy_capture_input_filepath = (
326          test_data_generators.noisy_signal_filepaths[config_name])
327      reference_signal_filepath = (
328          test_data_generators.reference_signal_filepaths[config_name])
329
330      # Output path for the evaluation (e.g., APM output file).
331      evaluation_output_path = test_data_generators.apm_output_paths[
332          config_name]
333
334      # Paths to the APM input signals.
335      echo_path_filepath = echo_path_simulator.Simulate(
336          echo_test_data_cache_path)
337      apm_input_filepath = input_mixer.ApmInputMixer.Mix(
338          echo_test_data_cache_path, noisy_capture_input_filepath,
339          echo_path_filepath)
340
341      # Extract annotations for the APM input mix.
342      apm_input_basepath, apm_input_filename = os.path.split(
343          apm_input_filepath)
344      self._ExtractCaptureAnnotations(
345          apm_input_filepath, apm_input_basepath,
346          os.path.splitext(apm_input_filename)[0] + '-')
347
348      # Simulate a call using APM.
349      self._audioproc_wrapper.Run(
350          config_filepath=config_filepath,
351          capture_input_filepath=apm_input_filepath,
352          render_input_filepath=render_input_filepath,
353          output_path=evaluation_output_path)
354
355      try:
356        # Evaluate.
357        self._evaluator.Run(
358            evaluation_score_workers=self._evaluation_score_workers,
359            apm_input_metadata=apm_input_metadata,
360            apm_output_filepath=self._audioproc_wrapper.output_filepath,
361            reference_input_filepath=reference_signal_filepath,
362            render_input_filepath=render_input_filepath,
363            output_path=evaluation_output_path,
364        )
365
366        # Save simulation metadata.
367        data_access.Metadata.SaveAudioTestDataPaths(
368            output_path=evaluation_output_path,
369            clean_capture_input_filepath=clean_capture_input_filepath,
370            echo_free_capture_filepath=noisy_capture_input_filepath,
371            echo_filepath=echo_path_filepath,
372            render_filepath=render_input_filepath,
373            capture_filepath=apm_input_filepath,
374            apm_output_filepath=self._audioproc_wrapper.output_filepath,
375            apm_reference_filepath=reference_signal_filepath,
376            apm_config_filepath=config_filepath,
377        )
378      except exceptions.EvaluationScoreException as e:
379        logging.warning('the evaluation failed: %s', e.message)
380        continue
381
382  def _SetTestInputSignalFilePaths(self, capture_input_filepaths,
383                                   render_input_filepaths):
384    """Sets input and render input file paths collections.
385
386    Pairs the input and render input files by storing the file paths into two
387    collections. The key is the file name of the input file.
388
389    Args:
390      capture_input_filepaths: list of file paths.
391      render_input_filepaths: list of file paths.
392    """
393    self._capture_input_filepaths = {}
394    self._render_input_filepaths = {}
395    assert len(capture_input_filepaths) == len(render_input_filepaths)
396    for capture_input_filepath, render_input_filepath in zip(
397        capture_input_filepaths, render_input_filepaths):
398      name = self._ExtractFileName(capture_input_filepath)
399      self._capture_input_filepaths[name] = os.path.abspath(
400          capture_input_filepath)
401      self._render_input_filepaths[name] = os.path.abspath(
402          render_input_filepath)
403
404  @classmethod
405  def _CreatePathsCollection(cls, filepaths):
406    """Creates a collection of file paths.
407
408    Given a list of file paths, makes a collection with one item for each file
409    path. The value is absolute path, the key is the file name without
410    extenstion.
411
412    Args:
413      filepaths: list of file paths.
414
415    Returns:
416      A dict.
417    """
418    filepaths_collection = {}
419    for filepath in filepaths:
420      name = cls._ExtractFileName(filepath)
421      filepaths_collection[name] = os.path.abspath(filepath)
422    return filepaths_collection
423
424  @classmethod
425  def _ExtractFileName(cls, filepath):
426    return os.path.splitext(os.path.split(filepath)[-1])[0]
427