import sox # pragma: no cover
import re
import os
import json
import csv
import shutil
import logging
import warnings
import tempfile
import soundfile
import numpy as np
from copy import deepcopy
from collections import namedtuple
from .scaper_exceptions import ScaperError
from .scaper_warnings import ScaperWarning
from .util import _close_temp_files
from .util import _set_temp_logging_level
from .util import _get_sorted_files
from .util import _validate_folder_path
from .util import _populate_label_list
from .util import _check_random_state
from .util import _sample_trunc_norm
from .util import _sample_uniform
from .util import _sample_choose
from .util import _sample_choose_weighted
from .util import _sample_normal
from .util import _sample_const
from .util import max_polyphony
from .util import polyphony_gini
from .util import is_real_number, is_real_array
from .audio import get_integrated_lufs
from .audio import peak_normalize
from .version import version as scaper_version


# HEADS UP! Adding a new distribution tuple?
# Make sure it's properly handled in all of the following:
# SUPPORTED_DIST
# _validate_distribution
# _validate_time
# _validate_duration
# _validate_time_stretch
# _validate_pitch_shift
# _validate_label
# _validate_source_file
# _validate_snr
# _ensure_satisfiable_source_time_tuple
# _instantiate_event
# add_background (docstring)
# add_event (docstring)
SUPPORTED_DIST = {"const": _sample_const,
                  "choose": _sample_choose,
                  "choose_weighted": _sample_choose_weighted,
                  "uniform": _sample_uniform,
                  "normal": _sample_normal,
                  "truncnorm": _sample_trunc_norm}

# Define single event spec as namedtuple
EventSpec = namedtuple(
    'EventSpec',
    ['label', 'source_file', 'source_time', 'event_time', 'event_duration',
     'snr', 'role', 'pitch_shift', 'time_stretch','position', 'library', 'speaker', 'text'])
'''
Container for storing event specifications, either probabilistic (i.e. using
distribution tuples to specify possible values) or instantiated (i.e. storing
constants directly).
'''


def generate_from_jams(jams_infile,
                       audio_outfile=None,
                       fg_path=None,
                       bg_path=None,
                       jams_outfile=None,
                       save_isolated_events=False,
                       isolated_events_path=None,
                       disable_sox_warnings=True,
                       txt_path=None,
                       save_isolated_positions=False,
                       isolated_positions_path=None,
                       txt_sep='\t'):
    '''
    Generate a soundscape based on an existing scaper JAMS file and return as
    an audio file, a JAMS annotation, a simplified annotation list, and a
    list containing the audio samples of each individual background and
    foreground event. If output paths are provided, these objects will also
    be saved to disk.

    Parameters
    ----------
    jams_infile : str
        Path to JAMS file (must be a file previously generated by Scaper).
    audio_outfile : str
        Path for saving the generated soundscape audio.
    fg_path : str or None
        Specifies a different path for foreground audio than the one stored in
        the input jams file. For the reconstruction to be successful the folder
        and file structure inside this path must be identical the one that was
        used to create the input jams file. If None (default), the fg_path from
        the input jams file will be used.
    bg_path : str or None
        Specifies a different path for background audio than the one stored in
        the input jams file. For the reconstruction to be successful the folder
        and file structure inside this path must be identical the one that was
        used to create the input jams file. If None (default), the bg_path from
        the input jams file will be used.
    jams_outfile : str or None
        Path for saving new JAMS file, if None (default) a new JAMS is not
        saved. Useful when either fg_path or bg_path is not None, as it saves
        a new JAMS files where the source file paths match the new fg_path
        and/or bg_path.
    save_isolated_events : bool
        If True, this will save the isolated event audio in a directory adjacent to the generated soundscape
        mixture, or to the path defined by `isolated_events_path`. The audio of the isolated events sum 
        up to the mixture if reverb is not applied. Isolated events can be found 
        (by default) at `<audio_outfile parent folder>/<audio_outfile name>_events`.
        Isolated event file names follow the pattern: `<role><idx>_<label>`, where idx
        is the index of the isolated event in 
        self.fg_spec or self.bg_spec (this allows events of the same label to be added more than 
        once to the soundscape without breaking things). Role is "background" or "foreground".
        For example: `foreground0_siren.wav` or `background0_park.wav`.
    isolated_events_path : str
        Path to folder for saving isolated events. If None, defaults to
        `<audio_outfile parent folder>/<audio_outfile name>_events`.
    disable_sox_warnings : bool
            When True (default), warnings from the pysox module are suppressed
            unless their level is ``'CRITICAL'``. If you're experiencing issues related
            to audio I/O setting this parameter to False may help with debugging.
    txt_path: str or None
            Path for saving a simplified annotation in a space separated format
            [onset  offset  label] where onset and offset are in seconds. Good
            for loading labels in e.g. Audacity. If None, does not save txt
            annotation to disk.
    txt_sep: str
        The separator to use when saving a simplified annotation as a text
        file (default is tab for compatibility with Audacity label files).
        Only relevant if txt_path is not None.

    Returns
    -------
    soundscape_audio : np.ndarray
        The audio samples of the generated soundscape. Returns None if
        no_audio=True.
    soundscape_jam: jams.JAMS
        The JAMS object containing the full soundscape annotation.
    annotation_list : list
        A simplified annotation in a space-separated format
        [onset  offset  label] where onset and offset are in seconds.
    event_audio_list: list
        A list of np.ndarrays containing the audio samples of every
        individual background and foreground sound event. Events are listed
        in the same order in which they appear in the jams annotations data
        list, and can be matched with:
        `for obs, event_audio in zip(ann.data, event_audio_list): ...`.

    Raises
    ------
    ScaperError
        If jams_infile does not point to a valid JAMS file that was previously
        generated by Scaper and contains an annotation of the scaper
        namespace.

    '''
    import jams

    soundscape_jam = jams.load(jams_infile)
    anns = soundscape_jam.search(namespace='scaper')

    if len(anns) == 0:
        raise ScaperError(
            'JAMS file does not contain any annotation with namespace '
            'scaper.')

    ann = soundscape_jam.annotations.search(namespace='scaper')[0]

    # Update paths
    if fg_path is None:
        new_fg_path = ann.sandbox.scaper['fg_path']
    else:
        new_fg_path = os.path.expanduser(fg_path)
        # Update source files
        for obs in ann.data:
            if obs.value['role'] == 'foreground':
                sourcefile = obs.value['source_file']
                sourcefilename = os.path.basename(sourcefile)
                parent = os.path.dirname(sourcefile)
                parentname = os.path.basename(parent)
                newsourcefile = os.path.join(
                    new_fg_path, parentname, sourcefilename)
                obs.value['source_file'] = newsourcefile  # hacky
        # Update sandbox
        ann.sandbox.scaper['fg_path'] = new_fg_path

    if bg_path is None:
        new_bg_path = ann.sandbox.scaper['bg_path']
    else:
        new_bg_path = os.path.expanduser(bg_path)
        # Update source files
        for obs in ann.data:
            if obs.value['role'] == 'background':
                sourcefile = obs.value['source_file']
                sourcefilename = os.path.basename(sourcefile)
                parent = os.path.dirname(sourcefile)
                parentname = os.path.basename(parent)
                newsourcefile = os.path.join(
                    new_bg_path, parentname, sourcefilename)
                obs.value['source_file'] = newsourcefile  # hacky
        # Update sandbox
        ann.sandbox.scaper['bg_path'] = new_bg_path

    # Create scaper object
    if 'original_duration' in ann.sandbox.scaper:
        duration = ann.sandbox.scaper['original_duration']
    else:
        duration = ann.sandbox.scaper['duration']
        warnings.warn(
            "Couldn't find original_duration field in the scaper sandbox, "
            "using duration field instead. This can lead to incorrect behavior "
            "if generating from a jams file that has been trimmed previously.",
            ScaperWarning)
    
    protected_labels = ann.sandbox.scaper['protected_labels']
    sc = Scaper(duration, new_fg_path, new_bg_path, protected_labels)

    # Set synthesis parameters
    if 'sr' in ann.sandbox.scaper: # backwards compatibility
        sc.sr = ann.sandbox.scaper['sr']
    sc.ref_db = ann.sandbox.scaper['ref_db']
    sc.n_channels = ann.sandbox.scaper['n_channels']
    sc.fade_in_len = ann.sandbox.scaper['fade_in_len']
    sc.fade_out_len = ann.sandbox.scaper['fade_out_len']

    # Pull generation parameters from annotation
    reverb = ann.sandbox.scaper['reverb']

    if 'fix_clipping' in ann.sandbox.scaper.keys():
        fix_clipping = ann.sandbox.scaper['fix_clipping']
    else:
        fix_clipping = False

    if 'peak_normalization' in ann.sandbox.scaper.keys():
        peak_normalization = ann.sandbox.scaper['peak_normalization']
    else:
        peak_normalization = False

    if 'quick_pitch_time' in ann.sandbox.scaper.keys():
        quick_pitch_time = ann.sandbox.scaper['quick_pitch_time']
    else:
        quick_pitch_time = False

    # Cast ann.sandbox.scaper to a Sandbox object
    ann.sandbox.scaper = jams.Sandbox(**ann.sandbox.scaper)

    # Generate audio
    soundscape_audio, event_audio_list, scale_factor, ref_db_change = \
        sc._generate_audio(audio_outfile,
                           ann,
                           reverb=reverb,
                           fix_clipping=fix_clipping,
                           peak_normalization=peak_normalization,
                           quick_pitch_time=quick_pitch_time,
                           save_isolated_events=save_isolated_events,
                           isolated_events_path=isolated_events_path,
                           disable_sox_warnings=disable_sox_warnings,
                           save_isolated_positions=save_isolated_positions,
                           isolated_positions_path=isolated_positions_path)
    
    # TODO: Stick to heavy handed overwriting for now, in the future we
    #  should consolidate this with what happens inside _instantiate().
    ann.sandbox.scaper.reverb = reverb
    ann.sandbox.scaper.fix_clipping = fix_clipping
    ann.sandbox.scaper.peak_normalization = peak_normalization
    ann.sandbox.scaper.quick_pitch_time = quick_pitch_time
    ann.sandbox.scaper.save_isolated_events = save_isolated_events
    ann.sandbox.scaper.isolated_events_path = isolated_events_path
    ann.sandbox.scaper.disable_sox_warnings = disable_sox_warnings
    ann.sandbox.scaper.peak_normalization_scale_factor = scale_factor
    ann.sandbox.scaper.ref_db_change = ref_db_change
    ann.sandbox.scaper.ref_db_generated = sc.ref_db + ref_db_change
    ann.sandbox.scaper.save_isolated_positions = save_isolated_positions
    ann.sandbox.scaper.isolated_positions_path = isolated_positions_path
    
    # If there are slice (trim) operations, need to perform them!
    # Need to add this logic for the isolated events too.
    if 'slice' in ann.sandbox.keys():
        for sliceop in ann.sandbox['slice']:
            # must use temp file in order to save to same file
            tmpfiles = []
            audio_files = [audio_outfile] + ann.sandbox.scaper.isolated_events_audio_path
            with _close_temp_files(tmpfiles):
                for audio_file in audio_files:
                    # Create tmp file
                    tmpfiles.append(
                        tempfile.NamedTemporaryFile(suffix='.wav', delete=False))
                    # Save trimmed result to temp file
                    tfm = sox.Transformer()
                    tfm.trim(sliceop['slice_start'], sliceop['slice_end'])
                    tfm.build(audio_file, tmpfiles[-1].name)
                    # Copy result back to original file
                    shutil.copyfile(tmpfiles[-1].name, audio_file)

    # Optionally save new jams file
    if jams_outfile is not None:
        soundscape_jam.save(jams_outfile)

    # Create annotation list
    annotation_list = []
    for obs in ann.data:
        if obs.value['role'] == 'foreground':
            annotation_list.append(
                [obs.time, obs.time + obs.duration, obs.value['label']])

    if txt_path is not None:
        with open(txt_path, 'w') as csv_file:
            writer = csv.writer(csv_file, delimiter=txt_sep)
            writer.writerows(annotation_list)

    return soundscape_audio, soundscape_jam, annotation_list, event_audio_list


