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