Home | History | Annotate | Download | only in audio
      1 #!/usr/bin/python
      2 
      3 # Copyright 2016 The Chromium OS Authors. All rights reserved.
      4 # Use of this source code is governed by a BSD-style license that can be
      5 # found in the LICENSE file.
      6 
      7 """Command line tool to analyze wave file and detect artifacts."""
      8 
      9 import argparse
     10 import collections
     11 import json
     12 import logging
     13 import math
     14 import numpy
     15 import os
     16 import pprint
     17 import subprocess
     18 import tempfile
     19 import wave
     20 
     21 # Normal autotest environment.
     22 try:
     23     import common
     24     from autotest_lib.client.cros.audio import audio_analysis
     25     from autotest_lib.client.cros.audio import audio_data
     26     from autotest_lib.client.cros.audio import audio_quality_measurement
     27 # Standalone execution without autotest environment.
     28 except ImportError:
     29     import audio_analysis
     30     import audio_data
     31     import audio_quality_measurement
     32 
     33 
     34 # Holder for quality parameters used in audio_quality_measurement module.
     35 QualityParams = collections.namedtuple('QualityParams',
     36       ['block_size_secs',
     37        'frequency_error_threshold',
     38        'delay_amplitude_threshold',
     39        'noise_amplitude_threshold',
     40        'burst_amplitude_threshold'])
     41 
     42 
     43 def add_args(parser):
     44     """Adds command line arguments."""
     45     parser.add_argument('filename', metavar='FILE', type=str,
     46                         help='The wav or raw file to check.'
     47                              'The file format is determined by file extension.'
     48                              'For raw format, user must also pass -b, -r, -c'
     49                              'for bit width, rate, and number of channels.')
     50     parser.add_argument('--debug', action='store_true', default=False,
     51                         help='Show debug message.')
     52     parser.add_argument('--spectral-only', action='store_true', default=False,
     53                         help='Only do spectral analysis on each channel.')
     54     parser.add_argument('--freqs', metavar='FREQ', type=float,
     55                         nargs='*',
     56                         help='Expected frequencies in the channels. '
     57                              'Frequencies are separated by space. '
     58                              'E.g.: --freqs 1000 2000. '
     59                              'It means only the first two '
     60                              'channels (1000Hz, 2000Hz) are to be checked. '
     61                              'Unwanted channels can be specified by 0. '
     62                              'E.g.: --freqs 1000 0 2000 0 3000. '
     63                              'It means only channe 0,2,4 are to be examined.')
     64     parser.add_argument('--freq-threshold', metavar='FREQ_THRESHOLD', type=float,
     65                         default=5,
     66                         help='Frequency difference threshold in Hz. '
     67                              'Default is 5Hz')
     68     parser.add_argument('--ignore-high-freq', metavar='HIGH_FREQ_THRESHOLD',
     69                         type=float, default=5000,
     70                         help='Frequency threshold in Hz to be ignored for '
     71                              'high frequency. Default is 5KHz')
     72     parser.add_argument('--output-file', metavar='OUTPUT_FILE', type=str,
     73                         help='Output file to dump analysis result in JSON format')
     74     parser.add_argument('-b', '--bit-width', metavar='BIT_WIDTH', type=int,
     75                         default=32,
     76                         help='For raw file. Bit width of a sample. '
     77                              'Assume sample format is little-endian signed int. '
     78                              'Default is 32')
     79     parser.add_argument('-r', '--rate', metavar='RATE', type=int,
     80                         default=48000,
     81                         help='For raw file. Sampling rate. Default is 48000')
     82     parser.add_argument('-c', '--channel', metavar='CHANNEL', type=int,
     83                         default=8,
     84                         help='For raw file. Number of channels. '
     85                              'Default is 8.')
     86 
     87     # Arguments for quality measurement customization.
     88     parser.add_argument(
     89              '--quality-block-size-secs',
     90              metavar='BLOCK_SIZE_SECS', type=float,
     91              default=audio_quality_measurement.DEFAULT_BLOCK_SIZE_SECS,
     92              help='Block size for quality measurement. '
     93                   'Refer to audio_quality_measurement module for detail.')
     94     parser.add_argument(
     95              '--quality-frequency-error-threshold',
     96              metavar='FREQ_ERR_THRESHOLD', type=float,
     97              default=audio_quality_measurement.DEFAULT_FREQUENCY_ERROR,
     98              help='Frequency error threshold for identifying sine wave'
     99                   'in quality measurement. '
    100                   'Refer to audio_quality_measurement module for detail.')
    101     parser.add_argument(
    102              '--quality-delay-amplitude-threshold',
    103              metavar='DELAY_AMPLITUDE_THRESHOLD', type=float,
    104              default=audio_quality_measurement.DEFAULT_DELAY_AMPLITUDE_THRESHOLD,
    105              help='Amplitude ratio threshold for identifying delay in sine wave'
    106                   'in quality measurement. '
    107                   'Refer to audio_quality_measurement module for detail.')
    108     parser.add_argument(
    109              '--quality-noise-amplitude-threshold',
    110              metavar='NOISE_AMPLITUDE_THRESHOLD', type=float,
    111              default=audio_quality_measurement.DEFAULT_NOISE_AMPLITUDE_THRESHOLD,
    112              help='Amplitude ratio threshold for identifying noise in sine wave'
    113                   'in quality measurement. '
    114                   'Refer to audio_quality_measurement module for detail.')
    115     parser.add_argument(
    116              '--quality-burst-amplitude-threshold',
    117              metavar='BURST_AMPLITUDE_THRESHOLD', type=float,
    118              default=audio_quality_measurement.DEFAULT_BURST_AMPLITUDE_THRESHOLD,
    119              help='Amplitude ratio threshold for identifying burst in sine wave'
    120                   'in quality measurement. '
    121                   'Refer to audio_quality_measurement module for detail.')
    122 
    123 
    124 def parse_args(parser):
    125     """Parses args."""
    126     args = parser.parse_args()
    127     return args
    128 
    129 
    130 class WaveFileException(Exception):
    131     """Error in WaveFile."""
    132     pass
    133 
    134 
    135 class WaveFormatExtensibleException(Exception):
    136     """Wave file is in WAVE_FORMAT_EXTENSIBLE format which is not supported."""
    137     pass
    138 
    139 
    140 class WaveFile(object):
    141     """Class which handles wave file reading.
    142 
    143     Properties:
    144         raw_data: audio_data.AudioRawData object for data in wave file.
    145         rate: sampling rate.
    146 
    147     """
    148     def __init__(self, filename):
    149         """Inits a wave file.
    150 
    151         @param filename: file name of the wave file.
    152 
    153         """
    154         self.raw_data = None
    155         self.rate = None
    156 
    157         self._wave_reader = None
    158         self._n_channels = None
    159         self._sample_width_bits = None
    160         self._n_frames = None
    161         self._binary = None
    162 
    163         try:
    164             self._read_wave_file(filename)
    165         except WaveFormatExtensibleException:
    166             logging.warning(
    167                     'WAVE_FORMAT_EXTENSIBLE is not supproted. '
    168                     'Try command "sox in.wav -t wavpcm out.wav" to convert '
    169                     'the file to WAVE_FORMAT_PCM format.')
    170             self._convert_and_read_wav_file(filename)
    171 
    172 
    173     def _convert_and_read_wav_file(self, filename):
    174         """Converts the wav file and read it.
    175 
    176         Converts the file into WAVE_FORMAT_PCM format using sox command and
    177         reads its content.
    178 
    179         @param filename: The wave file to be read.
    180 
    181         @raises: RuntimeError: sox is not installed.
    182 
    183         """
    184         # Checks if sox is installed.
    185         try:
    186             subprocess.check_output(['sox', '--version'])
    187         except:
    188             raise RuntimeError('sox command is not installed. '
    189                                'Try sudo apt-get install sox')
    190 
    191         with tempfile.NamedTemporaryFile(suffix='.wav') as converted_file:
    192             command = ['sox', filename, '-t', 'wavpcm', converted_file.name]
    193             logging.debug('Convert the file using sox: %s', command)
    194             subprocess.check_call(command)
    195             self._read_wave_file(converted_file.name)
    196 
    197 
    198     def _read_wave_file(self, filename):
    199         """Reads wave file header and samples.
    200 
    201         @param filename: The wave file to be read.
    202 
    203         @raises WaveFormatExtensibleException: Wave file is in
    204                                                WAVE_FORMAT_EXTENSIBLE format.
    205         @raises WaveFileException: Wave file format is not supported.
    206 
    207         """
    208         try:
    209             self._wave_reader = wave.open(filename, 'r')
    210             self._read_wave_header()
    211             self._read_wave_binary()
    212         except wave.Error as e:
    213             if 'unknown format: 65534' in str(e):
    214                 raise WaveFormatExtensibleException()
    215             else:
    216                 logging.exception('Unsupported wave format')
    217                 raise WaveFileException()
    218         finally:
    219             if self._wave_reader:
    220                 self._wave_reader.close()
    221 
    222 
    223     def _read_wave_header(self):
    224         """Reads wave file header.
    225 
    226         @raises WaveFileException: wave file is compressed.
    227 
    228         """
    229         # Header is a tuple of
    230         # (nchannels, sampwidth, framerate, nframes, comptype, compname).
    231         header = self._wave_reader.getparams()
    232         logging.debug('Wave header: %s', header)
    233 
    234         self._n_channels = header[0]
    235         self._sample_width_bits = header[1] * 8
    236         self.rate = header[2]
    237         self._n_frames = header[3]
    238         comptype = header[4]
    239         compname = header[5]
    240 
    241         if comptype != 'NONE' or compname != 'not compressed':
    242             raise WaveFileException('Can not support compressed wav file.')
    243 
    244 
    245     def _read_wave_binary(self):
    246         """Reads in samples in wave file."""
    247         self._binary = self._wave_reader.readframes(self._n_frames)
    248         format_str = 'S%d_LE' % self._sample_width_bits
    249         self.raw_data = audio_data.AudioRawData(
    250                 binary=self._binary,
    251                 channel=self._n_channels,
    252                 sample_format=format_str)
    253 
    254 
    255 class QualityCheckerError(Exception):
    256     """Error in QualityChecker."""
    257     pass
    258 
    259 
    260 class CompareFailure(QualityCheckerError):
    261     """Exception when frequency comparison fails."""
    262     pass
    263 
    264 
    265 class QualityFailure(QualityCheckerError):
    266     """Exception when quality check fails."""
    267     pass
    268 
    269 
    270 class QualityChecker(object):
    271     """Quality checker controls the flow of checking quality of raw data."""
    272     def __init__(self, raw_data, rate):
    273         """Inits a quality checker.
    274 
    275         @param raw_data: An audio_data.AudioRawData object.
    276         @param rate: Sampling rate.
    277 
    278         """
    279         self._raw_data = raw_data
    280         self._rate = rate
    281         self._spectrals = []
    282         self._quality_result = []
    283 
    284 
    285     def do_spectral_analysis(self, ignore_high_freq, check_quality,
    286                              quality_params):
    287         """Gets the spectral_analysis result.
    288 
    289         @param ignore_high_freq: Ignore high frequencies above this threshold.
    290         @param check_quality: Check quality of each channel.
    291         @param quality_params: A QualityParams object for quality measurement.
    292 
    293         """
    294         self.has_data()
    295         for channel_idx in xrange(self._raw_data.channel):
    296             signal = self._raw_data.channel_data[channel_idx]
    297             max_abs = max(numpy.abs(signal))
    298             logging.debug('Channel %d max abs signal: %f', channel_idx, max_abs)
    299             if max_abs == 0:
    300                 logging.info('No data on channel %d, skip this channel',
    301                               channel_idx)
    302                 continue
    303 
    304             saturate_value = audio_data.get_maximum_value_from_sample_format(
    305                     self._raw_data.sample_format)
    306             normalized_signal = audio_analysis.normalize_signal(
    307                     signal, saturate_value)
    308             logging.debug('saturate_value: %f', saturate_value)
    309             logging.debug('max signal after normalized: %f', max(normalized_signal))
    310             spectral = audio_analysis.spectral_analysis(
    311                     normalized_signal, self._rate)
    312 
    313             logging.debug('Channel %d spectral:\n%s', channel_idx,
    314                           pprint.pformat(spectral))
    315 
    316             # Ignore high frequencies above the threshold.
    317             spectral = [(f, c) for (f, c) in spectral if f < ignore_high_freq]
    318 
    319             logging.info('Channel %d spectral after ignoring high frequencies '
    320                           'above %f:\n%s', channel_idx, ignore_high_freq,
    321                           pprint.pformat(spectral))
    322 
    323             if check_quality:
    324                 quality = audio_quality_measurement.quality_measurement(
    325                         signal=normalized_signal,
    326                         rate=self._rate,
    327                         dominant_frequency=spectral[0][0],
    328                         block_size_secs=quality_params.block_size_secs,
    329                         frequency_error_threshold=quality_params.frequency_error_threshold,
    330                         delay_amplitude_threshold=quality_params.delay_amplitude_threshold,
    331                         noise_amplitude_threshold=quality_params.noise_amplitude_threshold,
    332                         burst_amplitude_threshold=quality_params.burst_amplitude_threshold)
    333 
    334                 logging.debug('Channel %d quality:\n%s', channel_idx,
    335                               pprint.pformat(quality))
    336                 self._quality_result.append(quality)
    337 
    338             self._spectrals.append(spectral)
    339 
    340 
    341     def has_data(self):
    342         """Checks if data has been set.
    343 
    344         @raises QualityCheckerError: if data or rate is not set yet.
    345 
    346         """
    347         if not self._raw_data or not self._rate:
    348             raise QualityCheckerError('Data and rate is not set yet')
    349 
    350 
    351     def check_freqs(self, expected_freqs, freq_threshold):
    352         """Checks the dominant frequencies in the channels.
    353 
    354         @param expected_freq: A list of frequencies. If frequency is 0, it
    355                               means this channel should be ignored.
    356         @param freq_threshold: The difference threshold to compare two
    357                                frequencies.
    358 
    359         """
    360         logging.debug('expected_freqs: %s', expected_freqs)
    361         for idx, expected_freq in enumerate(expected_freqs):
    362             if expected_freq == 0:
    363                 continue
    364             if not self._spectrals[idx]:
    365                 raise CompareFailure(
    366                         'Failed at channel %d: no dominant frequency' % idx)
    367             dominant_freq = self._spectrals[idx][0][0]
    368             if abs(dominant_freq - expected_freq) > freq_threshold:
    369                 raise CompareFailure(
    370                         'Failed at channel %d: %f is too far away from %f' % (
    371                                 idx, dominant_freq, expected_freq))
    372 
    373 
    374     def check_quality(self):
    375         """Checks the quality measurement results on each channel.
    376 
    377         @raises: QualityFailure when there is artifact.
    378 
    379         """
    380         error_msgs = []
    381 
    382         for idx, quality_res in enumerate(self._quality_result):
    383             artifacts = quality_res['artifacts']
    384             if artifacts['noise_before_playback']:
    385                 error_msgs.append(
    386                         'Found noise before playback: %s' % (
    387                                 artifacts['noise_before_playback']))
    388             if artifacts['noise_after_playback']:
    389                 error_msgs.append(
    390                         'Found noise after playback: %s' % (
    391                                 artifacts['noise_after_playback']))
    392             if artifacts['delay_during_playback']:
    393                 error_msgs.append(
    394                         'Found delay during playback: %s' % (
    395                                 artifacts['delay_during_playback']))
    396             if artifacts['burst_during_playback']:
    397                 error_msgs.append(
    398                         'Found burst during playback: %s' % (
    399                                 artifacts['burst_during_playback']))
    400         if error_msgs:
    401             raise QualityFailure('Found bad quality: %s', '\n'.join(error_msgs))
    402 
    403 
    404     def dump(self, output_file):
    405         """Dumps the result into a file in json format.
    406 
    407         @param output_file: A file path to dump spectral and quality
    408                             measurement result of each channel.
    409 
    410         """
    411         dump_dict = {
    412             'spectrals': self._spectrals,
    413             'quality_result': self._quality_result
    414         }
    415         with open(output_file, 'w') as f:
    416             json.dump(dump_dict, f)
    417 
    418 
    419 class CheckQualityError(Exception):
    420     """Error in check_quality main function."""
    421     pass
    422 
    423 
    424 def read_audio_file(args):
    425     """Reads audio file.
    426 
    427     @param args: The namespace parsed from command line arguments.
    428 
    429     @returns: A tuple (raw_data, rate) where raw_data is
    430               audio_data.AudioRawData, rate is sampling rate.
    431 
    432     """
    433     if args.filename.endswith('.wav'):
    434         wavefile = WaveFile(args.filename)
    435         raw_data = wavefile.raw_data
    436         rate = wavefile.rate
    437     elif args.filename.endswith('.raw'):
    438         binary = None
    439         with open(args.filename, 'r') as f:
    440             binary = f.read()
    441 
    442         raw_data = audio_data.AudioRawData(
    443                 binary=binary,
    444                 channel=args.channel,
    445                 sample_format='S%d_LE' % args.bit_width)
    446         rate = args.rate
    447     else:
    448         raise CheckQualityError(
    449                 'File format for %s is not supported' % args.filename)
    450 
    451     return raw_data, rate
    452 
    453 
    454 def get_quality_params(args):
    455     """Gets quality parameters in arguments.
    456 
    457     @param args: The namespace parsed from command line arguments.
    458 
    459     @returns: A QualityParams object.
    460 
    461     """
    462     quality_params = QualityParams(
    463             block_size_secs=args.quality_block_size_secs,
    464             frequency_error_threshold=args.quality_frequency_error_threshold,
    465             delay_amplitude_threshold=args.quality_delay_amplitude_threshold,
    466             noise_amplitude_threshold=args.quality_noise_amplitude_threshold,
    467             burst_amplitude_threshold=args.quality_burst_amplitude_threshold)
    468 
    469     return quality_params
    470 
    471 
    472 if __name__ == "__main__":
    473     parser = argparse.ArgumentParser(
    474         description='Check signal quality of a wave file. Each channel should'
    475                     ' either be all zeros, or sine wave of a fixed frequency.')
    476     add_args(parser)
    477     args = parse_args(parser)
    478 
    479     level = logging.DEBUG if args.debug else logging.INFO
    480     format = '%(asctime)-15s:%(levelname)s:%(pathname)s:%(lineno)d: %(message)s'
    481     logging.basicConfig(format=format, level=level)
    482 
    483     raw_data, rate = read_audio_file(args)
    484 
    485     checker = QualityChecker(raw_data, rate)
    486 
    487     quality_params = get_quality_params(args)
    488 
    489     checker.do_spectral_analysis(ignore_high_freq=args.ignore_high_freq,
    490                                  check_quality=(not args.spectral_only),
    491                                  quality_params=quality_params)
    492 
    493     if args.output_file:
    494         checker.dump(args.output_file)
    495 
    496     if args.freqs:
    497         checker.check_freqs(args.freqs, args.freq_threshold)
    498 
    499     if not args.spectral_only:
    500         checker.check_quality()
    501