def trim(audio_infile, jams_infile, audio_outfile, jams_outfile, start_time,
         end_time, no_audio=False):
    '''
    Trim an audio file and corresponding Scaper JAMS file and save to disk.

    Given an input audio file and corresponding jams file, trim both the audio
    and all annotations in the jams file to the time range ``[start_time,
    end_time]`` and save the result to ``audio_outfile`` and ``jams_outfile``
    respectively. This function uses ``jams.slice()`` for trimming the jams
    file while ensuring the start times of the jam's annotations and
    observations they contain match the trimmed audio file.

    Parameters
    ----------
    audio_infile : str
        Path to input audio file
    jams_infile : str
        Path to input jams file
    audio_outfile : str
        Path to output trimmed audio file
    jams_outfile : str
        Path to output trimmed jams file
    start_time : float
        Start time for trimmed audio/jams
    end_time : float
        End time for trimmed audio/jams
    no_audio : bool
        If true, operates on the jams only. Audio input and output paths
        don't have to point to valid files.

    '''
    import jams
    
    # First trim jams (might raise an error)
    jam = jams.load(jams_infile)
    jam_sliced = jam.slice(start_time, end_time, strict=False)

    # Special work for annotations of the scaper 'scaper' namespace
    for ann in jam_sliced.annotations:
        if ann.namespace == 'scaper':
            # DON'T MODIFY event's value dict! Keeps original instantiated
            # values for reconstruction / reproducibility.
            # Count number of FG events
            n_events = 0
            for obs in ann.data:
                if obs.value['role'] == 'foreground':
                    n_events += 1

            # Re-compute max polyphony
            poly = max_polyphony(ann)

            # Re-compute polyphony gini
            gini = polyphony_gini(ann)

            # Update specs in sandbox
            ann.sandbox.scaper['n_events'] = n_events
            ann.sandbox.scaper['polyphony_max'] = poly
            ann.sandbox.scaper['polyphony_gini'] = gini
            ann.sandbox.scaper['duration'] = ann.duration

    # Save result to output jams file
    jam_sliced.save(jams_outfile)

    # Next, trim audio
    if not no_audio:
        tfm = sox.Transformer()
        tfm.trim(start_time, end_time)
        if audio_outfile != audio_infile:
            tfm.build(audio_infile, audio_outfile)
        else:
            # must use temp file in order to save to same file
            tmpfiles = []
            with _close_temp_files(tmpfiles):
                # Create tmp file
                tmpfiles.append(
                    tempfile.NamedTemporaryFile(
                        suffix='.wav', delete=False))
                # Save trimmed result to temp file
                tfm.build(audio_infile, tmpfiles[-1].name)
                # Copy result back to original file
                shutil.copyfile(tmpfiles[-1].name, audio_outfile)


def _get_value_from_dist(dist_tuple, random_state):
    '''
    Sample a value from the provided distribution tuple.

    Given a distribution tuple, validate its format/values and then sample
    and return a single value from the distribution specified by the tuple.

    Parameters
    ----------
    dist_tuple : tuple
        Distribution tuple to be validated. See ``Scaper.add_event`` for
        details about the expected format for the distribution tuple.

    Returns
    -------
    value
        A value from the specified distribution.

    See Also
    --------
    Scaper.add_event :  Add a foreground sound event to the foreground
    specification.
    _validate_distribution : Check whether a tuple specifying a parameter
    distribution has a valid format, if not raise an error.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(dist_tuple)
    return SUPPORTED_DIST[dist_tuple[0]](*dist_tuple[1:], random_state=random_state)


def _validate_distribution(dist_tuple):
    '''
    Check whether a tuple specifying a parameter distribution has a valid
    format, if not raise an error.

    Parameters
    ----------
    dist_tuple : tuple
        Tuple specifying a distribution to sample from. See Scaper.add_event
        for details about the expected format of the tuple and allowed values.

    Raises
    ------
    ScaperError
        If the tuple does not have a valid format.

    See Also
    --------
    Scaper.add_event : Add a foreground sound event to the foreground
    specification.
    '''
    # Make sure it's a tuple
    if not isinstance(dist_tuple, tuple):
        raise ScaperError('Distribution tuple must be of type tuple.')

    # Make sure the tuple contains at least 2 items
    if len(dist_tuple) < 2:
        raise ScaperError('Distribution tuple must be at least of length 2.')

    # Make sure the first item is one of the supported distribution names
    if dist_tuple[0] not in SUPPORTED_DIST.keys():
        raise ScaperError(
            "Unsupported distribution name: {:s}".format(dist_tuple[0]))

    # If it's a constant distribution, tuple must be of length 2
    if dist_tuple[0] == 'const':
        if len(dist_tuple) != 2:
            raise ScaperError('"const" distribution tuple must be of length 2')
    # If it's a choose, tuple must be of length 2 and second item of type list
    elif dist_tuple[0] == 'choose':
        if len(dist_tuple) != 2 or not isinstance(dist_tuple[1], list):
            raise ScaperError(
                'The "choose" distribution tuple must be of length 2 where '
                'the second item is a list.')
    # If it's a choose_weighted, tuple must be of length 3, items 2 and 3 must
    # be lists of the same length, and the list in item 3 must contain floats 
    # in the range [0, 1] that sum to 1 (i.e. valid probabilities).
    elif dist_tuple[0] == 'choose_weighted':
        if len(dist_tuple) != 3:
            raise ScaperError('"choose_weighted" distribution tuple must have length 3')
        if not isinstance(dist_tuple[1], list) or \
           not isinstance(dist_tuple[2], list) or \
           len(dist_tuple[1]) != len(dist_tuple[2]):
           msg = ('The 2nd and 3rd items of the "choose_weighted" distribution tuple '
                  'must be lists of the same length.')
           raise ScaperError(msg)
        probabilities = np.asarray(dist_tuple[2])
        if probabilities.min() < 0 or probabilities.max() > 1:
            msg = ('Values in the probabilities list of the "choose_weighted" '
                   'distribution tuple must be in the range [0, 1].')
            raise ScaperError(msg)
        if not np.allclose(probabilities.sum(), 1):
            msg = ('Values in the probabilities list of the "choose_weighted" '
                   'distribution tuple must sum to 1.')
            raise ScaperError(msg)
    # If it's a uniform distribution, tuple must be of length 3, 2nd item must
    # be a real number and 3rd item must be real and greater/equal to the 2nd.
    elif dist_tuple[0] == 'uniform':
        if (len(dist_tuple) != 3 or
                not is_real_number(dist_tuple[1]) or
                not is_real_number(dist_tuple[2]) or
                dist_tuple[1] > dist_tuple[2]):
            raise ScaperError(
                'The "uniform" distribution tuple be of length 2, where the '
                '2nd item is a real number and the 3rd item is a real number '
                'and greater/equal to the 2nd item.')
    # If it's a normal distribution, tuple must be of length 3, 2nd item must
    # be a real number and 3rd item must be a non-negative real
    elif dist_tuple[0] == 'normal':
        if (len(dist_tuple) != 3 or
                not is_real_number(dist_tuple[1]) or
                not is_real_number(dist_tuple[2]) or
                dist_tuple[2] < 0):
            raise ScaperError(
                'The "normal" distribution tuple must be of length 3, where '
                'the 2nd item (mean) is a real number and the 3rd item (std '
                'dev) is real and non-negative.')
    elif dist_tuple[0] == 'truncnorm':
        if (len(dist_tuple) != 5 or
                not is_real_number(dist_tuple[1]) or
                not is_real_number(dist_tuple[2]) or
                not is_real_number(dist_tuple[3]) or
                not is_real_number(dist_tuple[4]) or
                dist_tuple[2] < 0 or
                dist_tuple[4] < dist_tuple[3]):
            raise ScaperError(
                'The "truncnorm" distribution tuple must be of length 5, '
                'where the 2nd item (mean) is a real number, the 3rd item '
                '(std dev) is real and non-negative, the 4th item (trunc_min) '
                'is a real number and the 5th item (trun_max) is a real '
                'number that is equal to or greater than trunc_min.')


def _ensure_satisfiable_source_time_tuple(source_time, source_duration, event_duration):
    '''
    Modify a source_time distribution tuple according to the duration of the
    source and the duration of the event. This allows you to sample from 
    anywhere in a source file without knowing the exact duration of every
    source file. 

    Parameters
    ----------
    source_time : tuple
        Tuple specifying a distribution to sample from. See Scaper.add_event
        for details about the expected format of the tuple and allowed values.
    source_duration : float
        Duration of the source audio file.
    event_duration : float
        Duration of the event to be extracted from the source file.

    See Also
    --------
    Scaper.add_event : Add a foreground sound event to the foreground
    specification.
    '''
    _validate_distribution(source_time)
    old_source_time = deepcopy(source_time)
    source_time = list(source_time)

    # If it's a constant distribution, just make sure it's within bounds.
    if source_time[0] == 'const':
        if source_time[1] + event_duration > source_duration:
            source_time[1] = max(0, source_duration - event_duration)

    # If it's a choose, iterate through the list to make sure it's all in bounds.
    # Some logic here so we don't add stuff out of bounds more than once.
    elif source_time[0] == 'choose':
        for i, t in enumerate(source_time[1]):
            if t + event_duration > source_duration:
                source_time[1][i] = max(0, source_duration - event_duration)
        source_time[1] = list(set(source_time[1]))

    # For weighted_choose we do the same as choose but without removing duplicates
    elif source_time[0] == 'choose_weighted':
        for i, t in enumerate(source_time[1]):
            if t + event_duration > source_duration:
                source_time[1][i] = max(0, source_duration - event_duration)

    # If it's a uniform distribution, tuple must be of length 3, We change the 3rd
    # item to source_duration - event_duration so that we stay in bounds. If the min
    # out of bounds, we change it to be source_duration - event_duration.
    elif source_time[0] == 'uniform':
        if source_time[1] + event_duration > source_duration:
            source_time[1] = max(0, source_duration - event_duration)
        if source_time[2] + event_duration > source_duration:
            source_time[2] = max(0, source_duration - event_duration)
        if (source_time[1] == source_time[2]):
            # switch to const
            source_time = ['const', source_time[1]]
        
    # If it's a normal distribution, we change the mean of the distribution to
    # source_duration - event_duration if source_duration - mean < event_duration.
    elif source_time[0] == 'normal':
        if source_time[1] + event_duration > source_duration:
            source_time[1] = max(0, source_duration - event_duration)

    # If it's a truncated normal distribution, we change the mean as we did above for a
    # normal distribution, and change the max (5th item) to 
    # source_duration - event_duration if it's bigger. If the min is out of bounds, we
    # change it like in the uniform case.
    elif source_time[0] == 'truncnorm':
        if source_time[1] + event_duration > source_duration:
            source_time[1] = max(0, source_duration - event_duration)
        if source_time[3] + event_duration > source_duration:
            source_time[3] = max(0, source_duration - event_duration)
        if source_time[4] + event_duration > source_duration:
            source_time[4] = max(0, source_duration - event_duration)
        if (source_time[3] == source_time[4]):
            # switch to const
            source_time = ['const', source_time[1]]
    
    source_time = tuple(source_time)
    # check if the source_time changed from the old_source_time to throw a warning.
    # it gets set here but the warning happens after the return from this call
    warn = (source_time != old_source_time)

    return tuple(source_time), warn


def _validate_label(label, allowed_labels):
    '''
    Validate that a label tuple is in the right format and that it's values
    are valid.

    Parameters
    ----------
    label : tuple
        Label tuple (see ```Scaper.add_event``` for required format).
    allowed_labels : list
        List of allowed labels.

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(label)

    # Make sure it's one of the allowed distributions for a label and that the
    # label value is one of the allowed labels.
    if label[0] == "const":
        if not label[1] in allowed_labels:
            raise ScaperError(
                'Label value must match one of the available labels: '
                '{:s}'.format(str(allowed_labels)))
    elif label[0] == "choose" or label[0] == "choose_weighted":
        if label[1]:  # list is not empty
            if not set(label[1]).issubset(set(allowed_labels)):
                raise ScaperError(
                    'Label list provided must be a subset of the available '
                    'labels: {:s}'.format(str(allowed_labels)))
    else:
        raise ScaperError(
            'Label must be specified using a "const" or "choose" tuple.')


