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