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')
class SoundPack:
 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

SoundPack( *sounds, names=None, fundamentals=None, SoundParams=None, equalize_time=True)
 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'])
def sounds_from_files(self, sound_files, names=None, fundamentals=None):
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

def equalize_time(self):
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

def normalize(self):
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

def plot(self, kind, **kwargs):
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

def compare_plot(self, kind, **kwargs):
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.

def freq_bin_plot(self, f_bin='all'):
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.

def fundamentals(self):
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 __

def integral_plot(self, f_bin='all'):
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)

def bin_power_table(self):
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.

def bin_power_hist(self):
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.

def listen(self):
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 __

def compare_peaks(self):
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.

def fft_mirror(self):
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

def fft_diff(self, fraction=3, ticks=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

def integral_compare(self, f_bin='all'):
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

class Sound:
 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.

Sound( data, name='', fundamental=None, condition=True, auto_trim=False, use_raw_signal=False, normalize_raw_signal=False, SoundParams=None)
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, the Sound 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 is True, setting normalize_raw_signal to True will normalize the raw signal before it is used in the Sound class.
  • SoundParams: SoundParameters to use with the Sound instance
def condition( self, verbose=True, return_self=False, auto_trim=False, resample=True):
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

def use_raw_signal(self, normalized=False, return_self=False):
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

def bin_divide(self):
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.

def trim_signal(self, verbose=True):
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.

def listen_freq_bins(self):
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.

def plot_freq_bins(self, bins='all'):
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)

def peak_damping(self):
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.

def bin_hist(self):
 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.

class Signal:
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.

Signal(signal, sr, SoundParam=None, freq_range=None)
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
def time(self):
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

def listen(self):
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.

def old_plot(self, kind, **kwargs):
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

def fft(self):
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

def spectral_centroid(self):
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.

def peaks(self, max_freq=None, height=False, result=False):
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.

def time_damping(self):
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.

def peak_damping(self):
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

def fundamental(self):
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)

def cavity_peak(self):
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

def cavity_frequency(self):
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

def fft_frequencies(self):
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.

def fft_bins(self):
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.

def envelope(self, window=None, overlap=None):
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

def log_envelope(self):
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

def find_onset(self, verbose=True):
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

def trim_onset(self, verbose=True):
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
def trim_time(self, time_length):
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

def normalize(self):
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

def make_freq_bins(self):
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
def save_wav(self, name, path=''):
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
class Plot:
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'

Plot()
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, }
def sanitize_kwargs(self, kwargs):
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

def set_bin_ticks(self):
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

def signal(self, **kwargs):
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.

def envelope(self, **kwargs):
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.

def log_envelope(self, **kwargs):
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)
def fft(self, **kwargs):
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.

def fft_hist(self, **kwargs):
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

def peaks(self, **kwargs):
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.

def peak_damping(self, **kwargs):
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.

def time_damping(self, **kwargs):
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.

def integral(self, **kwargs):
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.