def _validate_source_file(source_file_tuple, label_tuple, library=None):
    '''
    Validate that a source_file tuple is in the right format a that it's values
    are valid.

    Parameters
    ----------
    source_file : tuple
        Source file tuple (see ```Scaper.add_event``` for required format).
    label : str
        Label tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(source_file_tuple)
    _validate_distribution(label_tuple)

    # If source file is specified explicitly
    if source_file_tuple[0] == "const":
        # 1. the filepath must point to an existing file
        # get source file path
        if library is None:
            source_file_path = source_file_tuple[1]
        else:
            source_file_path = os.path.join(str(library), str(label_tuple[1]), str(source_file_tuple[1]))
        if not os.path.isfile(source_file_path):
            raise ScaperError(
                "Source file not found: {:s}".format(source_file_path))
        # 2. the label must match the file's parent folder name
        parent_name = os.path.basename(os.path.dirname(source_file_path))
        if label_tuple[0] != "const" or label_tuple[1] != parent_name:
            raise ScaperError(
                "Source file's parent folder name does not match label.")
    # Otherwise it must be specified using one of "choose" or "choose_weighted"
    elif source_file_tuple[0] == "choose" or source_file_tuple[0] == "choose_weighted":
        if source_file_tuple[1]:  # list is not empty
            if not all(os.path.isfile(x) for x in source_file_tuple[1]):
                raise ScaperError(
                    'Source file list must either be empty or all paths in '
                    'the list must point to valid files.')
    else:
        raise ScaperError(
            'Source file must be specified using a "const" or "choose" tuple.')


def _validate_time(time_tuple):
    '''
    Validate that a time tuple has the right format and that the
    specified distribution cannot result in a negative time.

    Parameters
    ----------
    time_tuple : tuple
        Time tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(time_tuple)

    # Ensure the values are valid for time
    if time_tuple[0] == "const":
        if (time_tuple[1] is None or
                not is_real_number(time_tuple[1]) or
                time_tuple[1] < 0):
            raise ScaperError(
                'Time must be a real non-negative number.')
    elif time_tuple[0] == "choose" or time_tuple[0] == "choose_weighted":
        if (not time_tuple[1] or
                not is_real_array(time_tuple[1]) or
                not all(x is not None for x in time_tuple[1]) or
                not all(x >= 0 for x in time_tuple[1])):
            raise ScaperError(
                'Time list must be a non-empty list of non-negative real '
                'numbers.')
    elif time_tuple[0] == "uniform":
        if time_tuple[1] < 0:
            raise ScaperError(
                'A "uniform" distribution tuple for time must have '
                'min_value >= 0')
    elif time_tuple[0] == "normal":
        warnings.warn(
            'A "normal" distribution tuple for time can result in '
            'negative values, in which case the distribution will be '
            're-sampled until a positive value is returned: this can result '
            'in an infinite loop!',
            ScaperWarning)
    elif time_tuple[0] == "truncnorm":
        if time_tuple[3] < 0:
            raise ScaperError(
                'A "truncnorm" distirbution tuple for time must specify a non-'
                'negative trunc_min value.')


def _validate_duration(duration_tuple):
    '''
    Validate that a duration tuple has the right format and that the
    specified distribution cannot result in a negative or zero value.

    Parameters
    ----------
    duration : tuple
        Duration tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(duration_tuple)

    # Ensure the values are valid for duration
    if duration_tuple[0] == "const":
        if (not is_real_number(duration_tuple[1]) or
                duration_tuple[1] <= 0):
            raise ScaperError(
                'Duration must be a real number greater than zero.')
    elif duration_tuple[0] == "choose" or duration_tuple[0] == "choose_weighted":
        if (not duration_tuple[1] or
                not is_real_array(duration_tuple[1]) or
                not all(x > 0 for x in duration_tuple[1])):
            raise ScaperError(
                'Duration list must be a non-empty list of positive real '
                'numbers.')
    elif duration_tuple[0] == "uniform":
        if duration_tuple[1] <= 0:
            raise ScaperError(
                'A "uniform" distribution tuple for duration must have '
                'min_value > 0')
    elif duration_tuple[0] == "normal":
        warnings.warn(
            'A "normal" distribution tuple for duration can result in '
            'non-positives values, in which case the distribution will be '
            're-sampled until a positive value is returned: this can result '
            'in an infinite loop!',
            ScaperWarning)
    elif duration_tuple[0] == "truncnorm":
        if duration_tuple[3] <= 0:
            raise ScaperError(
                'A "truncnorm" distirbution tuple for time must specify a '
                'positive trunc_min value.')


def _validate_snr(snr_tuple):
    '''
    Validate that an snr distribution tuple has the right format.

    Parameters
    ----------
    snr : tuple
        SNR tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # Make sure it's a valid distribution tuple
    _validate_distribution(snr_tuple)

    # Ensure the values are valid for SNR
    if snr_tuple[0] == "const":
        if not is_real_number(snr_tuple[1]):
            raise ScaperError(
                'SNR must be a real number.')
    elif snr_tuple[0] == "choose" or snr_tuple[0] == "choose_weighted":
        if (not snr_tuple[1] or
                not is_real_array(snr_tuple[1])):
            raise ScaperError(
                'SNR list must be a non-empty list of real numbers.')

    # No need to check for "uniform" and "normal" since they must produce a
    # real number and technically speaking any real number is a valid SNR.
    # TODO: do we want to impose limits on the possible SNR values?


def _validate_pitch_shift(pitch_shift_tuple):
    '''
    Validate that a pitch_shift distribution tuple has the right format.

    Parameters
    ----------
    pitch_shift_tuple : tuple
        Pitch shift tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # If the tuple is none then it's valid
    if pitch_shift_tuple is not None:
        # Make sure it's a valid distribution tuple
        _validate_distribution(pitch_shift_tuple)

        # Ensure the values are valid for pitch shift
        if pitch_shift_tuple[0] == "const":
            if not is_real_number(pitch_shift_tuple[1]):
                raise ScaperError(
                    'Pitch shift must be a real number.')
        elif pitch_shift_tuple[0] == "choose" or pitch_shift_tuple[0] == "choose_weighted":
            if (not pitch_shift_tuple[1] or
                    not is_real_array(pitch_shift_tuple[1])):
                raise ScaperError(
                    'Pitch shift list must be a non-empty list of real '
                    'numbers.')

        # No need to check for "uniform" and "normal" since they must produce a
        # real number and technically speaking any real number is a valid pitch
        # shift
        # TODO: do we want to impose limits on the possible pitch shift values?


def _validate_time_stretch(time_stretch_tuple):
    '''
    Validate that a time_stretch distribution tuple has the right format.

    Parameters
    ----------
    time_stretch_tuple: tuple
        Time stretch tuple (see ```Scaper.add_event``` for required format).

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    # if the tuple is none then its valid
    if time_stretch_tuple is not None:
        # Make sure it's a valid distribution tuple
        _validate_distribution(time_stretch_tuple)

        # Ensure the values are valid for time stretch
        if time_stretch_tuple[0] == "const":
            if (not is_real_number(time_stretch_tuple[1]) or
                    time_stretch_tuple[1] <= 0):
                raise ScaperError(
                    'Time stretch must be a real number greater than zero.')
        elif time_stretch_tuple[0] == "choose" or time_stretch_tuple[0] == "choose_weighted":
            if (not time_stretch_tuple[1] or
                    not is_real_array(time_stretch_tuple[1]) or
                    not all(x > 0 for x in time_stretch_tuple[1])):
                raise ScaperError(
                    'Time stretch list must be a non-empty list of positive '
                    'real numbers.')
        elif time_stretch_tuple[0] == "uniform":
            if time_stretch_tuple[1] <= 0:
                raise ScaperError(
                    'A "uniform" distribution tuple for time stretch must have '
                    'min_value > 0')
        elif time_stretch_tuple[0] == "normal":
            warnings.warn(
                'A "normal" distribution tuple for time stretch can result in '
                'non-positives values, in which case the distribution will be '
                're-sampled until a positive value is returned: this can '
                'result in an infinite loop!',
                ScaperWarning)
        elif time_stretch_tuple[0] == "truncnorm":
            if time_stretch_tuple[3] <= 0:
                raise ScaperError(
                    'A "truncnorm" distirbution tuple for time stretch must '
                    'specify a positive trunc_min value.')

        # TODO: do we want to impose limits on the possible time stretch
        # values?


