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