guitarsounds.analysis
1from soundfile import write 2import IPython.display as ipd 3import matplotlib.ticker as ticker 4import matplotlib.pyplot as plt 5import matplotlib.cm 6import numpy as np 7import os 8import scipy 9import scipy.optimize 10import scipy.integrate 11import scipy.interpolate 12from scipy.integrate import trapezoid 13from scipy import signal as sig 14from guitarsounds.parameters import sound_parameters 15import guitarsounds.utils as utils 16from tabulate import tabulate 17import wave 18 19 20""" 21Classes 22""" 23 24class SoundPack(object): 25 """ 26 A class to store and analyse multiple sounds 27 Some methods are only available for SoundPacks containing two sounds 28 """ 29 30 def __init__(self, *sounds, names=None, fundamentals=None, 31 SoundParams=None, equalize_time=True): 32 """ 33 The SoundPack can be instantiated from existing Sound class instances, 34 either in a list or as multiple arguments 35 The class can also handle the creation of Sound class instances if the 36 arguments are filenames, either a list or multiple arguments. 37 38 :param sounds: `guitarsounds.Sound` instaces or filenames either as 39 multiple arguments or as a list 40 :param names: list of strings with the names of the sounds that will be 41 used in the plot legend labels 42 :param fundamentals: list of numbers corresponding to the known sound 43 fundamental frequencies. 44 :param SoundParams: `guitarsounds.SoundParams` instance used to get 45 the parameters used in the computation of the sound attributes 46 :param equalize_time: if True, all the sounds used to create the 47 SoundPack are truncated to the length of the shortest sound. 48 49 If the number of Sound contained is equal to two, the SoundPack will 50 be 'dual' and the associated methods will be available : 51 52 - `SoundPack.compare_peaks` 53 - `SoundPack.fft_mirror` 54 - `SoundPack.fft_diff` 55 - `SoundPack.integral_compare` 56 57 If it contains multiple sounds the SoundPack will be multiple and a 58 reduced number of methods will be available to call 59 60 If the fundamental frequency is supplied for each sound, the 61 computation of certain features can be more efficient, such as the 62 time damping computation or Hemlotz frequency computation. 63 64 Examples : 65 ```python 66 Sound_Test = SoundPack('sounds/test1.wav', 'sounds/test2.wav', names=['A', 'B'], fundamentals = [134, 134]) 67 68 sounds = [sound1, sound2, sound3, sound4, sound5] # instances of the Sound class 69 large_Test = SoundPack(sounds, names=['1', '2', '3', '4', '5']) 70 ``` 71 """ 72 # create a copy of the sound parameters 73 if SoundParams is None: 74 self.SP = sound_parameters() 75 else: 76 self.SP = SoundParams 77 78 # Check if the sounds argument is a list 79 if type(sounds[0]) is list: 80 sounds = sounds[0] # unpack the list 81 82 # Check for special case 83 if len(sounds) == 2: 84 # special case to compare two sounds 85 self.kind = 'dual' 86 87 elif len(sounds) > 1: 88 # general case for multiple sounds 89 self.kind = 'multiple' 90 91 # If filenames are supplied 92 if type(sounds[0]) is str: 93 self.sounds_from_files(sounds, names=names, fundamentals=fundamentals) 94 95 # Else sound instances are supplied 96 else: 97 self.sounds = sounds 98 99 # sound name defined in constructor 100 if names and (len(names) == len(self.sounds)): 101 for sound, n in zip(self.sounds, names): 102 sound.name = n 103 104 else: 105 # names obtained from the supplied sounds 106 names = [sound.name for sound in self.sounds if sound.name] 107 108 # all sounds have a name 109 if len(names) == len(sounds): 110 self.names = names 111 112 # Assign a default value to names 113 else: 114 names = [str(n) for n in np.arange(1, len(sounds) + 1)] 115 for sound, n in zip(self.sounds, names): 116 sound.name = n 117 118 # If the sounds are not conditionned condition them 119 for s in self.sounds: 120 if ~hasattr(s, 'signal'): 121 s.condition() 122 123 if equalize_time: 124 self.equalize_time() 125 126 # Define bin strings 127 self.bin_strings = [*list(self.SP.bins.__dict__.keys())[1:], 'brillance'] 128 129 # Sort according to fundamental 130 key = np.argsort([sound.fundamental for sound in self.sounds]) 131 self.sounds = np.array(self.sounds)[key] 132 133 def sounds_from_files(self, sound_files, names=None, fundamentals=None): 134 """ 135 Create Sound class instances and assign them to the SoundPack 136 from a list of files 137 138 :param sound_files: sound filenames 139 :param names: sound names 140 :param fundamentals: user specified fundamental frequencies 141 :return: None 142 """ 143 # Make the default name list from sound filenames if none is supplied 144 if (names is None) or (len(names) != len(sound_files)): 145 names = [os.path.split(file)[-1][:-4] for file in sound_files] # remove the .wav 146 147 # If the fundamentals are not supplied or mismatch in number None is used 148 if (fundamentals is None) or (len(fundamentals) != len(sound_files)): 149 fundamentals = len(sound_files) * [None] 150 151 # Create Sound instances from files 152 self.sounds = [] 153 for file, name, fundamental in zip(sound_files, names, fundamentals): 154 self.sounds.append(Sound(file, name=name, fundamental=fundamental, 155 SoundParams=self.SP)) 156 157 def equalize_time(self): 158 """ 159 Trim the sounds so that they all have the length of the shortest sound, 160 trimming is done at the end of the sounds. 161 :return: None 162 """ 163 trim_index = np.min([len(sound.signal.signal) for sound in self.sounds]) 164 trimmed_sounds = [] 165 for sound in self.sounds: 166 new_sound = sound 167 new_sound.signal = new_sound.signal.trim_time(trim_index / sound.signal.sr) 168 new_sound.bin_divide() 169 trimmed_sounds.append(new_sound) 170 self.sounds = trimmed_sounds 171 172 def normalize(self): 173 """ 174 Normalize all the signals in the SoundPack and returns a normalized 175 instance of itself. See `Signal.normalize` for more information. 176 :return: SoundPack with normalized signals 177 """ 178 new_sounds = [] 179 names = [sound.name for sound in self.sounds] 180 fundamentals = [sound.fundamental for sound in self.sounds] 181 for sound in self.sounds: 182 sound.signal = sound.signal.normalize() 183 new_sounds.append(sound) 184 185 self.sounds = new_sounds 186 187 return self 188 189 """ 190 Methods for all SoundPacks 191 """ 192 193 def plot(self, kind, **kwargs): 194 """ 195 Superimposed plot of all the sounds on one figure for a specific kind 196 197 :param kind: feature name passed to the `signal.plot()` method 198 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 199 method 200 :return: None 201 202 __ Multiple SoundPack Method __ 203 Plots a specific signal.plot for all sounds on the same figure 204 205 Ex : SoundPack.plot('fft') plots the fft of all sounds on a single figure 206 The color argument is set to none so that the plots have different colors 207 """ 208 plt.figure(figsize=(8, 6)) 209 for sound in self.sounds: 210 kwargs['label'] = sound.name 211 kwargs['color'] = None 212 sound.signal.plot.method_dict[kind](**kwargs) 213 ax = plt.gca() 214 ax.set_title(kind + ' plot') 215 ax.legend() 216 return ax 217 218 def compare_plot(self, kind, **kwargs): 219 """ 220 Plots all the sounds on different figures to compare them for a specific kind 221 222 :param kind: feature name passed to the `signal.plot()` method 223 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 224 method 225 :return: None 226 227 __ Multiple SoundPack Method __ 228 Draws the same kind of plot on a different axis for each sound 229 Example : `SoundPack.compare_plot('peaks')` with 4 Sounds will plot a 230 figure with 4 axes, with each a different 'peak' plot. 231 """ 232 # if a dual SoundPack : only plot two big plots 233 if self.kind == 'dual': 234 fig, axs = plt.subplots(1, 2, figsize=(12, 4)) 235 for sound, ax in zip(self.sounds, axs): 236 plt.sca(ax) 237 sound.signal.plot.method_dict[kind](**kwargs) 238 ax.set_title(kind + ' ' + sound.name) 239 plt.tight_layout() 240 241 # If a multiple SoundPack : plot on a grid of axes 242 elif self.kind == 'multiple': 243 # find the n, m values for the subplots line and columns 244 n = len(self.sounds) 245 cols = 0 246 if n // 4 >= 10: 247 # a lot of sounds 248 cols = 4 249 elif n // 3 >= 10: 250 # many sounds 251 cols = 3 252 elif n // 2 <= 4: 253 # a few sounds 254 cols = 2 255 256 remainder = n % cols 257 if remainder == 0: 258 rows = n // cols 259 else: 260 rows = n // cols + 1 261 262 fig, axs = plt.subplots(rows, cols, figsize=(12, 4 * rows)) 263 axs = axs.reshape(-1) 264 for sound, ax in zip(self.sounds, axs): 265 plt.sca(ax) 266 sound.signal.plot.method_dict[kind](**kwargs) 267 title = ax.get_title() 268 title = sound.name + ' ' + title 269 ax.set_title(title) 270 271 if remainder != 0: 272 for ax in axs[-(cols - remainder):]: 273 ax.set_axis_off() 274 plt.tight_layout() 275 else: 276 raise Exception 277 return axs 278 279 def freq_bin_plot(self, f_bin='all'): 280 """ 281 Plots the log envelope of specified frequency bins 282 :param f_bin: frequency bins to compare, Supported arguments are : 283 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 284 285 __ Multiple SoundPack Method __ 286 A function to compare signals decomposed frequency wise in the time 287 domain on a logarithm scale. The methods plot all the sounds and plots 288 their frequency bins according to the frequency bin argument f_bin. 289 290 Example : SoundPack.freq_bin_plot(f_bin='mid') will plot the log-scale 291 envelope of the 'mid' signal of every sound in the SoundPack. 292 """ 293 294 if f_bin == 'all': 295 # Create one plot per bin 296 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 297 axs = axs.reshape(-1) 298 for key, ax in zip([*list(self.SP.bins.__dict__.keys())[1:], 'brillance'], axs): 299 plt.sca(ax) 300 # plot every sound for a frequency bin 301 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 302 for i, son in enumerate(self.sounds): 303 son.bins[key].normalize().old_plot('log envelope', label=son.name) 304 plt.xscale('log') 305 plt.legend() 306 son = self.sounds[-1] 307 title0 = ' ' + key + ' : ' + str(int(son.bins[key].freq_range[0])) + ' - ' + str( 308 int(son.bins[key].freq_range[1])) + ' Hz, ' 309 title1 = 'Norm. Factors : ' 310 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 311 plt.title(title0 + title1 + title2) 312 plt.tight_layout() 313 314 elif f_bin in [*list(sound_parameters().bins.__dict__.keys())[1:], 'brillance']: 315 plt.figure(figsize=(10, 4)) 316 # Plot every envelope for a single frequency bin 317 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 318 for i, son in enumerate(self.sounds): 319 son.bins[f_bin].normalize().old_plot('log envelope', label=(str(i + 1) + '. ' + son.name)) 320 plt.xscale('log') 321 plt.legend() 322 son = self.sounds[-1] 323 title0 = ' ' + f_bin + ' : ' + str(int(son.bins[f_bin].freq_range[0])) + ' - ' + str( 324 int(son.bins[f_bin].freq_range[1])) + ' Hz, ' 325 title1 = 'Norm. Factors : ' 326 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 327 plt.title(title0 + title1 + title2) 328 329 else: 330 print('invalid frequency bin') 331 332 def fundamentals(self): 333 """ 334 Displays the fundamentals of every sound in the SoundPack 335 336 :return: None 337 338 __ Multiple Soundpack Method __ 339 """ 340 names = np.array([sound.name for sound in self.sounds]) 341 fundamentals = np.array([np.around(sound.fundamental, 1) for sound in self.sounds]) 342 key = np.argsort(fundamentals) 343 table_data = [names[key], fundamentals[key]] 344 345 table_data = np.array(table_data).transpose() 346 347 print(tabulate(table_data, headers=['Name', 'Fundamental (Hz)'])) 348 349 def integral_plot(self, f_bin='all'): 350 """ 351 Normalized cumulative bin power plot for the frequency bins. 352 See `Plot.integral` for more information. 353 354 :param f_bin: frequency bins to compare, Supported arguments are 355 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 356 357 __ Multiple SoundPack Method __ 358 Plots the cumulative integral plot of specified frequency bins 359 see help(Plot.integral) 360 """ 361 362 if f_bin == 'all': 363 # create a figure with 6 axes 364 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 365 axs = axs.reshape(-1) 366 367 for key, ax in zip(self.bin_strings, axs): 368 plt.sca(ax) 369 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 370 for sound in self.sounds: 371 sound.bins[key].plot.integral(label=sound.name) 372 plt.legend() 373 sound = self.sounds[-1] 374 title0 = ' ' + key + ' : ' + str(int(sound.bins[key].freq_range[0])) + ' - ' + str( 375 int(sound.bins[key].freq_range[1])) + ' Hz, ' 376 title1 = 'Norm. Factors : ' 377 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 378 plt.title(title0 + title1 + title2) 379 plt.title(title0 + title1 + title2) 380 plt.tight_layout() 381 382 elif f_bin in self.bin_strings: 383 fig, ax = plt.subplots(figsize=(6, 4)) 384 plt.sca(ax) 385 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 386 for sound in self.sounds: 387 sound.bins[f_bin].plot.integral(label=sound.name) 388 plt.legend() 389 sound = self.sounds[-1] 390 title0 = ' ' + f_bin + ' : ' + str(int(sound.bins[f_bin].freq_range[0])) + ' - ' + str( 391 int(sound.bins[f_bin].freq_range[1])) + ' Hz, ' 392 title1 = 'Norm. Factors : ' 393 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 394 plt.title(title0 + title1 + title2) 395 396 else: 397 print('invalid frequency bin') 398 399 def bin_power_table(self): 400 """ 401 Displays a table with the signal power contained in every frequency bin 402 403 The power is computed as the time integral of the signal, such as : 404 405 $ P = \int_0^{t_{max}} sig(t) dt $ 406 407 __ Multiple SoundPack Method __ 408 The sounds are always normalized before computing the power. Because 409 the signal amplitude is normalized between -1 and 1, the power value 410 is adimentional, and can only be used to compare two sounds between 411 eachother. 412 """ 413 # Bin power distribution table 414 bin_strings = self.bin_strings 415 integrals = [] 416 417 # for every sound in the SoundPack 418 for sound in self.sounds: 419 420 integral = [] 421 # for every frequency bin in the sound 422 for f_bin in bin_strings: 423 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 424 integral.append(scipy.integrate.trapezoid(log_envelope, log_time)) 425 426 # a list of dict for every sound 427 integrals.append(integral) 428 429 # make the table 430 table_data = np.array([list(bin_strings), *integrals]).transpose() 431 sound_names = [sound.name for sound in self.sounds] 432 433 print('___ Signal Power Frequency Bin Distribution ___ \n') 434 print(tabulate(table_data, headers=['bin', *sound_names])) 435 436 def bin_power_hist(self): 437 """ 438 Histogram of the frequency bin power for multiple sounds 439 440 The power is computed as the time integral of the signal, such as : 441 442 $ P = \int_0^{t_{max}} sig(t) dt $ 443 444 __ Multiple SoundPack Method __ 445 The sounds are always normalized before computing the power. Because 446 the signal amplitude is normalized between -1 and 1, the power value 447 is adimentional, and can only be used to compare two sounds between 448 eachother. 449 """ 450 # Compute the bin powers 451 bin_strings = self.bin_strings 452 integrals = [] 453 454 # for every sound in the SoundPack 455 for sound in self.sounds: 456 457 integral = [] 458 # for every frequency bin in the sound 459 for f_bin in bin_strings: 460 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 461 integral.append(trapezoid(log_envelope, log_time)) 462 463 # a list of dict for every sound 464 integral = np.array(integral) 465 integral /= np.max(integral) 466 integrals.append(integral) 467 468 # create the bar plotting vectors 469 fig, ax = plt.subplots(figsize=(6, 6)) 470 471 # make the bar plot 472 n = len(self.sounds) 473 width = 0.8 / n 474 # get nice colors 475 cmap = matplotlib.cm.get_cmap('Set2') 476 for i, sound in enumerate(self.sounds): 477 x = np.arange(i * width, len(bin_strings) + i * width) 478 y = integrals[i] 479 if n < 8: 480 color = cmap(i) 481 else: 482 color = None 483 484 if i == n // 2: 485 ax.bar(x, y, width=width, tick_label=list(bin_strings), label=sound.name, color=color) 486 else: 487 ax.bar(x, y, width=width, label=sound.name, color=color) 488 ax.set_xlabel('frequency bin name') 489 ax.set_ylabel('normalized power') 490 plt.legend() 491 492 def listen(self): 493 """ 494 Listen to all the sounds in the SoundPack inside the Jupyter Notebook 495 environment 496 497 __ Multiple SoundPack Method __ 498 """ 499 for sound in self.sounds: 500 sound.signal.listen() 501 502 """ 503 Methods for dual SoundPacks 504 """ 505 506 def compare_peaks(self): 507 """ 508 Plot to compare the FFT peaks values of two sounds 509 510 __ Dual SoundPack Method __ 511 Compares the peaks in the Fourier Transform of two Sounds, 512 the peaks having the highest difference are highlighted. If no peaks 513 are found to have a significant difference, only the two normalized 514 Fourier transform are plotted in a mirror configuration to visualize 515 them. 516 """ 517 if self.kind == 'dual': 518 son1 = self.sounds[0] 519 son2 = self.sounds[1] 520 index1 = np.where(son1.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 521 index2 = np.where(son2.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 522 523 # Get the peak data from the sounds 524 peaks1 = son1.signal.peaks() 525 peaks2 = son2.signal.peaks() 526 freq1 = son1.signal.fft_frequencies()[:index1] 527 freq2 = son2.signal.fft_frequencies()[:index2] 528 fft1 = son1.signal.fft()[:index1] 529 fft2 = son2.signal.fft()[:index2] 530 531 short_peaks_1 = [peak for peak in peaks1 if peak < (len(freq1) - 1)] 532 short_peaks_2 = [peak for peak in peaks2 if peak < (len(freq1) - 1)] 533 peak_distance1 = np.mean([freq1[peaks1[i]] - freq1[peaks1[i + 1]] for i in range(len(short_peaks_1) - 1)]) / 4 534 peak_distance2 = np.mean([freq2[peaks2[i]] - freq2[peaks2[i + 1]] for i in range(len(short_peaks_2) - 1)]) / 4 535 peak_distance = np.abs(np.mean([peak_distance1, peak_distance2])) 536 537 # Align the two peak vectors 538 new_peaks1 = [] 539 new_peaks2 = [] 540 for peak1 in short_peaks_1: 541 for peak2 in short_peaks_2: 542 if np.abs(freq1[peak1] - freq2[peak2]) < peak_distance: 543 new_peaks1.append(peak1) 544 new_peaks2.append(peak2) 545 new_peaks1 = np.unique(np.array(new_peaks1)) 546 new_peaks2 = np.unique(np.array(new_peaks2)) 547 548 different_peaks1 = [] 549 different_peaks2 = [] 550 difference_threshold = 0.5 551 while len(different_peaks1) < 1: 552 for peak1, peak2 in zip(new_peaks1, new_peaks2): 553 if np.abs(fft1[peak1] - fft2[peak2]) > difference_threshold: 554 different_peaks1.append(peak1) 555 different_peaks2.append(peak2) 556 difference_threshold -= 0.01 557 if np.isclose(difference_threshold, 0.): 558 break 559 560 # Plot the output 561 plt.figure(figsize=(10, 6)) 562 plt.yscale('symlog', linthresh=10e-1) 563 564 # Sound 1 565 plt.plot(freq1, fft1, color='#919191', label=son1.name) 566 plt.scatter(freq1[new_peaks1], fft1[new_peaks1], color='b', label='peaks') 567 if len(different_peaks1) > 0: 568 plt.scatter(freq1[different_peaks1[0]], fft1[different_peaks1[0]], color='g', label='diff peaks') 569 annotation_string = 'Peaks with ' + str(np.around(difference_threshold, 2)) + ' difference' 570 plt.annotate(annotation_string, (freq1[different_peaks1[0]] + peak_distance / 2, fft1[different_peaks1[0]])) 571 572 # Sound2 573 plt.plot(freq2, -fft2, color='#3d3d3d', label=son2.name) 574 plt.scatter(freq2[new_peaks2], -fft2[new_peaks2], color='b') 575 if len(different_peaks2) > 0: 576 plt.scatter(freq2[different_peaks2[0]], -fft2[different_peaks2[0]], color='g') 577 plt.title('Fourier Transform Peak Analysis for ' + son1.name + ' and ' + son2.name) 578 plt.grid('on') 579 plt.legend() 580 ax = plt.gca() 581 ax.set_xlabel('frequency (Hz)') 582 ax.set_ylabel('mirror amplitude (0-1)') 583 else: 584 raise ValueError('Unsupported for multiple sounds SoundPacks') 585 586 def fft_mirror(self): 587 """ 588 Plot the Fourier Transforms of two sounds on opposed axis to compare the spectral content 589 590 __ Dual SoundPack Method __ 591 The fourier transforms are plotted normalized between 0 and 1. 592 The y scale is symmetric logarithmic, so that a signal is plotted 593 between 0 and -1, and the other is plotted between 0 and 1. 594 :return: None 595 """ 596 if self.kind == 'dual': 597 son1 = self.sounds[0] 598 son2 = self.sounds[1] 599 fft_range_value = sound_parameters().general.fft_range.value 600 fft_freq_value = son1.signal.fft_frequencies() 601 index = np.where(fft_freq_value > fft_range_value)[0][0] 602 603 plt.figure(figsize=(10, 6)) 604 plt.yscale('symlog') 605 plt.grid('on') 606 plt.plot(son1.signal.fft_frequencies()[:index], son1.signal.fft()[:index], label=son1.name) 607 plt.plot(son2.signal.fft_frequencies()[:index], -son2.signal.fft()[:index], label=son2.name) 608 plt.xlabel('frequency (Hz)') 609 plt.ylabel('mirror amplitude (normalized)') 610 plt.title('Mirror Fourier Transform for ' + son1.name + ' and ' + son2.name) 611 plt.legend() 612 613 else: 614 print('Unsupported for multiple sounds SoundPacks') 615 616 def fft_diff(self, fraction=3, ticks=None): 617 """ 618 Plot the difference between the spectral distribution in the two sounds 619 620 :param fraction: octave fraction value used to compute the frequency bins A higher number will show 621 a more precise comparison, but conclusions may be harder to draw. 622 :param ticks: If equal to 'bins' the frequency bins intervals are used as X axis ticks 623 :return: None 624 625 __ Dual SoundPack Method __ 626 Compare the Fourier Transform of two sounds by computing the differences of the octave bins heights. 627 The two FTs are superimposed on the first plot to show differences 628 The difference between the two FTs is plotted on the second plot 629 """ 630 if self.kind == 'dual': 631 # Separate the sounds 632 son1 = self.sounds[0] 633 son2 = self.sounds[1] 634 635 # Compute plotting bins 636 x_values = utils.octave_values(fraction) 637 hist_bins = utils.octave_histogram(fraction) 638 bar_widths = np.array([hist_bins[i + 1] - hist_bins[i] for i in range(0, len(hist_bins) - 1)]) 639 640 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) 641 plot1 = ax1.hist(son1.signal.fft_bins(), utils.octave_histogram(fraction), color='blue', alpha=0.6, 642 label=son1.name) 643 plot2 = ax1.hist(son2.signal.fft_bins(), utils.octave_histogram(fraction), color='orange', alpha=0.6, 644 label=son2.name) 645 ax1.set_title('FT Histogram for ' + son1.name + ' and ' + son2.name) 646 ax1.set_xscale('log') 647 ax1.set_xlabel('frequency (Hz)') 648 ax1.set_ylabel('amplitude') 649 ax1.grid('on') 650 ax1.legend() 651 652 diff = plot1[0] - plot2[0] 653 n_index = np.where(diff <= 0)[0] 654 p_index = np.where(diff >= 0)[0] 655 656 # Negative difference corresponding to sound 2 657 ax2.bar(x_values[n_index], diff[n_index], width=bar_widths[n_index], color='orange', alpha=0.6) 658 # Positive difference corresponding to sound1 659 ax2.bar(x_values[p_index], diff[p_index], width=bar_widths[p_index], color='blue', alpha=0.6) 660 ax2.set_title('Difference ' + son1.name + ' - ' + son2.name) 661 ax2.set_xscale('log') 662 ax2.set_xlabel('frequency (Hz)') 663 ax2.set_ylabel('<- sound 2 : sound 1 ->') 664 ax2.grid('on') 665 666 if ticks == 'bins': 667 labels = [label for label in self.SP.bins.__dict__ if label != 'name'] 668 labels.append('brillance') 669 x = [param.value for param in self.SP.bins.__dict__.values() if param != 'bins'] 670 x.append(11250) 671 x_formatter = ticker.FixedFormatter(labels) 672 x_locator = ticker.FixedLocator(x) 673 ax1.xaxis.set_major_locator(x_locator) 674 ax1.xaxis.set_major_formatter(x_formatter) 675 ax1.tick_params(axis="x", labelrotation=90) 676 ax2.xaxis.set_major_locator(x_locator) 677 ax2.xaxis.set_major_formatter(x_formatter) 678 ax2.tick_params(axis="x", labelrotation=90) 679 680 else: 681 print('Unsupported for multiple sounds SoundPacks') 682 683 def integral_compare(self, f_bin='all'): 684 """ 685 Cumulative bin envelope integral comparison for two signals 686 687 :param f_bin: frequency bins to compare, Supported arguments are : 688 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 689 :return: None 690 691 __ Dual SoundPack Method __ 692 Plots the cumulative integral plot of specified frequency bins 693 and their difference as surfaces 694 """ 695 696 # Case when plotting all the frequency bins 697 if f_bin == 'all': 698 fig, axs = plt.subplots(3, 2, figsize=(16, 16)) 699 axs = axs.reshape(-1) 700 701 # get the bins frequency values 702 self.bin_strings = self.sounds[0].bins.keys() 703 bins1 = self.sounds[0].bins.values() 704 bins2 = self.sounds[1].bins.values() 705 706 for signal1, signal2, bin_string, ax in zip(bins1, bins2, self.bin_strings, axs): 707 # Compute the log time and envelopes integrals 708 log_envelope1, log_time1 = signal1.normalize().log_envelope() 709 log_envelope2, log_time2 = signal2.normalize().log_envelope() 710 env_range1 = np.arange(2, len(log_envelope1), 1) 711 env_range2 = np.arange(2, len(log_envelope2), 1) 712 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in env_range1]) 713 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in env_range2]) 714 time1 = log_time1[2:len(log_time1):1] 715 time2 = log_time2[2:len(log_time2):1] 716 717 # resize arrays to match shape 718 common_len = min(len(time1), len(time2)) 719 time1 = time1[:common_len] 720 time2 = time2[:common_len] 721 integral1 = integral1[:common_len] 722 integral2 = integral2[:common_len] 723 # Normalize 724 max_value = np.max(np.hstack([integral1, integral2])) 725 integral1 /= max_value 726 integral2 /= max_value 727 728 # plot the integral area curves 729 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 730 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 731 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 732 ax.set_xlabel('time (s)') 733 ax.set_ylabel('mirror cumulative power (normalized)') 734 ax.set_xscale('log') 735 ax.set_title(bin_string) 736 ax.legend() 737 ax.grid('on') 738 739 plt.tight_layout() 740 741 elif f_bin in self.bin_strings: 742 743 # Compute the log envelopes and areau curves 744 signal1 = self.sounds[0].bins[f_bin] 745 signal2 = self.sounds[1].bins[f_bin] 746 log_envelope1, log_time1 = signal1.normalize().log_envelope() 747 log_envelope2, log_time2 = signal2.normalize().log_envelope() 748 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in np.arange(2, len(log_envelope1), 1)]) 749 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in np.arange(2, len(log_envelope2), 1)]) 750 time1 = log_time1[2:len(log_time1):1] 751 time2 = log_time2[2:len(log_time2):1] 752 753 # resize arrays to match shape 754 common_len = min(len(time1), len(time2)) 755 time1 = time1[:common_len] 756 time2 = time2[:common_len] 757 integral1 = integral1[:common_len] 758 integral2 = integral2[:common_len] 759 # Normalize 760 max_value = np.max(np.hstack([integral1, integral2])) 761 integral1 /= max_value 762 integral2 /= max_value 763 764 fig, ax = plt.subplots(figsize=(8, 6)) 765 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 766 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 767 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 768 769 ax.set_xlabel('time (s)') 770 ax.set_ylabel('mirror cumulative power (normalized)') 771 ax.set_xscale('log') 772 ax.set_title(f_bin) 773 ax.legend(loc='upper left') 774 ax.grid('on') 775 776 else: 777 print('invalid frequency bin') 778 779 780class Sound(object): 781 """ 782 A class to store audio signals corresponding to a sound and compute 783 features on them. 784 """ 785 786 def __init__(self, data, name='', fundamental=None, condition=True, 787 auto_trim=False, use_raw_signal=False, 788 normalize_raw_signal=False, SoundParams=None): 789 """ 790 Creates a Sound instance from a .wav file. A string can be supplied to 791 give a name to the sound. The fundamental frequency value can be 792 specified to increase the accuracy of certain features. 793 794 :param data: path to the .wav data file 795 :param name: name to use in plot legend and titles 796 :param fundamental: Fundamental frequency value if None the value is 797 estimated from the Fourier transform of the sound. 798 :param condition: Bool, whether to condition or not the Sound instance 799 if `True`, the `Sound` instance is conditioned in the constructor 800 :param auto_trim: Bool, whether to trim the end of the sound or not 801 according to a predefined sound length determined based on the 802 fundamental frequency of the sound. 803 :param use_raw_signal: Do not condition the `Sound` and instead 804 use the raw signal read from the file. 805 :param normalize_raw_signal: If `use_raw_signal` is `True`, setting 806 `normalize_raw_signal` to `True` will normalize the raw signal before it 807 is used in the `Sound` class. 808 :param SoundParams: SoundParameters to use with the Sound instance 809 """ 810 # create a reference of the parameters 811 if SoundParams is None: 812 self.SP = sound_parameters() 813 else: 814 self.SP = SoundParams 815 816 if type(data) == str: 817 # Load the sound data using librosa 818 if data.split('.')[-1] != 'wav': 819 raise ValueError('Only .wav are supported') 820 else: 821 signal, sr = utils.load_wav(data) 822 823 elif type(data) == tuple: 824 signal, sr = data 825 826 else: 827 raise TypeError 828 829 # create a Signal class from the signal and sample rate 830 self.raw_signal = Signal(signal, sr, self.SP) 831 # create an empty plot attribute 832 self.plot = None 833 # Allow user specified fundamental 834 self.fundamental = fundamental 835 self.name = name 836 # create an empty signal attribute 837 self.signal = None 838 self.trimmed_signal = None 839 self.bins = None 840 self.bass = None 841 self.mid = None 842 self.highmid = None 843 self.uppermid = None 844 self.presence = None 845 self.brillance = None 846 847 if condition: 848 self.condition(verbose=True, 849 return_self=False, 850 auto_trim=auto_trim, 851 resample=True) 852 else: 853 if use_raw_signal: 854 self.use_raw_signal(normalized=normalize_raw_signal, 855 return_self=False) 856 857 def condition(self, verbose=True, return_self=False, auto_trim=False, resample=True): 858 """ 859 A method conditioning the Sound instance by trimming it 0.1s before the 860 onset and dividing it into frequency bins. 861 :param verbose: if True problems with the trimming process are reported 862 :param return_self: If True the method returns the conditioned Sound 863 instance 864 :param auto_trim: If True, the sound is trimmed to a fixed length 865 according to its fundamental 866 :param resample: If True, the signal is resampled to 22050 Hz 867 :return: a conditioned Sound instance if `return_self = True` 868 """ 869 # Resample only if the sample rate is not 22050 870 if resample & (self.raw_signal.sr != 22050): 871 signal, sr = self.raw_signal.signal, self.raw_signal.sr 872 self.raw_signal = Signal(utils.resample(signal, sr, 22050), 22050, self.SP) 873 874 self.trim_signal(verbose=verbose) 875 self.signal = self.trimmed_signal 876 if self.fundamental is None: 877 self.fundamental = self.signal.fundamental() 878 if auto_trim: 879 time = utils.freq2trim(self.fundamental) 880 self.signal = self.signal.trim_time(time) 881 self.plot = self.signal.plot 882 self.bin_divide() 883 if return_self: 884 return self 885 886 def use_raw_signal(self, normalized=False, return_self=False): 887 """ 888 Assigns the raw signal to the `signal` attribute of the Sound instance. 889 :param normalized: if True, the raw signal is first normalized 890 :param return_self: if True the Sound instance is return after the 891 signal attribute is defined 892 :return: self if return_self is True, else None 893 """ 894 if normalized: 895 self.signal = self.raw_signal.normalize() 896 else: 897 self.signal = self.raw_signal 898 self.bin_divide() 899 if return_self: 900 return self 901 902 def bin_divide(self): 903 """ 904 Calls the `.make_freq_bins` method of the signal to create the 905 signals instances associated to the frequency bins. 906 :return: None 907 908 The bins are all stored in the `.bin` attribute and also as 909 their names (Ex: `Sound.mid` contains the mid signal). The cutoff 910 frequencies of the different bins can be changed in the 911 `SoundParameters` instance of the Sound under the attribute `.SP`. 912 See guitarsounds.parameters.sound_parameters().bins.info() for the 913 frequency bin intervals. 914 """ 915 # divide in frequency bins 916 self.bins = self.signal.make_freq_bins() 917 # unpack the bins 918 self.bass, self.mid, self.highmid, self.uppermid, self.presence, self.brillance = self.bins.values() 919 920 def trim_signal(self, verbose=True): 921 """ 922 A method to trim the signal to a specific time before the onset. 923 :param verbose: if True problems encountered are printed to the terminal 924 :return: None 925 926 The default time value of 0.1s can be changed in the SoundParameters. 927 """ 928 # Trim the signal in the signal class 929 self.trimmed_signal = self.raw_signal.trim_onset(verbose=verbose) 930 931 def listen_freq_bins(self): 932 """ 933 Method to listen to all the frequency bins of a sound 934 :return: None 935 936 See `help(Sound.bin_divide)` for more information. 937 """ 938 for key in self.bins.keys(): 939 print(key) 940 self.bins[key].normalize().listen() 941 942 def plot_freq_bins(self, bins='all'): 943 """ 944 Method to plot all the frequency bins logarithmic envelopes of a sound 945 :return: None 946 947 The parameter `bins` allows choosing specific frequency bins to plot 948 By default the function plots all the bins 949 Supported bins arguments are : 950 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 951 952 Example : 953 `Sound.plot_freq_bins(bins='all')` plots all the frequency bins 954 `Sound.plot_freq_bins(bins=['bass', 'mid'])` plots the bass and mid bins 955 956 For more information on the logarithmic envelope, see : 957 `help(Signal.log_envelope)` 958 """ 959 960 if bins[0] == 'all': 961 bins = 'all' 962 963 if bins == 'all': 964 bins = self.bins.keys() 965 966 for key in bins: 967 range_start = str(int(self.bins[key].freq_range[0])) 968 range_end = str(int(self.bins[key].freq_range[1])) 969 lab = key + ' : ' + range_start + ' - ' + range_end + ' Hz' 970 self.bins[key].plot.log_envelope(label=lab) 971 972 plt.xscale('log') 973 plt.yscale('log') 974 plt.legend(fontsize="x-small") # using a named size 975 976 def peak_damping(self): 977 """ 978 Prints a table with peak damping values and peak frequency values 979 980 The peaks are found with the `signal.peaks()` function and the damping 981 values are computed using the half power bandwidth method. 982 983 see `help(Signal.peak_damping)` for more information. 984 """ 985 peak_indexes = self.signal.peaks() 986 frequencies = self.signal.fft_frequencies()[peak_indexes] 987 damping = self.signal.peak_damping() 988 table_data = np.array([frequencies, np.array(damping) * 100]).transpose() 989 print(tabulate(table_data, headers=['Frequency (Hz)', 'Damping ratio (%)'])) 990 991 def bin_hist(self): 992 """ 993 Histogram of the frequency bin power 994 995 Frequency bin power is computed as the integral of the bin envelope. 996 The power value is non dimensional and normalized. 997 998 See guitarsounds.parameters.sound_parameters().bins.info() for the 999 frequency bin frequency intervals. 1000 """ 1001 # Compute the bin powers 1002 bin_strings = list(self.bins.keys()) 1003 integrals = [] 1004 1005 for f_bin in bin_strings: 1006 log_envelope, log_time = self.bins[f_bin].normalize().log_envelope() 1007 integral = trapezoid(log_envelope, log_time) 1008 integrals.append(integral) 1009 max_value = np.max(integrals) 1010 integrals = np.array(integrals)/max_value 1011 1012 # create the bar plotting vectors 1013 fig, ax = plt.subplots(figsize=(6, 6)) 1014 1015 x = np.arange(0, len(bin_strings)) 1016 y = integrals 1017 ax.bar(x, y, tick_label=list(bin_strings)) 1018 ax.set_xlabel("frequency bin name") 1019 ax.set_ylabel("frequency bin power (normalized)") 1020 1021 1022class Signal(object): 1023 """ 1024 A Class to do computations on an audio signal. 1025 1026 The signal is never changed in the class, when transformations are made, a new instance is returned. 1027 """ 1028 1029 def __init__(self, signal, sr, SoundParam=None, freq_range=None): 1030 """ 1031 Create a Signal class from a vector of samples and a sample rate. 1032 1033 :param signal: vector containing the signal samples 1034 :param sr: sample rate of the signal (Hz) 1035 :param SoundParam: Sound Parameter instance to use with the signal 1036 """ 1037 if SoundParam is None: 1038 self.SP = sound_parameters() 1039 else: 1040 self.SP = SoundParam 1041 self.onset = None 1042 self.signal = signal 1043 self.sr = sr 1044 self.plot = Plot() 1045 self.plot.parent = self 1046 self.norm_factor = None 1047 self.freq_range = freq_range 1048 1049 def time(self): 1050 """ 1051 Returns the time vector associated with the signal 1052 :return: numpy array corresponding to the time values 1053 of the signal samples in seconds 1054 """ 1055 return np.linspace(0, 1056 len(self.signal) * (1 / self.sr), 1057 len(self.signal)) 1058 1059 def listen(self): 1060 """ 1061 Method to listen the sound signal in a Jupyter Notebook 1062 :return: None 1063 1064 Listening to the sounds imported in the analysis tool allows the 1065 user to validate if the sound was well trimmed and filtered 1066 1067 A temporary file is created, the IPython display Audio function is 1068 called on it, and then the file is removed. 1069 """ 1070 file = 'temp.wav' 1071 write(file, self.signal, self.sr) 1072 ipd.display(ipd.Audio(file)) 1073 os.remove(file) 1074 1075 def old_plot(self, kind, **kwargs): 1076 """ 1077 Convenience function for the different signal plots 1078 1079 Calls the function corresponding to Plot.kind() 1080 See help(guitarsounds.analysis.Plot) for info on the different plots 1081 """ 1082 self.plot.method_dict[kind](**kwargs) 1083 1084 def fft(self): 1085 """ 1086 Computes the Fast Fourier Transform of the signal and returns the 1087 normalized amplitude vector. 1088 :return: Fast Fourier Transform amplitude values in a numpy array 1089 """ 1090 fft = np.fft.fft(self.signal) 1091 # Only the symmetric part of the absolute value 1092 fft = np.abs(fft[:int(len(fft) // 2)]) 1093 return fft / np.max(fft) 1094 1095 def spectral_centroid(self): 1096 """ 1097 Spectral centroid of the frequency content of the signal 1098 :return: Spectral centroid of the signal (float) 1099 1100 The spectral centroid corresponds to the frequency where the area 1101 under the fourier transform curve is equal on both sides. 1102 This feature is usefull in determining the global frequency content 1103 of a sound. A sound having an overall higher spectral centroid would 1104 be perceived as higher, or brighter. 1105 """ 1106 SC = np.sum(self.fft() * self.fft_frequencies()) / np.sum(self.fft()) 1107 return SC 1108 1109 1110 def peaks(self, max_freq=None, height=False, result=False): 1111 """ 1112 Computes the harmonic peaks indexes from the Fourier Transform of 1113 the signal. 1114 :param max_freq: Supply a max frequency value overriding the one in 1115 guitarsounds_parameters 1116 :param height: if True the height threshold is returned to be used 1117 in the 'peaks' plot 1118 :param result: if True the Scipy peak finding results dictionary 1119 is returned 1120 :return: peak indexes 1121 1122 Because the sound is assumed to be harmonic, the Fourier transform 1123 peaks should be positionned at frequencies $nf$, where $f$ is the 1124 fundamental frequency of the signal. The peak data is usefull to 1125 compare the frequency content of two sounds, as in 1126 `Sound.compare_peaks`. 1127 """ 1128 # Replace None by the default value 1129 if max_freq is None: 1130 max_freq = self.SP.general.fft_range.value 1131 1132 # Get the fft and fft frequencies from the signal 1133 fft, fft_freq = self.fft(), self.fft_frequencies() 1134 1135 # Find the max index 1136 try: 1137 max_index = np.where(fft_freq >= max_freq)[0][0] 1138 except IndexError: 1139 max_index = fft_freq.shape[0] 1140 1141 # Find an approximation of the distance between peaks, this only works for harmonic signals 1142 peak_distance = np.argmax(fft) // 2 1143 1144 # Maximum of the signal in a small region on both ends 1145 fft_max_start = np.max(fft[:peak_distance]) 1146 fft_max_end = np.max(fft[max_index - peak_distance:max_index]) 1147 1148 # Build the curve below the peaks but above the noise 1149 exponents = np.linspace(np.log10(fft_max_start), np.log10(fft_max_end), max_index) 1150 intersect = 10 ** exponents[peak_distance] 1151 diff_start = fft_max_start - intersect # offset by a small distance so that the first max is not a peak 1152 min_height = 10 ** np.linspace(np.log10(fft_max_start + diff_start), np.log10(fft_max_end), max_index) 1153 1154 first_peak_indexes, _ = sig.find_peaks(fft[:max_index], height=min_height, distance=peak_distance) 1155 1156 number_of_peaks = len(first_peak_indexes) 1157 if number_of_peaks > 0: 1158 average_len = int(max_index / number_of_peaks) * 3 1159 else: 1160 average_len = int(max_index / 3) 1161 1162 if average_len % 2 == 0: 1163 average_len += 1 1164 1165 average_fft = sig.savgol_filter(fft[:max_index], average_len, 1, mode='mirror') * 1.9 1166 min_freq_index = np.where(fft_freq >= 70)[0][0] 1167 average_fft[:min_freq_index] = 1 1168 1169 peak_indexes, res = sig.find_peaks(fft[:max_index], height=average_fft, distance=min_freq_index) 1170 1171 # Remove noisy peaks at the low frequencies 1172 while fft[peak_indexes[0]] < 5e-2: 1173 peak_indexes = np.delete(peak_indexes, 0) 1174 while fft[peak_indexes[-1]] < 1e-4: 1175 peak_indexes = np.delete(peak_indexes, -1) 1176 1177 if not height and not result: 1178 return peak_indexes 1179 elif height: 1180 return peak_indexes, average_fft 1181 elif result: 1182 return peak_indexes, res 1183 elif height and result: 1184 return peak_indexes, height, res 1185 1186 def time_damping(self): 1187 """ 1188 Computes the time wise damping ratio of the signal 1189 :return: The damping ratio (float). 1190 1191 by fitting a negative exponential curve 1192 to the Signal envelope and computing the ratio with the Signal fundamental frequency. 1193 """ 1194 # Get the envelope data 1195 envelope, envelope_time = self.normalize().envelope() 1196 1197 # First point is the maximum because e^-kt is strictly decreasing 1198 first_index = np.argmax(envelope) 1199 1200 # The second point is the first point where the signal crosses the lower_threshold line 1201 second_point_thresh = self.SP.damping.lower_threshold.value 1202 try: 1203 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1204 except IndexError: 1205 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh * 2)[0] 1206 1207 # Function to compute the residual for the exponential curve fit 1208 def residual_function(zeta_w, t, s): 1209 """ 1210 Function computing the residual to curve fit a negative exponential to the signal envelope 1211 :param zeta_w: zeta*omega constant 1212 :param t: time vector 1213 :param s: signal 1214 :return: residual 1215 """ 1216 return np.exp(zeta_w[0] * t) - s 1217 1218 zeta_guess = [-0.5] 1219 1220 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1221 args=(envelope_time[first_index:second_index], 1222 envelope[first_index:second_index])) 1223 # Get the zeta*omega constant 1224 zeta_omega = result.x[0] 1225 1226 # Compute the fundamental frequency in radians of the signal 1227 wd = 2 * np.pi * self.fundamental() 1228 return -zeta_omega / wd 1229 1230 def peak_damping(self): 1231 """ 1232 Computes the frequency wise damping with the half bandwidth method on the Fourier Transform peaks 1233 :return: an array containing the peak damping values 1234 """ 1235 zetas = [] 1236 fft_freqs = self.fft_frequencies() 1237 fft = self.fft()[:len(fft_freqs)] 1238 for peak in self.peaks(): 1239 peak_frequency = fft_freqs[peak] 1240 peak_height = fft[peak] 1241 root_height = peak_height / np.sqrt(2) 1242 frequency_roots = scipy.interpolate.InterpolatedUnivariateSpline(fft_freqs, fft - root_height).roots() 1243 sorted_roots_indexes = np.argsort(np.abs(frequency_roots - peak_frequency)) 1244 w2, w1 = frequency_roots[sorted_roots_indexes[:2]] 1245 w1, w2 = np.sort([w1, w2]) 1246 zeta = (w2 - w1) / (2 * peak_frequency) 1247 zetas.append(zeta) 1248 return np.array(zetas) 1249 1250 def fundamental(self): 1251 """ 1252 Returns the fundamental approximated by the first peak of the fft 1253 :return: fundamental value (Hz) 1254 """ 1255 index = self.peaks()[0] 1256 fundamental = self.fft_frequencies()[index] 1257 return fundamental 1258 1259 def cavity_peak(self): 1260 """ 1261 Finds the Hemlotz cavity frequency index from the Fourier Transform by searching for a peak in the expected 1262 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1263 is printed and None is returned. 1264 :return: The index of the cavity peak 1265 """ 1266 first_index = np.where(self.fft_frequencies() >= 80)[0][0] 1267 second_index = np.where(self.fft_frequencies() >= 110)[0][0] 1268 cavity_peak = np.argmax(self.fft()[first_index:second_index]) + first_index 1269 return cavity_peak 1270 1271 def cavity_frequency(self): 1272 """ 1273 Finds the Hemlotz cavity frequency from the Fourier Transform by searching for a peak in the expected 1274 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1275 is printed and None is returned. 1276 :return: If successful, the cavity peak frequency 1277 """ 1278 cavity_peak = self.cavity_peak() 1279 if self.fundamental() == self.fft_frequencies()[cavity_peak]: 1280 print('Cavity peak is obscured by the fundamental') 1281 return 0 1282 else: 1283 return self.fft_frequencies()[cavity_peak] 1284 1285 def fft_frequencies(self): 1286 """ 1287 Computes the frequency vector associated with the Signal Fourier Transform 1288 :return: an array containing the frequency values. 1289 """ 1290 fft = self.fft() 1291 fft_frequencies = np.fft.fftfreq(len(fft) * 2, 1 / self.sr) # Frequencies corresponding to the bins 1292 return fft_frequencies[:len(fft)] 1293 1294 def fft_bins(self): 1295 """ 1296 Transforms the Fourier transform array into a statistic distribution 1297 ranging from 0 to 100. 1298 Accordingly, the maximum of the Fourier transform with value 1.0 will 1299 be equal to 100 and casted as an integer. 1300 Values below 0.001 will be equal to 0. 1301 This representation of the Fourier transform is used to construct 1302 octave bands histograms. 1303 :return : a list containing the frequency occurrences. 1304 """ 1305 1306 # Make the FT values integers 1307 fft_integers = [int(np.around(sample * 100, 0)) for sample in self.fft()] 1308 1309 # Create a list of the frequency occurrences in the signal 1310 occurrences = [] 1311 for freq, count in zip(self.fft_frequencies(), fft_integers): 1312 occurrences.append([freq] * count) 1313 1314 # flatten the list 1315 return [item for sublist in occurrences for item in sublist] 1316 1317 def envelope(self, window=None, overlap=None): 1318 """ 1319 Method calculating the amplitude envelope of a signal as a 1320 maximum of the absolute value of the signal. 1321 The same `window` and `overlap` parameters should be used to compute 1322 the signal and time arrays so that they contain the same 1323 number of points (and can be plotted together). 1324 :param window: integer, length in samples of the window used to 1325 compute the signal envelope. 1326 :param overlap: integer, overlap in samples used to overlap two 1327 subsequent windows in the computation of the signal envelope. 1328 The overlap value should be smaller than the window value. 1329 :return: Amplitude envelope of the signal 1330 """ 1331 if window is None: 1332 window = self.SP.envelope.frame_size.value 1333 if overlap is None: 1334 overlap = window // 2 1335 elif overlap >= window: 1336 raise ValueError('Overlap must be smaller than window') 1337 signal_array = np.abs(self.signal) 1338 t = self.time() 1339 # Empty envelope and envelope time 1340 env = [0] 1341 env_time = [0] 1342 idx = 0 1343 while idx + window < signal_array.shape[0]: 1344 env.append(np.max(signal_array[idx:idx + window])) 1345 pt_idx = np.argmax(signal_array[idx:idx + window]) + idx 1346 env_time.append(t[pt_idx]) 1347 idx += overlap 1348 _, unique_time_index = np.unique(env_time, return_index=True) 1349 return np.array(env)[unique_time_index], np.unique(env_time) 1350 1351 def log_envelope(self): 1352 """ 1353 Computes the logarithmic scale envelope of the signal. 1354 The width of the samples increases exponentially so that 1355 the envelope appears having a constant window width on 1356 an X axis logarithmic scale. 1357 :return: The log envelope and the time vector associated in a tuple 1358 """ 1359 if self.onset is None: 1360 onset = np.argmax(np.abs(self.signal)) 1361 else: 1362 onset = self.onset 1363 1364 start_time = self.SP.log_envelope.start_time.value 1365 while start_time > (onset / self.sr): 1366 start_time /= 10. 1367 1368 start_exponent = int(np.log10(start_time)) # closest 10^x value for smooth graph 1369 1370 if self.SP.log_envelope.min_window.value is None: 1371 min_window = 15 ** (start_exponent + 4) 1372 if min_window < 15: # Value should at least be 10 1373 min_window = 15 1374 else: 1375 min_window = self.SP.log_envelope.min_window.value 1376 1377 # initial values 1378 current_exponent = start_exponent 1379 current_time = 10 ** current_exponent # start time on log scale 1380 index = int(current_time * self.sr) # Start at the specified time 1381 window = min_window # number of samples per window 1382 overlap = window // 2 1383 log_envelope = [0] 1384 log_envelope_time = [0] # First value for comparison 1385 1386 while index + window <= len(self.signal): 1387 1388 while log_envelope_time[-1] < 10 ** (current_exponent + 1): 1389 if (index + window) < len(self.signal): 1390 log_envelope.append(np.max(np.abs(self.signal[index:index + window]))) 1391 pt_idx = np.argmax(np.abs(self.signal[index:index + window])) 1392 log_envelope_time.append(self.time()[index + pt_idx]) 1393 index += overlap 1394 else: 1395 break 1396 1397 if window * 10 < self.SP.log_envelope.max_window.value: 1398 window = window * 10 1399 else: 1400 window = self.SP.log_envelope.max_window.value 1401 overlap = window // 2 1402 current_exponent += 1 1403 time, idxs = np.unique(log_envelope_time, return_index=True) 1404 return np.array(log_envelope)[idxs], time 1405 1406 def find_onset(self, verbose=True): 1407 """ 1408 Finds the onset as an increase in more of 50% with the maximum normalized value above 0.5 1409 :param verbose: Prints a warning if the algorithm does not converge 1410 :return: the index of the onset in the signal 1411 """ 1412 # Index corresponding to the onset time interval 1413 window_index = np.ceil(self.SP.onset.onset_time.value * self.sr).astype(int) 1414 # Use the normalized signal to compare against a fixed value 1415 onset_signal = self.normalize() 1416 overlap = window_index // 2 # overlap for algorithm progression 1417 # Initial values 1418 increase = 0 1419 i = 0 1420 broke = False 1421 while increase <= 0.5: 1422 signal_min = np.min(np.abs(onset_signal.signal[i:i + window_index])) 1423 signal_max = np.max(np.abs(onset_signal.signal[i:i + window_index])) 1424 if (signal_max > 0.5) and (signal_min != 0): 1425 increase = signal_max / signal_min 1426 else: 1427 increase = 0 1428 i += overlap 1429 if i + window_index > len(self.signal): 1430 if verbose: 1431 print('Onset detection did not converge \n') 1432 print('Approximating onset with signal max value \n') 1433 broke = True 1434 break 1435 if broke: 1436 return np.argmax(np.abs(self.signal)) 1437 else: 1438 i -= overlap 1439 return np.argmax(np.abs(self.signal[i:i + window_index])) + i 1440 1441 def trim_onset(self, verbose=True): 1442 """ 1443 Trim the signal at the onset (max) minus the delay in milliseconds as 1444 Specified in the SoundParameters 1445 :param verbose: if False the warning comments are not displayed 1446 :return : The trimmed signal 1447 """ 1448 # nb of samples to keep before the onset 1449 delay_samples = int((self.SP.onset.onset_delay.value / 1000) * self.sr) 1450 onset = self.find_onset(verbose=verbose) # find the onset 1451 1452 if onset > delay_samples: # To make sure the index is positive 1453 new_signal = self.signal[onset - delay_samples:] 1454 new_signal[:delay_samples // 2] = new_signal[:delay_samples // 2] * np.linspace(0, 1, delay_samples // 2) 1455 trimmed_signal = Signal(new_signal, self.sr, self.SP) 1456 trimmed_signal.onset = trimmed_signal.find_onset(verbose=verbose) 1457 return trimmed_signal 1458 1459 else: 1460 if verbose: 1461 print('Signal is too short to be trimmed before onset.') 1462 print('') 1463 return self 1464 1465 def trim_time(self, time_length): 1466 """ 1467 Trims the signal to the specified length and returns a new Signal instance. 1468 :param time_length: desired length of the new signal in seconds. 1469 :return: A trimmed Signal 1470 """ 1471 max_index = int(time_length * self.sr) 1472 new_signal = self.signal[:max_index] 1473 new_signal[-50:] = new_signal[-50:] * np.linspace(1, 0, 50) 1474 time_trimmed_signal = Signal(new_signal, self.sr, self.SP) 1475 return time_trimmed_signal 1476 1477 def normalize(self): 1478 """ 1479 Normalizes the signal to [-1, 1] and returns the normalized instance. 1480 return : A normalized signal 1481 """ 1482 factor = np.max(np.abs(self.signal)) 1483 normalised_signal = Signal((self.signal / factor), self.sr, self.SP) 1484 normalised_signal.norm_factor = (1 / factor) 1485 return normalised_signal 1486 1487 def make_freq_bins(self): 1488 """ 1489 Method to divide a signal in frequency bins using butterworth filters 1490 bins are passed as a dict, default values are : 1491 - bass < 100 Hz 1492 - mid = 100 - 700 Hz 1493 - highmid = 700 - 2000 Hz 1494 - uppermid = 2000 - 4000 Hz 1495 - presence = 4000 - 6000 Hz 1496 - brillance > 6000 Hz 1497 :return : A dictionary with the divided signal as values and bin names as keys 1498 """ 1499 1500 bins = self.SP.bins.__dict__ 1501 1502 bass_filter = sig.butter(12, bins["bass"].value, 'lp', fs=self.sr, output='sos') 1503 mid_filter = sig.butter(12, [bins["bass"].value, bins['mid'].value], 'bp', fs=self.sr, output='sos') 1504 himid_filter = sig.butter(12, [bins["mid"].value, bins['highmid'].value], 'bp', fs=self.sr, output='sos') 1505 upmid_filter = sig.butter(12, [bins["highmid"].value, bins['uppermid'].value], 'bp', fs=self.sr, output='sos') 1506 pres_filter = sig.butter(12, [bins["uppermid"].value, bins['presence'].value], 'bp', fs=self.sr, output='sos') 1507 bril_filter = sig.butter(12, bins['presence'].value, 'hp', fs=self.sr, output='sos') 1508 1509 return { 1510 "bass": Signal(sig.sosfilt(bass_filter, self.signal), self.sr, self.SP, 1511 freq_range=[0, bins["bass"].value]), 1512 "mid": Signal(sig.sosfilt(mid_filter, self.signal), self.sr, self.SP, 1513 freq_range=[bins["bass"].value, bins["mid"].value]), 1514 "highmid": Signal(sig.sosfilt(himid_filter, self.signal), self.sr, self.SP, 1515 freq_range=[bins["mid"].value, bins["highmid"].value]), 1516 "uppermid": Signal(sig.sosfilt(upmid_filter, self.signal), self.sr, self.SP, 1517 freq_range=[bins["highmid"].value, bins["uppermid"].value]), 1518 "presence": Signal(sig.sosfilt(pres_filter, self.signal), self.sr, self.SP, 1519 freq_range=[bins['uppermid'].value, bins["presence"].value]), 1520 "brillance": Signal(sig.sosfilt(bril_filter, self.signal), self.sr, self.SP, 1521 freq_range=[bins["presence"].value, max(self.fft_frequencies())])} 1522 1523 def save_wav(self, name, path=''): 1524 """ 1525 Create a soundfile from a signal 1526 :param name: the name of the saved file 1527 :param path: the path were the '.wav' file is saved 1528 """ 1529 write(path + name + ".wav", self.signal, self.sr) 1530 1531 1532class Plot(object): 1533 """ 1534 A class to handle all the plotting functions of the Signal and to allow a nice call signature : 1535 Signal.plot.envelope() 1536 1537 Supported plots are : 1538 'signal', 'envelope', 'log envelope', 'fft', 'fft hist', 'peaks', 1539 'peak damping', 'time damping', 'integral' 1540 """ 1541 1542 # Illegal plot key word arguments 1543 illegal_kwargs = ['max_time', 'n', 'ticks', 'normalize', 'inverse', 1544 'peak_height', 'fill'] 1545 1546 def __init__(self): 1547 # define the parent attribute 1548 self.parent = None 1549 1550 # dictionary with methods and keywords 1551 self.method_dict = {'signal': self.signal, 1552 'envelope': self.envelope, 1553 'log envelope': self.log_envelope, 1554 'fft': self.fft, 1555 'fft hist': self.fft_hist, 1556 'peaks': self.peaks, 1557 'peak damping': self.peak_damping, 1558 'time damping': self.time_damping, 1559 'integral': self.integral, } 1560 1561 def sanitize_kwargs(self, kwargs): 1562 """ 1563 Remove illegal keywords to supply the key word arguments to matplotlib 1564 :param kwargs: a dictionary of key word arguments 1565 :return: sanitized kwargs 1566 """ 1567 return {i: kwargs[i] for i in kwargs if i not in self.illegal_kwargs} 1568 1569 def set_bin_ticks(self): 1570 """ Applies the frequency bin ticks to the current plot """ 1571 labels = [label for label in self.parent.SP.bins.__dict__ if label != 'name'] 1572 labels.append('brillance') 1573 x = [param.value for param in self.parent.SP.bins.__dict__.values() if param != 'bins'] 1574 x.append(11025) 1575 x_formatter = ticker.FixedFormatter(labels) 1576 x_locator = ticker.FixedLocator(x) 1577 ax = plt.gca() 1578 ax.xaxis.set_major_locator(x_locator) 1579 ax.xaxis.set_major_formatter(x_formatter) 1580 ax.tick_params(axis="x", labelrotation=90) 1581 1582 def signal(self, **kwargs): 1583 """ Plots the time varying real signal as amplitude vs time. """ 1584 plot_kwargs = self.sanitize_kwargs(kwargs) 1585 plt.plot(self.parent.time(), self.parent.signal, alpha=0.6, **plot_kwargs) 1586 plt.xlabel('time (s)') 1587 plt.ylabel('amplitude [-1, 1]') 1588 plt.grid('on') 1589 1590 def envelope(self, **kwargs): 1591 """ 1592 Plots the envelope of the signal as amplitude vs time. 1593 """ 1594 plot_kwargs = self.sanitize_kwargs(kwargs) 1595 envelope_arr, envelope_time = self.parent.envelope() 1596 plt.plot(envelope_time, envelope_arr, **plot_kwargs) 1597 plt.xlabel("time (s)") 1598 plt.ylabel("amplitude [0, 1]") 1599 plt.grid('on') 1600 1601 def log_envelope(self, **kwargs): 1602 """ 1603 Plots the signal envelope with logarithmic window widths on a logarithmic x-axis scale. 1604 :param max_time: maximum time used for the x-axis in the plot (seconds) 1605 """ 1606 plot_kwargs = self.sanitize_kwargs(kwargs) 1607 log_envelope, log_envelope_time = self.parent.log_envelope() 1608 1609 if ('max_time' in kwargs.keys()) and (kwargs['max_time'] < log_envelope_time[-1]): 1610 max_index = np.nonzero(log_envelope_time >= kwargs['max_time'])[0][0] 1611 else: 1612 max_index = len(log_envelope_time) 1613 1614 plt.plot(log_envelope_time[:max_index], log_envelope[:max_index], **plot_kwargs) 1615 plt.xlabel("time (s)") 1616 plt.ylabel("amplitude [0, 1]") 1617 plt.xscale('log') 1618 plt.grid('on') 1619 1620 def fft(self, **kwargs): 1621 """ 1622 Plots the Fourier Transform of the Signal. 1623 1624 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1625 with the frequency bin values. 1626 """ 1627 1628 plot_kwargs = self.sanitize_kwargs(kwargs) 1629 1630 # find the index corresponding to the fft range 1631 fft_frequencies = self.parent.fft_frequencies() 1632 fft_range = self.parent.SP.general.fft_range.value 1633 result = np.where(fft_frequencies >= fft_range)[0] 1634 if len(result) == 0: 1635 last_index = len(fft_frequencies) 1636 else: 1637 last_index = result[0] 1638 1639 plt.plot(self.parent.fft_frequencies()[:last_index], 1640 self.parent.fft()[:last_index], 1641 **plot_kwargs) 1642 plt.xlabel("frequency (Hz)"), 1643 plt.ylabel("amplitude (normalized)"), 1644 plt.yscale('log') 1645 plt.grid('on') 1646 1647 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1648 self.set_bin_ticks() 1649 1650 def fft_hist(self, **kwargs): 1651 """ 1652 Plots the octave based Fourier Transform Histogram. 1653 Both axes are on a log scale. 1654 1655 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1656 with the frequency bin values 1657 """ 1658 1659 plot_kwargs = self.sanitize_kwargs(kwargs) 1660 1661 # Histogram of frequency values occurrences in octave bins 1662 plt.hist(self.parent.fft_bins(), utils.octave_histogram(self.parent.SP.general.octave_fraction.value), 1663 alpha=0.7, **plot_kwargs) 1664 plt.xlabel('frequency (Hz)') 1665 plt.ylabel('amplitude (normalized)') 1666 plt.xscale('log') 1667 plt.yscale('log') 1668 plt.grid('on') 1669 1670 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1671 self.set_bin_ticks() 1672 1673 def peaks(self, **kwargs): 1674 """ 1675 Plots the Fourier Transform of the Signal, with the peaks detected 1676 with the `Signal.peaks()` method. 1677 1678 If `peak_height = True` is supplied in the keyword arguments the 1679 computed height threshold is 1680 shown on the plot. 1681 """ 1682 1683 plot_kwargs = self.sanitize_kwargs(kwargs) 1684 1685 fft_freqs = self.parent.fft_frequencies() 1686 fft = self.parent.fft() 1687 fft_range = self.parent.SP.general.fft_range.value 1688 max_index = np.where(fft_freqs >= fft_range)[0][0] 1689 peak_indexes, height = self.parent.peaks(height=True) 1690 plt.xlabel('frequency (Hz)') 1691 plt.ylabel('amplitude') 1692 plt.yscale('log') 1693 plt.grid('on') 1694 1695 if 'color' not in plot_kwargs.keys(): 1696 plot_kwargs['color'] = 'k' 1697 plt.plot(fft_freqs[:max_index], fft[:max_index], **plot_kwargs) 1698 plt.scatter(fft_freqs[peak_indexes], fft[peak_indexes], color='r') 1699 if ('peak_height' in kwargs.keys()) and (kwargs['peak_height']): 1700 plt.plot(fft_freqs[:max_index], height, color='r') 1701 1702 def peak_damping(self, **kwargs): 1703 """ 1704 Plots the frequency vs damping scatter of the damping ratio computed from the 1705 Fourier Transform peak shapes. A polynomial curve fit is added to help visualisation. 1706 1707 Supported key word arguments are : 1708 1709 `n=5` : The order of the fitted polynomial curve, default is 5, 1710 if the supplied value is too high, it will be reduced until the 1711 number of peaks is sufficient to fit the polynomial. 1712 1713 `inverse=True` : Default value is True, if False, the damping ratio is shown instead 1714 of its inverse. 1715 1716 `normalize=False` : Default value is False, if True the damping values are normalized 1717 from 0 to 1, to help analyze results and compare Sounds. 1718 1719 `ticks=None` : Default value is None, if `ticks='bins'` the x-axis ticks are replaced with 1720 frequency bin values. 1721 """ 1722 plot_kwargs = self.sanitize_kwargs(kwargs) 1723 # Get the damping ratio and peak frequencies 1724 if 'inverse' in kwargs.keys() and kwargs['inverse'] is False: 1725 zetas = np.array(self.parent.peak_damping()) 1726 ylabel = r'damping $\zeta$' 1727 else: 1728 zetas = 1 / np.array(self.parent.peak_damping()) 1729 ylabel = r'inverse damping $1/\zeta$' 1730 1731 peak_freqs = self.parent.fft_frequencies()[self.parent.peaks()] 1732 1733 # If a polynomial order is supplied assign it, if not default is 5 1734 if 'n' in kwargs.keys(): 1735 n = kwargs['n'] 1736 else: 1737 n = 5 1738 1739 # If labels are supplied the default color are used 1740 if 'label' in plot_kwargs: 1741 plot_kwargs['color'] = None 1742 plot2_kwargs = plot_kwargs.copy() 1743 plot2_kwargs['label'] = None 1744 1745 # If not black and red are used 1746 else: 1747 plot_kwargs['color'] = 'r' 1748 plot2_kwargs = plot_kwargs.copy() 1749 plot2_kwargs['color'] = 'k' 1750 1751 if 'normalize' in kwargs.keys() and kwargs['normalize']: 1752 zetas = np.array(zetas) / np.array(zetas).max(initial=0) 1753 1754 plt.scatter(peak_freqs, zetas, **plot_kwargs) 1755 fun = utils.nth_order_polynomial_fit(n, peak_freqs, zetas) 1756 freq = np.linspace(peak_freqs[0], peak_freqs[-1], 100) 1757 plt.plot(freq, fun(freq), **plot2_kwargs) 1758 plt.grid('on') 1759 plt.title('Frequency vs Damping Factor with Order ' + str(n)) 1760 plt.xlabel('frequency (Hz)') 1761 plt.ylabel(ylabel) 1762 1763 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1764 self.set_bin_ticks() 1765 1766 def time_damping(self, **kwargs): 1767 """ 1768 Shows the signal envelope with the fitted negative exponential 1769 curve used to determine the time damping ratio of the signal. 1770 """ 1771 plot_kwargs = self.sanitize_kwargs(kwargs) 1772 # Get the envelope data 1773 envelope, envelope_time = self.parent.normalize().envelope() 1774 1775 # First point is the maximum because e^-kt is strictly decreasing 1776 first_index = np.argmax(envelope) 1777 1778 # The second point is the first point where the signal crosses the lower_threshold line 1779 second_point_thresh = self.parent.SP.damping.lower_threshold.value 1780 while True: 1781 try: 1782 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1783 break 1784 except IndexError: 1785 second_point_thresh *= 2 1786 if second_point_thresh > 1: 1787 raise ValueError("invalid second point threshold encountered, something went wrong") 1788 1789 # Function to compute the residual for the exponential curve fit 1790 def residual_function(zeta_w, t, s): 1791 return np.exp(zeta_w[0] * t) - s 1792 1793 zeta_guess = [-0.5] 1794 1795 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1796 args=(envelope_time[first_index:second_index], 1797 envelope[first_index:second_index])) 1798 # Get the zeta*omega constant 1799 zeta_omega = result.x[0] 1800 1801 # Compute the fundamental frequency in radians of the signal 1802 wd = 2 * np.pi * self.parent.fundamental() 1803 1804 # Plot the two points used for the regression 1805 plt.scatter(envelope_time[[first_index, first_index + second_index]], 1806 envelope[[first_index, first_index + second_index]], color='r') 1807 1808 # get the current ax 1809 ax = plt.gca() 1810 1811 # Plot the damping curve 1812 ax.plot(envelope_time[first_index:second_index + first_index], 1813 np.exp(zeta_omega * envelope_time[first_index:second_index + first_index]), 1814 c='k', 1815 linestyle='--') 1816 1817 plt.sca(ax) 1818 if 'alpha' not in plot_kwargs: 1819 plot_kwargs['alpha'] = 0.6 1820 self.parent.normalize().plot.envelope(**plot_kwargs) 1821 1822 if 'label' not in plot_kwargs.keys(): 1823 ax.legend(['damping curve', 'signal envelope']) 1824 1825 title = 'Zeta : ' + str(np.around(-zeta_omega / wd, 5)) + ' Fundamental ' + \ 1826 str(np.around(self.parent.fundamental(), 0)) + 'Hz' 1827 plt.title(title) 1828 1829 def integral(self, **kwargs): 1830 """ 1831 Cumulative integral plot of the normalized signal log envelope 1832 1833 Represents the power distribution variation in time for the signal. 1834 This is a plot of the function $F(x)$ such as : 1835 1836 $ F(x) = \int_0^x env(x) dx $ 1837 1838 Where e(x) is the signal envelope. 1839 """ 1840 # sanitize the kwargs 1841 plot_kwargs = self.sanitize_kwargs(kwargs) 1842 1843 # Compute log envelope and log time 1844 log_envelope, log_time = self.parent.normalize().log_envelope() 1845 1846 1847 # compute the cumulative integral 1848 integral = [trapezoid(log_envelope[:i], log_time[:i]) for i in np.arange(2, len(log_envelope), 1)] 1849 integral /= np.max(integral) 1850 1851 # plot the integral 1852 plt.plot(log_time[2:], integral, **plot_kwargs) 1853 1854 # Add labels and scale 1855 plt.xlabel('time (s)') 1856 plt.ylabel('cumulative power (normalized)') 1857 plt.xscale('log') 1858 plt.grid('on')
25class SoundPack(object): 26 """ 27 A class to store and analyse multiple sounds 28 Some methods are only available for SoundPacks containing two sounds 29 """ 30 31 def __init__(self, *sounds, names=None, fundamentals=None, 32 SoundParams=None, equalize_time=True): 33 """ 34 The SoundPack can be instantiated from existing Sound class instances, 35 either in a list or as multiple arguments 36 The class can also handle the creation of Sound class instances if the 37 arguments are filenames, either a list or multiple arguments. 38 39 :param sounds: `guitarsounds.Sound` instaces or filenames either as 40 multiple arguments or as a list 41 :param names: list of strings with the names of the sounds that will be 42 used in the plot legend labels 43 :param fundamentals: list of numbers corresponding to the known sound 44 fundamental frequencies. 45 :param SoundParams: `guitarsounds.SoundParams` instance used to get 46 the parameters used in the computation of the sound attributes 47 :param equalize_time: if True, all the sounds used to create the 48 SoundPack are truncated to the length of the shortest sound. 49 50 If the number of Sound contained is equal to two, the SoundPack will 51 be 'dual' and the associated methods will be available : 52 53 - `SoundPack.compare_peaks` 54 - `SoundPack.fft_mirror` 55 - `SoundPack.fft_diff` 56 - `SoundPack.integral_compare` 57 58 If it contains multiple sounds the SoundPack will be multiple and a 59 reduced number of methods will be available to call 60 61 If the fundamental frequency is supplied for each sound, the 62 computation of certain features can be more efficient, such as the 63 time damping computation or Hemlotz frequency computation. 64 65 Examples : 66 ```python 67 Sound_Test = SoundPack('sounds/test1.wav', 'sounds/test2.wav', names=['A', 'B'], fundamentals = [134, 134]) 68 69 sounds = [sound1, sound2, sound3, sound4, sound5] # instances of the Sound class 70 large_Test = SoundPack(sounds, names=['1', '2', '3', '4', '5']) 71 ``` 72 """ 73 # create a copy of the sound parameters 74 if SoundParams is None: 75 self.SP = sound_parameters() 76 else: 77 self.SP = SoundParams 78 79 # Check if the sounds argument is a list 80 if type(sounds[0]) is list: 81 sounds = sounds[0] # unpack the list 82 83 # Check for special case 84 if len(sounds) == 2: 85 # special case to compare two sounds 86 self.kind = 'dual' 87 88 elif len(sounds) > 1: 89 # general case for multiple sounds 90 self.kind = 'multiple' 91 92 # If filenames are supplied 93 if type(sounds[0]) is str: 94 self.sounds_from_files(sounds, names=names, fundamentals=fundamentals) 95 96 # Else sound instances are supplied 97 else: 98 self.sounds = sounds 99 100 # sound name defined in constructor 101 if names and (len(names) == len(self.sounds)): 102 for sound, n in zip(self.sounds, names): 103 sound.name = n 104 105 else: 106 # names obtained from the supplied sounds 107 names = [sound.name for sound in self.sounds if sound.name] 108 109 # all sounds have a name 110 if len(names) == len(sounds): 111 self.names = names 112 113 # Assign a default value to names 114 else: 115 names = [str(n) for n in np.arange(1, len(sounds) + 1)] 116 for sound, n in zip(self.sounds, names): 117 sound.name = n 118 119 # If the sounds are not conditionned condition them 120 for s in self.sounds: 121 if ~hasattr(s, 'signal'): 122 s.condition() 123 124 if equalize_time: 125 self.equalize_time() 126 127 # Define bin strings 128 self.bin_strings = [*list(self.SP.bins.__dict__.keys())[1:], 'brillance'] 129 130 # Sort according to fundamental 131 key = np.argsort([sound.fundamental for sound in self.sounds]) 132 self.sounds = np.array(self.sounds)[key] 133 134 def sounds_from_files(self, sound_files, names=None, fundamentals=None): 135 """ 136 Create Sound class instances and assign them to the SoundPack 137 from a list of files 138 139 :param sound_files: sound filenames 140 :param names: sound names 141 :param fundamentals: user specified fundamental frequencies 142 :return: None 143 """ 144 # Make the default name list from sound filenames if none is supplied 145 if (names is None) or (len(names) != len(sound_files)): 146 names = [os.path.split(file)[-1][:-4] for file in sound_files] # remove the .wav 147 148 # If the fundamentals are not supplied or mismatch in number None is used 149 if (fundamentals is None) or (len(fundamentals) != len(sound_files)): 150 fundamentals = len(sound_files) * [None] 151 152 # Create Sound instances from files 153 self.sounds = [] 154 for file, name, fundamental in zip(sound_files, names, fundamentals): 155 self.sounds.append(Sound(file, name=name, fundamental=fundamental, 156 SoundParams=self.SP)) 157 158 def equalize_time(self): 159 """ 160 Trim the sounds so that they all have the length of the shortest sound, 161 trimming is done at the end of the sounds. 162 :return: None 163 """ 164 trim_index = np.min([len(sound.signal.signal) for sound in self.sounds]) 165 trimmed_sounds = [] 166 for sound in self.sounds: 167 new_sound = sound 168 new_sound.signal = new_sound.signal.trim_time(trim_index / sound.signal.sr) 169 new_sound.bin_divide() 170 trimmed_sounds.append(new_sound) 171 self.sounds = trimmed_sounds 172 173 def normalize(self): 174 """ 175 Normalize all the signals in the SoundPack and returns a normalized 176 instance of itself. See `Signal.normalize` for more information. 177 :return: SoundPack with normalized signals 178 """ 179 new_sounds = [] 180 names = [sound.name for sound in self.sounds] 181 fundamentals = [sound.fundamental for sound in self.sounds] 182 for sound in self.sounds: 183 sound.signal = sound.signal.normalize() 184 new_sounds.append(sound) 185 186 self.sounds = new_sounds 187 188 return self 189 190 """ 191 Methods for all SoundPacks 192 """ 193 194 def plot(self, kind, **kwargs): 195 """ 196 Superimposed plot of all the sounds on one figure for a specific kind 197 198 :param kind: feature name passed to the `signal.plot()` method 199 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 200 method 201 :return: None 202 203 __ Multiple SoundPack Method __ 204 Plots a specific signal.plot for all sounds on the same figure 205 206 Ex : SoundPack.plot('fft') plots the fft of all sounds on a single figure 207 The color argument is set to none so that the plots have different colors 208 """ 209 plt.figure(figsize=(8, 6)) 210 for sound in self.sounds: 211 kwargs['label'] = sound.name 212 kwargs['color'] = None 213 sound.signal.plot.method_dict[kind](**kwargs) 214 ax = plt.gca() 215 ax.set_title(kind + ' plot') 216 ax.legend() 217 return ax 218 219 def compare_plot(self, kind, **kwargs): 220 """ 221 Plots all the sounds on different figures to compare them for a specific kind 222 223 :param kind: feature name passed to the `signal.plot()` method 224 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 225 method 226 :return: None 227 228 __ Multiple SoundPack Method __ 229 Draws the same kind of plot on a different axis for each sound 230 Example : `SoundPack.compare_plot('peaks')` with 4 Sounds will plot a 231 figure with 4 axes, with each a different 'peak' plot. 232 """ 233 # if a dual SoundPack : only plot two big plots 234 if self.kind == 'dual': 235 fig, axs = plt.subplots(1, 2, figsize=(12, 4)) 236 for sound, ax in zip(self.sounds, axs): 237 plt.sca(ax) 238 sound.signal.plot.method_dict[kind](**kwargs) 239 ax.set_title(kind + ' ' + sound.name) 240 plt.tight_layout() 241 242 # If a multiple SoundPack : plot on a grid of axes 243 elif self.kind == 'multiple': 244 # find the n, m values for the subplots line and columns 245 n = len(self.sounds) 246 cols = 0 247 if n // 4 >= 10: 248 # a lot of sounds 249 cols = 4 250 elif n // 3 >= 10: 251 # many sounds 252 cols = 3 253 elif n // 2 <= 4: 254 # a few sounds 255 cols = 2 256 257 remainder = n % cols 258 if remainder == 0: 259 rows = n // cols 260 else: 261 rows = n // cols + 1 262 263 fig, axs = plt.subplots(rows, cols, figsize=(12, 4 * rows)) 264 axs = axs.reshape(-1) 265 for sound, ax in zip(self.sounds, axs): 266 plt.sca(ax) 267 sound.signal.plot.method_dict[kind](**kwargs) 268 title = ax.get_title() 269 title = sound.name + ' ' + title 270 ax.set_title(title) 271 272 if remainder != 0: 273 for ax in axs[-(cols - remainder):]: 274 ax.set_axis_off() 275 plt.tight_layout() 276 else: 277 raise Exception 278 return axs 279 280 def freq_bin_plot(self, f_bin='all'): 281 """ 282 Plots the log envelope of specified frequency bins 283 :param f_bin: frequency bins to compare, Supported arguments are : 284 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 285 286 __ Multiple SoundPack Method __ 287 A function to compare signals decomposed frequency wise in the time 288 domain on a logarithm scale. The methods plot all the sounds and plots 289 their frequency bins according to the frequency bin argument f_bin. 290 291 Example : SoundPack.freq_bin_plot(f_bin='mid') will plot the log-scale 292 envelope of the 'mid' signal of every sound in the SoundPack. 293 """ 294 295 if f_bin == 'all': 296 # Create one plot per bin 297 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 298 axs = axs.reshape(-1) 299 for key, ax in zip([*list(self.SP.bins.__dict__.keys())[1:], 'brillance'], axs): 300 plt.sca(ax) 301 # plot every sound for a frequency bin 302 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 303 for i, son in enumerate(self.sounds): 304 son.bins[key].normalize().old_plot('log envelope', label=son.name) 305 plt.xscale('log') 306 plt.legend() 307 son = self.sounds[-1] 308 title0 = ' ' + key + ' : ' + str(int(son.bins[key].freq_range[0])) + ' - ' + str( 309 int(son.bins[key].freq_range[1])) + ' Hz, ' 310 title1 = 'Norm. Factors : ' 311 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 312 plt.title(title0 + title1 + title2) 313 plt.tight_layout() 314 315 elif f_bin in [*list(sound_parameters().bins.__dict__.keys())[1:], 'brillance']: 316 plt.figure(figsize=(10, 4)) 317 # Plot every envelope for a single frequency bin 318 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 319 for i, son in enumerate(self.sounds): 320 son.bins[f_bin].normalize().old_plot('log envelope', label=(str(i + 1) + '. ' + son.name)) 321 plt.xscale('log') 322 plt.legend() 323 son = self.sounds[-1] 324 title0 = ' ' + f_bin + ' : ' + str(int(son.bins[f_bin].freq_range[0])) + ' - ' + str( 325 int(son.bins[f_bin].freq_range[1])) + ' Hz, ' 326 title1 = 'Norm. Factors : ' 327 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 328 plt.title(title0 + title1 + title2) 329 330 else: 331 print('invalid frequency bin') 332 333 def fundamentals(self): 334 """ 335 Displays the fundamentals of every sound in the SoundPack 336 337 :return: None 338 339 __ Multiple Soundpack Method __ 340 """ 341 names = np.array([sound.name for sound in self.sounds]) 342 fundamentals = np.array([np.around(sound.fundamental, 1) for sound in self.sounds]) 343 key = np.argsort(fundamentals) 344 table_data = [names[key], fundamentals[key]] 345 346 table_data = np.array(table_data).transpose() 347 348 print(tabulate(table_data, headers=['Name', 'Fundamental (Hz)'])) 349 350 def integral_plot(self, f_bin='all'): 351 """ 352 Normalized cumulative bin power plot for the frequency bins. 353 See `Plot.integral` for more information. 354 355 :param f_bin: frequency bins to compare, Supported arguments are 356 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 357 358 __ Multiple SoundPack Method __ 359 Plots the cumulative integral plot of specified frequency bins 360 see help(Plot.integral) 361 """ 362 363 if f_bin == 'all': 364 # create a figure with 6 axes 365 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 366 axs = axs.reshape(-1) 367 368 for key, ax in zip(self.bin_strings, axs): 369 plt.sca(ax) 370 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 371 for sound in self.sounds: 372 sound.bins[key].plot.integral(label=sound.name) 373 plt.legend() 374 sound = self.sounds[-1] 375 title0 = ' ' + key + ' : ' + str(int(sound.bins[key].freq_range[0])) + ' - ' + str( 376 int(sound.bins[key].freq_range[1])) + ' Hz, ' 377 title1 = 'Norm. Factors : ' 378 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 379 plt.title(title0 + title1 + title2) 380 plt.title(title0 + title1 + title2) 381 plt.tight_layout() 382 383 elif f_bin in self.bin_strings: 384 fig, ax = plt.subplots(figsize=(6, 4)) 385 plt.sca(ax) 386 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 387 for sound in self.sounds: 388 sound.bins[f_bin].plot.integral(label=sound.name) 389 plt.legend() 390 sound = self.sounds[-1] 391 title0 = ' ' + f_bin + ' : ' + str(int(sound.bins[f_bin].freq_range[0])) + ' - ' + str( 392 int(sound.bins[f_bin].freq_range[1])) + ' Hz, ' 393 title1 = 'Norm. Factors : ' 394 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 395 plt.title(title0 + title1 + title2) 396 397 else: 398 print('invalid frequency bin') 399 400 def bin_power_table(self): 401 """ 402 Displays a table with the signal power contained in every frequency bin 403 404 The power is computed as the time integral of the signal, such as : 405 406 $ P = \int_0^{t_{max}} sig(t) dt $ 407 408 __ Multiple SoundPack Method __ 409 The sounds are always normalized before computing the power. Because 410 the signal amplitude is normalized between -1 and 1, the power value 411 is adimentional, and can only be used to compare two sounds between 412 eachother. 413 """ 414 # Bin power distribution table 415 bin_strings = self.bin_strings 416 integrals = [] 417 418 # for every sound in the SoundPack 419 for sound in self.sounds: 420 421 integral = [] 422 # for every frequency bin in the sound 423 for f_bin in bin_strings: 424 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 425 integral.append(scipy.integrate.trapezoid(log_envelope, log_time)) 426 427 # a list of dict for every sound 428 integrals.append(integral) 429 430 # make the table 431 table_data = np.array([list(bin_strings), *integrals]).transpose() 432 sound_names = [sound.name for sound in self.sounds] 433 434 print('___ Signal Power Frequency Bin Distribution ___ \n') 435 print(tabulate(table_data, headers=['bin', *sound_names])) 436 437 def bin_power_hist(self): 438 """ 439 Histogram of the frequency bin power for multiple sounds 440 441 The power is computed as the time integral of the signal, such as : 442 443 $ P = \int_0^{t_{max}} sig(t) dt $ 444 445 __ Multiple SoundPack Method __ 446 The sounds are always normalized before computing the power. Because 447 the signal amplitude is normalized between -1 and 1, the power value 448 is adimentional, and can only be used to compare two sounds between 449 eachother. 450 """ 451 # Compute the bin powers 452 bin_strings = self.bin_strings 453 integrals = [] 454 455 # for every sound in the SoundPack 456 for sound in self.sounds: 457 458 integral = [] 459 # for every frequency bin in the sound 460 for f_bin in bin_strings: 461 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 462 integral.append(trapezoid(log_envelope, log_time)) 463 464 # a list of dict for every sound 465 integral = np.array(integral) 466 integral /= np.max(integral) 467 integrals.append(integral) 468 469 # create the bar plotting vectors 470 fig, ax = plt.subplots(figsize=(6, 6)) 471 472 # make the bar plot 473 n = len(self.sounds) 474 width = 0.8 / n 475 # get nice colors 476 cmap = matplotlib.cm.get_cmap('Set2') 477 for i, sound in enumerate(self.sounds): 478 x = np.arange(i * width, len(bin_strings) + i * width) 479 y = integrals[i] 480 if n < 8: 481 color = cmap(i) 482 else: 483 color = None 484 485 if i == n // 2: 486 ax.bar(x, y, width=width, tick_label=list(bin_strings), label=sound.name, color=color) 487 else: 488 ax.bar(x, y, width=width, label=sound.name, color=color) 489 ax.set_xlabel('frequency bin name') 490 ax.set_ylabel('normalized power') 491 plt.legend() 492 493 def listen(self): 494 """ 495 Listen to all the sounds in the SoundPack inside the Jupyter Notebook 496 environment 497 498 __ Multiple SoundPack Method __ 499 """ 500 for sound in self.sounds: 501 sound.signal.listen() 502 503 """ 504 Methods for dual SoundPacks 505 """ 506 507 def compare_peaks(self): 508 """ 509 Plot to compare the FFT peaks values of two sounds 510 511 __ Dual SoundPack Method __ 512 Compares the peaks in the Fourier Transform of two Sounds, 513 the peaks having the highest difference are highlighted. If no peaks 514 are found to have a significant difference, only the two normalized 515 Fourier transform are plotted in a mirror configuration to visualize 516 them. 517 """ 518 if self.kind == 'dual': 519 son1 = self.sounds[0] 520 son2 = self.sounds[1] 521 index1 = np.where(son1.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 522 index2 = np.where(son2.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 523 524 # Get the peak data from the sounds 525 peaks1 = son1.signal.peaks() 526 peaks2 = son2.signal.peaks() 527 freq1 = son1.signal.fft_frequencies()[:index1] 528 freq2 = son2.signal.fft_frequencies()[:index2] 529 fft1 = son1.signal.fft()[:index1] 530 fft2 = son2.signal.fft()[:index2] 531 532 short_peaks_1 = [peak for peak in peaks1 if peak < (len(freq1) - 1)] 533 short_peaks_2 = [peak for peak in peaks2 if peak < (len(freq1) - 1)] 534 peak_distance1 = np.mean([freq1[peaks1[i]] - freq1[peaks1[i + 1]] for i in range(len(short_peaks_1) - 1)]) / 4 535 peak_distance2 = np.mean([freq2[peaks2[i]] - freq2[peaks2[i + 1]] for i in range(len(short_peaks_2) - 1)]) / 4 536 peak_distance = np.abs(np.mean([peak_distance1, peak_distance2])) 537 538 # Align the two peak vectors 539 new_peaks1 = [] 540 new_peaks2 = [] 541 for peak1 in short_peaks_1: 542 for peak2 in short_peaks_2: 543 if np.abs(freq1[peak1] - freq2[peak2]) < peak_distance: 544 new_peaks1.append(peak1) 545 new_peaks2.append(peak2) 546 new_peaks1 = np.unique(np.array(new_peaks1)) 547 new_peaks2 = np.unique(np.array(new_peaks2)) 548 549 different_peaks1 = [] 550 different_peaks2 = [] 551 difference_threshold = 0.5 552 while len(different_peaks1) < 1: 553 for peak1, peak2 in zip(new_peaks1, new_peaks2): 554 if np.abs(fft1[peak1] - fft2[peak2]) > difference_threshold: 555 different_peaks1.append(peak1) 556 different_peaks2.append(peak2) 557 difference_threshold -= 0.01 558 if np.isclose(difference_threshold, 0.): 559 break 560 561 # Plot the output 562 plt.figure(figsize=(10, 6)) 563 plt.yscale('symlog', linthresh=10e-1) 564 565 # Sound 1 566 plt.plot(freq1, fft1, color='#919191', label=son1.name) 567 plt.scatter(freq1[new_peaks1], fft1[new_peaks1], color='b', label='peaks') 568 if len(different_peaks1) > 0: 569 plt.scatter(freq1[different_peaks1[0]], fft1[different_peaks1[0]], color='g', label='diff peaks') 570 annotation_string = 'Peaks with ' + str(np.around(difference_threshold, 2)) + ' difference' 571 plt.annotate(annotation_string, (freq1[different_peaks1[0]] + peak_distance / 2, fft1[different_peaks1[0]])) 572 573 # Sound2 574 plt.plot(freq2, -fft2, color='#3d3d3d', label=son2.name) 575 plt.scatter(freq2[new_peaks2], -fft2[new_peaks2], color='b') 576 if len(different_peaks2) > 0: 577 plt.scatter(freq2[different_peaks2[0]], -fft2[different_peaks2[0]], color='g') 578 plt.title('Fourier Transform Peak Analysis for ' + son1.name + ' and ' + son2.name) 579 plt.grid('on') 580 plt.legend() 581 ax = plt.gca() 582 ax.set_xlabel('frequency (Hz)') 583 ax.set_ylabel('mirror amplitude (0-1)') 584 else: 585 raise ValueError('Unsupported for multiple sounds SoundPacks') 586 587 def fft_mirror(self): 588 """ 589 Plot the Fourier Transforms of two sounds on opposed axis to compare the spectral content 590 591 __ Dual SoundPack Method __ 592 The fourier transforms are plotted normalized between 0 and 1. 593 The y scale is symmetric logarithmic, so that a signal is plotted 594 between 0 and -1, and the other is plotted between 0 and 1. 595 :return: None 596 """ 597 if self.kind == 'dual': 598 son1 = self.sounds[0] 599 son2 = self.sounds[1] 600 fft_range_value = sound_parameters().general.fft_range.value 601 fft_freq_value = son1.signal.fft_frequencies() 602 index = np.where(fft_freq_value > fft_range_value)[0][0] 603 604 plt.figure(figsize=(10, 6)) 605 plt.yscale('symlog') 606 plt.grid('on') 607 plt.plot(son1.signal.fft_frequencies()[:index], son1.signal.fft()[:index], label=son1.name) 608 plt.plot(son2.signal.fft_frequencies()[:index], -son2.signal.fft()[:index], label=son2.name) 609 plt.xlabel('frequency (Hz)') 610 plt.ylabel('mirror amplitude (normalized)') 611 plt.title('Mirror Fourier Transform for ' + son1.name + ' and ' + son2.name) 612 plt.legend() 613 614 else: 615 print('Unsupported for multiple sounds SoundPacks') 616 617 def fft_diff(self, fraction=3, ticks=None): 618 """ 619 Plot the difference between the spectral distribution in the two sounds 620 621 :param fraction: octave fraction value used to compute the frequency bins A higher number will show 622 a more precise comparison, but conclusions may be harder to draw. 623 :param ticks: If equal to 'bins' the frequency bins intervals are used as X axis ticks 624 :return: None 625 626 __ Dual SoundPack Method __ 627 Compare the Fourier Transform of two sounds by computing the differences of the octave bins heights. 628 The two FTs are superimposed on the first plot to show differences 629 The difference between the two FTs is plotted on the second plot 630 """ 631 if self.kind == 'dual': 632 # Separate the sounds 633 son1 = self.sounds[0] 634 son2 = self.sounds[1] 635 636 # Compute plotting bins 637 x_values = utils.octave_values(fraction) 638 hist_bins = utils.octave_histogram(fraction) 639 bar_widths = np.array([hist_bins[i + 1] - hist_bins[i] for i in range(0, len(hist_bins) - 1)]) 640 641 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) 642 plot1 = ax1.hist(son1.signal.fft_bins(), utils.octave_histogram(fraction), color='blue', alpha=0.6, 643 label=son1.name) 644 plot2 = ax1.hist(son2.signal.fft_bins(), utils.octave_histogram(fraction), color='orange', alpha=0.6, 645 label=son2.name) 646 ax1.set_title('FT Histogram for ' + son1.name + ' and ' + son2.name) 647 ax1.set_xscale('log') 648 ax1.set_xlabel('frequency (Hz)') 649 ax1.set_ylabel('amplitude') 650 ax1.grid('on') 651 ax1.legend() 652 653 diff = plot1[0] - plot2[0] 654 n_index = np.where(diff <= 0)[0] 655 p_index = np.where(diff >= 0)[0] 656 657 # Negative difference corresponding to sound 2 658 ax2.bar(x_values[n_index], diff[n_index], width=bar_widths[n_index], color='orange', alpha=0.6) 659 # Positive difference corresponding to sound1 660 ax2.bar(x_values[p_index], diff[p_index], width=bar_widths[p_index], color='blue', alpha=0.6) 661 ax2.set_title('Difference ' + son1.name + ' - ' + son2.name) 662 ax2.set_xscale('log') 663 ax2.set_xlabel('frequency (Hz)') 664 ax2.set_ylabel('<- sound 2 : sound 1 ->') 665 ax2.grid('on') 666 667 if ticks == 'bins': 668 labels = [label for label in self.SP.bins.__dict__ if label != 'name'] 669 labels.append('brillance') 670 x = [param.value for param in self.SP.bins.__dict__.values() if param != 'bins'] 671 x.append(11250) 672 x_formatter = ticker.FixedFormatter(labels) 673 x_locator = ticker.FixedLocator(x) 674 ax1.xaxis.set_major_locator(x_locator) 675 ax1.xaxis.set_major_formatter(x_formatter) 676 ax1.tick_params(axis="x", labelrotation=90) 677 ax2.xaxis.set_major_locator(x_locator) 678 ax2.xaxis.set_major_formatter(x_formatter) 679 ax2.tick_params(axis="x", labelrotation=90) 680 681 else: 682 print('Unsupported for multiple sounds SoundPacks') 683 684 def integral_compare(self, f_bin='all'): 685 """ 686 Cumulative bin envelope integral comparison for two signals 687 688 :param f_bin: frequency bins to compare, Supported arguments are : 689 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 690 :return: None 691 692 __ Dual SoundPack Method __ 693 Plots the cumulative integral plot of specified frequency bins 694 and their difference as surfaces 695 """ 696 697 # Case when plotting all the frequency bins 698 if f_bin == 'all': 699 fig, axs = plt.subplots(3, 2, figsize=(16, 16)) 700 axs = axs.reshape(-1) 701 702 # get the bins frequency values 703 self.bin_strings = self.sounds[0].bins.keys() 704 bins1 = self.sounds[0].bins.values() 705 bins2 = self.sounds[1].bins.values() 706 707 for signal1, signal2, bin_string, ax in zip(bins1, bins2, self.bin_strings, axs): 708 # Compute the log time and envelopes integrals 709 log_envelope1, log_time1 = signal1.normalize().log_envelope() 710 log_envelope2, log_time2 = signal2.normalize().log_envelope() 711 env_range1 = np.arange(2, len(log_envelope1), 1) 712 env_range2 = np.arange(2, len(log_envelope2), 1) 713 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in env_range1]) 714 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in env_range2]) 715 time1 = log_time1[2:len(log_time1):1] 716 time2 = log_time2[2:len(log_time2):1] 717 718 # resize arrays to match shape 719 common_len = min(len(time1), len(time2)) 720 time1 = time1[:common_len] 721 time2 = time2[:common_len] 722 integral1 = integral1[:common_len] 723 integral2 = integral2[:common_len] 724 # Normalize 725 max_value = np.max(np.hstack([integral1, integral2])) 726 integral1 /= max_value 727 integral2 /= max_value 728 729 # plot the integral area curves 730 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 731 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 732 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 733 ax.set_xlabel('time (s)') 734 ax.set_ylabel('mirror cumulative power (normalized)') 735 ax.set_xscale('log') 736 ax.set_title(bin_string) 737 ax.legend() 738 ax.grid('on') 739 740 plt.tight_layout() 741 742 elif f_bin in self.bin_strings: 743 744 # Compute the log envelopes and areau curves 745 signal1 = self.sounds[0].bins[f_bin] 746 signal2 = self.sounds[1].bins[f_bin] 747 log_envelope1, log_time1 = signal1.normalize().log_envelope() 748 log_envelope2, log_time2 = signal2.normalize().log_envelope() 749 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in np.arange(2, len(log_envelope1), 1)]) 750 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in np.arange(2, len(log_envelope2), 1)]) 751 time1 = log_time1[2:len(log_time1):1] 752 time2 = log_time2[2:len(log_time2):1] 753 754 # resize arrays to match shape 755 common_len = min(len(time1), len(time2)) 756 time1 = time1[:common_len] 757 time2 = time2[:common_len] 758 integral1 = integral1[:common_len] 759 integral2 = integral2[:common_len] 760 # Normalize 761 max_value = np.max(np.hstack([integral1, integral2])) 762 integral1 /= max_value 763 integral2 /= max_value 764 765 fig, ax = plt.subplots(figsize=(8, 6)) 766 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 767 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 768 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 769 770 ax.set_xlabel('time (s)') 771 ax.set_ylabel('mirror cumulative power (normalized)') 772 ax.set_xscale('log') 773 ax.set_title(f_bin) 774 ax.legend(loc='upper left') 775 ax.grid('on') 776 777 else: 778 print('invalid frequency bin')
A class to store and analyse multiple sounds Some methods are only available for SoundPacks containing two sounds
31 def __init__(self, *sounds, names=None, fundamentals=None, 32 SoundParams=None, equalize_time=True): 33 """ 34 The SoundPack can be instantiated from existing Sound class instances, 35 either in a list or as multiple arguments 36 The class can also handle the creation of Sound class instances if the 37 arguments are filenames, either a list or multiple arguments. 38 39 :param sounds: `guitarsounds.Sound` instaces or filenames either as 40 multiple arguments or as a list 41 :param names: list of strings with the names of the sounds that will be 42 used in the plot legend labels 43 :param fundamentals: list of numbers corresponding to the known sound 44 fundamental frequencies. 45 :param SoundParams: `guitarsounds.SoundParams` instance used to get 46 the parameters used in the computation of the sound attributes 47 :param equalize_time: if True, all the sounds used to create the 48 SoundPack are truncated to the length of the shortest sound. 49 50 If the number of Sound contained is equal to two, the SoundPack will 51 be 'dual' and the associated methods will be available : 52 53 - `SoundPack.compare_peaks` 54 - `SoundPack.fft_mirror` 55 - `SoundPack.fft_diff` 56 - `SoundPack.integral_compare` 57 58 If it contains multiple sounds the SoundPack will be multiple and a 59 reduced number of methods will be available to call 60 61 If the fundamental frequency is supplied for each sound, the 62 computation of certain features can be more efficient, such as the 63 time damping computation or Hemlotz frequency computation. 64 65 Examples : 66 ```python 67 Sound_Test = SoundPack('sounds/test1.wav', 'sounds/test2.wav', names=['A', 'B'], fundamentals = [134, 134]) 68 69 sounds = [sound1, sound2, sound3, sound4, sound5] # instances of the Sound class 70 large_Test = SoundPack(sounds, names=['1', '2', '3', '4', '5']) 71 ``` 72 """ 73 # create a copy of the sound parameters 74 if SoundParams is None: 75 self.SP = sound_parameters() 76 else: 77 self.SP = SoundParams 78 79 # Check if the sounds argument is a list 80 if type(sounds[0]) is list: 81 sounds = sounds[0] # unpack the list 82 83 # Check for special case 84 if len(sounds) == 2: 85 # special case to compare two sounds 86 self.kind = 'dual' 87 88 elif len(sounds) > 1: 89 # general case for multiple sounds 90 self.kind = 'multiple' 91 92 # If filenames are supplied 93 if type(sounds[0]) is str: 94 self.sounds_from_files(sounds, names=names, fundamentals=fundamentals) 95 96 # Else sound instances are supplied 97 else: 98 self.sounds = sounds 99 100 # sound name defined in constructor 101 if names and (len(names) == len(self.sounds)): 102 for sound, n in zip(self.sounds, names): 103 sound.name = n 104 105 else: 106 # names obtained from the supplied sounds 107 names = [sound.name for sound in self.sounds if sound.name] 108 109 # all sounds have a name 110 if len(names) == len(sounds): 111 self.names = names 112 113 # Assign a default value to names 114 else: 115 names = [str(n) for n in np.arange(1, len(sounds) + 1)] 116 for sound, n in zip(self.sounds, names): 117 sound.name = n 118 119 # If the sounds are not conditionned condition them 120 for s in self.sounds: 121 if ~hasattr(s, 'signal'): 122 s.condition() 123 124 if equalize_time: 125 self.equalize_time() 126 127 # Define bin strings 128 self.bin_strings = [*list(self.SP.bins.__dict__.keys())[1:], 'brillance'] 129 130 # Sort according to fundamental 131 key = np.argsort([sound.fundamental for sound in self.sounds]) 132 self.sounds = np.array(self.sounds)[key]
The SoundPack can be instantiated from existing Sound class instances, either in a list or as multiple arguments The class can also handle the creation of Sound class instances if the arguments are filenames, either a list or multiple arguments.
Parameters
- sounds:
guitarsounds.Sound
instaces or filenames either as multiple arguments or as a list - names: list of strings with the names of the sounds that will be used in the plot legend labels
- fundamentals: list of numbers corresponding to the known sound fundamental frequencies.
- SoundParams:
guitarsounds.SoundParams
instance used to get the parameters used in the computation of the sound attributes - equalize_time: if True, all the sounds used to create the SoundPack are truncated to the length of the shortest sound.
If the number of Sound contained is equal to two, the SoundPack will be 'dual' and the associated methods will be available :
- `SoundPack.compare_peaks`
- `SoundPack.fft_mirror`
- `SoundPack.fft_diff`
- `SoundPack.integral_compare`
If it contains multiple sounds the SoundPack will be multiple and a reduced number of methods will be available to call
If the fundamental frequency is supplied for each sound, the computation of certain features can be more efficient, such as the time damping computation or Hemlotz frequency computation.
Examples :
Sound_Test = SoundPack('sounds/test1.wav', 'sounds/test2.wav', names=['A', 'B'], fundamentals = [134, 134])
sounds = [sound1, sound2, sound3, sound4, sound5] # instances of the Sound class
large_Test = SoundPack(sounds, names=['1', '2', '3', '4', '5'])
134 def sounds_from_files(self, sound_files, names=None, fundamentals=None): 135 """ 136 Create Sound class instances and assign them to the SoundPack 137 from a list of files 138 139 :param sound_files: sound filenames 140 :param names: sound names 141 :param fundamentals: user specified fundamental frequencies 142 :return: None 143 """ 144 # Make the default name list from sound filenames if none is supplied 145 if (names is None) or (len(names) != len(sound_files)): 146 names = [os.path.split(file)[-1][:-4] for file in sound_files] # remove the .wav 147 148 # If the fundamentals are not supplied or mismatch in number None is used 149 if (fundamentals is None) or (len(fundamentals) != len(sound_files)): 150 fundamentals = len(sound_files) * [None] 151 152 # Create Sound instances from files 153 self.sounds = [] 154 for file, name, fundamental in zip(sound_files, names, fundamentals): 155 self.sounds.append(Sound(file, name=name, fundamental=fundamental, 156 SoundParams=self.SP))
Create Sound class instances and assign them to the SoundPack from a list of files
Parameters
- sound_files: sound filenames
- names: sound names
- fundamentals: user specified fundamental frequencies
Returns
None
158 def equalize_time(self): 159 """ 160 Trim the sounds so that they all have the length of the shortest sound, 161 trimming is done at the end of the sounds. 162 :return: None 163 """ 164 trim_index = np.min([len(sound.signal.signal) for sound in self.sounds]) 165 trimmed_sounds = [] 166 for sound in self.sounds: 167 new_sound = sound 168 new_sound.signal = new_sound.signal.trim_time(trim_index / sound.signal.sr) 169 new_sound.bin_divide() 170 trimmed_sounds.append(new_sound) 171 self.sounds = trimmed_sounds
Trim the sounds so that they all have the length of the shortest sound, trimming is done at the end of the sounds.
Returns
None
173 def normalize(self): 174 """ 175 Normalize all the signals in the SoundPack and returns a normalized 176 instance of itself. See `Signal.normalize` for more information. 177 :return: SoundPack with normalized signals 178 """ 179 new_sounds = [] 180 names = [sound.name for sound in self.sounds] 181 fundamentals = [sound.fundamental for sound in self.sounds] 182 for sound in self.sounds: 183 sound.signal = sound.signal.normalize() 184 new_sounds.append(sound) 185 186 self.sounds = new_sounds 187 188 return self
Normalize all the signals in the SoundPack and returns a normalized
instance of itself. See Signal.normalize
for more information.
Returns
SoundPack with normalized signals
194 def plot(self, kind, **kwargs): 195 """ 196 Superimposed plot of all the sounds on one figure for a specific kind 197 198 :param kind: feature name passed to the `signal.plot()` method 199 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 200 method 201 :return: None 202 203 __ Multiple SoundPack Method __ 204 Plots a specific signal.plot for all sounds on the same figure 205 206 Ex : SoundPack.plot('fft') plots the fft of all sounds on a single figure 207 The color argument is set to none so that the plots have different colors 208 """ 209 plt.figure(figsize=(8, 6)) 210 for sound in self.sounds: 211 kwargs['label'] = sound.name 212 kwargs['color'] = None 213 sound.signal.plot.method_dict[kind](**kwargs) 214 ax = plt.gca() 215 ax.set_title(kind + ' plot') 216 ax.legend() 217 return ax
Superimposed plot of all the sounds on one figure for a specific kind
Parameters
- kind: feature name passed to the
signal.plot()
method - kwargs: keywords arguments to pass to the
matplotlib.plot()
method
Returns
None
__ Multiple SoundPack Method __ Plots a specific signal.plot for all sounds on the same figure
Ex : SoundPack.plot('fft') plots the fft of all sounds on a single figure The color argument is set to none so that the plots have different colors
219 def compare_plot(self, kind, **kwargs): 220 """ 221 Plots all the sounds on different figures to compare them for a specific kind 222 223 :param kind: feature name passed to the `signal.plot()` method 224 :param kwargs: keywords arguments to pass to the `matplotlib.plot()` 225 method 226 :return: None 227 228 __ Multiple SoundPack Method __ 229 Draws the same kind of plot on a different axis for each sound 230 Example : `SoundPack.compare_plot('peaks')` with 4 Sounds will plot a 231 figure with 4 axes, with each a different 'peak' plot. 232 """ 233 # if a dual SoundPack : only plot two big plots 234 if self.kind == 'dual': 235 fig, axs = plt.subplots(1, 2, figsize=(12, 4)) 236 for sound, ax in zip(self.sounds, axs): 237 plt.sca(ax) 238 sound.signal.plot.method_dict[kind](**kwargs) 239 ax.set_title(kind + ' ' + sound.name) 240 plt.tight_layout() 241 242 # If a multiple SoundPack : plot on a grid of axes 243 elif self.kind == 'multiple': 244 # find the n, m values for the subplots line and columns 245 n = len(self.sounds) 246 cols = 0 247 if n // 4 >= 10: 248 # a lot of sounds 249 cols = 4 250 elif n // 3 >= 10: 251 # many sounds 252 cols = 3 253 elif n // 2 <= 4: 254 # a few sounds 255 cols = 2 256 257 remainder = n % cols 258 if remainder == 0: 259 rows = n // cols 260 else: 261 rows = n // cols + 1 262 263 fig, axs = plt.subplots(rows, cols, figsize=(12, 4 * rows)) 264 axs = axs.reshape(-1) 265 for sound, ax in zip(self.sounds, axs): 266 plt.sca(ax) 267 sound.signal.plot.method_dict[kind](**kwargs) 268 title = ax.get_title() 269 title = sound.name + ' ' + title 270 ax.set_title(title) 271 272 if remainder != 0: 273 for ax in axs[-(cols - remainder):]: 274 ax.set_axis_off() 275 plt.tight_layout() 276 else: 277 raise Exception 278 return axs
Plots all the sounds on different figures to compare them for a specific kind
Parameters
- kind: feature name passed to the
signal.plot()
method - kwargs: keywords arguments to pass to the
matplotlib.plot()
method
Returns
None
__ Multiple SoundPack Method __
Draws the same kind of plot on a different axis for each sound
Example : SoundPack.compare_plot('peaks')
with 4 Sounds will plot a
figure with 4 axes, with each a different 'peak' plot.
280 def freq_bin_plot(self, f_bin='all'): 281 """ 282 Plots the log envelope of specified frequency bins 283 :param f_bin: frequency bins to compare, Supported arguments are : 284 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 285 286 __ Multiple SoundPack Method __ 287 A function to compare signals decomposed frequency wise in the time 288 domain on a logarithm scale. The methods plot all the sounds and plots 289 their frequency bins according to the frequency bin argument f_bin. 290 291 Example : SoundPack.freq_bin_plot(f_bin='mid') will plot the log-scale 292 envelope of the 'mid' signal of every sound in the SoundPack. 293 """ 294 295 if f_bin == 'all': 296 # Create one plot per bin 297 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 298 axs = axs.reshape(-1) 299 for key, ax in zip([*list(self.SP.bins.__dict__.keys())[1:], 'brillance'], axs): 300 plt.sca(ax) 301 # plot every sound for a frequency bin 302 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 303 for i, son in enumerate(self.sounds): 304 son.bins[key].normalize().old_plot('log envelope', label=son.name) 305 plt.xscale('log') 306 plt.legend() 307 son = self.sounds[-1] 308 title0 = ' ' + key + ' : ' + str(int(son.bins[key].freq_range[0])) + ' - ' + str( 309 int(son.bins[key].freq_range[1])) + ' Hz, ' 310 title1 = 'Norm. Factors : ' 311 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 312 plt.title(title0 + title1 + title2) 313 plt.tight_layout() 314 315 elif f_bin in [*list(sound_parameters().bins.__dict__.keys())[1:], 'brillance']: 316 plt.figure(figsize=(10, 4)) 317 # Plot every envelope for a single frequency bin 318 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 319 for i, son in enumerate(self.sounds): 320 son.bins[f_bin].normalize().old_plot('log envelope', label=(str(i + 1) + '. ' + son.name)) 321 plt.xscale('log') 322 plt.legend() 323 son = self.sounds[-1] 324 title0 = ' ' + f_bin + ' : ' + str(int(son.bins[f_bin].freq_range[0])) + ' - ' + str( 325 int(son.bins[f_bin].freq_range[1])) + ' Hz, ' 326 title1 = 'Norm. Factors : ' 327 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 328 plt.title(title0 + title1 + title2) 329 330 else: 331 print('invalid frequency bin')
Plots the log envelope of specified frequency bins
Parameters
- f_bin: frequency bins to compare, Supported arguments are: 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance'
__ Multiple SoundPack Method __ A function to compare signals decomposed frequency wise in the time domain on a logarithm scale. The methods plot all the sounds and plots their frequency bins according to the frequency bin argument f_bin.
Example : SoundPack.freq_bin_plot(f_bin='mid') will plot the log-scale envelope of the 'mid' signal of every sound in the SoundPack.
333 def fundamentals(self): 334 """ 335 Displays the fundamentals of every sound in the SoundPack 336 337 :return: None 338 339 __ Multiple Soundpack Method __ 340 """ 341 names = np.array([sound.name for sound in self.sounds]) 342 fundamentals = np.array([np.around(sound.fundamental, 1) for sound in self.sounds]) 343 key = np.argsort(fundamentals) 344 table_data = [names[key], fundamentals[key]] 345 346 table_data = np.array(table_data).transpose() 347 348 print(tabulate(table_data, headers=['Name', 'Fundamental (Hz)']))
Displays the fundamentals of every sound in the SoundPack
Returns
None
__ Multiple Soundpack Method __
350 def integral_plot(self, f_bin='all'): 351 """ 352 Normalized cumulative bin power plot for the frequency bins. 353 See `Plot.integral` for more information. 354 355 :param f_bin: frequency bins to compare, Supported arguments are 356 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 357 358 __ Multiple SoundPack Method __ 359 Plots the cumulative integral plot of specified frequency bins 360 see help(Plot.integral) 361 """ 362 363 if f_bin == 'all': 364 # create a figure with 6 axes 365 fig, axs = plt.subplots(3, 2, figsize=(12, 12)) 366 axs = axs.reshape(-1) 367 368 for key, ax in zip(self.bin_strings, axs): 369 plt.sca(ax) 370 norm_factors = np.array([son.bins[key].normalize().norm_factor for son in self.sounds]) 371 for sound in self.sounds: 372 sound.bins[key].plot.integral(label=sound.name) 373 plt.legend() 374 sound = self.sounds[-1] 375 title0 = ' ' + key + ' : ' + str(int(sound.bins[key].freq_range[0])) + ' - ' + str( 376 int(sound.bins[key].freq_range[1])) + ' Hz, ' 377 title1 = 'Norm. Factors : ' 378 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 379 plt.title(title0 + title1 + title2) 380 plt.title(title0 + title1 + title2) 381 plt.tight_layout() 382 383 elif f_bin in self.bin_strings: 384 fig, ax = plt.subplots(figsize=(6, 4)) 385 plt.sca(ax) 386 norm_factors = np.array([son.bins[f_bin].normalize().norm_factor for son in self.sounds]) 387 for sound in self.sounds: 388 sound.bins[f_bin].plot.integral(label=sound.name) 389 plt.legend() 390 sound = self.sounds[-1] 391 title0 = ' ' + f_bin + ' : ' + str(int(sound.bins[f_bin].freq_range[0])) + ' - ' + str( 392 int(sound.bins[f_bin].freq_range[1])) + ' Hz, ' 393 title1 = 'Norm. Factors : ' 394 title2 = 'x, '.join(str(np.around(norm_factor, 0)) for norm_factor in norm_factors) 395 plt.title(title0 + title1 + title2) 396 397 else: 398 print('invalid frequency bin')
Normalized cumulative bin power plot for the frequency bins.
See Plot.integral
for more information.
Parameters
- f_bin: frequency bins to compare, Supported arguments are 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance'
__ Multiple SoundPack Method __ Plots the cumulative integral plot of specified frequency bins see help(Plot.integral)
400 def bin_power_table(self): 401 """ 402 Displays a table with the signal power contained in every frequency bin 403 404 The power is computed as the time integral of the signal, such as : 405 406 $ P = \int_0^{t_{max}} sig(t) dt $ 407 408 __ Multiple SoundPack Method __ 409 The sounds are always normalized before computing the power. Because 410 the signal amplitude is normalized between -1 and 1, the power value 411 is adimentional, and can only be used to compare two sounds between 412 eachother. 413 """ 414 # Bin power distribution table 415 bin_strings = self.bin_strings 416 integrals = [] 417 418 # for every sound in the SoundPack 419 for sound in self.sounds: 420 421 integral = [] 422 # for every frequency bin in the sound 423 for f_bin in bin_strings: 424 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 425 integral.append(scipy.integrate.trapezoid(log_envelope, log_time)) 426 427 # a list of dict for every sound 428 integrals.append(integral) 429 430 # make the table 431 table_data = np.array([list(bin_strings), *integrals]).transpose() 432 sound_names = [sound.name for sound in self.sounds] 433 434 print('___ Signal Power Frequency Bin Distribution ___ \n') 435 print(tabulate(table_data, headers=['bin', *sound_names]))
Displays a table with the signal power contained in every frequency bin
The power is computed as the time integral of the signal, such as :
$ P = \int_0^{t_{max}} sig(t) dt $
__ Multiple SoundPack Method __
The sounds are always normalized before computing the power. Because
the signal amplitude is normalized between -1 and 1, the power value
is adimentional, and can only be used to compare two sounds between
eachother.
437 def bin_power_hist(self): 438 """ 439 Histogram of the frequency bin power for multiple sounds 440 441 The power is computed as the time integral of the signal, such as : 442 443 $ P = \int_0^{t_{max}} sig(t) dt $ 444 445 __ Multiple SoundPack Method __ 446 The sounds are always normalized before computing the power. Because 447 the signal amplitude is normalized between -1 and 1, the power value 448 is adimentional, and can only be used to compare two sounds between 449 eachother. 450 """ 451 # Compute the bin powers 452 bin_strings = self.bin_strings 453 integrals = [] 454 455 # for every sound in the SoundPack 456 for sound in self.sounds: 457 458 integral = [] 459 # for every frequency bin in the sound 460 for f_bin in bin_strings: 461 log_envelope, log_time = sound.bins[f_bin].normalize().log_envelope() 462 integral.append(trapezoid(log_envelope, log_time)) 463 464 # a list of dict for every sound 465 integral = np.array(integral) 466 integral /= np.max(integral) 467 integrals.append(integral) 468 469 # create the bar plotting vectors 470 fig, ax = plt.subplots(figsize=(6, 6)) 471 472 # make the bar plot 473 n = len(self.sounds) 474 width = 0.8 / n 475 # get nice colors 476 cmap = matplotlib.cm.get_cmap('Set2') 477 for i, sound in enumerate(self.sounds): 478 x = np.arange(i * width, len(bin_strings) + i * width) 479 y = integrals[i] 480 if n < 8: 481 color = cmap(i) 482 else: 483 color = None 484 485 if i == n // 2: 486 ax.bar(x, y, width=width, tick_label=list(bin_strings), label=sound.name, color=color) 487 else: 488 ax.bar(x, y, width=width, label=sound.name, color=color) 489 ax.set_xlabel('frequency bin name') 490 ax.set_ylabel('normalized power') 491 plt.legend()
Histogram of the frequency bin power for multiple sounds
The power is computed as the time integral of the signal, such as :
$ P = \int_0^{t_{max}} sig(t) dt $
__ Multiple SoundPack Method __
The sounds are always normalized before computing the power. Because
the signal amplitude is normalized between -1 and 1, the power value
is adimentional, and can only be used to compare two sounds between
eachother.
493 def listen(self): 494 """ 495 Listen to all the sounds in the SoundPack inside the Jupyter Notebook 496 environment 497 498 __ Multiple SoundPack Method __ 499 """ 500 for sound in self.sounds: 501 sound.signal.listen()
Listen to all the sounds in the SoundPack inside the Jupyter Notebook environment
__ Multiple SoundPack Method __
507 def compare_peaks(self): 508 """ 509 Plot to compare the FFT peaks values of two sounds 510 511 __ Dual SoundPack Method __ 512 Compares the peaks in the Fourier Transform of two Sounds, 513 the peaks having the highest difference are highlighted. If no peaks 514 are found to have a significant difference, only the two normalized 515 Fourier transform are plotted in a mirror configuration to visualize 516 them. 517 """ 518 if self.kind == 'dual': 519 son1 = self.sounds[0] 520 son2 = self.sounds[1] 521 index1 = np.where(son1.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 522 index2 = np.where(son2.signal.fft_frequencies() > self.SP.general.fft_range.value)[0][0] 523 524 # Get the peak data from the sounds 525 peaks1 = son1.signal.peaks() 526 peaks2 = son2.signal.peaks() 527 freq1 = son1.signal.fft_frequencies()[:index1] 528 freq2 = son2.signal.fft_frequencies()[:index2] 529 fft1 = son1.signal.fft()[:index1] 530 fft2 = son2.signal.fft()[:index2] 531 532 short_peaks_1 = [peak for peak in peaks1 if peak < (len(freq1) - 1)] 533 short_peaks_2 = [peak for peak in peaks2 if peak < (len(freq1) - 1)] 534 peak_distance1 = np.mean([freq1[peaks1[i]] - freq1[peaks1[i + 1]] for i in range(len(short_peaks_1) - 1)]) / 4 535 peak_distance2 = np.mean([freq2[peaks2[i]] - freq2[peaks2[i + 1]] for i in range(len(short_peaks_2) - 1)]) / 4 536 peak_distance = np.abs(np.mean([peak_distance1, peak_distance2])) 537 538 # Align the two peak vectors 539 new_peaks1 = [] 540 new_peaks2 = [] 541 for peak1 in short_peaks_1: 542 for peak2 in short_peaks_2: 543 if np.abs(freq1[peak1] - freq2[peak2]) < peak_distance: 544 new_peaks1.append(peak1) 545 new_peaks2.append(peak2) 546 new_peaks1 = np.unique(np.array(new_peaks1)) 547 new_peaks2 = np.unique(np.array(new_peaks2)) 548 549 different_peaks1 = [] 550 different_peaks2 = [] 551 difference_threshold = 0.5 552 while len(different_peaks1) < 1: 553 for peak1, peak2 in zip(new_peaks1, new_peaks2): 554 if np.abs(fft1[peak1] - fft2[peak2]) > difference_threshold: 555 different_peaks1.append(peak1) 556 different_peaks2.append(peak2) 557 difference_threshold -= 0.01 558 if np.isclose(difference_threshold, 0.): 559 break 560 561 # Plot the output 562 plt.figure(figsize=(10, 6)) 563 plt.yscale('symlog', linthresh=10e-1) 564 565 # Sound 1 566 plt.plot(freq1, fft1, color='#919191', label=son1.name) 567 plt.scatter(freq1[new_peaks1], fft1[new_peaks1], color='b', label='peaks') 568 if len(different_peaks1) > 0: 569 plt.scatter(freq1[different_peaks1[0]], fft1[different_peaks1[0]], color='g', label='diff peaks') 570 annotation_string = 'Peaks with ' + str(np.around(difference_threshold, 2)) + ' difference' 571 plt.annotate(annotation_string, (freq1[different_peaks1[0]] + peak_distance / 2, fft1[different_peaks1[0]])) 572 573 # Sound2 574 plt.plot(freq2, -fft2, color='#3d3d3d', label=son2.name) 575 plt.scatter(freq2[new_peaks2], -fft2[new_peaks2], color='b') 576 if len(different_peaks2) > 0: 577 plt.scatter(freq2[different_peaks2[0]], -fft2[different_peaks2[0]], color='g') 578 plt.title('Fourier Transform Peak Analysis for ' + son1.name + ' and ' + son2.name) 579 plt.grid('on') 580 plt.legend() 581 ax = plt.gca() 582 ax.set_xlabel('frequency (Hz)') 583 ax.set_ylabel('mirror amplitude (0-1)') 584 else: 585 raise ValueError('Unsupported for multiple sounds SoundPacks')
Plot to compare the FFT peaks values of two sounds
__ Dual SoundPack Method __ Compares the peaks in the Fourier Transform of two Sounds, the peaks having the highest difference are highlighted. If no peaks are found to have a significant difference, only the two normalized Fourier transform are plotted in a mirror configuration to visualize them.
587 def fft_mirror(self): 588 """ 589 Plot the Fourier Transforms of two sounds on opposed axis to compare the spectral content 590 591 __ Dual SoundPack Method __ 592 The fourier transforms are plotted normalized between 0 and 1. 593 The y scale is symmetric logarithmic, so that a signal is plotted 594 between 0 and -1, and the other is plotted between 0 and 1. 595 :return: None 596 """ 597 if self.kind == 'dual': 598 son1 = self.sounds[0] 599 son2 = self.sounds[1] 600 fft_range_value = sound_parameters().general.fft_range.value 601 fft_freq_value = son1.signal.fft_frequencies() 602 index = np.where(fft_freq_value > fft_range_value)[0][0] 603 604 plt.figure(figsize=(10, 6)) 605 plt.yscale('symlog') 606 plt.grid('on') 607 plt.plot(son1.signal.fft_frequencies()[:index], son1.signal.fft()[:index], label=son1.name) 608 plt.plot(son2.signal.fft_frequencies()[:index], -son2.signal.fft()[:index], label=son2.name) 609 plt.xlabel('frequency (Hz)') 610 plt.ylabel('mirror amplitude (normalized)') 611 plt.title('Mirror Fourier Transform for ' + son1.name + ' and ' + son2.name) 612 plt.legend() 613 614 else: 615 print('Unsupported for multiple sounds SoundPacks')
Plot the Fourier Transforms of two sounds on opposed axis to compare the spectral content
__ Dual SoundPack Method __ The fourier transforms are plotted normalized between 0 and 1. The y scale is symmetric logarithmic, so that a signal is plotted between 0 and -1, and the other is plotted between 0 and 1.
Returns
None
617 def fft_diff(self, fraction=3, ticks=None): 618 """ 619 Plot the difference between the spectral distribution in the two sounds 620 621 :param fraction: octave fraction value used to compute the frequency bins A higher number will show 622 a more precise comparison, but conclusions may be harder to draw. 623 :param ticks: If equal to 'bins' the frequency bins intervals are used as X axis ticks 624 :return: None 625 626 __ Dual SoundPack Method __ 627 Compare the Fourier Transform of two sounds by computing the differences of the octave bins heights. 628 The two FTs are superimposed on the first plot to show differences 629 The difference between the two FTs is plotted on the second plot 630 """ 631 if self.kind == 'dual': 632 # Separate the sounds 633 son1 = self.sounds[0] 634 son2 = self.sounds[1] 635 636 # Compute plotting bins 637 x_values = utils.octave_values(fraction) 638 hist_bins = utils.octave_histogram(fraction) 639 bar_widths = np.array([hist_bins[i + 1] - hist_bins[i] for i in range(0, len(hist_bins) - 1)]) 640 641 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) 642 plot1 = ax1.hist(son1.signal.fft_bins(), utils.octave_histogram(fraction), color='blue', alpha=0.6, 643 label=son1.name) 644 plot2 = ax1.hist(son2.signal.fft_bins(), utils.octave_histogram(fraction), color='orange', alpha=0.6, 645 label=son2.name) 646 ax1.set_title('FT Histogram for ' + son1.name + ' and ' + son2.name) 647 ax1.set_xscale('log') 648 ax1.set_xlabel('frequency (Hz)') 649 ax1.set_ylabel('amplitude') 650 ax1.grid('on') 651 ax1.legend() 652 653 diff = plot1[0] - plot2[0] 654 n_index = np.where(diff <= 0)[0] 655 p_index = np.where(diff >= 0)[0] 656 657 # Negative difference corresponding to sound 2 658 ax2.bar(x_values[n_index], diff[n_index], width=bar_widths[n_index], color='orange', alpha=0.6) 659 # Positive difference corresponding to sound1 660 ax2.bar(x_values[p_index], diff[p_index], width=bar_widths[p_index], color='blue', alpha=0.6) 661 ax2.set_title('Difference ' + son1.name + ' - ' + son2.name) 662 ax2.set_xscale('log') 663 ax2.set_xlabel('frequency (Hz)') 664 ax2.set_ylabel('<- sound 2 : sound 1 ->') 665 ax2.grid('on') 666 667 if ticks == 'bins': 668 labels = [label for label in self.SP.bins.__dict__ if label != 'name'] 669 labels.append('brillance') 670 x = [param.value for param in self.SP.bins.__dict__.values() if param != 'bins'] 671 x.append(11250) 672 x_formatter = ticker.FixedFormatter(labels) 673 x_locator = ticker.FixedLocator(x) 674 ax1.xaxis.set_major_locator(x_locator) 675 ax1.xaxis.set_major_formatter(x_formatter) 676 ax1.tick_params(axis="x", labelrotation=90) 677 ax2.xaxis.set_major_locator(x_locator) 678 ax2.xaxis.set_major_formatter(x_formatter) 679 ax2.tick_params(axis="x", labelrotation=90) 680 681 else: 682 print('Unsupported for multiple sounds SoundPacks')
Plot the difference between the spectral distribution in the two sounds
Parameters
- fraction: octave fraction value used to compute the frequency bins A higher number will show a more precise comparison, but conclusions may be harder to draw.
- ticks: If equal to 'bins' the frequency bins intervals are used as X axis ticks
Returns
None
__ Dual SoundPack Method __ Compare the Fourier Transform of two sounds by computing the differences of the octave bins heights. The two FTs are superimposed on the first plot to show differences The difference between the two FTs is plotted on the second plot
684 def integral_compare(self, f_bin='all'): 685 """ 686 Cumulative bin envelope integral comparison for two signals 687 688 :param f_bin: frequency bins to compare, Supported arguments are : 689 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 690 :return: None 691 692 __ Dual SoundPack Method __ 693 Plots the cumulative integral plot of specified frequency bins 694 and their difference as surfaces 695 """ 696 697 # Case when plotting all the frequency bins 698 if f_bin == 'all': 699 fig, axs = plt.subplots(3, 2, figsize=(16, 16)) 700 axs = axs.reshape(-1) 701 702 # get the bins frequency values 703 self.bin_strings = self.sounds[0].bins.keys() 704 bins1 = self.sounds[0].bins.values() 705 bins2 = self.sounds[1].bins.values() 706 707 for signal1, signal2, bin_string, ax in zip(bins1, bins2, self.bin_strings, axs): 708 # Compute the log time and envelopes integrals 709 log_envelope1, log_time1 = signal1.normalize().log_envelope() 710 log_envelope2, log_time2 = signal2.normalize().log_envelope() 711 env_range1 = np.arange(2, len(log_envelope1), 1) 712 env_range2 = np.arange(2, len(log_envelope2), 1) 713 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in env_range1]) 714 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in env_range2]) 715 time1 = log_time1[2:len(log_time1):1] 716 time2 = log_time2[2:len(log_time2):1] 717 718 # resize arrays to match shape 719 common_len = min(len(time1), len(time2)) 720 time1 = time1[:common_len] 721 time2 = time2[:common_len] 722 integral1 = integral1[:common_len] 723 integral2 = integral2[:common_len] 724 # Normalize 725 max_value = np.max(np.hstack([integral1, integral2])) 726 integral1 /= max_value 727 integral2 /= max_value 728 729 # plot the integral area curves 730 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 731 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 732 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 733 ax.set_xlabel('time (s)') 734 ax.set_ylabel('mirror cumulative power (normalized)') 735 ax.set_xscale('log') 736 ax.set_title(bin_string) 737 ax.legend() 738 ax.grid('on') 739 740 plt.tight_layout() 741 742 elif f_bin in self.bin_strings: 743 744 # Compute the log envelopes and areau curves 745 signal1 = self.sounds[0].bins[f_bin] 746 signal2 = self.sounds[1].bins[f_bin] 747 log_envelope1, log_time1 = signal1.normalize().log_envelope() 748 log_envelope2, log_time2 = signal2.normalize().log_envelope() 749 integral1 = np.array([trapezoid(log_envelope1[:i], log_time1[:i]) for i in np.arange(2, len(log_envelope1), 1)]) 750 integral2 = np.array([trapezoid(log_envelope2[:i], log_time2[:i]) for i in np.arange(2, len(log_envelope2), 1)]) 751 time1 = log_time1[2:len(log_time1):1] 752 time2 = log_time2[2:len(log_time2):1] 753 754 # resize arrays to match shape 755 common_len = min(len(time1), len(time2)) 756 time1 = time1[:common_len] 757 time2 = time2[:common_len] 758 integral1 = integral1[:common_len] 759 integral2 = integral2[:common_len] 760 # Normalize 761 max_value = np.max(np.hstack([integral1, integral2])) 762 integral1 /= max_value 763 integral2 /= max_value 764 765 fig, ax = plt.subplots(figsize=(8, 6)) 766 ax.fill_between(time1, integral1, label=self.sounds[0].name, alpha=0.4) 767 ax.fill_between(time2, -integral2, label=self.sounds[1].name, alpha=0.4) 768 ax.fill_between(time2, integral1 - integral2, color='g', label='int diff', alpha=0.6) 769 770 ax.set_xlabel('time (s)') 771 ax.set_ylabel('mirror cumulative power (normalized)') 772 ax.set_xscale('log') 773 ax.set_title(f_bin) 774 ax.legend(loc='upper left') 775 ax.grid('on') 776 777 else: 778 print('invalid frequency bin')
Cumulative bin envelope integral comparison for two signals
Parameters
- f_bin: frequency bins to compare, Supported arguments are: 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance'
Returns
None
__ Dual SoundPack Method __ Plots the cumulative integral plot of specified frequency bins and their difference as surfaces
781class Sound(object): 782 """ 783 A class to store audio signals corresponding to a sound and compute 784 features on them. 785 """ 786 787 def __init__(self, data, name='', fundamental=None, condition=True, 788 auto_trim=False, use_raw_signal=False, 789 normalize_raw_signal=False, SoundParams=None): 790 """ 791 Creates a Sound instance from a .wav file. A string can be supplied to 792 give a name to the sound. The fundamental frequency value can be 793 specified to increase the accuracy of certain features. 794 795 :param data: path to the .wav data file 796 :param name: name to use in plot legend and titles 797 :param fundamental: Fundamental frequency value if None the value is 798 estimated from the Fourier transform of the sound. 799 :param condition: Bool, whether to condition or not the Sound instance 800 if `True`, the `Sound` instance is conditioned in the constructor 801 :param auto_trim: Bool, whether to trim the end of the sound or not 802 according to a predefined sound length determined based on the 803 fundamental frequency of the sound. 804 :param use_raw_signal: Do not condition the `Sound` and instead 805 use the raw signal read from the file. 806 :param normalize_raw_signal: If `use_raw_signal` is `True`, setting 807 `normalize_raw_signal` to `True` will normalize the raw signal before it 808 is used in the `Sound` class. 809 :param SoundParams: SoundParameters to use with the Sound instance 810 """ 811 # create a reference of the parameters 812 if SoundParams is None: 813 self.SP = sound_parameters() 814 else: 815 self.SP = SoundParams 816 817 if type(data) == str: 818 # Load the sound data using librosa 819 if data.split('.')[-1] != 'wav': 820 raise ValueError('Only .wav are supported') 821 else: 822 signal, sr = utils.load_wav(data) 823 824 elif type(data) == tuple: 825 signal, sr = data 826 827 else: 828 raise TypeError 829 830 # create a Signal class from the signal and sample rate 831 self.raw_signal = Signal(signal, sr, self.SP) 832 # create an empty plot attribute 833 self.plot = None 834 # Allow user specified fundamental 835 self.fundamental = fundamental 836 self.name = name 837 # create an empty signal attribute 838 self.signal = None 839 self.trimmed_signal = None 840 self.bins = None 841 self.bass = None 842 self.mid = None 843 self.highmid = None 844 self.uppermid = None 845 self.presence = None 846 self.brillance = None 847 848 if condition: 849 self.condition(verbose=True, 850 return_self=False, 851 auto_trim=auto_trim, 852 resample=True) 853 else: 854 if use_raw_signal: 855 self.use_raw_signal(normalized=normalize_raw_signal, 856 return_self=False) 857 858 def condition(self, verbose=True, return_self=False, auto_trim=False, resample=True): 859 """ 860 A method conditioning the Sound instance by trimming it 0.1s before the 861 onset and dividing it into frequency bins. 862 :param verbose: if True problems with the trimming process are reported 863 :param return_self: If True the method returns the conditioned Sound 864 instance 865 :param auto_trim: If True, the sound is trimmed to a fixed length 866 according to its fundamental 867 :param resample: If True, the signal is resampled to 22050 Hz 868 :return: a conditioned Sound instance if `return_self = True` 869 """ 870 # Resample only if the sample rate is not 22050 871 if resample & (self.raw_signal.sr != 22050): 872 signal, sr = self.raw_signal.signal, self.raw_signal.sr 873 self.raw_signal = Signal(utils.resample(signal, sr, 22050), 22050, self.SP) 874 875 self.trim_signal(verbose=verbose) 876 self.signal = self.trimmed_signal 877 if self.fundamental is None: 878 self.fundamental = self.signal.fundamental() 879 if auto_trim: 880 time = utils.freq2trim(self.fundamental) 881 self.signal = self.signal.trim_time(time) 882 self.plot = self.signal.plot 883 self.bin_divide() 884 if return_self: 885 return self 886 887 def use_raw_signal(self, normalized=False, return_self=False): 888 """ 889 Assigns the raw signal to the `signal` attribute of the Sound instance. 890 :param normalized: if True, the raw signal is first normalized 891 :param return_self: if True the Sound instance is return after the 892 signal attribute is defined 893 :return: self if return_self is True, else None 894 """ 895 if normalized: 896 self.signal = self.raw_signal.normalize() 897 else: 898 self.signal = self.raw_signal 899 self.bin_divide() 900 if return_self: 901 return self 902 903 def bin_divide(self): 904 """ 905 Calls the `.make_freq_bins` method of the signal to create the 906 signals instances associated to the frequency bins. 907 :return: None 908 909 The bins are all stored in the `.bin` attribute and also as 910 their names (Ex: `Sound.mid` contains the mid signal). The cutoff 911 frequencies of the different bins can be changed in the 912 `SoundParameters` instance of the Sound under the attribute `.SP`. 913 See guitarsounds.parameters.sound_parameters().bins.info() for the 914 frequency bin intervals. 915 """ 916 # divide in frequency bins 917 self.bins = self.signal.make_freq_bins() 918 # unpack the bins 919 self.bass, self.mid, self.highmid, self.uppermid, self.presence, self.brillance = self.bins.values() 920 921 def trim_signal(self, verbose=True): 922 """ 923 A method to trim the signal to a specific time before the onset. 924 :param verbose: if True problems encountered are printed to the terminal 925 :return: None 926 927 The default time value of 0.1s can be changed in the SoundParameters. 928 """ 929 # Trim the signal in the signal class 930 self.trimmed_signal = self.raw_signal.trim_onset(verbose=verbose) 931 932 def listen_freq_bins(self): 933 """ 934 Method to listen to all the frequency bins of a sound 935 :return: None 936 937 See `help(Sound.bin_divide)` for more information. 938 """ 939 for key in self.bins.keys(): 940 print(key) 941 self.bins[key].normalize().listen() 942 943 def plot_freq_bins(self, bins='all'): 944 """ 945 Method to plot all the frequency bins logarithmic envelopes of a sound 946 :return: None 947 948 The parameter `bins` allows choosing specific frequency bins to plot 949 By default the function plots all the bins 950 Supported bins arguments are : 951 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 952 953 Example : 954 `Sound.plot_freq_bins(bins='all')` plots all the frequency bins 955 `Sound.plot_freq_bins(bins=['bass', 'mid'])` plots the bass and mid bins 956 957 For more information on the logarithmic envelope, see : 958 `help(Signal.log_envelope)` 959 """ 960 961 if bins[0] == 'all': 962 bins = 'all' 963 964 if bins == 'all': 965 bins = self.bins.keys() 966 967 for key in bins: 968 range_start = str(int(self.bins[key].freq_range[0])) 969 range_end = str(int(self.bins[key].freq_range[1])) 970 lab = key + ' : ' + range_start + ' - ' + range_end + ' Hz' 971 self.bins[key].plot.log_envelope(label=lab) 972 973 plt.xscale('log') 974 plt.yscale('log') 975 plt.legend(fontsize="x-small") # using a named size 976 977 def peak_damping(self): 978 """ 979 Prints a table with peak damping values and peak frequency values 980 981 The peaks are found with the `signal.peaks()` function and the damping 982 values are computed using the half power bandwidth method. 983 984 see `help(Signal.peak_damping)` for more information. 985 """ 986 peak_indexes = self.signal.peaks() 987 frequencies = self.signal.fft_frequencies()[peak_indexes] 988 damping = self.signal.peak_damping() 989 table_data = np.array([frequencies, np.array(damping) * 100]).transpose() 990 print(tabulate(table_data, headers=['Frequency (Hz)', 'Damping ratio (%)'])) 991 992 def bin_hist(self): 993 """ 994 Histogram of the frequency bin power 995 996 Frequency bin power is computed as the integral of the bin envelope. 997 The power value is non dimensional and normalized. 998 999 See guitarsounds.parameters.sound_parameters().bins.info() for the 1000 frequency bin frequency intervals. 1001 """ 1002 # Compute the bin powers 1003 bin_strings = list(self.bins.keys()) 1004 integrals = [] 1005 1006 for f_bin in bin_strings: 1007 log_envelope, log_time = self.bins[f_bin].normalize().log_envelope() 1008 integral = trapezoid(log_envelope, log_time) 1009 integrals.append(integral) 1010 max_value = np.max(integrals) 1011 integrals = np.array(integrals)/max_value 1012 1013 # create the bar plotting vectors 1014 fig, ax = plt.subplots(figsize=(6, 6)) 1015 1016 x = np.arange(0, len(bin_strings)) 1017 y = integrals 1018 ax.bar(x, y, tick_label=list(bin_strings)) 1019 ax.set_xlabel("frequency bin name") 1020 ax.set_ylabel("frequency bin power (normalized)")
A class to store audio signals corresponding to a sound and compute features on them.
787 def __init__(self, data, name='', fundamental=None, condition=True, 788 auto_trim=False, use_raw_signal=False, 789 normalize_raw_signal=False, SoundParams=None): 790 """ 791 Creates a Sound instance from a .wav file. A string can be supplied to 792 give a name to the sound. The fundamental frequency value can be 793 specified to increase the accuracy of certain features. 794 795 :param data: path to the .wav data file 796 :param name: name to use in plot legend and titles 797 :param fundamental: Fundamental frequency value if None the value is 798 estimated from the Fourier transform of the sound. 799 :param condition: Bool, whether to condition or not the Sound instance 800 if `True`, the `Sound` instance is conditioned in the constructor 801 :param auto_trim: Bool, whether to trim the end of the sound or not 802 according to a predefined sound length determined based on the 803 fundamental frequency of the sound. 804 :param use_raw_signal: Do not condition the `Sound` and instead 805 use the raw signal read from the file. 806 :param normalize_raw_signal: If `use_raw_signal` is `True`, setting 807 `normalize_raw_signal` to `True` will normalize the raw signal before it 808 is used in the `Sound` class. 809 :param SoundParams: SoundParameters to use with the Sound instance 810 """ 811 # create a reference of the parameters 812 if SoundParams is None: 813 self.SP = sound_parameters() 814 else: 815 self.SP = SoundParams 816 817 if type(data) == str: 818 # Load the sound data using librosa 819 if data.split('.')[-1] != 'wav': 820 raise ValueError('Only .wav are supported') 821 else: 822 signal, sr = utils.load_wav(data) 823 824 elif type(data) == tuple: 825 signal, sr = data 826 827 else: 828 raise TypeError 829 830 # create a Signal class from the signal and sample rate 831 self.raw_signal = Signal(signal, sr, self.SP) 832 # create an empty plot attribute 833 self.plot = None 834 # Allow user specified fundamental 835 self.fundamental = fundamental 836 self.name = name 837 # create an empty signal attribute 838 self.signal = None 839 self.trimmed_signal = None 840 self.bins = None 841 self.bass = None 842 self.mid = None 843 self.highmid = None 844 self.uppermid = None 845 self.presence = None 846 self.brillance = None 847 848 if condition: 849 self.condition(verbose=True, 850 return_self=False, 851 auto_trim=auto_trim, 852 resample=True) 853 else: 854 if use_raw_signal: 855 self.use_raw_signal(normalized=normalize_raw_signal, 856 return_self=False)
Creates a Sound instance from a .wav file. A string can be supplied to give a name to the sound. The fundamental frequency value can be specified to increase the accuracy of certain features.
Parameters
- data: path to the .wav data file
- name: name to use in plot legend and titles
- fundamental: Fundamental frequency value if None the value is estimated from the Fourier transform of the sound.
- condition: Bool, whether to condition or not the Sound instance
if
True
, theSound
instance is conditioned in the constructor - auto_trim: Bool, whether to trim the end of the sound or not according to a predefined sound length determined based on the fundamental frequency of the sound.
- use_raw_signal: Do not condition the
Sound
and instead use the raw signal read from the file. - normalize_raw_signal: If
use_raw_signal
isTrue
, settingnormalize_raw_signal
toTrue
will normalize the raw signal before it is used in theSound
class. - SoundParams: SoundParameters to use with the Sound instance
858 def condition(self, verbose=True, return_self=False, auto_trim=False, resample=True): 859 """ 860 A method conditioning the Sound instance by trimming it 0.1s before the 861 onset and dividing it into frequency bins. 862 :param verbose: if True problems with the trimming process are reported 863 :param return_self: If True the method returns the conditioned Sound 864 instance 865 :param auto_trim: If True, the sound is trimmed to a fixed length 866 according to its fundamental 867 :param resample: If True, the signal is resampled to 22050 Hz 868 :return: a conditioned Sound instance if `return_self = True` 869 """ 870 # Resample only if the sample rate is not 22050 871 if resample & (self.raw_signal.sr != 22050): 872 signal, sr = self.raw_signal.signal, self.raw_signal.sr 873 self.raw_signal = Signal(utils.resample(signal, sr, 22050), 22050, self.SP) 874 875 self.trim_signal(verbose=verbose) 876 self.signal = self.trimmed_signal 877 if self.fundamental is None: 878 self.fundamental = self.signal.fundamental() 879 if auto_trim: 880 time = utils.freq2trim(self.fundamental) 881 self.signal = self.signal.trim_time(time) 882 self.plot = self.signal.plot 883 self.bin_divide() 884 if return_self: 885 return self
A method conditioning the Sound instance by trimming it 0.1s before the onset and dividing it into frequency bins.
Parameters
- verbose: if True problems with the trimming process are reported
- return_self: If True the method returns the conditioned Sound instance
- auto_trim: If True, the sound is trimmed to a fixed length according to its fundamental
- resample: If True, the signal is resampled to 22050 Hz
Returns
a conditioned Sound instance if
return_self = True
887 def use_raw_signal(self, normalized=False, return_self=False): 888 """ 889 Assigns the raw signal to the `signal` attribute of the Sound instance. 890 :param normalized: if True, the raw signal is first normalized 891 :param return_self: if True the Sound instance is return after the 892 signal attribute is defined 893 :return: self if return_self is True, else None 894 """ 895 if normalized: 896 self.signal = self.raw_signal.normalize() 897 else: 898 self.signal = self.raw_signal 899 self.bin_divide() 900 if return_self: 901 return self
Assigns the raw signal to the signal
attribute of the Sound instance.
Parameters
- normalized: if True, the raw signal is first normalized
- return_self: if True the Sound instance is return after the signal attribute is defined
Returns
self if return_self is True, else None
903 def bin_divide(self): 904 """ 905 Calls the `.make_freq_bins` method of the signal to create the 906 signals instances associated to the frequency bins. 907 :return: None 908 909 The bins are all stored in the `.bin` attribute and also as 910 their names (Ex: `Sound.mid` contains the mid signal). The cutoff 911 frequencies of the different bins can be changed in the 912 `SoundParameters` instance of the Sound under the attribute `.SP`. 913 See guitarsounds.parameters.sound_parameters().bins.info() for the 914 frequency bin intervals. 915 """ 916 # divide in frequency bins 917 self.bins = self.signal.make_freq_bins() 918 # unpack the bins 919 self.bass, self.mid, self.highmid, self.uppermid, self.presence, self.brillance = self.bins.values()
Calls the .make_freq_bins
method of the signal to create the
signals instances associated to the frequency bins.
Returns
None
The bins are all stored in the .bin
attribute and also as
their names (Ex: Sound.mid
contains the mid signal). The cutoff
frequencies of the different bins can be changed in the
SoundParameters
instance of the Sound under the attribute .SP
.
See guitarsounds.parameters.sound_parameters().bins.info() for the
frequency bin intervals.
921 def trim_signal(self, verbose=True): 922 """ 923 A method to trim the signal to a specific time before the onset. 924 :param verbose: if True problems encountered are printed to the terminal 925 :return: None 926 927 The default time value of 0.1s can be changed in the SoundParameters. 928 """ 929 # Trim the signal in the signal class 930 self.trimmed_signal = self.raw_signal.trim_onset(verbose=verbose)
A method to trim the signal to a specific time before the onset.
Parameters
- verbose: if True problems encountered are printed to the terminal
Returns
None
The default time value of 0.1s can be changed in the SoundParameters.
932 def listen_freq_bins(self): 933 """ 934 Method to listen to all the frequency bins of a sound 935 :return: None 936 937 See `help(Sound.bin_divide)` for more information. 938 """ 939 for key in self.bins.keys(): 940 print(key) 941 self.bins[key].normalize().listen()
Method to listen to all the frequency bins of a sound
Returns
None
See help(Sound.bin_divide)
for more information.
943 def plot_freq_bins(self, bins='all'): 944 """ 945 Method to plot all the frequency bins logarithmic envelopes of a sound 946 :return: None 947 948 The parameter `bins` allows choosing specific frequency bins to plot 949 By default the function plots all the bins 950 Supported bins arguments are : 951 'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance' 952 953 Example : 954 `Sound.plot_freq_bins(bins='all')` plots all the frequency bins 955 `Sound.plot_freq_bins(bins=['bass', 'mid'])` plots the bass and mid bins 956 957 For more information on the logarithmic envelope, see : 958 `help(Signal.log_envelope)` 959 """ 960 961 if bins[0] == 'all': 962 bins = 'all' 963 964 if bins == 'all': 965 bins = self.bins.keys() 966 967 for key in bins: 968 range_start = str(int(self.bins[key].freq_range[0])) 969 range_end = str(int(self.bins[key].freq_range[1])) 970 lab = key + ' : ' + range_start + ' - ' + range_end + ' Hz' 971 self.bins[key].plot.log_envelope(label=lab) 972 973 plt.xscale('log') 974 plt.yscale('log') 975 plt.legend(fontsize="x-small") # using a named size
Method to plot all the frequency bins logarithmic envelopes of a sound
Returns
None
The parameter bins
allows choosing specific frequency bins to plot
By default the function plots all the bins
Supported bins arguments are :
'all', 'bass', 'mid', 'highmid', 'uppermid', 'presence', 'brillance'
Example :
Sound.plot_freq_bins(bins='all')
plots all the frequency bins
Sound.plot_freq_bins(bins=['bass', 'mid'])
plots the bass and mid bins
For more information on the logarithmic envelope, see :
help(Signal.log_envelope)
977 def peak_damping(self): 978 """ 979 Prints a table with peak damping values and peak frequency values 980 981 The peaks are found with the `signal.peaks()` function and the damping 982 values are computed using the half power bandwidth method. 983 984 see `help(Signal.peak_damping)` for more information. 985 """ 986 peak_indexes = self.signal.peaks() 987 frequencies = self.signal.fft_frequencies()[peak_indexes] 988 damping = self.signal.peak_damping() 989 table_data = np.array([frequencies, np.array(damping) * 100]).transpose() 990 print(tabulate(table_data, headers=['Frequency (Hz)', 'Damping ratio (%)']))
Prints a table with peak damping values and peak frequency values
The peaks are found with the signal.peaks()
function and the damping
values are computed using the half power bandwidth method.
see help(Signal.peak_damping)
for more information.
992 def bin_hist(self): 993 """ 994 Histogram of the frequency bin power 995 996 Frequency bin power is computed as the integral of the bin envelope. 997 The power value is non dimensional and normalized. 998 999 See guitarsounds.parameters.sound_parameters().bins.info() for the 1000 frequency bin frequency intervals. 1001 """ 1002 # Compute the bin powers 1003 bin_strings = list(self.bins.keys()) 1004 integrals = [] 1005 1006 for f_bin in bin_strings: 1007 log_envelope, log_time = self.bins[f_bin].normalize().log_envelope() 1008 integral = trapezoid(log_envelope, log_time) 1009 integrals.append(integral) 1010 max_value = np.max(integrals) 1011 integrals = np.array(integrals)/max_value 1012 1013 # create the bar plotting vectors 1014 fig, ax = plt.subplots(figsize=(6, 6)) 1015 1016 x = np.arange(0, len(bin_strings)) 1017 y = integrals 1018 ax.bar(x, y, tick_label=list(bin_strings)) 1019 ax.set_xlabel("frequency bin name") 1020 ax.set_ylabel("frequency bin power (normalized)")
Histogram of the frequency bin power
Frequency bin power is computed as the integral of the bin envelope. The power value is non dimensional and normalized.
See guitarsounds.parameters.sound_parameters().bins.info() for the frequency bin frequency intervals.
1023class Signal(object): 1024 """ 1025 A Class to do computations on an audio signal. 1026 1027 The signal is never changed in the class, when transformations are made, a new instance is returned. 1028 """ 1029 1030 def __init__(self, signal, sr, SoundParam=None, freq_range=None): 1031 """ 1032 Create a Signal class from a vector of samples and a sample rate. 1033 1034 :param signal: vector containing the signal samples 1035 :param sr: sample rate of the signal (Hz) 1036 :param SoundParam: Sound Parameter instance to use with the signal 1037 """ 1038 if SoundParam is None: 1039 self.SP = sound_parameters() 1040 else: 1041 self.SP = SoundParam 1042 self.onset = None 1043 self.signal = signal 1044 self.sr = sr 1045 self.plot = Plot() 1046 self.plot.parent = self 1047 self.norm_factor = None 1048 self.freq_range = freq_range 1049 1050 def time(self): 1051 """ 1052 Returns the time vector associated with the signal 1053 :return: numpy array corresponding to the time values 1054 of the signal samples in seconds 1055 """ 1056 return np.linspace(0, 1057 len(self.signal) * (1 / self.sr), 1058 len(self.signal)) 1059 1060 def listen(self): 1061 """ 1062 Method to listen the sound signal in a Jupyter Notebook 1063 :return: None 1064 1065 Listening to the sounds imported in the analysis tool allows the 1066 user to validate if the sound was well trimmed and filtered 1067 1068 A temporary file is created, the IPython display Audio function is 1069 called on it, and then the file is removed. 1070 """ 1071 file = 'temp.wav' 1072 write(file, self.signal, self.sr) 1073 ipd.display(ipd.Audio(file)) 1074 os.remove(file) 1075 1076 def old_plot(self, kind, **kwargs): 1077 """ 1078 Convenience function for the different signal plots 1079 1080 Calls the function corresponding to Plot.kind() 1081 See help(guitarsounds.analysis.Plot) for info on the different plots 1082 """ 1083 self.plot.method_dict[kind](**kwargs) 1084 1085 def fft(self): 1086 """ 1087 Computes the Fast Fourier Transform of the signal and returns the 1088 normalized amplitude vector. 1089 :return: Fast Fourier Transform amplitude values in a numpy array 1090 """ 1091 fft = np.fft.fft(self.signal) 1092 # Only the symmetric part of the absolute value 1093 fft = np.abs(fft[:int(len(fft) // 2)]) 1094 return fft / np.max(fft) 1095 1096 def spectral_centroid(self): 1097 """ 1098 Spectral centroid of the frequency content of the signal 1099 :return: Spectral centroid of the signal (float) 1100 1101 The spectral centroid corresponds to the frequency where the area 1102 under the fourier transform curve is equal on both sides. 1103 This feature is usefull in determining the global frequency content 1104 of a sound. A sound having an overall higher spectral centroid would 1105 be perceived as higher, or brighter. 1106 """ 1107 SC = np.sum(self.fft() * self.fft_frequencies()) / np.sum(self.fft()) 1108 return SC 1109 1110 1111 def peaks(self, max_freq=None, height=False, result=False): 1112 """ 1113 Computes the harmonic peaks indexes from the Fourier Transform of 1114 the signal. 1115 :param max_freq: Supply a max frequency value overriding the one in 1116 guitarsounds_parameters 1117 :param height: if True the height threshold is returned to be used 1118 in the 'peaks' plot 1119 :param result: if True the Scipy peak finding results dictionary 1120 is returned 1121 :return: peak indexes 1122 1123 Because the sound is assumed to be harmonic, the Fourier transform 1124 peaks should be positionned at frequencies $nf$, where $f$ is the 1125 fundamental frequency of the signal. The peak data is usefull to 1126 compare the frequency content of two sounds, as in 1127 `Sound.compare_peaks`. 1128 """ 1129 # Replace None by the default value 1130 if max_freq is None: 1131 max_freq = self.SP.general.fft_range.value 1132 1133 # Get the fft and fft frequencies from the signal 1134 fft, fft_freq = self.fft(), self.fft_frequencies() 1135 1136 # Find the max index 1137 try: 1138 max_index = np.where(fft_freq >= max_freq)[0][0] 1139 except IndexError: 1140 max_index = fft_freq.shape[0] 1141 1142 # Find an approximation of the distance between peaks, this only works for harmonic signals 1143 peak_distance = np.argmax(fft) // 2 1144 1145 # Maximum of the signal in a small region on both ends 1146 fft_max_start = np.max(fft[:peak_distance]) 1147 fft_max_end = np.max(fft[max_index - peak_distance:max_index]) 1148 1149 # Build the curve below the peaks but above the noise 1150 exponents = np.linspace(np.log10(fft_max_start), np.log10(fft_max_end), max_index) 1151 intersect = 10 ** exponents[peak_distance] 1152 diff_start = fft_max_start - intersect # offset by a small distance so that the first max is not a peak 1153 min_height = 10 ** np.linspace(np.log10(fft_max_start + diff_start), np.log10(fft_max_end), max_index) 1154 1155 first_peak_indexes, _ = sig.find_peaks(fft[:max_index], height=min_height, distance=peak_distance) 1156 1157 number_of_peaks = len(first_peak_indexes) 1158 if number_of_peaks > 0: 1159 average_len = int(max_index / number_of_peaks) * 3 1160 else: 1161 average_len = int(max_index / 3) 1162 1163 if average_len % 2 == 0: 1164 average_len += 1 1165 1166 average_fft = sig.savgol_filter(fft[:max_index], average_len, 1, mode='mirror') * 1.9 1167 min_freq_index = np.where(fft_freq >= 70)[0][0] 1168 average_fft[:min_freq_index] = 1 1169 1170 peak_indexes, res = sig.find_peaks(fft[:max_index], height=average_fft, distance=min_freq_index) 1171 1172 # Remove noisy peaks at the low frequencies 1173 while fft[peak_indexes[0]] < 5e-2: 1174 peak_indexes = np.delete(peak_indexes, 0) 1175 while fft[peak_indexes[-1]] < 1e-4: 1176 peak_indexes = np.delete(peak_indexes, -1) 1177 1178 if not height and not result: 1179 return peak_indexes 1180 elif height: 1181 return peak_indexes, average_fft 1182 elif result: 1183 return peak_indexes, res 1184 elif height and result: 1185 return peak_indexes, height, res 1186 1187 def time_damping(self): 1188 """ 1189 Computes the time wise damping ratio of the signal 1190 :return: The damping ratio (float). 1191 1192 by fitting a negative exponential curve 1193 to the Signal envelope and computing the ratio with the Signal fundamental frequency. 1194 """ 1195 # Get the envelope data 1196 envelope, envelope_time = self.normalize().envelope() 1197 1198 # First point is the maximum because e^-kt is strictly decreasing 1199 first_index = np.argmax(envelope) 1200 1201 # The second point is the first point where the signal crosses the lower_threshold line 1202 second_point_thresh = self.SP.damping.lower_threshold.value 1203 try: 1204 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1205 except IndexError: 1206 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh * 2)[0] 1207 1208 # Function to compute the residual for the exponential curve fit 1209 def residual_function(zeta_w, t, s): 1210 """ 1211 Function computing the residual to curve fit a negative exponential to the signal envelope 1212 :param zeta_w: zeta*omega constant 1213 :param t: time vector 1214 :param s: signal 1215 :return: residual 1216 """ 1217 return np.exp(zeta_w[0] * t) - s 1218 1219 zeta_guess = [-0.5] 1220 1221 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1222 args=(envelope_time[first_index:second_index], 1223 envelope[first_index:second_index])) 1224 # Get the zeta*omega constant 1225 zeta_omega = result.x[0] 1226 1227 # Compute the fundamental frequency in radians of the signal 1228 wd = 2 * np.pi * self.fundamental() 1229 return -zeta_omega / wd 1230 1231 def peak_damping(self): 1232 """ 1233 Computes the frequency wise damping with the half bandwidth method on the Fourier Transform peaks 1234 :return: an array containing the peak damping values 1235 """ 1236 zetas = [] 1237 fft_freqs = self.fft_frequencies() 1238 fft = self.fft()[:len(fft_freqs)] 1239 for peak in self.peaks(): 1240 peak_frequency = fft_freqs[peak] 1241 peak_height = fft[peak] 1242 root_height = peak_height / np.sqrt(2) 1243 frequency_roots = scipy.interpolate.InterpolatedUnivariateSpline(fft_freqs, fft - root_height).roots() 1244 sorted_roots_indexes = np.argsort(np.abs(frequency_roots - peak_frequency)) 1245 w2, w1 = frequency_roots[sorted_roots_indexes[:2]] 1246 w1, w2 = np.sort([w1, w2]) 1247 zeta = (w2 - w1) / (2 * peak_frequency) 1248 zetas.append(zeta) 1249 return np.array(zetas) 1250 1251 def fundamental(self): 1252 """ 1253 Returns the fundamental approximated by the first peak of the fft 1254 :return: fundamental value (Hz) 1255 """ 1256 index = self.peaks()[0] 1257 fundamental = self.fft_frequencies()[index] 1258 return fundamental 1259 1260 def cavity_peak(self): 1261 """ 1262 Finds the Hemlotz cavity frequency index from the Fourier Transform by searching for a peak in the expected 1263 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1264 is printed and None is returned. 1265 :return: The index of the cavity peak 1266 """ 1267 first_index = np.where(self.fft_frequencies() >= 80)[0][0] 1268 second_index = np.where(self.fft_frequencies() >= 110)[0][0] 1269 cavity_peak = np.argmax(self.fft()[first_index:second_index]) + first_index 1270 return cavity_peak 1271 1272 def cavity_frequency(self): 1273 """ 1274 Finds the Hemlotz cavity frequency from the Fourier Transform by searching for a peak in the expected 1275 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1276 is printed and None is returned. 1277 :return: If successful, the cavity peak frequency 1278 """ 1279 cavity_peak = self.cavity_peak() 1280 if self.fundamental() == self.fft_frequencies()[cavity_peak]: 1281 print('Cavity peak is obscured by the fundamental') 1282 return 0 1283 else: 1284 return self.fft_frequencies()[cavity_peak] 1285 1286 def fft_frequencies(self): 1287 """ 1288 Computes the frequency vector associated with the Signal Fourier Transform 1289 :return: an array containing the frequency values. 1290 """ 1291 fft = self.fft() 1292 fft_frequencies = np.fft.fftfreq(len(fft) * 2, 1 / self.sr) # Frequencies corresponding to the bins 1293 return fft_frequencies[:len(fft)] 1294 1295 def fft_bins(self): 1296 """ 1297 Transforms the Fourier transform array into a statistic distribution 1298 ranging from 0 to 100. 1299 Accordingly, the maximum of the Fourier transform with value 1.0 will 1300 be equal to 100 and casted as an integer. 1301 Values below 0.001 will be equal to 0. 1302 This representation of the Fourier transform is used to construct 1303 octave bands histograms. 1304 :return : a list containing the frequency occurrences. 1305 """ 1306 1307 # Make the FT values integers 1308 fft_integers = [int(np.around(sample * 100, 0)) for sample in self.fft()] 1309 1310 # Create a list of the frequency occurrences in the signal 1311 occurrences = [] 1312 for freq, count in zip(self.fft_frequencies(), fft_integers): 1313 occurrences.append([freq] * count) 1314 1315 # flatten the list 1316 return [item for sublist in occurrences for item in sublist] 1317 1318 def envelope(self, window=None, overlap=None): 1319 """ 1320 Method calculating the amplitude envelope of a signal as a 1321 maximum of the absolute value of the signal. 1322 The same `window` and `overlap` parameters should be used to compute 1323 the signal and time arrays so that they contain the same 1324 number of points (and can be plotted together). 1325 :param window: integer, length in samples of the window used to 1326 compute the signal envelope. 1327 :param overlap: integer, overlap in samples used to overlap two 1328 subsequent windows in the computation of the signal envelope. 1329 The overlap value should be smaller than the window value. 1330 :return: Amplitude envelope of the signal 1331 """ 1332 if window is None: 1333 window = self.SP.envelope.frame_size.value 1334 if overlap is None: 1335 overlap = window // 2 1336 elif overlap >= window: 1337 raise ValueError('Overlap must be smaller than window') 1338 signal_array = np.abs(self.signal) 1339 t = self.time() 1340 # Empty envelope and envelope time 1341 env = [0] 1342 env_time = [0] 1343 idx = 0 1344 while idx + window < signal_array.shape[0]: 1345 env.append(np.max(signal_array[idx:idx + window])) 1346 pt_idx = np.argmax(signal_array[idx:idx + window]) + idx 1347 env_time.append(t[pt_idx]) 1348 idx += overlap 1349 _, unique_time_index = np.unique(env_time, return_index=True) 1350 return np.array(env)[unique_time_index], np.unique(env_time) 1351 1352 def log_envelope(self): 1353 """ 1354 Computes the logarithmic scale envelope of the signal. 1355 The width of the samples increases exponentially so that 1356 the envelope appears having a constant window width on 1357 an X axis logarithmic scale. 1358 :return: The log envelope and the time vector associated in a tuple 1359 """ 1360 if self.onset is None: 1361 onset = np.argmax(np.abs(self.signal)) 1362 else: 1363 onset = self.onset 1364 1365 start_time = self.SP.log_envelope.start_time.value 1366 while start_time > (onset / self.sr): 1367 start_time /= 10. 1368 1369 start_exponent = int(np.log10(start_time)) # closest 10^x value for smooth graph 1370 1371 if self.SP.log_envelope.min_window.value is None: 1372 min_window = 15 ** (start_exponent + 4) 1373 if min_window < 15: # Value should at least be 10 1374 min_window = 15 1375 else: 1376 min_window = self.SP.log_envelope.min_window.value 1377 1378 # initial values 1379 current_exponent = start_exponent 1380 current_time = 10 ** current_exponent # start time on log scale 1381 index = int(current_time * self.sr) # Start at the specified time 1382 window = min_window # number of samples per window 1383 overlap = window // 2 1384 log_envelope = [0] 1385 log_envelope_time = [0] # First value for comparison 1386 1387 while index + window <= len(self.signal): 1388 1389 while log_envelope_time[-1] < 10 ** (current_exponent + 1): 1390 if (index + window) < len(self.signal): 1391 log_envelope.append(np.max(np.abs(self.signal[index:index + window]))) 1392 pt_idx = np.argmax(np.abs(self.signal[index:index + window])) 1393 log_envelope_time.append(self.time()[index + pt_idx]) 1394 index += overlap 1395 else: 1396 break 1397 1398 if window * 10 < self.SP.log_envelope.max_window.value: 1399 window = window * 10 1400 else: 1401 window = self.SP.log_envelope.max_window.value 1402 overlap = window // 2 1403 current_exponent += 1 1404 time, idxs = np.unique(log_envelope_time, return_index=True) 1405 return np.array(log_envelope)[idxs], time 1406 1407 def find_onset(self, verbose=True): 1408 """ 1409 Finds the onset as an increase in more of 50% with the maximum normalized value above 0.5 1410 :param verbose: Prints a warning if the algorithm does not converge 1411 :return: the index of the onset in the signal 1412 """ 1413 # Index corresponding to the onset time interval 1414 window_index = np.ceil(self.SP.onset.onset_time.value * self.sr).astype(int) 1415 # Use the normalized signal to compare against a fixed value 1416 onset_signal = self.normalize() 1417 overlap = window_index // 2 # overlap for algorithm progression 1418 # Initial values 1419 increase = 0 1420 i = 0 1421 broke = False 1422 while increase <= 0.5: 1423 signal_min = np.min(np.abs(onset_signal.signal[i:i + window_index])) 1424 signal_max = np.max(np.abs(onset_signal.signal[i:i + window_index])) 1425 if (signal_max > 0.5) and (signal_min != 0): 1426 increase = signal_max / signal_min 1427 else: 1428 increase = 0 1429 i += overlap 1430 if i + window_index > len(self.signal): 1431 if verbose: 1432 print('Onset detection did not converge \n') 1433 print('Approximating onset with signal max value \n') 1434 broke = True 1435 break 1436 if broke: 1437 return np.argmax(np.abs(self.signal)) 1438 else: 1439 i -= overlap 1440 return np.argmax(np.abs(self.signal[i:i + window_index])) + i 1441 1442 def trim_onset(self, verbose=True): 1443 """ 1444 Trim the signal at the onset (max) minus the delay in milliseconds as 1445 Specified in the SoundParameters 1446 :param verbose: if False the warning comments are not displayed 1447 :return : The trimmed signal 1448 """ 1449 # nb of samples to keep before the onset 1450 delay_samples = int((self.SP.onset.onset_delay.value / 1000) * self.sr) 1451 onset = self.find_onset(verbose=verbose) # find the onset 1452 1453 if onset > delay_samples: # To make sure the index is positive 1454 new_signal = self.signal[onset - delay_samples:] 1455 new_signal[:delay_samples // 2] = new_signal[:delay_samples // 2] * np.linspace(0, 1, delay_samples // 2) 1456 trimmed_signal = Signal(new_signal, self.sr, self.SP) 1457 trimmed_signal.onset = trimmed_signal.find_onset(verbose=verbose) 1458 return trimmed_signal 1459 1460 else: 1461 if verbose: 1462 print('Signal is too short to be trimmed before onset.') 1463 print('') 1464 return self 1465 1466 def trim_time(self, time_length): 1467 """ 1468 Trims the signal to the specified length and returns a new Signal instance. 1469 :param time_length: desired length of the new signal in seconds. 1470 :return: A trimmed Signal 1471 """ 1472 max_index = int(time_length * self.sr) 1473 new_signal = self.signal[:max_index] 1474 new_signal[-50:] = new_signal[-50:] * np.linspace(1, 0, 50) 1475 time_trimmed_signal = Signal(new_signal, self.sr, self.SP) 1476 return time_trimmed_signal 1477 1478 def normalize(self): 1479 """ 1480 Normalizes the signal to [-1, 1] and returns the normalized instance. 1481 return : A normalized signal 1482 """ 1483 factor = np.max(np.abs(self.signal)) 1484 normalised_signal = Signal((self.signal / factor), self.sr, self.SP) 1485 normalised_signal.norm_factor = (1 / factor) 1486 return normalised_signal 1487 1488 def make_freq_bins(self): 1489 """ 1490 Method to divide a signal in frequency bins using butterworth filters 1491 bins are passed as a dict, default values are : 1492 - bass < 100 Hz 1493 - mid = 100 - 700 Hz 1494 - highmid = 700 - 2000 Hz 1495 - uppermid = 2000 - 4000 Hz 1496 - presence = 4000 - 6000 Hz 1497 - brillance > 6000 Hz 1498 :return : A dictionary with the divided signal as values and bin names as keys 1499 """ 1500 1501 bins = self.SP.bins.__dict__ 1502 1503 bass_filter = sig.butter(12, bins["bass"].value, 'lp', fs=self.sr, output='sos') 1504 mid_filter = sig.butter(12, [bins["bass"].value, bins['mid'].value], 'bp', fs=self.sr, output='sos') 1505 himid_filter = sig.butter(12, [bins["mid"].value, bins['highmid'].value], 'bp', fs=self.sr, output='sos') 1506 upmid_filter = sig.butter(12, [bins["highmid"].value, bins['uppermid'].value], 'bp', fs=self.sr, output='sos') 1507 pres_filter = sig.butter(12, [bins["uppermid"].value, bins['presence'].value], 'bp', fs=self.sr, output='sos') 1508 bril_filter = sig.butter(12, bins['presence'].value, 'hp', fs=self.sr, output='sos') 1509 1510 return { 1511 "bass": Signal(sig.sosfilt(bass_filter, self.signal), self.sr, self.SP, 1512 freq_range=[0, bins["bass"].value]), 1513 "mid": Signal(sig.sosfilt(mid_filter, self.signal), self.sr, self.SP, 1514 freq_range=[bins["bass"].value, bins["mid"].value]), 1515 "highmid": Signal(sig.sosfilt(himid_filter, self.signal), self.sr, self.SP, 1516 freq_range=[bins["mid"].value, bins["highmid"].value]), 1517 "uppermid": Signal(sig.sosfilt(upmid_filter, self.signal), self.sr, self.SP, 1518 freq_range=[bins["highmid"].value, bins["uppermid"].value]), 1519 "presence": Signal(sig.sosfilt(pres_filter, self.signal), self.sr, self.SP, 1520 freq_range=[bins['uppermid'].value, bins["presence"].value]), 1521 "brillance": Signal(sig.sosfilt(bril_filter, self.signal), self.sr, self.SP, 1522 freq_range=[bins["presence"].value, max(self.fft_frequencies())])} 1523 1524 def save_wav(self, name, path=''): 1525 """ 1526 Create a soundfile from a signal 1527 :param name: the name of the saved file 1528 :param path: the path were the '.wav' file is saved 1529 """ 1530 write(path + name + ".wav", self.signal, self.sr)
A Class to do computations on an audio signal.
The signal is never changed in the class, when transformations are made, a new instance is returned.
1030 def __init__(self, signal, sr, SoundParam=None, freq_range=None): 1031 """ 1032 Create a Signal class from a vector of samples and a sample rate. 1033 1034 :param signal: vector containing the signal samples 1035 :param sr: sample rate of the signal (Hz) 1036 :param SoundParam: Sound Parameter instance to use with the signal 1037 """ 1038 if SoundParam is None: 1039 self.SP = sound_parameters() 1040 else: 1041 self.SP = SoundParam 1042 self.onset = None 1043 self.signal = signal 1044 self.sr = sr 1045 self.plot = Plot() 1046 self.plot.parent = self 1047 self.norm_factor = None 1048 self.freq_range = freq_range
Create a Signal class from a vector of samples and a sample rate.
Parameters
- signal: vector containing the signal samples
- sr: sample rate of the signal (Hz)
- SoundParam: Sound Parameter instance to use with the signal
1050 def time(self): 1051 """ 1052 Returns the time vector associated with the signal 1053 :return: numpy array corresponding to the time values 1054 of the signal samples in seconds 1055 """ 1056 return np.linspace(0, 1057 len(self.signal) * (1 / self.sr), 1058 len(self.signal))
Returns the time vector associated with the signal
Returns
numpy array corresponding to the time values of the signal samples in seconds
1060 def listen(self): 1061 """ 1062 Method to listen the sound signal in a Jupyter Notebook 1063 :return: None 1064 1065 Listening to the sounds imported in the analysis tool allows the 1066 user to validate if the sound was well trimmed and filtered 1067 1068 A temporary file is created, the IPython display Audio function is 1069 called on it, and then the file is removed. 1070 """ 1071 file = 'temp.wav' 1072 write(file, self.signal, self.sr) 1073 ipd.display(ipd.Audio(file)) 1074 os.remove(file)
Method to listen the sound signal in a Jupyter Notebook
Returns
None
Listening to the sounds imported in the analysis tool allows the user to validate if the sound was well trimmed and filtered
A temporary file is created, the IPython display Audio function is called on it, and then the file is removed.
1076 def old_plot(self, kind, **kwargs): 1077 """ 1078 Convenience function for the different signal plots 1079 1080 Calls the function corresponding to Plot.kind() 1081 See help(guitarsounds.analysis.Plot) for info on the different plots 1082 """ 1083 self.plot.method_dict[kind](**kwargs)
Convenience function for the different signal plots
Calls the function corresponding to Plot.kind() See help(guitarsounds.analysis.Plot) for info on the different plots
1085 def fft(self): 1086 """ 1087 Computes the Fast Fourier Transform of the signal and returns the 1088 normalized amplitude vector. 1089 :return: Fast Fourier Transform amplitude values in a numpy array 1090 """ 1091 fft = np.fft.fft(self.signal) 1092 # Only the symmetric part of the absolute value 1093 fft = np.abs(fft[:int(len(fft) // 2)]) 1094 return fft / np.max(fft)
Computes the Fast Fourier Transform of the signal and returns the normalized amplitude vector.
Returns
Fast Fourier Transform amplitude values in a numpy array
1096 def spectral_centroid(self): 1097 """ 1098 Spectral centroid of the frequency content of the signal 1099 :return: Spectral centroid of the signal (float) 1100 1101 The spectral centroid corresponds to the frequency where the area 1102 under the fourier transform curve is equal on both sides. 1103 This feature is usefull in determining the global frequency content 1104 of a sound. A sound having an overall higher spectral centroid would 1105 be perceived as higher, or brighter. 1106 """ 1107 SC = np.sum(self.fft() * self.fft_frequencies()) / np.sum(self.fft()) 1108 return SC
Spectral centroid of the frequency content of the signal
Returns
Spectral centroid of the signal (float)
The spectral centroid corresponds to the frequency where the area under the fourier transform curve is equal on both sides. This feature is usefull in determining the global frequency content of a sound. A sound having an overall higher spectral centroid would be perceived as higher, or brighter.
1111 def peaks(self, max_freq=None, height=False, result=False): 1112 """ 1113 Computes the harmonic peaks indexes from the Fourier Transform of 1114 the signal. 1115 :param max_freq: Supply a max frequency value overriding the one in 1116 guitarsounds_parameters 1117 :param height: if True the height threshold is returned to be used 1118 in the 'peaks' plot 1119 :param result: if True the Scipy peak finding results dictionary 1120 is returned 1121 :return: peak indexes 1122 1123 Because the sound is assumed to be harmonic, the Fourier transform 1124 peaks should be positionned at frequencies $nf$, where $f$ is the 1125 fundamental frequency of the signal. The peak data is usefull to 1126 compare the frequency content of two sounds, as in 1127 `Sound.compare_peaks`. 1128 """ 1129 # Replace None by the default value 1130 if max_freq is None: 1131 max_freq = self.SP.general.fft_range.value 1132 1133 # Get the fft and fft frequencies from the signal 1134 fft, fft_freq = self.fft(), self.fft_frequencies() 1135 1136 # Find the max index 1137 try: 1138 max_index = np.where(fft_freq >= max_freq)[0][0] 1139 except IndexError: 1140 max_index = fft_freq.shape[0] 1141 1142 # Find an approximation of the distance between peaks, this only works for harmonic signals 1143 peak_distance = np.argmax(fft) // 2 1144 1145 # Maximum of the signal in a small region on both ends 1146 fft_max_start = np.max(fft[:peak_distance]) 1147 fft_max_end = np.max(fft[max_index - peak_distance:max_index]) 1148 1149 # Build the curve below the peaks but above the noise 1150 exponents = np.linspace(np.log10(fft_max_start), np.log10(fft_max_end), max_index) 1151 intersect = 10 ** exponents[peak_distance] 1152 diff_start = fft_max_start - intersect # offset by a small distance so that the first max is not a peak 1153 min_height = 10 ** np.linspace(np.log10(fft_max_start + diff_start), np.log10(fft_max_end), max_index) 1154 1155 first_peak_indexes, _ = sig.find_peaks(fft[:max_index], height=min_height, distance=peak_distance) 1156 1157 number_of_peaks = len(first_peak_indexes) 1158 if number_of_peaks > 0: 1159 average_len = int(max_index / number_of_peaks) * 3 1160 else: 1161 average_len = int(max_index / 3) 1162 1163 if average_len % 2 == 0: 1164 average_len += 1 1165 1166 average_fft = sig.savgol_filter(fft[:max_index], average_len, 1, mode='mirror') * 1.9 1167 min_freq_index = np.where(fft_freq >= 70)[0][0] 1168 average_fft[:min_freq_index] = 1 1169 1170 peak_indexes, res = sig.find_peaks(fft[:max_index], height=average_fft, distance=min_freq_index) 1171 1172 # Remove noisy peaks at the low frequencies 1173 while fft[peak_indexes[0]] < 5e-2: 1174 peak_indexes = np.delete(peak_indexes, 0) 1175 while fft[peak_indexes[-1]] < 1e-4: 1176 peak_indexes = np.delete(peak_indexes, -1) 1177 1178 if not height and not result: 1179 return peak_indexes 1180 elif height: 1181 return peak_indexes, average_fft 1182 elif result: 1183 return peak_indexes, res 1184 elif height and result: 1185 return peak_indexes, height, res
Computes the harmonic peaks indexes from the Fourier Transform of the signal.
Parameters
- max_freq: Supply a max frequency value overriding the one in guitarsounds_parameters
- height: if True the height threshold is returned to be used in the 'peaks' plot
- result: if True the Scipy peak finding results dictionary is returned
Returns
peak indexes
Because the sound is assumed to be harmonic, the Fourier transform
peaks should be positionned at frequencies $nf$, where $f$ is the
fundamental frequency of the signal. The peak data is usefull to
compare the frequency content of two sounds, as in
Sound.compare_peaks
.
1187 def time_damping(self): 1188 """ 1189 Computes the time wise damping ratio of the signal 1190 :return: The damping ratio (float). 1191 1192 by fitting a negative exponential curve 1193 to the Signal envelope and computing the ratio with the Signal fundamental frequency. 1194 """ 1195 # Get the envelope data 1196 envelope, envelope_time = self.normalize().envelope() 1197 1198 # First point is the maximum because e^-kt is strictly decreasing 1199 first_index = np.argmax(envelope) 1200 1201 # The second point is the first point where the signal crosses the lower_threshold line 1202 second_point_thresh = self.SP.damping.lower_threshold.value 1203 try: 1204 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1205 except IndexError: 1206 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh * 2)[0] 1207 1208 # Function to compute the residual for the exponential curve fit 1209 def residual_function(zeta_w, t, s): 1210 """ 1211 Function computing the residual to curve fit a negative exponential to the signal envelope 1212 :param zeta_w: zeta*omega constant 1213 :param t: time vector 1214 :param s: signal 1215 :return: residual 1216 """ 1217 return np.exp(zeta_w[0] * t) - s 1218 1219 zeta_guess = [-0.5] 1220 1221 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1222 args=(envelope_time[first_index:second_index], 1223 envelope[first_index:second_index])) 1224 # Get the zeta*omega constant 1225 zeta_omega = result.x[0] 1226 1227 # Compute the fundamental frequency in radians of the signal 1228 wd = 2 * np.pi * self.fundamental() 1229 return -zeta_omega / wd
Computes the time wise damping ratio of the signal
Returns
The damping ratio (float).
by fitting a negative exponential curve to the Signal envelope and computing the ratio with the Signal fundamental frequency.
1231 def peak_damping(self): 1232 """ 1233 Computes the frequency wise damping with the half bandwidth method on the Fourier Transform peaks 1234 :return: an array containing the peak damping values 1235 """ 1236 zetas = [] 1237 fft_freqs = self.fft_frequencies() 1238 fft = self.fft()[:len(fft_freqs)] 1239 for peak in self.peaks(): 1240 peak_frequency = fft_freqs[peak] 1241 peak_height = fft[peak] 1242 root_height = peak_height / np.sqrt(2) 1243 frequency_roots = scipy.interpolate.InterpolatedUnivariateSpline(fft_freqs, fft - root_height).roots() 1244 sorted_roots_indexes = np.argsort(np.abs(frequency_roots - peak_frequency)) 1245 w2, w1 = frequency_roots[sorted_roots_indexes[:2]] 1246 w1, w2 = np.sort([w1, w2]) 1247 zeta = (w2 - w1) / (2 * peak_frequency) 1248 zetas.append(zeta) 1249 return np.array(zetas)
Computes the frequency wise damping with the half bandwidth method on the Fourier Transform peaks
Returns
an array containing the peak damping values
1251 def fundamental(self): 1252 """ 1253 Returns the fundamental approximated by the first peak of the fft 1254 :return: fundamental value (Hz) 1255 """ 1256 index = self.peaks()[0] 1257 fundamental = self.fft_frequencies()[index] 1258 return fundamental
Returns the fundamental approximated by the first peak of the fft
Returns
fundamental value (Hz)
1260 def cavity_peak(self): 1261 """ 1262 Finds the Hemlotz cavity frequency index from the Fourier Transform by searching for a peak in the expected 1263 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1264 is printed and None is returned. 1265 :return: The index of the cavity peak 1266 """ 1267 first_index = np.where(self.fft_frequencies() >= 80)[0][0] 1268 second_index = np.where(self.fft_frequencies() >= 110)[0][0] 1269 cavity_peak = np.argmax(self.fft()[first_index:second_index]) + first_index 1270 return cavity_peak
Finds the Hemlotz cavity frequency index from the Fourier Transform by searching for a peak in the expected range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment is printed and None is returned.
Returns
The index of the cavity peak
1272 def cavity_frequency(self): 1273 """ 1274 Finds the Hemlotz cavity frequency from the Fourier Transform by searching for a peak in the expected 1275 range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment 1276 is printed and None is returned. 1277 :return: If successful, the cavity peak frequency 1278 """ 1279 cavity_peak = self.cavity_peak() 1280 if self.fundamental() == self.fft_frequencies()[cavity_peak]: 1281 print('Cavity peak is obscured by the fundamental') 1282 return 0 1283 else: 1284 return self.fft_frequencies()[cavity_peak]
Finds the Hemlotz cavity frequency from the Fourier Transform by searching for a peak in the expected range (80 - 100 Hz), if the fundamental is too close to the expected Hemlotz frequency a comment is printed and None is returned.
Returns
If successful, the cavity peak frequency
1286 def fft_frequencies(self): 1287 """ 1288 Computes the frequency vector associated with the Signal Fourier Transform 1289 :return: an array containing the frequency values. 1290 """ 1291 fft = self.fft() 1292 fft_frequencies = np.fft.fftfreq(len(fft) * 2, 1 / self.sr) # Frequencies corresponding to the bins 1293 return fft_frequencies[:len(fft)]
Computes the frequency vector associated with the Signal Fourier Transform
Returns
an array containing the frequency values.
1295 def fft_bins(self): 1296 """ 1297 Transforms the Fourier transform array into a statistic distribution 1298 ranging from 0 to 100. 1299 Accordingly, the maximum of the Fourier transform with value 1.0 will 1300 be equal to 100 and casted as an integer. 1301 Values below 0.001 will be equal to 0. 1302 This representation of the Fourier transform is used to construct 1303 octave bands histograms. 1304 :return : a list containing the frequency occurrences. 1305 """ 1306 1307 # Make the FT values integers 1308 fft_integers = [int(np.around(sample * 100, 0)) for sample in self.fft()] 1309 1310 # Create a list of the frequency occurrences in the signal 1311 occurrences = [] 1312 for freq, count in zip(self.fft_frequencies(), fft_integers): 1313 occurrences.append([freq] * count) 1314 1315 # flatten the list 1316 return [item for sublist in occurrences for item in sublist]
Transforms the Fourier transform array into a statistic distribution ranging from 0 to 100. Accordingly, the maximum of the Fourier transform with value 1.0 will be equal to 100 and casted as an integer. Values below 0.001 will be equal to 0. This representation of the Fourier transform is used to construct octave bands histograms. :return : a list containing the frequency occurrences.
1318 def envelope(self, window=None, overlap=None): 1319 """ 1320 Method calculating the amplitude envelope of a signal as a 1321 maximum of the absolute value of the signal. 1322 The same `window` and `overlap` parameters should be used to compute 1323 the signal and time arrays so that they contain the same 1324 number of points (and can be plotted together). 1325 :param window: integer, length in samples of the window used to 1326 compute the signal envelope. 1327 :param overlap: integer, overlap in samples used to overlap two 1328 subsequent windows in the computation of the signal envelope. 1329 The overlap value should be smaller than the window value. 1330 :return: Amplitude envelope of the signal 1331 """ 1332 if window is None: 1333 window = self.SP.envelope.frame_size.value 1334 if overlap is None: 1335 overlap = window // 2 1336 elif overlap >= window: 1337 raise ValueError('Overlap must be smaller than window') 1338 signal_array = np.abs(self.signal) 1339 t = self.time() 1340 # Empty envelope and envelope time 1341 env = [0] 1342 env_time = [0] 1343 idx = 0 1344 while idx + window < signal_array.shape[0]: 1345 env.append(np.max(signal_array[idx:idx + window])) 1346 pt_idx = np.argmax(signal_array[idx:idx + window]) + idx 1347 env_time.append(t[pt_idx]) 1348 idx += overlap 1349 _, unique_time_index = np.unique(env_time, return_index=True) 1350 return np.array(env)[unique_time_index], np.unique(env_time)
Method calculating the amplitude envelope of a signal as a
maximum of the absolute value of the signal.
The same window
and overlap
parameters should be used to compute
the signal and time arrays so that they contain the same
number of points (and can be plotted together).
Parameters
- window: integer, length in samples of the window used to compute the signal envelope.
- overlap: integer, overlap in samples used to overlap two subsequent windows in the computation of the signal envelope. The overlap value should be smaller than the window value.
Returns
Amplitude envelope of the signal
1352 def log_envelope(self): 1353 """ 1354 Computes the logarithmic scale envelope of the signal. 1355 The width of the samples increases exponentially so that 1356 the envelope appears having a constant window width on 1357 an X axis logarithmic scale. 1358 :return: The log envelope and the time vector associated in a tuple 1359 """ 1360 if self.onset is None: 1361 onset = np.argmax(np.abs(self.signal)) 1362 else: 1363 onset = self.onset 1364 1365 start_time = self.SP.log_envelope.start_time.value 1366 while start_time > (onset / self.sr): 1367 start_time /= 10. 1368 1369 start_exponent = int(np.log10(start_time)) # closest 10^x value for smooth graph 1370 1371 if self.SP.log_envelope.min_window.value is None: 1372 min_window = 15 ** (start_exponent + 4) 1373 if min_window < 15: # Value should at least be 10 1374 min_window = 15 1375 else: 1376 min_window = self.SP.log_envelope.min_window.value 1377 1378 # initial values 1379 current_exponent = start_exponent 1380 current_time = 10 ** current_exponent # start time on log scale 1381 index = int(current_time * self.sr) # Start at the specified time 1382 window = min_window # number of samples per window 1383 overlap = window // 2 1384 log_envelope = [0] 1385 log_envelope_time = [0] # First value for comparison 1386 1387 while index + window <= len(self.signal): 1388 1389 while log_envelope_time[-1] < 10 ** (current_exponent + 1): 1390 if (index + window) < len(self.signal): 1391 log_envelope.append(np.max(np.abs(self.signal[index:index + window]))) 1392 pt_idx = np.argmax(np.abs(self.signal[index:index + window])) 1393 log_envelope_time.append(self.time()[index + pt_idx]) 1394 index += overlap 1395 else: 1396 break 1397 1398 if window * 10 < self.SP.log_envelope.max_window.value: 1399 window = window * 10 1400 else: 1401 window = self.SP.log_envelope.max_window.value 1402 overlap = window // 2 1403 current_exponent += 1 1404 time, idxs = np.unique(log_envelope_time, return_index=True) 1405 return np.array(log_envelope)[idxs], time
Computes the logarithmic scale envelope of the signal. The width of the samples increases exponentially so that the envelope appears having a constant window width on an X axis logarithmic scale.
Returns
The log envelope and the time vector associated in a tuple
1407 def find_onset(self, verbose=True): 1408 """ 1409 Finds the onset as an increase in more of 50% with the maximum normalized value above 0.5 1410 :param verbose: Prints a warning if the algorithm does not converge 1411 :return: the index of the onset in the signal 1412 """ 1413 # Index corresponding to the onset time interval 1414 window_index = np.ceil(self.SP.onset.onset_time.value * self.sr).astype(int) 1415 # Use the normalized signal to compare against a fixed value 1416 onset_signal = self.normalize() 1417 overlap = window_index // 2 # overlap for algorithm progression 1418 # Initial values 1419 increase = 0 1420 i = 0 1421 broke = False 1422 while increase <= 0.5: 1423 signal_min = np.min(np.abs(onset_signal.signal[i:i + window_index])) 1424 signal_max = np.max(np.abs(onset_signal.signal[i:i + window_index])) 1425 if (signal_max > 0.5) and (signal_min != 0): 1426 increase = signal_max / signal_min 1427 else: 1428 increase = 0 1429 i += overlap 1430 if i + window_index > len(self.signal): 1431 if verbose: 1432 print('Onset detection did not converge \n') 1433 print('Approximating onset with signal max value \n') 1434 broke = True 1435 break 1436 if broke: 1437 return np.argmax(np.abs(self.signal)) 1438 else: 1439 i -= overlap 1440 return np.argmax(np.abs(self.signal[i:i + window_index])) + i
Finds the onset as an increase in more of 50% with the maximum normalized value above 0.5
Parameters
- verbose: Prints a warning if the algorithm does not converge
Returns
the index of the onset in the signal
1442 def trim_onset(self, verbose=True): 1443 """ 1444 Trim the signal at the onset (max) minus the delay in milliseconds as 1445 Specified in the SoundParameters 1446 :param verbose: if False the warning comments are not displayed 1447 :return : The trimmed signal 1448 """ 1449 # nb of samples to keep before the onset 1450 delay_samples = int((self.SP.onset.onset_delay.value / 1000) * self.sr) 1451 onset = self.find_onset(verbose=verbose) # find the onset 1452 1453 if onset > delay_samples: # To make sure the index is positive 1454 new_signal = self.signal[onset - delay_samples:] 1455 new_signal[:delay_samples // 2] = new_signal[:delay_samples // 2] * np.linspace(0, 1, delay_samples // 2) 1456 trimmed_signal = Signal(new_signal, self.sr, self.SP) 1457 trimmed_signal.onset = trimmed_signal.find_onset(verbose=verbose) 1458 return trimmed_signal 1459 1460 else: 1461 if verbose: 1462 print('Signal is too short to be trimmed before onset.') 1463 print('') 1464 return self
Trim the signal at the onset (max) minus the delay in milliseconds as Specified in the SoundParameters
Parameters
- verbose: if False the warning comments are not displayed :return : The trimmed signal
1466 def trim_time(self, time_length): 1467 """ 1468 Trims the signal to the specified length and returns a new Signal instance. 1469 :param time_length: desired length of the new signal in seconds. 1470 :return: A trimmed Signal 1471 """ 1472 max_index = int(time_length * self.sr) 1473 new_signal = self.signal[:max_index] 1474 new_signal[-50:] = new_signal[-50:] * np.linspace(1, 0, 50) 1475 time_trimmed_signal = Signal(new_signal, self.sr, self.SP) 1476 return time_trimmed_signal
Trims the signal to the specified length and returns a new Signal instance.
Parameters
- time_length: desired length of the new signal in seconds.
Returns
A trimmed Signal
1478 def normalize(self): 1479 """ 1480 Normalizes the signal to [-1, 1] and returns the normalized instance. 1481 return : A normalized signal 1482 """ 1483 factor = np.max(np.abs(self.signal)) 1484 normalised_signal = Signal((self.signal / factor), self.sr, self.SP) 1485 normalised_signal.norm_factor = (1 / factor) 1486 return normalised_signal
Normalizes the signal to [-1, 1] and returns the normalized instance. return : A normalized signal
1488 def make_freq_bins(self): 1489 """ 1490 Method to divide a signal in frequency bins using butterworth filters 1491 bins are passed as a dict, default values are : 1492 - bass < 100 Hz 1493 - mid = 100 - 700 Hz 1494 - highmid = 700 - 2000 Hz 1495 - uppermid = 2000 - 4000 Hz 1496 - presence = 4000 - 6000 Hz 1497 - brillance > 6000 Hz 1498 :return : A dictionary with the divided signal as values and bin names as keys 1499 """ 1500 1501 bins = self.SP.bins.__dict__ 1502 1503 bass_filter = sig.butter(12, bins["bass"].value, 'lp', fs=self.sr, output='sos') 1504 mid_filter = sig.butter(12, [bins["bass"].value, bins['mid'].value], 'bp', fs=self.sr, output='sos') 1505 himid_filter = sig.butter(12, [bins["mid"].value, bins['highmid'].value], 'bp', fs=self.sr, output='sos') 1506 upmid_filter = sig.butter(12, [bins["highmid"].value, bins['uppermid'].value], 'bp', fs=self.sr, output='sos') 1507 pres_filter = sig.butter(12, [bins["uppermid"].value, bins['presence'].value], 'bp', fs=self.sr, output='sos') 1508 bril_filter = sig.butter(12, bins['presence'].value, 'hp', fs=self.sr, output='sos') 1509 1510 return { 1511 "bass": Signal(sig.sosfilt(bass_filter, self.signal), self.sr, self.SP, 1512 freq_range=[0, bins["bass"].value]), 1513 "mid": Signal(sig.sosfilt(mid_filter, self.signal), self.sr, self.SP, 1514 freq_range=[bins["bass"].value, bins["mid"].value]), 1515 "highmid": Signal(sig.sosfilt(himid_filter, self.signal), self.sr, self.SP, 1516 freq_range=[bins["mid"].value, bins["highmid"].value]), 1517 "uppermid": Signal(sig.sosfilt(upmid_filter, self.signal), self.sr, self.SP, 1518 freq_range=[bins["highmid"].value, bins["uppermid"].value]), 1519 "presence": Signal(sig.sosfilt(pres_filter, self.signal), self.sr, self.SP, 1520 freq_range=[bins['uppermid'].value, bins["presence"].value]), 1521 "brillance": Signal(sig.sosfilt(bril_filter, self.signal), self.sr, self.SP, 1522 freq_range=[bins["presence"].value, max(self.fft_frequencies())])}
Method to divide a signal in frequency bins using butterworth filters bins are passed as a dict, default values are :
- bass < 100 Hz
- mid = 100 - 700 Hz
- highmid = 700 - 2000 Hz
- uppermid = 2000 - 4000 Hz
- presence = 4000 - 6000 Hz
- brillance > 6000 Hz :return : A dictionary with the divided signal as values and bin names as keys
1524 def save_wav(self, name, path=''): 1525 """ 1526 Create a soundfile from a signal 1527 :param name: the name of the saved file 1528 :param path: the path were the '.wav' file is saved 1529 """ 1530 write(path + name + ".wav", self.signal, self.sr)
Create a soundfile from a signal
Parameters
- name: the name of the saved file
- path: the path were the '.wav' file is saved
1533class Plot(object): 1534 """ 1535 A class to handle all the plotting functions of the Signal and to allow a nice call signature : 1536 Signal.plot.envelope() 1537 1538 Supported plots are : 1539 'signal', 'envelope', 'log envelope', 'fft', 'fft hist', 'peaks', 1540 'peak damping', 'time damping', 'integral' 1541 """ 1542 1543 # Illegal plot key word arguments 1544 illegal_kwargs = ['max_time', 'n', 'ticks', 'normalize', 'inverse', 1545 'peak_height', 'fill'] 1546 1547 def __init__(self): 1548 # define the parent attribute 1549 self.parent = None 1550 1551 # dictionary with methods and keywords 1552 self.method_dict = {'signal': self.signal, 1553 'envelope': self.envelope, 1554 'log envelope': self.log_envelope, 1555 'fft': self.fft, 1556 'fft hist': self.fft_hist, 1557 'peaks': self.peaks, 1558 'peak damping': self.peak_damping, 1559 'time damping': self.time_damping, 1560 'integral': self.integral, } 1561 1562 def sanitize_kwargs(self, kwargs): 1563 """ 1564 Remove illegal keywords to supply the key word arguments to matplotlib 1565 :param kwargs: a dictionary of key word arguments 1566 :return: sanitized kwargs 1567 """ 1568 return {i: kwargs[i] for i in kwargs if i not in self.illegal_kwargs} 1569 1570 def set_bin_ticks(self): 1571 """ Applies the frequency bin ticks to the current plot """ 1572 labels = [label for label in self.parent.SP.bins.__dict__ if label != 'name'] 1573 labels.append('brillance') 1574 x = [param.value for param in self.parent.SP.bins.__dict__.values() if param != 'bins'] 1575 x.append(11025) 1576 x_formatter = ticker.FixedFormatter(labels) 1577 x_locator = ticker.FixedLocator(x) 1578 ax = plt.gca() 1579 ax.xaxis.set_major_locator(x_locator) 1580 ax.xaxis.set_major_formatter(x_formatter) 1581 ax.tick_params(axis="x", labelrotation=90) 1582 1583 def signal(self, **kwargs): 1584 """ Plots the time varying real signal as amplitude vs time. """ 1585 plot_kwargs = self.sanitize_kwargs(kwargs) 1586 plt.plot(self.parent.time(), self.parent.signal, alpha=0.6, **plot_kwargs) 1587 plt.xlabel('time (s)') 1588 plt.ylabel('amplitude [-1, 1]') 1589 plt.grid('on') 1590 1591 def envelope(self, **kwargs): 1592 """ 1593 Plots the envelope of the signal as amplitude vs time. 1594 """ 1595 plot_kwargs = self.sanitize_kwargs(kwargs) 1596 envelope_arr, envelope_time = self.parent.envelope() 1597 plt.plot(envelope_time, envelope_arr, **plot_kwargs) 1598 plt.xlabel("time (s)") 1599 plt.ylabel("amplitude [0, 1]") 1600 plt.grid('on') 1601 1602 def log_envelope(self, **kwargs): 1603 """ 1604 Plots the signal envelope with logarithmic window widths on a logarithmic x-axis scale. 1605 :param max_time: maximum time used for the x-axis in the plot (seconds) 1606 """ 1607 plot_kwargs = self.sanitize_kwargs(kwargs) 1608 log_envelope, log_envelope_time = self.parent.log_envelope() 1609 1610 if ('max_time' in kwargs.keys()) and (kwargs['max_time'] < log_envelope_time[-1]): 1611 max_index = np.nonzero(log_envelope_time >= kwargs['max_time'])[0][0] 1612 else: 1613 max_index = len(log_envelope_time) 1614 1615 plt.plot(log_envelope_time[:max_index], log_envelope[:max_index], **plot_kwargs) 1616 plt.xlabel("time (s)") 1617 plt.ylabel("amplitude [0, 1]") 1618 plt.xscale('log') 1619 plt.grid('on') 1620 1621 def fft(self, **kwargs): 1622 """ 1623 Plots the Fourier Transform of the Signal. 1624 1625 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1626 with the frequency bin values. 1627 """ 1628 1629 plot_kwargs = self.sanitize_kwargs(kwargs) 1630 1631 # find the index corresponding to the fft range 1632 fft_frequencies = self.parent.fft_frequencies() 1633 fft_range = self.parent.SP.general.fft_range.value 1634 result = np.where(fft_frequencies >= fft_range)[0] 1635 if len(result) == 0: 1636 last_index = len(fft_frequencies) 1637 else: 1638 last_index = result[0] 1639 1640 plt.plot(self.parent.fft_frequencies()[:last_index], 1641 self.parent.fft()[:last_index], 1642 **plot_kwargs) 1643 plt.xlabel("frequency (Hz)"), 1644 plt.ylabel("amplitude (normalized)"), 1645 plt.yscale('log') 1646 plt.grid('on') 1647 1648 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1649 self.set_bin_ticks() 1650 1651 def fft_hist(self, **kwargs): 1652 """ 1653 Plots the octave based Fourier Transform Histogram. 1654 Both axes are on a log scale. 1655 1656 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1657 with the frequency bin values 1658 """ 1659 1660 plot_kwargs = self.sanitize_kwargs(kwargs) 1661 1662 # Histogram of frequency values occurrences in octave bins 1663 plt.hist(self.parent.fft_bins(), utils.octave_histogram(self.parent.SP.general.octave_fraction.value), 1664 alpha=0.7, **plot_kwargs) 1665 plt.xlabel('frequency (Hz)') 1666 plt.ylabel('amplitude (normalized)') 1667 plt.xscale('log') 1668 plt.yscale('log') 1669 plt.grid('on') 1670 1671 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1672 self.set_bin_ticks() 1673 1674 def peaks(self, **kwargs): 1675 """ 1676 Plots the Fourier Transform of the Signal, with the peaks detected 1677 with the `Signal.peaks()` method. 1678 1679 If `peak_height = True` is supplied in the keyword arguments the 1680 computed height threshold is 1681 shown on the plot. 1682 """ 1683 1684 plot_kwargs = self.sanitize_kwargs(kwargs) 1685 1686 fft_freqs = self.parent.fft_frequencies() 1687 fft = self.parent.fft() 1688 fft_range = self.parent.SP.general.fft_range.value 1689 max_index = np.where(fft_freqs >= fft_range)[0][0] 1690 peak_indexes, height = self.parent.peaks(height=True) 1691 plt.xlabel('frequency (Hz)') 1692 plt.ylabel('amplitude') 1693 plt.yscale('log') 1694 plt.grid('on') 1695 1696 if 'color' not in plot_kwargs.keys(): 1697 plot_kwargs['color'] = 'k' 1698 plt.plot(fft_freqs[:max_index], fft[:max_index], **plot_kwargs) 1699 plt.scatter(fft_freqs[peak_indexes], fft[peak_indexes], color='r') 1700 if ('peak_height' in kwargs.keys()) and (kwargs['peak_height']): 1701 plt.plot(fft_freqs[:max_index], height, color='r') 1702 1703 def peak_damping(self, **kwargs): 1704 """ 1705 Plots the frequency vs damping scatter of the damping ratio computed from the 1706 Fourier Transform peak shapes. A polynomial curve fit is added to help visualisation. 1707 1708 Supported key word arguments are : 1709 1710 `n=5` : The order of the fitted polynomial curve, default is 5, 1711 if the supplied value is too high, it will be reduced until the 1712 number of peaks is sufficient to fit the polynomial. 1713 1714 `inverse=True` : Default value is True, if False, the damping ratio is shown instead 1715 of its inverse. 1716 1717 `normalize=False` : Default value is False, if True the damping values are normalized 1718 from 0 to 1, to help analyze results and compare Sounds. 1719 1720 `ticks=None` : Default value is None, if `ticks='bins'` the x-axis ticks are replaced with 1721 frequency bin values. 1722 """ 1723 plot_kwargs = self.sanitize_kwargs(kwargs) 1724 # Get the damping ratio and peak frequencies 1725 if 'inverse' in kwargs.keys() and kwargs['inverse'] is False: 1726 zetas = np.array(self.parent.peak_damping()) 1727 ylabel = r'damping $\zeta$' 1728 else: 1729 zetas = 1 / np.array(self.parent.peak_damping()) 1730 ylabel = r'inverse damping $1/\zeta$' 1731 1732 peak_freqs = self.parent.fft_frequencies()[self.parent.peaks()] 1733 1734 # If a polynomial order is supplied assign it, if not default is 5 1735 if 'n' in kwargs.keys(): 1736 n = kwargs['n'] 1737 else: 1738 n = 5 1739 1740 # If labels are supplied the default color are used 1741 if 'label' in plot_kwargs: 1742 plot_kwargs['color'] = None 1743 plot2_kwargs = plot_kwargs.copy() 1744 plot2_kwargs['label'] = None 1745 1746 # If not black and red are used 1747 else: 1748 plot_kwargs['color'] = 'r' 1749 plot2_kwargs = plot_kwargs.copy() 1750 plot2_kwargs['color'] = 'k' 1751 1752 if 'normalize' in kwargs.keys() and kwargs['normalize']: 1753 zetas = np.array(zetas) / np.array(zetas).max(initial=0) 1754 1755 plt.scatter(peak_freqs, zetas, **plot_kwargs) 1756 fun = utils.nth_order_polynomial_fit(n, peak_freqs, zetas) 1757 freq = np.linspace(peak_freqs[0], peak_freqs[-1], 100) 1758 plt.plot(freq, fun(freq), **plot2_kwargs) 1759 plt.grid('on') 1760 plt.title('Frequency vs Damping Factor with Order ' + str(n)) 1761 plt.xlabel('frequency (Hz)') 1762 plt.ylabel(ylabel) 1763 1764 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1765 self.set_bin_ticks() 1766 1767 def time_damping(self, **kwargs): 1768 """ 1769 Shows the signal envelope with the fitted negative exponential 1770 curve used to determine the time damping ratio of the signal. 1771 """ 1772 plot_kwargs = self.sanitize_kwargs(kwargs) 1773 # Get the envelope data 1774 envelope, envelope_time = self.parent.normalize().envelope() 1775 1776 # First point is the maximum because e^-kt is strictly decreasing 1777 first_index = np.argmax(envelope) 1778 1779 # The second point is the first point where the signal crosses the lower_threshold line 1780 second_point_thresh = self.parent.SP.damping.lower_threshold.value 1781 while True: 1782 try: 1783 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1784 break 1785 except IndexError: 1786 second_point_thresh *= 2 1787 if second_point_thresh > 1: 1788 raise ValueError("invalid second point threshold encountered, something went wrong") 1789 1790 # Function to compute the residual for the exponential curve fit 1791 def residual_function(zeta_w, t, s): 1792 return np.exp(zeta_w[0] * t) - s 1793 1794 zeta_guess = [-0.5] 1795 1796 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1797 args=(envelope_time[first_index:second_index], 1798 envelope[first_index:second_index])) 1799 # Get the zeta*omega constant 1800 zeta_omega = result.x[0] 1801 1802 # Compute the fundamental frequency in radians of the signal 1803 wd = 2 * np.pi * self.parent.fundamental() 1804 1805 # Plot the two points used for the regression 1806 plt.scatter(envelope_time[[first_index, first_index + second_index]], 1807 envelope[[first_index, first_index + second_index]], color='r') 1808 1809 # get the current ax 1810 ax = plt.gca() 1811 1812 # Plot the damping curve 1813 ax.plot(envelope_time[first_index:second_index + first_index], 1814 np.exp(zeta_omega * envelope_time[first_index:second_index + first_index]), 1815 c='k', 1816 linestyle='--') 1817 1818 plt.sca(ax) 1819 if 'alpha' not in plot_kwargs: 1820 plot_kwargs['alpha'] = 0.6 1821 self.parent.normalize().plot.envelope(**plot_kwargs) 1822 1823 if 'label' not in plot_kwargs.keys(): 1824 ax.legend(['damping curve', 'signal envelope']) 1825 1826 title = 'Zeta : ' + str(np.around(-zeta_omega / wd, 5)) + ' Fundamental ' + \ 1827 str(np.around(self.parent.fundamental(), 0)) + 'Hz' 1828 plt.title(title) 1829 1830 def integral(self, **kwargs): 1831 """ 1832 Cumulative integral plot of the normalized signal log envelope 1833 1834 Represents the power distribution variation in time for the signal. 1835 This is a plot of the function $F(x)$ such as : 1836 1837 $ F(x) = \int_0^x env(x) dx $ 1838 1839 Where e(x) is the signal envelope. 1840 """ 1841 # sanitize the kwargs 1842 plot_kwargs = self.sanitize_kwargs(kwargs) 1843 1844 # Compute log envelope and log time 1845 log_envelope, log_time = self.parent.normalize().log_envelope() 1846 1847 1848 # compute the cumulative integral 1849 integral = [trapezoid(log_envelope[:i], log_time[:i]) for i in np.arange(2, len(log_envelope), 1)] 1850 integral /= np.max(integral) 1851 1852 # plot the integral 1853 plt.plot(log_time[2:], integral, **plot_kwargs) 1854 1855 # Add labels and scale 1856 plt.xlabel('time (s)') 1857 plt.ylabel('cumulative power (normalized)') 1858 plt.xscale('log') 1859 plt.grid('on')
A class to handle all the plotting functions of the Signal and to allow a nice call signature : Signal.plot.envelope()
Supported plots are : 'signal', 'envelope', 'log envelope', 'fft', 'fft hist', 'peaks', 'peak damping', 'time damping', 'integral'
1547 def __init__(self): 1548 # define the parent attribute 1549 self.parent = None 1550 1551 # dictionary with methods and keywords 1552 self.method_dict = {'signal': self.signal, 1553 'envelope': self.envelope, 1554 'log envelope': self.log_envelope, 1555 'fft': self.fft, 1556 'fft hist': self.fft_hist, 1557 'peaks': self.peaks, 1558 'peak damping': self.peak_damping, 1559 'time damping': self.time_damping, 1560 'integral': self.integral, }
1562 def sanitize_kwargs(self, kwargs): 1563 """ 1564 Remove illegal keywords to supply the key word arguments to matplotlib 1565 :param kwargs: a dictionary of key word arguments 1566 :return: sanitized kwargs 1567 """ 1568 return {i: kwargs[i] for i in kwargs if i not in self.illegal_kwargs}
Remove illegal keywords to supply the key word arguments to matplotlib
Parameters
- kwargs: a dictionary of key word arguments
Returns
sanitized kwargs
1570 def set_bin_ticks(self): 1571 """ Applies the frequency bin ticks to the current plot """ 1572 labels = [label for label in self.parent.SP.bins.__dict__ if label != 'name'] 1573 labels.append('brillance') 1574 x = [param.value for param in self.parent.SP.bins.__dict__.values() if param != 'bins'] 1575 x.append(11025) 1576 x_formatter = ticker.FixedFormatter(labels) 1577 x_locator = ticker.FixedLocator(x) 1578 ax = plt.gca() 1579 ax.xaxis.set_major_locator(x_locator) 1580 ax.xaxis.set_major_formatter(x_formatter) 1581 ax.tick_params(axis="x", labelrotation=90)
Applies the frequency bin ticks to the current plot
1583 def signal(self, **kwargs): 1584 """ Plots the time varying real signal as amplitude vs time. """ 1585 plot_kwargs = self.sanitize_kwargs(kwargs) 1586 plt.plot(self.parent.time(), self.parent.signal, alpha=0.6, **plot_kwargs) 1587 plt.xlabel('time (s)') 1588 plt.ylabel('amplitude [-1, 1]') 1589 plt.grid('on')
Plots the time varying real signal as amplitude vs time.
1591 def envelope(self, **kwargs): 1592 """ 1593 Plots the envelope of the signal as amplitude vs time. 1594 """ 1595 plot_kwargs = self.sanitize_kwargs(kwargs) 1596 envelope_arr, envelope_time = self.parent.envelope() 1597 plt.plot(envelope_time, envelope_arr, **plot_kwargs) 1598 plt.xlabel("time (s)") 1599 plt.ylabel("amplitude [0, 1]") 1600 plt.grid('on')
Plots the envelope of the signal as amplitude vs time.
1602 def log_envelope(self, **kwargs): 1603 """ 1604 Plots the signal envelope with logarithmic window widths on a logarithmic x-axis scale. 1605 :param max_time: maximum time used for the x-axis in the plot (seconds) 1606 """ 1607 plot_kwargs = self.sanitize_kwargs(kwargs) 1608 log_envelope, log_envelope_time = self.parent.log_envelope() 1609 1610 if ('max_time' in kwargs.keys()) and (kwargs['max_time'] < log_envelope_time[-1]): 1611 max_index = np.nonzero(log_envelope_time >= kwargs['max_time'])[0][0] 1612 else: 1613 max_index = len(log_envelope_time) 1614 1615 plt.plot(log_envelope_time[:max_index], log_envelope[:max_index], **plot_kwargs) 1616 plt.xlabel("time (s)") 1617 plt.ylabel("amplitude [0, 1]") 1618 plt.xscale('log') 1619 plt.grid('on')
Plots the signal envelope with logarithmic window widths on a logarithmic x-axis scale.
Parameters
- max_time: maximum time used for the x-axis in the plot (seconds)
1621 def fft(self, **kwargs): 1622 """ 1623 Plots the Fourier Transform of the Signal. 1624 1625 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1626 with the frequency bin values. 1627 """ 1628 1629 plot_kwargs = self.sanitize_kwargs(kwargs) 1630 1631 # find the index corresponding to the fft range 1632 fft_frequencies = self.parent.fft_frequencies() 1633 fft_range = self.parent.SP.general.fft_range.value 1634 result = np.where(fft_frequencies >= fft_range)[0] 1635 if len(result) == 0: 1636 last_index = len(fft_frequencies) 1637 else: 1638 last_index = result[0] 1639 1640 plt.plot(self.parent.fft_frequencies()[:last_index], 1641 self.parent.fft()[:last_index], 1642 **plot_kwargs) 1643 plt.xlabel("frequency (Hz)"), 1644 plt.ylabel("amplitude (normalized)"), 1645 plt.yscale('log') 1646 plt.grid('on') 1647 1648 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1649 self.set_bin_ticks()
Plots the Fourier Transform of the Signal.
If ticks = 'bins'
is supplied in the keyword arguments, the frequency ticks are replaced
with the frequency bin values.
1651 def fft_hist(self, **kwargs): 1652 """ 1653 Plots the octave based Fourier Transform Histogram. 1654 Both axes are on a log scale. 1655 1656 If `ticks = 'bins'` is supplied in the keyword arguments, the frequency ticks are replaced 1657 with the frequency bin values 1658 """ 1659 1660 plot_kwargs = self.sanitize_kwargs(kwargs) 1661 1662 # Histogram of frequency values occurrences in octave bins 1663 plt.hist(self.parent.fft_bins(), utils.octave_histogram(self.parent.SP.general.octave_fraction.value), 1664 alpha=0.7, **plot_kwargs) 1665 plt.xlabel('frequency (Hz)') 1666 plt.ylabel('amplitude (normalized)') 1667 plt.xscale('log') 1668 plt.yscale('log') 1669 plt.grid('on') 1670 1671 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1672 self.set_bin_ticks()
Plots the octave based Fourier Transform Histogram. Both axes are on a log scale.
If ticks = 'bins'
is supplied in the keyword arguments, the frequency ticks are replaced
with the frequency bin values
1674 def peaks(self, **kwargs): 1675 """ 1676 Plots the Fourier Transform of the Signal, with the peaks detected 1677 with the `Signal.peaks()` method. 1678 1679 If `peak_height = True` is supplied in the keyword arguments the 1680 computed height threshold is 1681 shown on the plot. 1682 """ 1683 1684 plot_kwargs = self.sanitize_kwargs(kwargs) 1685 1686 fft_freqs = self.parent.fft_frequencies() 1687 fft = self.parent.fft() 1688 fft_range = self.parent.SP.general.fft_range.value 1689 max_index = np.where(fft_freqs >= fft_range)[0][0] 1690 peak_indexes, height = self.parent.peaks(height=True) 1691 plt.xlabel('frequency (Hz)') 1692 plt.ylabel('amplitude') 1693 plt.yscale('log') 1694 plt.grid('on') 1695 1696 if 'color' not in plot_kwargs.keys(): 1697 plot_kwargs['color'] = 'k' 1698 plt.plot(fft_freqs[:max_index], fft[:max_index], **plot_kwargs) 1699 plt.scatter(fft_freqs[peak_indexes], fft[peak_indexes], color='r') 1700 if ('peak_height' in kwargs.keys()) and (kwargs['peak_height']): 1701 plt.plot(fft_freqs[:max_index], height, color='r')
Plots the Fourier Transform of the Signal, with the peaks detected
with the Signal.peaks()
method.
If peak_height = True
is supplied in the keyword arguments the
computed height threshold is
shown on the plot.
1703 def peak_damping(self, **kwargs): 1704 """ 1705 Plots the frequency vs damping scatter of the damping ratio computed from the 1706 Fourier Transform peak shapes. A polynomial curve fit is added to help visualisation. 1707 1708 Supported key word arguments are : 1709 1710 `n=5` : The order of the fitted polynomial curve, default is 5, 1711 if the supplied value is too high, it will be reduced until the 1712 number of peaks is sufficient to fit the polynomial. 1713 1714 `inverse=True` : Default value is True, if False, the damping ratio is shown instead 1715 of its inverse. 1716 1717 `normalize=False` : Default value is False, if True the damping values are normalized 1718 from 0 to 1, to help analyze results and compare Sounds. 1719 1720 `ticks=None` : Default value is None, if `ticks='bins'` the x-axis ticks are replaced with 1721 frequency bin values. 1722 """ 1723 plot_kwargs = self.sanitize_kwargs(kwargs) 1724 # Get the damping ratio and peak frequencies 1725 if 'inverse' in kwargs.keys() and kwargs['inverse'] is False: 1726 zetas = np.array(self.parent.peak_damping()) 1727 ylabel = r'damping $\zeta$' 1728 else: 1729 zetas = 1 / np.array(self.parent.peak_damping()) 1730 ylabel = r'inverse damping $1/\zeta$' 1731 1732 peak_freqs = self.parent.fft_frequencies()[self.parent.peaks()] 1733 1734 # If a polynomial order is supplied assign it, if not default is 5 1735 if 'n' in kwargs.keys(): 1736 n = kwargs['n'] 1737 else: 1738 n = 5 1739 1740 # If labels are supplied the default color are used 1741 if 'label' in plot_kwargs: 1742 plot_kwargs['color'] = None 1743 plot2_kwargs = plot_kwargs.copy() 1744 plot2_kwargs['label'] = None 1745 1746 # If not black and red are used 1747 else: 1748 plot_kwargs['color'] = 'r' 1749 plot2_kwargs = plot_kwargs.copy() 1750 plot2_kwargs['color'] = 'k' 1751 1752 if 'normalize' in kwargs.keys() and kwargs['normalize']: 1753 zetas = np.array(zetas) / np.array(zetas).max(initial=0) 1754 1755 plt.scatter(peak_freqs, zetas, **plot_kwargs) 1756 fun = utils.nth_order_polynomial_fit(n, peak_freqs, zetas) 1757 freq = np.linspace(peak_freqs[0], peak_freqs[-1], 100) 1758 plt.plot(freq, fun(freq), **plot2_kwargs) 1759 plt.grid('on') 1760 plt.title('Frequency vs Damping Factor with Order ' + str(n)) 1761 plt.xlabel('frequency (Hz)') 1762 plt.ylabel(ylabel) 1763 1764 if 'ticks' in kwargs and kwargs['ticks'] == 'bins': 1765 self.set_bin_ticks()
Plots the frequency vs damping scatter of the damping ratio computed from the Fourier Transform peak shapes. A polynomial curve fit is added to help visualisation.
Supported key word arguments are :
n=5
: The order of the fitted polynomial curve, default is 5,
if the supplied value is too high, it will be reduced until the
number of peaks is sufficient to fit the polynomial.
inverse=True
: Default value is True, if False, the damping ratio is shown instead
of its inverse.
normalize=False
: Default value is False, if True the damping values are normalized
from 0 to 1, to help analyze results and compare Sounds.
ticks=None
: Default value is None, if ticks='bins'
the x-axis ticks are replaced with
frequency bin values.
1767 def time_damping(self, **kwargs): 1768 """ 1769 Shows the signal envelope with the fitted negative exponential 1770 curve used to determine the time damping ratio of the signal. 1771 """ 1772 plot_kwargs = self.sanitize_kwargs(kwargs) 1773 # Get the envelope data 1774 envelope, envelope_time = self.parent.normalize().envelope() 1775 1776 # First point is the maximum because e^-kt is strictly decreasing 1777 first_index = np.argmax(envelope) 1778 1779 # The second point is the first point where the signal crosses the lower_threshold line 1780 second_point_thresh = self.parent.SP.damping.lower_threshold.value 1781 while True: 1782 try: 1783 second_index = np.flatnonzero(envelope[first_index:] <= second_point_thresh)[0] 1784 break 1785 except IndexError: 1786 second_point_thresh *= 2 1787 if second_point_thresh > 1: 1788 raise ValueError("invalid second point threshold encountered, something went wrong") 1789 1790 # Function to compute the residual for the exponential curve fit 1791 def residual_function(zeta_w, t, s): 1792 return np.exp(zeta_w[0] * t) - s 1793 1794 zeta_guess = [-0.5] 1795 1796 result = scipy.optimize.least_squares(residual_function, zeta_guess, 1797 args=(envelope_time[first_index:second_index], 1798 envelope[first_index:second_index])) 1799 # Get the zeta*omega constant 1800 zeta_omega = result.x[0] 1801 1802 # Compute the fundamental frequency in radians of the signal 1803 wd = 2 * np.pi * self.parent.fundamental() 1804 1805 # Plot the two points used for the regression 1806 plt.scatter(envelope_time[[first_index, first_index + second_index]], 1807 envelope[[first_index, first_index + second_index]], color='r') 1808 1809 # get the current ax 1810 ax = plt.gca() 1811 1812 # Plot the damping curve 1813 ax.plot(envelope_time[first_index:second_index + first_index], 1814 np.exp(zeta_omega * envelope_time[first_index:second_index + first_index]), 1815 c='k', 1816 linestyle='--') 1817 1818 plt.sca(ax) 1819 if 'alpha' not in plot_kwargs: 1820 plot_kwargs['alpha'] = 0.6 1821 self.parent.normalize().plot.envelope(**plot_kwargs) 1822 1823 if 'label' not in plot_kwargs.keys(): 1824 ax.legend(['damping curve', 'signal envelope']) 1825 1826 title = 'Zeta : ' + str(np.around(-zeta_omega / wd, 5)) + ' Fundamental ' + \ 1827 str(np.around(self.parent.fundamental(), 0)) + 'Hz' 1828 plt.title(title)
Shows the signal envelope with the fitted negative exponential curve used to determine the time damping ratio of the signal.
1830 def integral(self, **kwargs): 1831 """ 1832 Cumulative integral plot of the normalized signal log envelope 1833 1834 Represents the power distribution variation in time for the signal. 1835 This is a plot of the function $F(x)$ such as : 1836 1837 $ F(x) = \int_0^x env(x) dx $ 1838 1839 Where e(x) is the signal envelope. 1840 """ 1841 # sanitize the kwargs 1842 plot_kwargs = self.sanitize_kwargs(kwargs) 1843 1844 # Compute log envelope and log time 1845 log_envelope, log_time = self.parent.normalize().log_envelope() 1846 1847 1848 # compute the cumulative integral 1849 integral = [trapezoid(log_envelope[:i], log_time[:i]) for i in np.arange(2, len(log_envelope), 1)] 1850 integral /= np.max(integral) 1851 1852 # plot the integral 1853 plt.plot(log_time[2:], integral, **plot_kwargs) 1854 1855 # Add labels and scale 1856 plt.xlabel('time (s)') 1857 plt.ylabel('cumulative power (normalized)') 1858 plt.xscale('log') 1859 plt.grid('on')
Cumulative integral plot of the normalized signal log envelope
Represents the power distribution variation in time for the signal. This is a plot of the function $F(x)$ such as :
$ F(x) = \int_0^x env(x) dx $
Where e(x) is the signal envelope.