def _validate_position(position):
    '''
    Validate that the position is a non empty string without whitespace and special
    characters.

    Parameters
    ----------
    position : str or None
        Event type to be validated.

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    if position is not None:
        if not isinstance(position, str):
            raise ScaperError('Event type must be a string.')
        if len(position) == 0:
            raise ScaperError('Event type cannot be an empty string.')
        if not re.match(r'^[\w-]+$', position):
            raise ScaperError(
                'Event type can only contain alphanumeric characters, '
                'underscores and hyphens. No whitespace or special '
                'characters are allowed.')


def _validate_library(library):
    '''
    Validate that a library is a valid Scaper library.

    Parameters
    ----------
    library : str or None
        path to the library

    Raises
    ------
    ScaperError
        If the validation fails.

    '''
    if library is None:
        return
    if not isinstance(library, str):
        raise ScaperError('Library must be a string.')
    if not os.path.isdir(library):
        raise ScaperError('Library must be a valid directory.')
    # check that library contains at least one subdirectory
    if not any(os.path.isdir(os.path.join(library, d)) for d in os.listdir(library)):
        raise ScaperError('Library must contain at least one subdirectory with audio files.')
    # check that all library subdirectories contain at least one audio file
    # with a valid extension

    audio_extensions = ('.wav', '.mp3', '.flac', '.ogg')
    for subdir in os.listdir(library):
        subdir_path = os.path.join(library, subdir)
        if os.path.isdir(subdir_path):
            if not any(os.path.isfile(os.path.join(subdir_path, f)) and
                       f.lower().endswith(audio_extensions)
                       for f in os.listdir(subdir_path)):
                raise ScaperError(
                    'Library subdirectory {:s} does not contain any audio '
                    'files with valid extensions.'.format(subdir_path))
  


def _validate_event(label, source_file, source_time, event_time,
                    event_duration, snr, allowed_labels, pitch_shift,
                    time_stretch, position, library):
    '''
    Check that event parameter values are valid.

    Parameters
    ----------
    label : tuple
    source_file : tuple
    source_time : tuple
    event_time : tuple
    event_duration : tuple
    snr : tuple
    allowed_labels : list
        List of allowed labels for the event.
    pitch_shift : tuple or None
    time_stretch: tuple or None
    position: str or None
    library : str or None

    Raises
    ------
    ScaperError :
        If any of the input parameters has an invalid format or value.

    See Also
    --------
    Scaper.add_event : Add a foreground sound event to the foreground
    specification.
    '''
    # allowed_labels must be a list. All other parameters will be validated
    # individually.
    if not isinstance(allowed_labels, list):
        raise ScaperError('allowed_labels must be of type list.')

    # SOURCE FILE
    _validate_source_file(source_file, label, library)

    # LABEL
    _validate_label(label, allowed_labels)

    # SOURCE TIME
    _validate_time(source_time)

    # EVENT TIME
    _validate_time(event_time)

    # EVENT DURATION
    _validate_duration(event_duration)

    # SNR
    _validate_snr(snr)

    # Pitch shift
    _validate_pitch_shift(pitch_shift)

    # Time stretch
    _validate_time_stretch(time_stretch)

    # Event type
    _validate_position(position)

    # Library
    _validate_library(library)


class Scaper(object):
    '''
    Create a Scaper object.

    Parameters
    ----------
    duration : float
        Duration of the soundscape, in seconds.
    fg_path : str
        Path to foreground folder.
    bg_path : str
        Path to background folder.
    protected_labels : list 
        Provide a list of protected foreground labels. When a foreground
        label is in the protected list it means that when a sound event
        matching the label gets added to a soundscape instantiation the
        duration of the source audio file cannot be altered, and the
        duration value that was provided in the specification will be
        ignored. Adding labels to the protected list is useful for sound events
        whose semantic validity would be lost if the sound were trimmed
        before the sound event ends, for example an animal vocalization
        such as a dog bark.
    random_state : int, RandomState instance or None, optional (default=None)
        If int, random_state is the seed used by the random number 
        generator; If RandomState instance, random_state is the random number 
        generator; If None, the random number generator is the RandomState 
        instance used by np.random. Note that if the random state is passed as a 
        RandomState instance, it is passed by reference, not value. This will lead to
        the Scaper object advancing the state of the random state object if you use
        it elsewhere.
    '''

    def __init__(self, duration, fg_path, bg_path, protected_labels=[], random_state=None):
        '''
        Create a Scaper object.

        Parameters
        ----------
        duration : float
            Duration of the soundscape, in seconds.
        fg_path : str or None
            Path to foreground folder. If None, test folder will be used.
            This only makes sense if all events have a library specified.
        bg_path : str or None
            Path to background folder. . If None, test folder will be used.
            This only makes sense if all events have a library specified.
        protected_labels : list 
            Provide a list of protected foreground labels. When a foreground
            label is in the protected list it means that when a sound event
            matching the label gets added to a soundscape instantiation the
            duration of the source audio file cannot be altered, and the
            duration value that was provided in the specification will be
            ignored. Adding labels to the protected list is useful for sound events
            whose semantic validity would be lost if the sound were trimmed
            before the sound event ends, for example an animal vocalization
            such as a dog bark.
        random_state : int, RandomState instance or None, optional (default=None)
            If int, random_state is the seed used by the random number 
            generator; If RandomState instance, random_state is the random number 
            generator; If None, the random number generator is the RandomState 
            instance used by np.random. Note that if the random state is passed as a 
            RandomState instance, it is passed by reference, not value. This will lead to
            the Scaper object advancing the state of the random state object if you use
            it elsewhere.
        '''
        # Duration must be a positive real number
        if np.isrealobj(duration) and duration > 0:
            self.duration = duration
        else:
            raise ScaperError('Duration must be a positive real value')

        # Initialize parameters
        self.sr = 44100
        self.ref_db = -12
        self.n_channels = 1
        self.fade_in_len = 0.01  # 10 ms
        self.fade_out_len = 0.01  # 10 ms

        # Start with empty specifications
        self.fg_spec = []
        self.bg_spec = []

        if fg_path is None:
            # If no foreground path is provided, use the test folder
            fg_path = os.path.join(os.getcwd(), "tests", "data", "audio", "foreground")
        if bg_path is None:
            # If no background path is provided, use the test folder
            bg_path = os.path.join(os.getcwd(), "tests", "data", "audio", "background")

        # Validate paths and set
        expanded_fg_path = os.path.expanduser(fg_path)
        expanded_bg_path = os.path.expanduser(bg_path)
        _validate_folder_path(expanded_fg_path)
        _validate_folder_path(expanded_bg_path)
        self.fg_path = expanded_fg_path
        self.bg_path = expanded_bg_path

        # Populate label lists from folder paths
        self.fg_labels = []
        self.bg_labels = []
        _populate_label_list(self.fg_path, self.fg_labels)
        _populate_label_list(self.bg_path, self.bg_labels)

        # Copy list of protected labels
        self.protected_labels = protected_labels[:]

        # Get random number generator
        self.random_state = _check_random_state(random_state)

    def reset_fg_event_spec(self):
        '''
        Resets the foreground event specification to be an empty list as it is when
        the Scaper object is initialized in the first place. This allows the same
        Scaper object to be used over and over again to generate new soundscapes
        with the same underlying settings (e.g. `ref_db`, `num_channels`, and so on.)

        See Also
        --------
        Scaper.reset_bg_event_spec : Same functionality but resets the background
        event specification instead of the foreground specification.
        '''
        self.fg_spec = []

    def reset_bg_event_spec(self):
        '''
        Resets the background event specification to be an empty list as it is when
        the Scaper object is initialized in the first place. This allows the same
        Scaper object to be used over and over again to generate new soundscapes
        with the same underlying settings (e.g. `ref_db`, `num_channels`, and so on.)

        See Also
        --------
        Scaper.reset_fg_event_spec : Same functionality but resets the foreground
        event specification instead of the foreground specification.
        '''
        self.bg_spec = []

    def set_random_state(self, random_state):
        '''
        Allows the user to set the random state after creating the Scaper object.

        Parameters
        ----------
        random_state : int, RandomState instance or None, optional (default=None)
            If int, random_state is the seed used by the random number 
            generator; If RandomState instance, random_state is the random number 
            generator; If None, the random number generator is the RandomState 
            instance used by np.random.
        '''
        self.random_state = _check_random_state(random_state)

    def add_background(self, label, source_file, source_time, library=None):
        '''
        Add a background recording to the background specification.

        The background duration will be equal to the duration of the
        soundscape ``Scaper.duration`` specified when initializing the Scaper
        object. If the source file is shorter than this duration then it will
        be concatenated to itself as many times as necessary to produce the
        specified duration when calling ``Scaper.generate``.

        Parameters
        ----------
        label : tuple
            Specifies the label of the background. See Notes below for the
            expected format of this tuple and the allowed values.
            NOTE: The label specified by this tuple must match one
            of the labels in the Scaper's background label list
            ``Scaper.bg_labels``. Furthermore, if ``source_file`` is
            specified using "const" (see Notes), then ``label`` must also be
            specified using "const" and its value (see Notes) must
            match the source file's parent folder's name.
        source_file : tuple
            Specifies the audio file to use as the source. See Notes below for
            the expected format of this tuple and the allowed values.
            NOTE: If ``source_file`` is specified using "const" (see Notes),
            then ``label`` must also be specified using "const" and its
            value (see Notes) must match the source file's parent folder's
            name.
        source_time : tuple
            Specifies the desired start time in the source file. See Notes
            below for the expected format of this tuple and the allowed values.
            NOTE: the source time specified by this tuple should be equal to or
            smaller than ``<source file duration> - <soundscape duration>``.
            Larger values will be automatically changed to fulfill this
            requirement when calling ``Scaper.generate``.
        library : str or None
            Specifies the library from which the background is taken. If not None, 
            audio is sampled from the library folder instead of the
            background folder. This is useful for sharing samples between
            different projects.

        Notes
        -----
        Each parameter of this function is set by passing a distribution
        tuple, whose first item is always the distribution name and subsequent
        items are distribution specific. The supported distribution tuples are:

        * ``("const", value)`` : a constant, given by ``value``.
        * ``("choose", valuelist)`` : choose a value from
          ``valuelist`` at random (uniformly). The ``label`` and
          ``source_file`` parameters also support providing an empty
          ``valuelist`` i.e. ``("choose", [])``, in which case the
          value will be chosen at random from all available labels or files
          as determined automatically by Scaper by examining the file
          structure of ``bg_path`` provided during initialization.
        * ``("choose_weighted", valuelist, probabilities)``: choose a value
          from ``valuelist`` via weighted sampling, where the probability of
          sampling ``valuelist[i]`` is given by ``probabilities[i]``. The
          ``probabilities`` list must contain a valid probability distribution,
          i.e., all values must be in the range [0, 1] and sum to one.
        * ``("uniform", min_value, max_value)`` : sample a random
          value from a uniform distribution between ``min_value``
          and ``max_value``.
        * ``("normal", mean, stddev)`` : sample a random value from a
          normal distribution defined by its mean ``mean`` and
          standard deviation ``stddev``.
        * ``("truncnorm", mean, stddev, min, max)``: sapmle a random value from 
        a truncated normal distribution defined by its mean ``mean``, standard
        deviation ``stddev``, minimum value ``min`` and maximum value ``max``.

        IMPORTANT: not all parameters support all distribution tuples. In
        particular, ``label`` and ``source_file`` only support ``"const"``,
        ``"choose"`` and ``choose_weighted``, whereas ``source_time`` supports 
        all distribution tuples. As noted above, only ``label`` and ``source_file`` 
        support providing an empty ``valuelist`` with ``"choose"``.
        '''

        # These values are fixed for the background sound
        event_time = ("const", 0)
        event_duration = ("const", self.duration)
        snr = ("const", 0)
        role = 'background'
        pitch_shift = None
        time_stretch = None

        # get allowed labels
        if library is None:
            allowed_labels = self.bg_labels
        else:
            # get allowed labels from the library
            _validate_library(library)
            allowed_labels = []
            _populate_label_list(library, allowed_labels)

        # Validate parameter format and values
        _validate_event(label, source_file, source_time, event_time,
                        event_duration, snr, allowed_labels, None, None, None, library)

        # Create background sound event
        bg_event = EventSpec(label=label,
                             source_file=source_file,
                             source_time=source_time,
                             event_time=event_time,
                             event_duration=event_duration,
                             snr=snr,
                             role=role,
                             pitch_shift=pitch_shift,
                             time_stretch=time_stretch,
                             position=None,
                             library=library,
                             speaker=None,
                             text=None)

        # Add event to background spec
        self.bg_spec.append(bg_event)


    def add_event(self, label, source_file, source_time, event_time,
                  event_duration, snr, pitch_shift, time_stretch, position=None, library=None, speaker=None, text=None):
        '''
        Add a foreground sound event to the foreground specification.

        Parameters
        ----------
        label : tuple
            Specifies the label of the sound event. See Notes below for the
            expected format of this tuple and the allowed values.
            NOTE: The label specified by this tuple must match one
            of the labels in the Scaper's foreground label list
            ``Scaper.fg_labels``. Furthermore, if ``source_file`` is
            specified using "const" (see Notes), then ``label`` must also be
            specified using "const" and its ``value `` (see Notes) must
            match the source file's parent folder's name.
        source_file : tuple
            Specifies the audio file to use as the source. See Notes below for
            the expected format of this tuple and the allowed values.
            NOTE: If ``source_file`` is specified using "const" (see Notes),
            then ``label`` must also be specified using "const" and its
            ``value`` (see Notes) must match the source file's parent
            folder's name.
        source_time : tuple
            Specifies the desired start time in the source file. See Notes
            below for the expected format of this tuple and the allowed values.
            NOTE: the source time specified by this tuple should be equal to or
            smaller than ``<source file duration> - event_duration``. Larger
            values will be automatically changed to fulfill this requirement
            when calling ``Scaper.generate``.
        event_time : tuple
            Specifies the desired start time of the event in the soundscape.
            See Notes below for the expected format of this tuple and the
            allowed values.
            NOTE: The value specified by this tuple should be equal to or
            smaller than ``<soundscapes duration> - event_duration``, and
            larger values will be automatically changed to fulfill this
            requirement when calling ``Scaper.generate``.
        event_duration : tuple
            Specifies the desired duration of the event. See Notes below for
            the expected format of this tuple and the allowed values.
            NOTE: The value specified by this tuple should be equal to or
            smaller than the source file's duration, and larger values will be
            automatically changed to fulfill this requirement when calling
            ``Scaper.generate``.
        snr : tuple
            Specifies the desired signal to noise ratio (SNR) between the event
            and the background. See Notes below for the expected format of
            this tuple and the allowed values.
        pitch_shift : tuple
            Specifies the number of semitones to shift the event by. None means
            no pitch shift.
        time_stretch: tuple
            Specifies the time stretch factor (value>1 will make it slower and
            longer, value<1 will makes it faster and shorter).
        position : str or None
            Specifies the type of the event. This is a string that can be used
            to categorize the events
        library : str or None
            Specifies the library from which the event is taken. If not None, 
            audio is sampled from the library folder instead of the
            foreground folder. This is useful for sharing samples between
            different projects.
        speaker : str or None
            Specifies the speaker of the event. This is a string that can be used
            to identify the speaker.
        text : str or None
            Used for audio with speech content. This is a string that can be used
            to save the content.

        Notes
        -----
        Each parameter of this function is set by passing a distribution
        tuple, whose first item is always the distribution name and subsequent
        items are distribution specific. The supported distribution tuples are:

        * ``("const", value)`` : a constant, given by ``value``.
        * ``("choose", valuelist)`` : choose a value from
          ``valuelist`` at random (uniformly). The ``label`` and
          ``source_file`` parameters also support providing an empty
          ``valuelist`` i.e. ``("choose", [])``, in which case the
          value will be chosen at random from all available labels or
          source files as determined automatically by Scaper by examining
          the file structure of ``fg_path`` provided during
          initialization.
        * ``("choose_weighted", valuelist, probabilities)``: choose a value
          from ``valuelist`` via weighted sampling, where the probability of
          sampling ``valuelist[i]`` is given by ``probabilities[i]``. The
          ``probabilities`` list must contain a valid probability distribution,
          i.e., all values must be in the range [0, 1] and sum to one.
        * ``("uniform", min_value, max_value)`` : sample a random
          value from a uniform distribution between ``min_value``
          and ``max_value`` (including ``max_value``).
        * ``("normal", mean, stddev)`` : sample a random value from a
          normal distribution defined by its mean ``mean`` and
          standard deviation ``stddev``.
        * ``("truncnorm", mean, stddev, min, max)``: sapmle a random value from 
        a truncated normal distribution defined by its mean ``mean``, standard
        deviation ``stddev``, minimum value ``min`` and maximum value ``max``.

        IMPORTANT: not all parameters support all distribution tuples. In
        particular, ``label`` and ``source_file`` only support ``"const"``,
        ``"choose"`` and ``"choose_weighted"``, whereas the remaining parameters 
        support all distribution tuples. As noted above, only ``label`` and 
        ``source_file`` support providing an empty ``valuelist`` with ``"choose"``.

        See Also
        --------
        _validate_event : Check that event parameter values are valid.

        Scaper.generate : Generate a soundscape based on the current
            specification and save to disk as both an audio file and a JAMS file
            describing the soundscape.

        '''

        # get allowed labels
        if library is None:
            allowed_labels = self.fg_labels
        else:
            # get allowed labels from the library
            _validate_library(library)
            allowed_labels = []
            _populate_label_list(library, allowed_labels)
        

        # SAFETY CHECKS
        _validate_event(label, source_file, source_time, event_time,
                        event_duration, snr, allowed_labels, pitch_shift,
                        time_stretch, position, library)

        # Create event
        event = EventSpec(label=label,
                          source_file=source_file,
                          source_time=source_time,
                          event_time=event_time,
                          event_duration=event_duration,
                          snr=snr,
                          role='foreground',
                          pitch_shift=pitch_shift,
                          time_stretch=time_stretch,
                          position=position,
                          library=library,
                          speaker=speaker,
                          text=text)

        # Add event to foreground specification
        self.fg_spec.append(event)

    def _instantiate_event(self, event, isbackground=False,
                           allow_repeated_label=True,
                           allow_repeated_source=True,
                           allowed_labels=[],
                           used_labels=[],
                           used_source_files=[],
                           disable_instantiation_warnings=False,
                           disable_event_looping=True):
        '''
        Instantiate an event specification.

        Given an event specification containing distribution tuples,
        instantiate the event, i.e. samples values for the label, source_file,
        source_time, event_time, event_duration and snr from their respective
        distribution tuples, and return the sampled values in as a new event
        specification.

        Parameters
        ----------
        event : EventSpec
            Event specification containing distribution tuples.
        isbackground : bool
            Flag indicating whether the event to instantiate is a background
            event or not (False implies it is a foreground event).
        allow_repeated_label : bool
            When True (default) any label can be used, including a label that
            has already been used for another event. When False, only a label
            that is not already in ``used_labels`` can be selected.
        allow_repeated_source : bool
            When True (default) any source file matching the selected label can
            be used, including a source file that has already been used for
            another event. When False, only a source file that is not already
            in ``used_source_files`` can be selected.
        used_labels : list
            List labels that have already been used in the current soundscape
            instantiation. The label selected for instantiating the event will
            be appended to this list unless its already in it.
        used_source_files : list
            List of full paths to source files that have already been used in
            the current soundscape instantiation. The source file selected for
            instantiating the event will be appended to this list unless its
            already in it.
        disable_instantiation_warnings : bool
            When True (default is False), warnings stemming from event
            instantiation (primarily about automatic duration adjustments) are
            disabled. Not recommended other than for testing purposes.

        Returns
        -------
        instantiated_event : EventSpec
            Event specification containing values sampled from the distribution
            tuples of the input event specification.

        Raises
        ------
        ScaperError
            If allow_repeated_source is False and there is no valid source file
            to select.

        '''
        # print("Instantiating event: ", event)

        if event.library is None:
            if isbackground:
                file_path = self.bg_path
            else:
                file_path = self.fg_path
        else:
            file_path = event.library

        # print("File path: ", file_path)

        # determine label
        # special case: choose tuple with empty list
        if event.label[0] == "choose" and not event.label[1]:
            label_tuple = list(event.label)
            label_tuple[1] = allowed_labels
            label_tuple = tuple(label_tuple)
        else:
            label_tuple = event.label
        label = _get_value_from_dist(label_tuple, self.random_state)

        # Make sure we can use this label
        if (not allow_repeated_label) and (label in used_labels):
            if (len(allowed_labels) == len(used_labels) or
                    label_tuple[0] == "const"):
                raise ScaperError(
                    "Cannot instantiate event {:s}: all available labels "
                    "have already been used and "
                    "allow_repeated_label=False.".format(label))
            else:
                while label in used_labels:
                    label = _get_value_from_dist(label_tuple, self.random_state)

        # Update the used labels list
        if label not in used_labels:
            used_labels.append(label)

        # determine source file
        # special case: choose tuple with empty list
        if event.source_file[0] == "choose" and not event.source_file[1]:
            source_files = _get_sorted_files(os.path.join(file_path, label))
            source_file_tuple = list(event.source_file)
            source_file_tuple[1] = source_files
            source_file_tuple = tuple(source_file_tuple)
        else:
            if not event.library is None:
                source_with_path = os.path.join(file_path, label, event.source_file[1])
                source_file_tuple = (event.source_file[0],source_with_path)
            else:
                source_file_tuple = event.source_file

        source_file = _get_value_from_dist(source_file_tuple, self.random_state)

        # Make sure we can use this source file
        if (not allow_repeated_source) and (source_file in used_source_files):
            source_files = _get_sorted_files(os.path.join(file_path, label))
            if (len(source_files) == len(used_source_files) or
                    source_file_tuple[0] == "const"):
                raise ScaperError(
                    "Cannot instantiate event {:s}: all available source "
                    "files have already been used and "
                    "allow_repeated_source=False.".format(label))
            else:
                while source_file in used_source_files:
                    source_file = _get_value_from_dist(source_file_tuple, self.random_state)

        # Update the used source files list
        if source_file not in used_source_files:
            used_source_files.append(source_file)

        # Get the duration of the source audio file
        source_duration = soundfile.info(source_file).duration

        # If this is a background event, the event duration is the 
        # duration of the soundscape.
        if isbackground:
            event_duration = self.duration
        # If the foreground event's label is in the protected list, use the
        # source file's duration without modification.
        elif label in self.protected_labels:
            event_duration = source_duration
        else:
            # determine event duration
            # For background events the duration is fixed to self.duration
            # (which must be > 0), but for foreground events it could
            # potentially be non-positive, hence the loop.
            event_duration = -np.inf
            while event_duration <= 0:
                event_duration = _get_value_from_dist(
                    event.event_duration, self.random_state
                )

            # Check if chosen event duration is longer than the duration of the
            # selected source file, if so adjust the event duration.
            if (event_duration > source_duration):
                # event duration is longer than source: loop the source (default)
                # or display a warning when looping is disabled
                if disable_event_looping:
                    old_duration = event_duration  # for warning
                    event_duration = source_duration
                    if not disable_instantiation_warnings:
                        warnings.warn(
                            "{:s} event duration ({:.2f}) is greater that source "
                            "duration ({:.2f}), changing to {:.2f}".format(
                                label, old_duration, source_duration, event_duration),
                            ScaperWarning)

        # Get time stretch value
        if event.time_stretch is None:
            time_stretch = None
            event_duration_stretched = event_duration
        else:
            time_stretch = -np.inf
            while time_stretch <= 0:
                time_stretch = _get_value_from_dist(
                    event.time_stretch, self.random_state
                )
            # compute duration after stretching
            event_duration_stretched = event_duration * time_stretch

        # If the event duration is longer than the soundscape we can trim it
        # without losing validity (since the event will end when the soundscape
        # ends).
        if time_stretch is None:
            if event_duration > self.duration:
                old_duration = event_duration  # for warning
                event_duration = self.duration
                if not disable_instantiation_warnings:
                    warnings.warn(
                        "{:s} event duration ({:.2f}) is greater than the "
                        "soundscape duration ({:.2f}), changing to "
                        "{:.2f}".format(
                            label, old_duration, self.duration, self.duration),
                        ScaperWarning)
        else:
            if event_duration_stretched > self.duration:
                old_duration = event_duration  # for warning
                event_duration = self.duration / float(time_stretch)
                event_duration_stretched = self.duration
                if not disable_instantiation_warnings:
                    warnings.warn(
                        "{:s} event duration ({:.2f}) with stretch factor "
                        "{:.2f} gives {:.2f} which is greater than the "
                        "soundscape duration ({:.2f}), changing to "
                        "{:.2f} ({:.2f} after time stretching)".format(
                            label, old_duration, time_stretch,
                            old_duration * time_stretch, self.duration,
                            event_duration, event_duration_stretched),
                        ScaperWarning)

        # Modify event.source_time so that sampling from the source time distribution
        # stays within the bounds of the audio file - event_duration. This allows users 
        # to sample from anywhere in a source file without knowing the exact duration 
        # of every source file. Only modify if label is not in protected labels.
        if label not in self.protected_labels:
            tuple_still_invalid = False
            modified_source_time, warn = _ensure_satisfiable_source_time_tuple(
                event.source_time, source_duration, event_duration
            )
            
            # determine source time and also check again just in case (for normal dist).
            # if it happens again, just use the old method.
            source_time = -np.inf
            while source_time < 0:
                source_time = _get_value_from_dist(
                    modified_source_time, self.random_state)
                if source_time + event_duration > source_duration:
                    source_time = max(0, source_duration - event_duration)
                    warn = True
                    tuple_still_invalid = True

            if warn and not disable_instantiation_warnings:
                old_source_time = ', '.join(map(str, event.source_time))
                new_source_time = ', '.join(map(str, modified_source_time))
                if not tuple_still_invalid:
                    warnings.warn(
                        "{:s} source time tuple ({:s}) could not be satisfied given "
                        "source duration ({:.2f}) and event duration ({:.2f}), "
                        "source time tuple changed to ({:s})".format(
                            label, old_source_time, source_duration, 
                            event_duration, new_source_time), 
                        ScaperWarning)
                else:
                    warnings.warn(
                        "{:s} source time tuple ({:s}) could not be satisfied given "
                        "source duration ({:.2f}) and event duration ({:.2f}), "
                        "source time tuple changed to ({:s}) but was still not "
                        "satisfiable, likely due to using 'normal' distribution with "
                        "bounds too close to the start or end of the audio file".format(
                            label, old_source_time, source_duration, 
                            event_duration, new_source_time),
                        ScaperWarning)
        else:
            source_time = 0.0

        # determine event time
        # for background events the event time is fixed to 0, but for
        # foreground events it's not.
        event_time = -np.inf
        while event_time < 0:
            event_time = _get_value_from_dist(
                event.event_time, self.random_state
            )

        # Make sure the selected event time + event duration are is not greater
        # than the total duration of the soundscape, if it is adjust the event
        # time. This means event duration takes precedence over the event
        # start time.
        if time_stretch is None:
            if event_time + event_duration > self.duration:
                old_event_time = event_time
                event_time = self.duration - event_duration
                if not disable_instantiation_warnings:
                    warnings.warn(
                        '{:s} event time ({:.2f}) is too great given event '
                        'duration ({:.2f}) and soundscape duration ({:.2f}), '
                        'changed to {:.2f}.'.format(
                            label, old_event_time, event_duration,
                            self.duration, event_time),
                        ScaperWarning)
        else:
            if event_time + event_duration_stretched > self.duration:
                old_event_time = event_time
                event_time = self.duration - event_duration_stretched
                if not disable_instantiation_warnings:
                    warnings.warn(
                        '{:s} event time ({:.2f}) is too great given '
                        'stretched event duration ({:.2f}) and soundscape '
                        'duration ({:.2f}), changed to {:.2f}.'.format(
                            label, old_event_time, event_duration_stretched,
                            self.duration, event_time),
                        ScaperWarning)

        # determine snr
        snr = _get_value_from_dist(event.snr, self.random_state)

        # get role (which can only take "foreground" or "background" and
        # is set internally, not by the user).
        role = event.role

        # determine pitch_shift
        if event.pitch_shift is not None:
            pitch_shift = _get_value_from_dist(event.pitch_shift, self.random_state)
        else:
            pitch_shift = None

        # pack up instantiated values in an EventSpec
        instantiated_event = EventSpec(label=label,
                                       source_file=source_file,
                                       source_time=source_time,
                                       event_time=event_time,
                                       event_duration=event_duration,
                                       snr=snr,
                                       role=role,
                                       pitch_shift=pitch_shift,
                                       time_stretch=time_stretch,
                                       position=event.position,
                                       library=event.library,
                                       speaker=event.speaker,
                                       text=event.text)
        # Return
        return instantiated_event

    def _instantiate(self, allow_repeated_label=True,
                     allow_repeated_source=True, reverb=None,
                     disable_instantiation_warnings=False,
                     disable_event_looping=True):
        '''
        Instantiate a specific soundscape in JAMS format based on the current
        specification.

        Any non-deterministic event values (i.e. distribution tuples) will be
        sampled randomly from based on the distribution parameters.

        Parameters
        ----------
        allow_repeated_label : bool
            When True (default) the same label can be used more than once
            in a soundscape instantiation. When False every label can
            only be used once.
        allow_repeated_source : bool
            When True (default) the same source file can be used more than once
            in a soundscape instantiation. When False every source file can
            only be used once.
        reverb : float or None
            Has no effect on this function other than being documented in the
            instantiated annotation's sandbox. Passed by ``Scaper.generate``.

        disable_instantiation_warnings : bool
            When True (default is False), warnings stemming from event
            instantiation (primarily about automatic duration adjustments) are
            disabled. Not recommended other than for testing purposes.

        Returns
        -------
        jam : JAMS object
            A JAMS object containing a scaper annotation representing the
            instantiated soundscape.

        See Also
        --------
        Scaper.generate

        '''
        import jams
        
        jam = jams.JAMS()
        ann = jams.Annotation(namespace='scaper')

        # Set annotation duration (might be changed later due to cropping)
        ann.duration = self.duration

        # INSTANTIATE BACKGROUND AND FOREGROUND EVENTS AND ADD TO ANNOTATION
        # NOTE: logic for instantiating bg and fg events is NOT the same.

        # Get the set of libraries used in the background and foreground
        background_libraries = set(
            [event.library for event in self.bg_spec if event.library is not None])
        foreground_libraries = set(
            [event.library for event in self.fg_spec if event.library is not None])
        
        # get the labels for each library
        bg_labels_available = {"default": self.bg_labels}
        fg_labels_available = {"default": self.fg_labels}
        bg_labels_used = {"default": []}
        fg_labels_used = {"default": []}
        bg_files_used = {"default": []}
        fg_files_used = {"default": []}
        
        for library in background_libraries:
            labels = []
            for root, dirs, files in os.walk(library):
                if root == library:
                    labels.extend(dirs)
            bg_labels_available[library] = labels
            bg_labels_used[library] = []
            bg_files_used[library] = []
        for library in foreground_libraries:
            labels = []
            for root, dirs, files in os.walk(library):
                if root == library:
                    labels.extend(dirs)
            fg_labels_available[library] = labels
            fg_labels_used[library] = []
            fg_files_used[library] = []
        
        # Print the labels for each library
        # for library, labels in bg_labels_available.items():
        #     print(f"Background library {library} labels: {labels}")
        # for library, labels in fg_labels_available.items():
        #     print(f"Foreground library {library} labels: {labels}")

        # Add background sounds
        for event in self.bg_spec:
            library = event.library if event.library is not None else "default"
            value = self._instantiate_event(
                event,
                isbackground=True,
                allow_repeated_label=allow_repeated_label,
                allow_repeated_source=allow_repeated_source,
                allowed_labels=bg_labels_available[library],
                used_labels=bg_labels_used[library],
                used_source_files=bg_files_used[library],
                disable_instantiation_warnings=disable_instantiation_warnings
            )

            # Note: add_background doesn't allow to set a time_stretch, i.e.
            # it's hardcoded to time_stretch=None, so we don't need to check
            # if value.time_stretch is not None, since it always will be.
            ann.append(time=value.event_time,
                       duration=value.event_duration,
                       value=value._asdict(),
                       confidence=1.0)

        # Add foreground events
        for event in self.fg_spec:
            library = event.library if event.library is not None else "default"
            value = self._instantiate_event(
                event,
                isbackground=False,
                allow_repeated_label=allow_repeated_label,
                allow_repeated_source=allow_repeated_source,
                allowed_labels=fg_labels_available[library],
                used_labels=fg_labels_available[library],
                used_source_files=fg_files_used[library],
                disable_instantiation_warnings=disable_instantiation_warnings,
                disable_event_looping=disable_event_looping
                )

            if value.time_stretch is not None:
                event_duration_stretched = (
                    value.event_duration * value.time_stretch)
            else:
                event_duration_stretched = value.event_duration

            ann.append(time=value.event_time,
                       duration=event_duration_stretched,
                       value=value._asdict(),
                       confidence=1.0)

        # Compute max polyphony
        poly = max_polyphony(ann)

        # Compute the number of foreground events
        n_events = len(self.fg_spec)

        # Compute gini
        gini = polyphony_gini(ann)

        # Add specs and other info to sandbox
        ann.sandbox.scaper = jams.Sandbox(
            duration=self.duration,
            original_duration=self.duration,
            fg_path=self.fg_path,
            bg_path=self.bg_path,
            fg_spec=self.fg_spec,
            bg_spec=self.bg_spec,
            fg_labels=self.fg_labels,
            bg_labels=self.bg_labels,
            protected_labels=self.protected_labels,
            sr=self.sr,
            ref_db=self.ref_db,
            n_channels=self.n_channels,
            fade_in_len=self.fade_in_len,
            fade_out_len=self.fade_out_len,
            n_events=n_events,
            polyphony_max=poly,
            polyphony_gini=gini,
            allow_repeated_label=allow_repeated_label,
            allow_repeated_source=allow_repeated_source,
            reverb=reverb,
            scaper_version=scaper_version,
            soundscape_audio_path=None,
            isolated_events_audio_path=[],
            # Initialize missing generate parameters
            audio_path=None,
            jams_path=None,
            fix_clipping=None,
            peak_normalization=None,
            save_isolated_events=None,
            isolated_events_path=None,
            disable_sox_warnings=None,
            no_audio=None,
            txt_path=None,
            txt_sep=None,
            disable_instantiation_warnings=None,
            peak_normalization_scale_factor=None,
            ref_db_change=None,
            ref_db_generated=None,
            save_isolated_positions=False,
            isolated_positions_path=None,
            )

        # Add annotation to jams
        jam.annotations.append(ann)

        # Set jam metadata
        jam.file_metadata.duration = ann.duration

        # Return
        return jam

    def _generate_audio(self,
                        audio_path,
                        ann,
                        reverb=None,
                        fix_clipping=False,
                        peak_normalization=False,
                        quick_pitch_time=False,
                        save_isolated_events=False,
                        isolated_events_path=None,
                        disable_event_looping=True,
                        disable_sox_warnings=True,
                        save_isolated_positions=False,
                        isolated_positions_path=None):
        '''
        Generate audio based on a scaper annotation and save to disk.

        Parameters
        ----------
        audio_path : str
            Path for saving soundscape audio file.
        ann : jams.Annotation
            Annotation of the scaper namespace.
        reverb : float or None
            Amount of reverb to apply to the generated soundscape between 0
            (no reverberation) and 1 (maximum reverberation). Use None
            (default) to prevent the soundscape from going through the reverb
            module at all.
        fix_clipping: bool
            When True (default=False), checks the soundscape audio for clipping
            (abs(sample) > 1). If so, the soundscape waveform is peak normalized,
            i.e., scaled such that max(abs(soundscape_audio)) = 1. The audio for
            each isolated event is also scaled accordingly. Note: this will change
            the actual value of `ref_db` in the generated audio. The scaling
            factor that was used is returned.
        peak_normalization : bool
            When True (default=False), normalize the generated soundscape audio
            such that max(abs(soundscape_audio)) = 1. The audio for
            each isolated event is also scaled accordingly. Note: this will change
            the actual value of `ref_db` in the generated audio. The scaling
            factor that was used is returned.
        quick_pitch_time : bool
            When True (default=False), time stretching and pitch shifting will be
            applied with `quick=True`. This is much faster but the resultant 
            audio is generally of lower audio quality.
        save_isolated_events : bool
            If True, this will save the isolated foreground events and
            backgrounds in a directory adjacent to the generated soundscape
            mixture, or to the path defined by `isolated_events_path`. The
            audio of the isolated events sum up to the mixture if reverb is not
            applied. Isolated events can be found (by default) at
            `<audio_outfile parent folder>/<audio_outfile name>_events`.
            Isolated event file names follow the pattern: `<role><idx>_<label>`,
            where idx is the index of the isolated event in self.fg_spec or
            self.bg_spec (this allows events of the same label to be added more
            than once to the soundscape without breaking things). Role is
            "background" or "foreground". For example: `foreground0_siren.wav`
            or `background0_park.wav`.
        isolated_events_path : str
            Path to folder for saving isolated events. If None, defaults to
            `<audio_path parent folder>/<audio_path name>_events`.
        disable_sox_warnings : bool
            When True (default), warnings from the pysox module are suppressed
            unless their level is ``'CRITICAL'``.
        save_isolated_positions : bool
            See description at generate.
        isolated_positions_path : str
            See description at generate.

        Returns
        -------
        soundscape_audio : np.ndarray
            The audio samples of the generated soundscape
        event_audio_list: list
            A list of np.ndarrays containing the audio samples of every
            individual background and foreground sound event. Events are listed
            in the same order in which they appear in the jams annotations data
            list, and can be matched with:
            `for obs, event_audio in zip(ann.data, event_audio_list): ...`.
        scale_factor : float
            If peak_normalization is True, or fix_clipping is True and the
            soundscape audio needs to be scaled to avoid clipping, scale_factor
            is the value used to scale the soundscape audio and the audio of the
            isolated events. Otherwise will return 1.0.
        ref_db_change : float
            The change (in dB) to the soundscape audio's ref_db if peak
            normalization is applied to fix clipping or because the user
            specified it. Otherwise will return 0.

        Raises
        ------
        ScaperError
            If annotation is not of the scpaper namespace.

        See Also
        --------
        Scaper.generate

        '''
        # Check that the annotation is of the scaper namespace
        if ann.namespace != 'scaper':
            raise ScaperError(
                'Annotation namespace must be scaper, found: {:s}'.format(
                    ann.namespace))

        # disable sox warnings
        if disable_sox_warnings:
            temp_logging_level = 'CRITICAL'  # only critical messages please
        else:
            temp_logging_level = logging.getLogger().level

        # List for storing all generated audio (one array for every event)
        soundscape_audio = None
        event_audio_list = []
        scale_factor = 1.0
        ref_db_change = 0

        with _set_temp_logging_level(temp_logging_level):

            isolated_events_audio_path = []
            duration_in_samples = int(self.duration * self.sr)

            for i, e in enumerate(ann.data):
                # Concatenate if necessary.
                source_duration = soundfile.info(e.value['source_file']).duration
                ntiles = int(
                    max(self.duration // source_duration + 1, 1))
                
                if e.value['role'] == 'background':
                    # Create transformer
                    tfm = sox.Transformer()
                    # Ensure consistent sampling rate and channels
                    # Need both a convert operation (to do the conversion),
                    # and set_output_format (to have sox interpret the output
                    # correctly).
                    tfm.convert(
                        samplerate=self.sr,
                        n_channels=self.n_channels,
                        bitdepth=None
                    )
                    tfm.set_output_format(
                        rate=self.sr,
                        channels=self.n_channels
                    )

                    # PROCESS BEFORE COMPUTING LUFS
                    tmpfiles_internal = []
                    with _close_temp_files(tmpfiles_internal):
                        # create internal tmpfile
                        tmpfiles_internal.append(
                            tempfile.NamedTemporaryFile(
                                suffix='.wav', delete=False))
                        # read in background off disk, using start and stop 
                        # to only read the necessary audio
                        event_sr = soundfile.info(e.value['source_file']).samplerate
                        start = int(e.value['source_time'] * event_sr)
                        stop = int((e.value['source_time'] + e.value['event_duration']) * event_sr)
                        event_audio, event_sr = soundfile.read(
                            e.value['source_file'], always_2d=True,
                            start=start, stop=stop)
                        # tile the background along the appropriate dimensions
                        event_audio = np.tile(event_audio, (ntiles, 1))
                        event_audio = event_audio[:stop]
                        event_audio = tfm.build_array(
                            input_array=event_audio,
                            sample_rate_in=event_sr
                        )
                        event_audio = event_audio.reshape(-1, self.n_channels)
                        # NOW compute LUFS
                        bg_lufs = get_integrated_lufs(event_audio, self.sr)

                        # Normalize background to reference DB.
                        gain = self.ref_db - bg_lufs
                        event_audio = np.exp(gain * np.log(10) / 20) * event_audio

                        event_audio_list.append(event_audio[:duration_in_samples])

                elif e.value['role'] == 'foreground':
                    # Create transformer
                    tfm = sox.Transformer()
                    # Ensure consistent sampling rate and channels
                    # Need both a convert operation (to do the conversion),
                    # and set_output_format (to have sox interpret the output
                    # correctly).
                    tfm.convert(
                        samplerate=self.sr,
                        n_channels=self.n_channels,
                        bitdepth=None
                    )
                    tfm.set_output_format(
                        rate=self.sr,
                        channels=self.n_channels
                    )

                    # Pitch shift
                    if e.value['pitch_shift'] is not None:
                        tfm.pitch(e.value['pitch_shift'], quick=quick_pitch_time)

                    # Time stretch
                    if e.value['time_stretch'] is not None:
                        factor = 1.0 / float(e.value['time_stretch'])
                        tfm.tempo(factor, audio_type='s', quick=quick_pitch_time)

                    # PROCESS BEFORE COMPUTING LUFS
                    tmpfiles_internal = []
                    with _close_temp_files(tmpfiles_internal):
                        # create internal tmpfile
                        tmpfiles_internal.append(
                            tempfile.NamedTemporaryFile(
                                suffix='.wav', delete=False))
                        
                        # synthesize edited foreground sound event, 
                        # doing the trim via soundfile
                        event_sr = soundfile.info(e.value['source_file']).samplerate
                        start = int(e.value['source_time'] * event_sr)
                        stop = int((e.value['source_time'] + e.value['event_duration']) * event_sr)
                        event_audio, event_sr = soundfile.read(
                            e.value['source_file'], always_2d=True,
                            start=start, stop=stop)
                        # tile the event along the appropriate dimensions
                        if not disable_event_looping:
                            event_audio = np.tile(event_audio, (ntiles, 1))
                            event_audio = event_audio[:stop]
                        event_audio = tfm.build_array(
                            input_array=event_audio,
                            sample_rate_in=event_sr
                        )
                        event_audio = event_audio.reshape(-1, self.n_channels)
                        
                        # NOW compute LUFS
                        fg_lufs = get_integrated_lufs(event_audio, self.sr)

                        # Normalize to specified SNR with respect to
                        # background
                        gain = self.ref_db + e.value['snr'] - fg_lufs
                        event_audio = np.exp(gain * np.log(10) / 20) * event_audio

                        # Apply short fade in and out
                        # (avoid unnatural sound onsets/offsets)
                        if self.fade_in_len > 0:
                            fade_in_samples =  int(self.fade_in_len * self.sr)
                            fade_in_window = np.sin(np.linspace(0, np.pi / 2, fade_in_samples))[..., None]
                            event_audio[:fade_in_samples] *= fade_in_window

                        if self.fade_out_len > 0:
                            fade_out_samples = int(self.fade_out_len * self.sr)
                            fade_out_window = np.sin(np.linspace(np.pi / 2, 0, fade_out_samples))[..., None]
                            event_audio[-fade_out_samples:] *= fade_out_window

                        # Pad with silence before/after event to match the
                        # soundscape duration
                        prepad = int(self.sr * e.value['event_time'])
                        postpad = max(0, duration_in_samples - (event_audio.shape[0] + prepad))
                        event_audio = np.pad(event_audio, ((prepad, postpad), (0, 0)), 
                            mode='constant', constant_values=(0, 0))
                        event_audio = event_audio[:duration_in_samples]

                        event_audio_list.append(event_audio[:duration_in_samples])
                else:
                    raise ScaperError(
                        'Unsupported event role: {:s}'.format(
                            e.value['role']))

            # Finally combine all the files and optionally apply reverb.
            # If there are no events, throw a warning.
            if len(event_audio_list) == 0:
                warnings.warn(
                    "No events to synthesize (silent soundscape), no audio "
                    "generated.", ScaperWarning)
            else:                        

                # Sum all events to get soundscape audio
                soundscape_audio = sum(event_audio_list)

                # Check for clipping and fix [optional]
                max_sample = np.max(np.abs(soundscape_audio))
                clipping = max_sample > 1
                if clipping:
                    warnings.warn('Soundscape audio is clipping!',
                                  ScaperWarning)

                if peak_normalization or (clipping and fix_clipping):

                    # normalize soundscape audio and scale event audio
                    soundscape_audio, event_audio_list, scale_factor = \
                        peak_normalize(soundscape_audio, event_audio_list)

                    ref_db_change = 20 * np.log10(scale_factor)

                    if clipping and fix_clipping:
                        warnings.warn(
                            'Peak normalization applied to fix clipping with '
                            'scale factor = {}. The actual ref_db of the '
                            'generated soundscape audio will change by '
                            'approximately {:.2f}dB with respect to the target '
                            'ref_db of {})'.format(
                                scale_factor, ref_db_change, self.ref_db),
                            ScaperWarning)

                    if scale_factor < 0.05:
                        warnings.warn(
                            'Scale factor for peak normalization is extreme '
                            '(<0.05), event SNR values in the generated soundscape '
                            'audio may not perfectly match their specified values.',
                            ScaperWarning
                        )

                # Optionally apply reverb
                # NOTE: must apply AFTER peak normalization: applying reverb
                # to a clipping signal with sox and then normalizing doesn't
                # work as one would hope.
                if reverb is not None:
                    tfm = sox.Transformer()
                    tfm.reverb(reverberance=reverb * 100)
                    soundscape_audio = tfm.build_array(
                        input_array=soundscape_audio,
                        sample_rate_in=self.sr,
                    )

                # Reshape to ensure data are 2d
                soundscape_audio = soundscape_audio.reshape(-1, self.n_channels)

                # Optionally save soundscape audio to disk
                if audio_path is not None:
                    soundfile.write(audio_path, soundscape_audio, self.sr,
                                    subtype='PCM_32')

                # Optionally save isolated events to disk
                if save_isolated_events:
                    base, ext = os.path.splitext(audio_path)
                    if isolated_events_path is None:
                        event_folder = '{:s}_events'.format(base)
                    else:
                        event_folder = isolated_events_path

                    os.makedirs(event_folder, exist_ok=True)

                    iso_idx = 0
                    role_counter = {'background': 0, 'foreground': 0}
                    for i, e in enumerate(ann.data):
                        _role_count = role_counter[e.value['role']]
                        event_audio_path = os.path.join(
                            event_folder,
                            '{:s}{:d}_{:s}{:s}'.format(
                                e.value['role'], _role_count, e.value['label'], ext))
                        role_counter[e.value['role']] += 1

                        soundfile.write(event_audio_path, event_audio_list[iso_idx], self.sr, subtype='PCM_32')
                        isolated_events_audio_path.append(event_audio_path)
                        iso_idx += 1

                    # TODO what do we do in this case? for now throw a warning
                    if reverb is not None:
                        warnings.warn(
                            "Reverb is on and save_isolated_events is True. Reverberation "
                            "is applied to the mixture but not output "
                            "source files. In this case the sum of the "
                            "audio of the isolated events will not add up to the "
                            "mixture", ScaperWarning)
               
        # Document output paths
        # TODO: this is redundant with audio_path and isolated_events_path that
        #  are also stored in ann.sandbox.scaper. For now we're keeping these
        #  here for now for backwards compatibility e.g. with FUSS. Eventually
        #  we should remove these two lines and consolidate how/where JAMS
        #  metadata is stored (cf. generate() and generate_from_jams()).
        ann.sandbox.scaper.soundscape_audio_path = audio_path
        ann.sandbox.scaper.isolated_events_audio_path = isolated_events_audio_path
        
        # Return audio for in-memory processing
        return soundscape_audio, event_audio_list, scale_factor, ref_db_change
    

    def generate(self,
                 audio_path=None,
                 jams_path=None,
                 allow_repeated_label=True,
                 allow_repeated_source=True,
                 reverb=None,
                 fix_clipping=False,
                 peak_normalization=False,
                 quick_pitch_time=False,
                 save_isolated_events=False,
                 isolated_events_path=None,
                 disable_sox_warnings=True,
                 no_audio=False,
                 txt_path=None,
                 txt_sep='\t',
                 disable_instantiation_warnings=False,
                 save_isolated_positions=False,
                 isolated_positions_path=None,
                 disable_event_looping=True):
        """
        Generate a soundscape based on the current specification and return as
        an audio file, a JAMS annotation, a simplified annotation list, and a
        list containing the audio samples of each individual background and
        foreground event. If output paths are provided, these objects will also
        be saved to disk.

        Parameters
        ----------
        audio_path : str
            Path for saving soundscape audio to disk. If None, does not save
            audio to disk.
        jams_path : str
            Path for saving soundscape jams annotation to disk. If None, does
            not save JAMS to disk.
        allow_repeated_label : bool
            When True (default) the same label can be used more than once
            in a soundscape instantiation. When False every label can
            only be used once.
        allow_repeated_source : bool
            When True (default) the same source file can be used more than once
            in a soundscape instantiation. When False every source file can
            only be used once.
        reverb : float or None
            Amount of reverb to apply to the generated soundscape between 0
            (no reverberation) and 1 (maximum reverberation). Use None
            (default) to prevent the soundscape from going through the reverb
            module at all.
        fix_clipping: bool
            When True (default=False), checks the soundscape audio for clipping
            (abs(sample) > 1). If so, the soundscape waveform is peak normalized,
            i.e., scaled such that max(abs(soundscape_audio)) = 1. The audio for
            each isolated event is also scaled accordingly. Note: this will change
            the actual value of `ref_db` in the generated audio. The updated
            `ref_db` value will be stored in the JAMS annotation. The SNR of
            foreground events with respect to the background is unaffected except
            when extreme scaling is required to prevent clipping.
        peak_normalization : bool
            When True (default=False), normalize the generated soundscape audio
            such that max(abs(soundscape_audio)) = 1. The audio for
            each isolated event is also scaled accordingly. Note: this will change
            the actual value of `ref_db` in the generated audio. The updated
            `ref_db` value will be stored in the JAMS annotation. The SNR of
            foreground events with respect to the background is unaffected except
            when extreme scaling is required to achieve peak normalization.
        quick_pitch_time : bool
            When True (default=False), time stretching and pitch shifting will be
            applied with `quick=True`. This is much faster but the resultant 
            audio is generally of lower audio quality.
        save_isolated_events : bool
            If True, this will save the isolated foreground events and
            backgrounds in a directory adjacent to the generated soundscape
            mixture, or to the path defined by `isolated_events_path`. The
            audio of the isolated events sum up to the mixture if reverb is not
            applied. Isolated events can be found (by default) at
            `<audio_outfile parent folder>/<audio_outfile name>_events`.
            Isolated event file names follow the pattern: `<role><idx>_<label>`,
            where count is the index of the isolated event in self.fg_spec or
            self.bg_spec (this allows events of the same label to be added more
            than once to the soundscape without breaking things). Role is
            "background" or "foreground". For example: `foreground0_siren.wav`
            or `background0_park.wav`.
        isolated_events_path : str
            Path to folder for saving isolated events. If None, defaults to
            `<audio_path parent folder>/<audio_path name>_events`. Only relevant
            if save_isolated_events=True.
        disable_sox_warnings : bool
            When True (default), warnings from the pysox module are suppressed
            unless their level is ``'CRITICAL'``. If you're experiencing issues related 
            to audio I/O setting this parameter to False may help with debugging.
        no_audio : bool
            If True this function will only generates a JAMS file but will not
            generate any audio (neither in memory nor saved to disk). Useful for
            efficiently generating a large number of soundscape JAMS for
            later synthesis via `generate_from_jams()`.
        txt_path: str or None
            Path for saving a simplified annotation in a space separated format
            [onset  offset  label] where onset and offset are in seconds. Good
            for loading labels in e.g. Audacity. If None, does not save txt
            annotation to disk.
        txt_sep: str
            The separator to use when saving a simplified annotation as a text
            file (default is tab for compatibility with Audacity label files).
            Only relevant if txt_path is not None.
        disable_instantiation_warnings : bool
            When True (default is False), warnings stemming from event
            instantiation (primarily about automatic duration adjustments) are
            disabled. Not recommended other than for testing purposes.
        save_isolated_positions : bool
            If True, this will group events by their type (i.e. speakerA,
            speakerB, etc.) and save them together in one file per type in the 
            directory defined by `isolated_positions_path`. Events without a
            type will be saved in a file called `no_type`. The audio of the
            isolated events sum up to the mixture of all events if reverb is not
            applied.
        isolated_positions_path : str
            Path to folder for saving isolated events grouped by type. If None,
            defaults to `<audio_path parent folder>/<audio_path name>_positions`.

        Returns
        -------
        soundscape_audio : np.ndarray
            The audio samples of the generated soundscape. Returns None if
            no_audio=True.
        soundscape_jam: jams.JAMS
            The JAMS object containing the full soundscape annotation.
        annotation_list : list
            A simplified annotation in a space-separated format
            [onset  offset  label] where onset and offset are in seconds.
        event_audio_list: list
            A list of np.ndarrays containing the audio samples of every
            individual background and foreground sound event. Events are listed
            in the same order in which they appear in the jams annotations data
            list, and can be matched with:
            `for obs, event_audio in zip(ann.data, event_audio_list): ...`.

        Raises
        ------
        ScaperError
            If the reverb parameter is passed an invalid value.

        See Also
        --------
        Scaper.generate_from_jams

        Scaper._instantiate

        Scaper._generate_audio

        """
        # Check parameter validity
        if reverb is not None:
            if not (0 <= reverb <= 1):
                raise ScaperError(
                    'Invalid value for reverb: must be in range [0, 1] or '
                    'None.')

        # Create specific instance of a soundscape based on the spec
        soundscape_jam = self._instantiate(
            allow_repeated_label=allow_repeated_label,
            allow_repeated_source=allow_repeated_source,
            reverb=reverb,
            disable_instantiation_warnings=disable_instantiation_warnings,
            disable_event_looping=disable_event_looping
            )
        ann = soundscape_jam.annotations.search(namespace='scaper')[0]

        soundscape_audio, event_audio_list = None, None

        # Generate the audio and save to disk
        scale_factor = 1.0
        ref_db_change = 0
        if not no_audio:
            soundscape_audio, event_audio_list, scale_factor, ref_db_change = \
                self._generate_audio(audio_path, ann,
                                     reverb=reverb,
                                     save_isolated_events=save_isolated_events,
                                     isolated_events_path=isolated_events_path,
                                     disable_sox_warnings=disable_sox_warnings,
                                     fix_clipping=fix_clipping,
                                     peak_normalization=peak_normalization,
                                     quick_pitch_time=quick_pitch_time,
                                     save_isolated_positions=save_isolated_positions,
                                     isolated_positions_path=isolated_positions_path,
                                     disable_event_looping=disable_event_looping)

        # TODO: Stick to heavy handed overwriting for now, in the future we
        #  should consolidate this with what happens inside _instantiate().
        ann.sandbox.scaper.audio_path = audio_path
        ann.sandbox.scaper.jams_path = jams_path
        ann.sandbox.scaper.allow_repeated_label = allow_repeated_label
        ann.sandbox.scaper.allow_repeated_source = allow_repeated_source
        ann.sandbox.scaper.reverb = reverb
        ann.sandbox.scaper.fix_clipping = fix_clipping
        ann.sandbox.scaper.peak_normalization = peak_normalization
        ann.sandbox.scaper.quick_pitch_time = quick_pitch_time
        ann.sandbox.scaper.save_isolated_events = save_isolated_events
        ann.sandbox.scaper.isolated_events_path = isolated_events_path
        ann.sandbox.scaper.save_isolated_positions = save_isolated_positions
        ann.sandbox.scaper.isolated_positions_path = isolated_positions_path
        ann.sandbox.scaper.disable_sox_warnings = disable_sox_warnings
        ann.sandbox.scaper.no_audio = no_audio
        ann.sandbox.scaper.txt_path = txt_path
        ann.sandbox.scaper.txt_sep = txt_sep
        ann.sandbox.scaper.disable_instantiation_warnings = disable_instantiation_warnings
        ann.sandbox.scaper.peak_normalization_scale_factor = scale_factor
        ann.sandbox.scaper.ref_db_change = ref_db_change
        ann.sandbox.scaper.ref_db_generated = self.ref_db + ref_db_change
        
        # Save JAMS to disk too
        if jams_path is not None:
            soundscape_jam.save(jams_path)

        # Create annotation list
        annotation_list = []
        for obs in ann.data:
            if obs.value['role'] == 'foreground':
                annotation_list.append(
                    [obs.time, obs.time + obs.duration, obs.value['label']])

        if txt_path is not None:
            with open(txt_path, 'w') as csv_file:
                writer = csv.writer(csv_file, delimiter=txt_sep)
                writer.writerows(annotation_list)

        
        # Optionally save isolated positions to disk
        if save_isolated_positions:
            base, ext = os.path.splitext(str(audio_path))
            positions_folder = '{:s}_positions'.format(base)
            if not isolated_positions_path is None:
                positions_folder = isolated_positions_path
            os.makedirs(positions_folder, exist_ok=True)

            # Create a dict to store the audio for each event type
            position_audio = {}
            for e in ann.data:
                position = e.value['position']
                if position is None:
                    position = "no_type"
                if position not in position_audio:
                    position_audio[position] = []
                position_audio[position].append(e)
            
            # Generate audio for each event type and save to disk
            for etype, etype_events in position_audio.items():
                position_file = os.path.join(positions_folder, etype + ext)
                ann.data = etype_events
                self._generate_audio(
                    position_file,
                    ann,
                    reverb=reverb,
                    fix_clipping=fix_clipping,
                    peak_normalization=peak_normalization,
                    quick_pitch_time= quick_pitch_time,
                    save_isolated_events=False,
                    disable_sox_warnings=disable_sox_warnings,
                    save_isolated_positions=False,
                    disable_event_looping=disable_event_looping
                    )
                
                # Save the JAMS file for this event type
                if jams_path is not None:
                    position_jams_file = os.path.join(positions_folder, etype + '.jams')
                    soundscape_jam.save(position_jams_file)
      
        # Return
        return soundscape_audio, soundscape_jam, annotation_list, event_audio_list
