• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
9import argparse
10import collections
11import json
12import logging
13import math
14import numpy
15import os
16import pprint
17import subprocess
18import tempfile
19import wave
20
21# Normal autotest environment.
22try:
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.
28except 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.
35QualityParams = 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
43def 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
124def parse_args(parser):
125    """Parses args."""
126    args = parser.parse_args()
127    return args
128
129
130class WaveFileException(Exception):
131    """Error in WaveFile."""
132    pass
133
134
135class WaveFormatExtensibleException(Exception):
136    """Wave file is in WAVE_FORMAT_EXTENSIBLE format which is not supported."""
137    pass
138
139
140class 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
255class QualityCheckerError(Exception):
256    """Error in QualityChecker."""
257    pass
258
259
260class CompareFailure(QualityCheckerError):
261    """Exception when frequency comparison fails."""
262    pass
263
264
265class QualityFailure(QualityCheckerError):
266    """Exception when quality check fails."""
267    pass
268
269
270class 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
419class CheckQualityError(Exception):
420    """Error in check_quality main function."""
421    pass
422
423
424def 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
454def 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
472if __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