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