Source code for strauss.generator

""" The :obj:`generator` submodule: creating sounds for the sonification.

This submodule handles the actual generation of sound for the
sonification, after parameterisation by the :obj:`Sources` and musical
choices dictated by the :obj:`Score`.

Todo:
    * Consolidate more common code into the :obj:`Generator` parent
      class.
    * Support more Envelope and LFO types in the :obj:`play` methods
      (want pitch, volume and filter options for each)
    * Check buffer length consistency for spectralizer - do we hit
      grid points?
    * Throw appropriate errors when rendering with unreasonable length
      and freq combinations
"""

from . import stream
from . import notes
from . import presets
from . import utilities as utils
from . import filters
import numpy as np
import scipy
# can we use FFTW backend in scipy?
try:
    import pyfftw
    scipy.fft.set_backend(pyfftw.interfaces.scipy_fft)
except (OSError, ModuleNotFoundError):
    pass
from scipy.fft import fft, ifft, fftfreq
import glob
import copy
import scipy
import json
from scipy.io import wavfile
from scipy.interpolate import interp1d
import matplotlib.pyplot as plt
import warnings
import logging
from sf2utils.sf2parse import Sf2File
from pathlib import Path
import os

# ignore wavfile read warning that complains due to WAV file metadata
warnings.filterwarnings("ignore", message="Chunk \(non-data\) not understood, skipping it\.")

# TO DO:
# - Ultimately have Synth and Sampler classes that own their own stream (stream.py) object
#   allowing ADSR volume and filter enveloping, LFO implementation etc.
# - Functions here will generally be called from a "Score" class that is provided with the
#   musical choices and uses these to generate sound, but can be interfaced with directly.

[docs] def forward_loopsamp(s, start, end): """Produce array of sample indices for looping a sample forward. Sample indices between values `start` and `end` that will loop the sample such that it loops "forward", i.e. start, start+1, ..., end-1, end, start, ... etc. Args: s (:obj:`ndarray`): array of input sample indices start (:obj:`int`): Index of sample after which looping should commence end (:obj:`int`): Index of sample after which audio loops Returns: out (:obj:`ndarray`): array of output sample indices """ delsamp = end-start return np.piecewise(s, [s < start, s >= start], [lambda x: x, lambda x: (x-start)%(delsamp) + start])
[docs] def forward_back_loopsamp(s, start, end): """Produce array of sample indices for looping a sample forward-back. Sample indices between values `start` and `end` that will loop the sample such that it loops "forward-back", i.e. `start, start+1, ..., end-1, end, end-1, ..., start+1, start, start+1, ...` etc. ... etc. Args: s (:obj:`ndarray`): array of input sample indices start (:obj:`int`): Index of sample after which looping should commence end (:obj:`int`): Index of sample after which audio loops Returns: out (:obj:`ndarray`): array of output sample indices """ delsamp = end-start return np.piecewise(s, [s < start, s >= start], [lambda x: x, lambda x: end - abs((x-start)%(2*(delsamp)) - (delsamp))])
[docs] class Generator: """Generic generator Class, defining common code for child classes Generators have common initialisation and methods that are defined by this parent class. Attributes: samprate (:obj:`int`): Samples per second of audio stream (Hz) audbuff (:obj:`int`): Samples per audio buffer preset (:obj:`dict`): Dictionary of parameters defining the generator. """ def __init__(self, params={}, samprate=48000): """ Args: params (`optional`, :obj:`dict`): any generator parameters that differ from the generator :obj:`preset`, where keys and values are parameter names and values respectively. samprate (`optional`, :obj:`int`): the sample rate of the generated audio in samples per second (Hz) """ self.samprate = samprate # samples per buffer (use 30Hz as minimum) self.audbuff = self.samprate / 30. # modify or load preset if specified if params: self.preset = self.modify_preset(params)
[docs] def load_preset(self, preset='default'): """Load parameters from a preset YAML file. Wrapper method for the :obj:`presets.synth.load_preset` or :obj:`presets.sampler.load_preset` functions. Always load the :obj:`default` preset first to ensure all parameters are defined, and then if necessary reload parameters defined by :obj:`preset` Args: preset (:obj:`str`): name of the preset. Built-in presets can be named directly and looks to import the preset from the :obj:`<MODULE_PATH>/presets/<GENERATOR>/` directory as :obj:`<preset>.yml`, where :obj:`<GENERATOR>` is either `synth` or `sampler`, :obj:`<MODULE_PATH>` is the path to the strauss module (i.e. :obj:`strauss.__file__`). Custom presets can also be loaded from :obj:`<preset>.yml` if :obj:`preset` represents a path containing file separators. """ if not hasattr(self, preset): self.preset = getattr(presets, self.gtype).load_preset('default') self.preset['ranges'] = getattr(presets, self.gtype).load_ranges() if preset != 'default': preset = getattr(presets, self.gtype).load_preset(preset) self.modify_preset(preset)
[docs] def modify_preset(self, parameters, cleargroup=[]): """Modify parameters within current preset method allows user to tweak generator parameters directly, using a dictionary of parameters and their values. subgroups within the preset are represented as nested dictionaries. Args: parameters (:obj:`dict`): keys and items are the preset parameter names and new values. Nested dictionaries are used to redefine grouped parameters, e.g. :obj:`{'volume_envelope':{'A':0.5}}` cleargroup (optional :obj:`list(str)`): if required, list of the group names to be completely reset (e.g. if defining new :obj:`oscillators` set for synth). """ utils.nested_dict_reassign(parameters, self.preset) for grp in cleargroup: if grp in parameters: for k in list(self.preset[grp].keys()): if k not in parameters[grp]: del self.preset[grp][k]
[docs] def preset_details(self, term="*"): """Print the names and descriptions of presets Wrapper for preset_details function. lists the name and description of built-in presets with names matching the search term. Args: term (optional, :obj:`str`): name or glob term for built-in preset(s). Default '*' prints all. """ getattr(presets, self.gtype).preset_details(name=term)
[docs] def envelope(self, samp, params, etype='volume'): """Envelope function for modulating a single note The envelope function takes the pre-defined envelope parameters for the specified envelope type and returns the envelope value at each sample. envelopes are defined by attack, decay, sustain and release (:obj:`'A','D','S' & 'R`) values, as well as segment curvatures (:obj:`'Ac','Dc', & 'Rc`) and a normalisation :obj:`'level'`. See `this article <https://learnmusicproduction.in/blogs/music-production-and-audio-engineering/adsr-fundamentals-in-music-everything-you-need-to-know>`_ for a more detailed explanation of ADSR envelopes. Args: samp (:obj:`array-like`): Audio sample index params (:obj:`dict`): Keys and values of generator parameters etype (optional , :obj:`str`): type of envelope, indicating which :obj:`params` group to read (i.e. if :obj:`etype='volume'`, read `from :obj:`volume_envelope`) """ # TO DO: is it worth it to pre-set this in part if parameters don't change? nlen=params['note_length'] edict=params[f'{etype}_envelope'] # read envelope params from dictionary a = edict['A'] d = edict['D'] s = edict['S'] r = edict['R'] a_k = edict['Ac'] d_k = edict['Dc'] r_k = edict['Rc'] lvl = edict['level'] # effective input sample times, clipped to ensure always defined sampt = samp/self.samprate # handy time values t1 = a t2 = a+d t3 = nlen+r # determine segments and envelope value when note turns off a_seg = lambda t: 1-self.env_segment_curve(t, a, 1, -a_k) d_seg = lambda t: s+self.env_segment_curve(t-t1, d, 1-s, d_k) s_seg = lambda t: s o_seg = lambda t: 0. if nlen < t1: env_off = a_seg(nlen) elif nlen < t2: env_off = d_seg(nlen) else: env_off = s r_seg = lambda t: self.env_segment_curve(t-nlen, r, env_off, r_k) # conditionals to determine which segment a sample is in a_cond = sampt < t1 d_cond = np.logical_and(sampt<min(t2,nlen), sampt>=t1) s_cond = np.logical_and(sampt<nlen, sampt>=min(t2,nlen)) r_cond = sampt >= nlen o_cond = sampt >= t3 # compute envelope for each sample env = np.piecewise(sampt, [a_cond, d_cond, s_cond, r_cond, o_cond], [a_seg, d_seg, s_seg, r_seg, o_seg]) return lvl*env
[docs] def env_segment_curve(self, t, t1, y0, k): """Formula for segments of the envelope function Function to evaluate the segments of the envelope, allowing for curvature, i.e. concave & convex envelope segments. Args: t (:obj:`float`): time of each sample along segment t1 (:obj:`float`): time of segment endpoint y0 (:obj:`float`): starting value of segment k (:obj:`float`): curvature value of segment, from (-1,1), with positive values indicating concave and negative convex curvature. """ return y0/(1 + (1-k)*t / ((k+1)*(t1-t)))
# |||||||||||||||||||||||||||||||||||||||||||||||||| # OSC types # ||||||||||||||||||||||||||||||||||||||||||||||||||
[docs] def sine(self, s,f,p): """Sine-wave oscillator Args: s (:obj:`array`-like): sample index f (:obj:`float`): samples per cycle p (:obj:`float` or :obj:`str`): if numerical, phase in units of cycles, :obj:`'random'` indicates randomised. Returns: v (:obj:`array`-like): values for each sample """ return np.sin(2*np.pi*(s*f+p))
[docs] def saw(self,s,f,p): """Sawtooth-wave oscillator Args: s (:obj:`array`-like): sample index f (:obj:`float`): samples per cycle p (:obj:`float` or :obj:`str`): if numerical, phase in units of cycles, :obj:`'random'` indicates randomised. Returns: v (:obj:`array`-like): values for each sample """ return (2*(s*f+p) +1) % 2 - 1
[docs] def square(self,s,f,p): """Square-wave oscillator Args: s (:obj:`array`-like): sample index f (:obj:`float`): samples per cycle p (:obj:`float` or :obj:`str`): if numerical, phase in units of cycles, :obj:`'random'` indicates randomised. Returns: v (:obj:`array`-like): values for each sample """ return np.sign(self.saw(s,f,p))
[docs] def tri(self,s,f,p): """Triangle-wave oscillator Args: s (:obj:`array`-like): sample index f (:obj:`float`): samples per cycle p (:obj:`float` or :obj:`str`): if numerical, phase in units of cycles :obj:`'random'` indicates randomised. Returns: v (:obj:`array`-like): values for each sample """ return 1 - abs((4*(s*f+p) +1) % 4 - 2)
[docs] def noise(self,s,f,p): """White noise oscillator Note: :obj:`f` and :obj:`p` have no efffect for this oscillator, generating a random value for each sample. Args: s (:obj:`array`-like): sample index f (:obj:`float`): unused p (:obj:`float` or :obj:`str`): unused Returns: v (:obj:`array`-like): values for each sample """ return np.random.random(np.array(s).size)*2-1
[docs] def lfo(self, samp, sampfrac, params, ltype='pitch'): """Low-Frequency oscillator (LFO) This function takes the pre-defined LFO parameters (if switched on) for the specified LFO type and returns the LFO value at each sample. LFOs are defined by the same values as :meth:`strauss.generator.envelope`, with an additional :obj:`use` switch, a waveform (:obj:`wave`, e.g. :obj:`sine`), an amplitude (:obj:`amount`), a frequncy in Hz (:obj:`freq`) a frequency shift in octaves (:obj:`freq_shift`) and a :obj:`phase`, either numerical in cycles or :obj:`'random'` to indicate randomised. Note: To modulate the frequency of an oscillator, use the :obj:`freq_shift` parameter, rather than :obj:`freq` Args: samp (:obj:`array-like`): Audio sample index sampfrac (:obj:`array-like`): Audio sample as fraction of total number of samples params (:obj:`dict`): Keys and values of generator parameters ltype (optional , :obj:`str`): type of LFO, indicating which :obj:`params` group to read (i.e. if :obj:`ltype='volume', read `from :obj:`pitch_envelope`) Returns: v (:obj:`array-like`): amplitude of LFO at each input sample """ env_dict = {} lfo_key = f'{ltype}_lfo' lfo_params = params[lfo_key] env_dict['note_length'] = params['note_length'] env_dict['lfo_envelope'] = lfo_params freq = lfo_params['freq']/self.samprate effsamp = samp.astype(float) if callable(lfo_params['freq_shift']): findex = lfo_params['freq_shift'](sampfrac) effsamp = np.cumsum(pow(2, findex)) elif lfo_params['freq_shift'] != 0: effsamp *= pow(2,lfo_params['freq_shift']) if callable(lfo_params['amount']): amnt = lfo_params['amount'](sampfrac) else: amnt = lfo_params['amount'] if lfo_params['phase'] == 'random': phase = np.random.random() else: phase =lfo_params['phase'] osc = getattr(self,lfo_params['wave'])(effsamp, freq, phase) env = self.envelope(samp, env_dict, 'lfo') return amnt * env * osc
[docs] class Synthesizer(Generator): """Synthesizer generator class This generator class synthesises sound using mathmatically generated waveforms or 'oscillators', from a combination of oscillator methods defined in the parent class. The relative frequency, phase and amplitude of these oscillators are defined in the preset, and linearly combined to produce the sound. defines attribute :obj:`self.gtype = 'synth'`. Attributes: gtype (:obj:`str`): Generator type Todo: * Add other synthesiser types, aside from additive (e.g. FM, vector, wavetable)? """ def __init__(self, params=None, samprate=48000): """ Args: params (`optional`, :obj:`dict`): any generator parameters that differ from the generator :obj:`preset`, where keys and values are parameters names and values respectively. samprate (`optional`, :obj:`int`): the sample rate of the generated audio in samples per second (Hz) """ # default synth preset self.gtype = 'synth' self.preset = getattr(presets, self.gtype).load_preset() self.preset['ranges'] = getattr(presets, self.gtype).load_ranges() # universal initialisation for generator objects: super().__init__(params, samprate) # set up the oscillator banks self.setup_oscillators()
[docs] def setup_oscillators(self): """Setup and consolidate oscs into a two-variable function. Reads the parameterisation of each oscillator from the preset, specifying their waveform (:obj:`wave`), relative amplitude (:obj:`level`), detuning in cents (:obj:`det`) and :obj:`phase`, either a number in units of cycles, or a string specifying randomisation (:obj:`'random'`). Sets the :obj:`self.generate` method, using the :obj:`self.combine_oscs`. Note: This is deprecated and will likely be removed from future versions """ # oscdict = self.preset['oscillators'] # self.osclist = [] # for osc in oscdict.keys(): # lvl = oscdict[osc]['level'] # det = oscdict[osc]['detune'] # phase = oscdict[osc]['phase'] # form = oscdict[osc]['form'] # snorm = self.samprate # fnorm = (1 + det/100.) # if phase == 'random': # oscf = lambda samp, f: lvl * getattr(self,form)(samp/snorm, f*fnorm, np.random.random()) # else: # oscf = lambda samp, f: lvl * getattr(self,form)(samp/snorm, f*fnorm, phase) # self.osclist.append(oscf) # flg += 1 self.generate = self.combine_oscs
[docs] def modify_preset(self, parameters, clear_oscs=True): """Synthesizer-specific wrapper for the modify_preset method. This gives control over whether or not to clear the arbitrary number of oscillators for synthesizer. Args: parameters (:obj:`dict`): keys and items are the preset parameter names and new values. Nested dictionaries are used to redefine grouped parameters, e.g. :obj:`{'volume_envelope':{'A':0.5}}` clear_oscs (optional, :obj:`bool`): if True, clear all oscillators from the existing preset. Turn off if just wishing to tweak non-oscillator parameters. """ if clear_oscs: super().modify_preset(parameters, ['oscillators']) else: super().modify_preset(parameters) self.setup_oscillators()
[docs] def combine_oscs(self, s, f): """ Evaluate and linearly combine oscillators. Args: s (:obj:`array`-like): Sample index f (:obj:`float` or :obj:`str`): If numerical, frequency in cycles per second, if string, note name in scientific pitch notation (e.g. :obj:`'A4'`) Returns: tot (:obj:`array`-like): values for each sample """ tot = 0. oscdict = self.preset['oscillators'] if isinstance(f, str): # we want a numerical frequency to generate tone f = notes.parse_note(f) for osc in oscdict: lvl = oscdict[osc]['level'] det = oscdict[osc]['detune'] phase = oscdict[osc]['phase'] form = oscdict[osc]['form'] snorm = self.samprate fnorm = (1 + det/100.) if phase == 'random': tot += lvl * getattr(self,form)(s/snorm, f*fnorm, np.random.random()) else: tot += lvl * getattr(self,form)(s/snorm, f*fnorm, phase) # self.osclist.append(oscf) # flg += 1 return tot
[docs] def play(self, mapping): """ Play the sound for a given source. Play a given source and return the sample values for combination into the overall sonification. Note: :obj:`mapping` is a linear dictionary (not nested, as for :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). Args: mapping (:obj:`dict`): keys and items are generator parameter names and their values. This combines all the preset mapped parameters, overwritten by any :obj:`Source`-mapped parameters (represented as values or interpolation functions for static and evolving parameters, respectively). This is a linear dictionary (not nested, see :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). """ samprate = self.samprate audbuff = self.audbuff params = copy.deepcopy(self.preset) utils.linear_to_nested_dict_reassign(mapping, params) nlength = (params['note_length']+params['volume_envelope']['R'])*samprate # generator stream (attribute of stream?) sstream = stream.Stream(nlength/samprate, samprate) samples = sstream.samples sstream.get_sampfracs() pindex = np.zeros(samples.size) if callable(params['pitch_shift']): pindex += params['pitch_shift'](sstream.sampfracs)/12. elif params['pitch_shift'] != 0: pindex += params['pitch_shift']/12. if params['pitch_lfo']['use']: pindex += self.lfo(samples, sstream.sampfracs, params, 'pitch')/12. if np.any(pindex): samples = np.cumsum(pow(2., pindex)) # if callable(params['pitch_shift']): # samples = np.cumsum(pow(2., pindex)) # else: # samples = samples * pow(2., pindex) # generate stream values values = self.generate(samples, params['note']) # get volume envelope env = self.envelope(sstream.samples, params) if params['volume_lfo']['use']: env *= np.clip(1.-self.lfo(sstream.samples, sstream.sampfracs, params, 'volume')*0.5, 0, 1) # apply volume normalisation or modulation (TO DO: envelope, pre or post filter?) sstream.values = values * utils.const_or_evo(params['volume'], sstream.sampfracs) * env # filter stream if params['filter'] == "on": if hasattr(params['cutoff'], "__iter__"): # if static cutoff, use minimum buffer count sstream.bufferize(sstream.length/4) else: # 30 ms buffer (hardcoded for now) sstream.bufferize(0.03) sstream.filt_sweep(getattr(filters, params['filter_type']), utils.const_or_evo_func(params['cutoff'])) return sstream
[docs] class Sampler(Generator): """Sampler generator class This generator class generates sound using pre-loaded audio samples, representing different notes. Presets define parameters controlling these defines attribute :obj:`self.gtype = 'sampler'`. Attributes: gtype (:obj:`str`): Generator type Todo: * Add zone mapping for samples (e.g. allow a sample to define a range of notes played at different speeds). * Support non-scientifically named notes? (e.g :obj:`'cymbal'`, :obj:`'snare'`). * Have sample loading defined via the preset rather than the :obj:`sampfiles` variable? """ def __init__(self, sampfiles, params=None, samprate=48000, sf_preset=None): """ Args: sampfiles (`required`, :obj:`str`): string pointing to samples to load. This can either point to a directory containing samples, where `"path/to/samples"` contains files named as `samples_A#4.wav` (ie. `<lowest_directory>_<note>.wav`), or a *Soundfont* file, with extension `.sf2`. params (`optional`, :obj:`dict`): any generator parameters that differ from the generator :obj:`preset`, where keys and values are parameters names and values respectively. samprate (`optional`, :obj:`int`): the sample rate of the generated audio in samples per second (Hz) sf_preset (`optional`, :obj:`int`) if using a *Soundfont* (`.sf2`) file, this is the number of the preset to use. All `.sf2` files should contain at least one preset. When given default `None` value, will print available presets and select the first preset. Note presets are 1-indexed. Note: It is necessary to assign a note for each sample in order to choose different sample based on the ``pitch`` parameter. This is also the case for non-pitched sounds, following a similar approach to a [keyboard sampler] (https://support.apple.com/en-lk/guide/logicpro/lgcp4eecaaff/mac) where each key can triggers a different chosen sample. If `drumset_C1.wav` is a kick drum and `drumset_D1.wav` is a snare drum for :obj:`Score` with `chord_sequence=[["C1", "D1"]]`, events mapped to a higher (lower) `pitch` will sound as snare (kick) drums. """ # default sampler preset self.gtype = 'sampler' self.preset = getattr(presets, self.gtype).load_preset() self.preset['ranges'] = getattr(presets, self.gtype).load_ranges() # universal initialisation for generator objects: super().__init__(params, samprate) if isinstance(sampfiles, dict): # catch case sample dictionary provided directly self.sampdict = sampfiles else: # re-cast sampfiles as a string sampfiles = str(sampfiles) if sampfiles[-4:] == '.sf2': # if a soundfont (.sf2) file, use read routines with open(sampfiles, 'rb') as sf2_file: self.sf2 = Sf2File(sf2_file) # number of presets (excluding EOS entry) npres = len(self.sf2.raw.pdta['Phdr'][:-1]) if npres == 1: # if there's only one preset, choose it sf_preset = 1 if not (isinstance(sf_preset, int) and (sf_preset >= 1) and (sf_preset <= npres)): # if there's more than one, and no valid number specified, ask for one. print("valid 'sf_preset' not provided for soundfont file, available presets are:") print('\n'+''.join(['-']*40)) choose_name = '' for i in range(npres): hdr = self.sf2.raw.pdta['Phdr'][i] name = json.dumps(hdr.name.decode('utf-8')).replace(r'\u0000', '') print(f"{i+1}. {name}") if not choose_name: choose_name = name print(''.join(['-']*40)+'\n') # TODO: zero index, but would 1 index be more user friendly? print(f"By default choosing preset 1 ({choose_name}).\n\n" "Re-run 'Sampler' with the 'sf_preset' keyword argument to select a specific\n" f"preset, ie. 'Sampler(\"{sampfiles}\",sf_preset=N)',\n" f"where N is an integer from 1-{i+1}.\n") sf_preset = 1 # TODO: isolate the warning suppression better? logger = logging.getLogger() pres = self.sf2.build_presets() logger.disabled = True sf_data = self.get_sfpreset_samples(pres[sf_preset-1]) self.sampdict = self.reconstruct_samples(sf_data) logger.disabled = False else: wavs = sorted(Path(sampfiles).glob("*")) self.sampdict = {} for w in wavs: filename = Path(w).name note = filename.split('_')[-1].split('.')[0] self.sampdict[note] = str(w) self.load_samples()
[docs] def get_sfpreset_samples(self, sfpreset): """Reading samples from a soundfont file along with metadata. Read in the audio samples from a ``.sf2`` file to populate available notes, mapping the MIDI key values to musical notes, scaling and tuning samples as appropriate. Args: sf_preset (`optional`, :obj:`int`) The number of the *Soundfont* preset to use. All `.sf2` files should contain at least one preset. When given default `None` value, will print available presets and select the first preset. Note presets are 1-indexed. Returns: sfpre_dict (:obj:`dict`): dictionary of data required to load soundfont samples in to the `Sampler`, including raw `samples`, `sample_rate`, `original_pitch` of the samples, the `min_note` and `max_note` in midi values to use the sample, and the `sample_map`, assigning each sample to a note. """ minmidi = np.inf maxmidi = -np.inf stdvel = 100 sampdat = {} sratedat = {} krangedat = {} mapsamps = {} opitchdat = {} # iterate through preset 'bags' containing # sample sets associated to each note for bag in sfpreset.bags: isvelstd = True inst = bag.instrument vr = bag.velocity_range if vr: isvelstd = (vr[0] <= stdvel) and (vr[1] >= stdvel) # we support a fixed velocity, choose value stdvel # as standard, so only want bags of samples # associated with that range if not isvelstd: continue if inst: # if bag is not empty, iterate through samples for sbag in inst.bags: # for i in range(len(inst.samples)): samp = sbag.sample if not samp: # if sbag not associated with a sample, skip continue # don't support stereo samples, due to spatialisation # in strauss. Only read in mono or left channel samples. if samp.is_left or samp.is_mono: tune = 0 ftun = 0 if sbag.tuning: tune += sbag.tuning if sbag.fine_tuning: tune += sbag.fine_tuning/100 sample = np.frombuffer(samp.raw_sample_data, dtype='int16') note = samp.original_pitch if sbag.base_note: note = sbag.base_note name = samp.name keys = np.array(sbag.key_range) minmidi = min(minmidi, keys[0]) maxmidi = max(maxmidi, keys[1]) for i in range(keys[0], keys[1]+1): if i not in mapsamps: mapsamps[i] = [] mapsamps[i].append(name) opitchdat[name] = note-tune sratedat[name] = samp.sample_rate sampdat[name] = sample return {'samples': sampdat, 'sample_rate': sratedat, 'original_pitch': opitchdat, 'min_note': minmidi, 'max_note': maxmidi, 'sample_map': mapsamps}
[docs] def reconstruct_samples(self, sfpre_dict): """Interpolate, combine and resample soundfont samples for each note, and load into the `Sampler`. Args: sfpre_dict (:obj:`dict`): dictionary of data required to load soundfont samples in to the `Sampler`, including raw `samples`, `sample_rate`, `original_pitch` of the samples, the `min_note` and `max_note` in midi values to use the sample, and the `sample_map`, assigning each sample to a note. Return: sampdict (:obj:`dict`): output dictionary of mapped notes, with values of arrays of sample values at the samplerate of the `Generator`. """ minkey = sfpre_dict['min_note'] maxkey = sfpre_dict['max_note'] smap = sfpre_dict['sample_map'] sampdict = {} for i in range(max(minkey,16), min(maxkey, 115)+1): wave_stack = [] maxlen = 0 for nme in smap[i]: # print((i-sfpre_dict['original_pitch'][nme])/12.) semi_shift = pow(2, (i-sfpre_dict['original_pitch'][nme])/12.) srate = sfpre_dict['sample_rate'][nme] samp = sfpre_dict['samples'][nme] vals = utils.resample(semi_shift*srate, self.samprate, samp) maxlen = max(maxlen, vals.size) wave_stack.append(vals) compwave = np.zeros(maxlen, dtype='int16') nwave = len(wave_stack) for wave in wave_stack: compwave[:wave.size] += wave//nwave nte = notes.mkey_to_note(i) sampdict[nte] = compwave # return notes using sharps if nte[1] == '#': # if a sharp, also assign flat... sampdict[nte.replace('#','b')] = compwave # outname = f'../../example_wavs/out_{nte}.wav' # write(outname, samprate, compwave) return sampdict
[docs] def load_samples(self): """Load audio samples into the sampler. Read audio samples in from a specified directory or via a dictionary of filepaths, generate interpolation functions for each, and assign them to a named note in scientific pitch notation (e.g. :obj:`'A4'`). Note: Notes are assigned based on a tag in the filename (see :obj:`Sampler`), not by analysing the audio itself. If a tuned sample is tagged as the wrong note, this will carry over to the sonification. However, this allows non-pitched samples to be assigned notes and triggered. """ self.samples = {} self.samplens = {} for note in self.sampdict.keys(): if isinstance(self.sampdict[note], str): rate_in, wavobj = wavfile.read(self.sampdict[note]) # If it doesn't match the required rate, resample and re-write if rate_in != self.samprate: wavobj = utils.resample(rate_in, self.samprate, wavobj) # force to mono array, else convert values to float if wavobj.ndim > 1: wavdat = np.mean(wavobj.data, axis=1) else: wavdat = np.array(wavobj.data, dtype='float64') else: wavdat = self.sampdict[note].astype('float64') # remove DC term dc = wavdat.mean() wavdat -= dc wavdat /= abs(wavdat).max() samps = range(wavdat.size) self.samples[note] = interp1d(samps, wavdat, bounds_error=False, fill_value = (0.,0.), assume_sorted=True) self.samplens[note] = wavdat.size
[docs] def forward_loopsamp(self, s, start, end): """Looping samples forward using indexing From a list of samples and start and end loop-points, return a new list of samples that index the audio file samples to create a forward-looping effect. Args: s (:obj:`array`-like): Sample indexes for the duration of a source's note start (:obj:`int`): Sample index at which to start the loop end (:obj:`int`): Sample index at which to end the loop Returns: s_new (:obj:`array`-like): new sample indices to create a forward-looping effect """ delsamp = end-start return np.piecewise(s, [s < start, s >= start], [lambda x: x, lambda x: (x-start)%(delsamp) + start])
[docs] def forward_back_loopsamp(self, s, start, end): """Looping samples forward-backward alternately using indexing From a list of samples and start and end loop-points, return a new list of samples that index the audio file samples to create a back and forth looping effect. Args: s (:obj:`array`-like): Sample indexes for the duration of a source's note start (:obj:`int`): Sample index at which to start the loop end (:obj:`int`): Sample index at which to end the loop Returns: s_new (:obj:`array`-like): new sample indices to create a back and forth looping effect """ delsamp = end-start return np.piecewise(s, [s < start, s >= start], [lambda x: x, lambda x: end - abs((x-start)%(2*(delsamp)) - (delsamp))])
[docs] def play(self, mapping): """ Play the sound for a given source. Play a given source and return the sample values for combination into the overall sonification. Note: :obj:`mapping` is a linear dictionary (not nested, as for :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). Args: mapping (:obj:`dict`): keys and items are generator parameter names and their values. This combines all the preset mapped parameters, overwritten by any :obj:`Source`-mapped parameters (represented as values or interpolation functions for static and evolving parameters, respectively). This is a linear dictionary (not nested, see :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). """ # TO DO: Generator should know samplerate and audbuff # TO DO: split this into common and generator-specific functions to minimise code duplication samprate = self.samprate audbuff = self.audbuff params = copy.deepcopy(self.preset) utils.linear_to_nested_dict_reassign(mapping, params) # for p in self.preset.keys(): # if p not in mapping: # mapping[p] = self.preset[p] # sample to use samplefunc = self.samples[params['note']] # note length if params['note_length'] == 'sample': nlength = self.samplens[params['note']] params['note_length'] = nlength/samprate else: nlength = (params['note_length']+params['volume_envelope']['R'])*samprate # generator stream (TO DO: attribute of stream?) sstream = stream.Stream(nlength/samprate, samprate) sstream.get_sampfracs() samples = sstream.samples.astype(float) pindex = np.zeros(samples.size) if callable(params['pitch_shift']): pindex += params['pitch_shift'](sstream.sampfracs)/12. elif params['pitch_shift'] != 0: pindex += params['pitch_shift']/12. if params['pitch_lfo']['use']: pindex += self.lfo(samples, sstream.sampfracs, params, 'pitch')/12. if np.any(pindex): samples = np.cumsum(pow(2., pindex)) # if callable(params['pitch_shift']): # pshift = np.cumsum(params['pitch_shift'](sstream.sampfracs)) # samples *= pow(2., pshift/12.) # else: # samples *= pow(2., params['pitch_shift']/12.) # sample looping if specified if params['looping'] != 'off': startsamp = params['loop_start']*samprate endsamp = params['loop_end']*samprate # find clean loop points within an audible (< 20Hz) cycle startsamp += np.argmin(samplefunc(np.arange(audbuff) + startsamp)) endsamp += np.argmin(samplefunc(np.arange(audbuff) + endsamp)) if params['looping'] == 'forwardback': samples = forward_back_loopsamp(samples,#sstream.samples, startsamp, endsamp) elif params['looping'] == 'forward': samples = forward_loopsamp(samples,#sstream.samples, startsamp, endsamp) # generate stream values values = samplefunc(samples) # get volume envelope env = self.envelope(sstream.samples, params) if params['volume_lfo']['use']: env *= np.clip(1.-self.lfo(sstream.samples, sstream.sampfracs, params, 'volume')*0.5, 0, 1) # apply volume normalisation or modulation (TO DO: envelope, pre or post filter?) sstream.values = values * env * utils.const_or_evo(params['volume'], sstream.sampfracs) # TO DO: filter envelope (specify as a cutoff array function? or filter twice?) # filter stream if params['filter'] == "on": if hasattr(params['cutoff'], "__iter__"): # if static cutoff, use minimum buffer count sstream.bufferize(sstream.length/4) else: # 30 ms buffer (hardcoded for now) sstream.bufferize(0.03) sstream.filt_sweep(getattr(filters, params['filter_type']), utils.const_or_evo_func(params['cutoff'])) return sstream
[docs] class Spectralizer(Generator): """Spectralizer generator class This generator class synthesises sound from a spectrum input using an *inverse Fast Fourier Transform* (iFFT) algorithm. Defining a minimum and maximum frequency in Hz, input spectrum is interpolated between these points such that the output audio signal has the requested length. Phases are randomised to avoid phase correlations. Attributes: gtype (:obj:`str`): Generator type Todo: * Add other synthesiser types, aside from additive (e.g. FM, vector, wavetable)? """ def __init__(self, params=None, samprate=48000): """ Args: params (`optional`, :obj:`dict`): any generator parameters that differ from the generator :obj:`preset`, where keys and values are parameters names and values respectively. samprate (`optional`, :obj:`int`): the sample rate of the generated audio in samples per second (Hz) """ # default synth preset self.gtype = 'spec' self.preset = getattr(presets, self.gtype).load_preset() self.preset['ranges'] = getattr(presets, self.gtype).load_ranges() self.eq = utils.Equaliser() self.freqwarn = True # universal initialisation for generator objects: super().__init__(params, samprate)
[docs] def spectrum_to_signal(self, spectrum, phases, new_nlen, mindx, maxdx, interp_type): """ Convert the input spectrum into sound signal Performs the inverse fast fourier transform to produce spectral sonification. Args: spectrum (:obj:`ndarray`): Values of the spectrum, ordered from high to low frequency phases (:obj:`ndarray`): Array of values of `[0,2*numpy.pi]` representing the complex number argument new_nlen (:obj:`int`): Number of samples needed to enclose the output signal. mindx (:obj:`int`): Index in total Fourier transform represnting the minimum audio frequency maxdx (:obj:`int`): Index in total Fourier transform represnting the maximum audio frequency interp_type (:obj:`str`): Interpolation approach, either `"sample"` interpolating between samples, or `"preserve_power"` where cumulative power is interpolated and then differentiated to avoid missing power. """ if interp_type == "sample": ps = np.interp(np.linspace(0,1,maxdx-mindx), np.linspace(0, 1, spectrum.size), spectrum) elif interp_type == "preserve_power": # we don't renormalise by len(ps) / spectrum.size, # as renormalising to peak later anyway. ps = np.diff(np.interp(np.linspace(0, 1, maxdx-mindx+1), np.linspace(0, 1, spectrum.size), np.cumsum(spectrum))) empt = np.zeros(new_nlen) empt[mindx:maxdx] = ps ps = empt PS = ps*np.cos(phases) + 1j*ps*np.sin(phases) return np.real(ifft(PS))[:new_nlen]
[docs] def play(self, mapping): """ Play the sound for a given source. Play a given source and return the sample values for combination into the overall sonification. Note: :obj:`mapping` is a linear dictionary (not nested, as for :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). Args: mapping (:obj:`dict`): keys and items are generator parameter names and their values. This combines all the preset mapped parameters, overwritten by any :obj:`Source`-mapped parameters (represented as values or interpolation functions for static and evolving parameters, respectively). This is a linear dictionary (not nested, see :meth:`strauss.generator.modify_preset`) where group members are indicated using :obj:`'/'` notation (e.g. :obj:`{'volume_envelope/A': 0.5, ...`). """ samprate = self.samprate audbuff = self.audbuff params = copy.deepcopy(self.preset) utils.linear_to_nested_dict_reassign(mapping, params) duration = (params['note_length']+params['volume_envelope']['R']) nlength = int(duration*samprate) # generator stream (attribute of stream?) sstream = stream.Stream(nlength/samprate, samprate) samples = sstream.samples sstream.get_sampfracs() spectrum = params['spectrum'] interp_type = params['interpolation_type'] if np.array(spectrum).ndim == 1: # number of discrete frequencies available in ifft between freq. limits discrete_freqs = duration*(params['max_freq']-params['min_freq']) # how many spectra points fit into the available intermediate frequencies spectra_multiples = (discrete_freqs - 1)/(spectrum.size - 1) # the minimum factor by which to increase the stream length to accomodate spectra in whole number multiples if params['fit_spec_multiples']: buffer_factor = np.ceil(spectra_multiples)/spectra_multiples else: buffer_factor = 1 # number of samples to generate including buffer new_nlen = int(buffer_factor * nlength) # the frequency bound indices which the spectrum will be mapped into mindx = int(params['min_freq'] * duration * buffer_factor) maxdx = int(params['max_freq'] * duration * buffer_factor) if params['equal_loudness_normalisation']: freqs = np.linspace(params['min_freq'], params['max_freq'], len(spectrum)) norm = self.eq.get_relative_loudness_norm(freqs) if not self.eq.factor_rms: self.eq.factor_rms = [] rms1 = np.sqrt(np.mean(spectrum**2)) spectrum *= norm self.eq.factor_rms.append(np.sqrt(np.mean(spectrum**2))/rms1) # hardcode phase randomisation for now phases = 2*np.pi*np.random.random(new_nlen) # generate stream values sstream.values = self.spectrum_to_signal(spectrum, phases, new_nlen, mindx, maxdx, interp_type)[:nlength] else: if 'time_evo' in params: np.diff(params['time_evo']) nspec, nwlen = np.array(spectrum).shape # buffer for each spectrum buffdur = duration / (nspec-1) sstream.bufferize(buffdur) # indices of IFFT input spectrum corresponding to the nearest desired frequencies # NOTE: we don't force the spectrum to reproduce desired frequencies to the accuracy of # a few samples in the evolving spectrum case. mindx = np.round(params['min_freq'] * buffdur).astype(int) maxdx = np.round(params['max_freq'] * buffdur).astype(int) if ((maxdx - mindx)*10 < nwlen) and self.freqwarn and params['interpolation_type'] == 'sample': basewarn = ("\n\n Spectrum strongly undersampled (by more than a factor 10) while using 'sample' \n" "interpolation type. This could miss spectral features. You could consider: \n" "\t - Changing interpolation type to 'preserve power' (i.e. generator.modify_preset({'interpolation_type':'preserve_power'})) \n") fdiff = params['max_freq']- params['min_freq'] newdur = (nwlen/fdiff) *(nspec-1) if newdur < 300: # i.e. 5 minutes # suggest increasing duration if reasonable basewarn += f"\t - Increase the duration of the sonification (e.g. to > {newdur:.0f}) \n" if ((2e4 - 30) * buffdur > nwlen): newdiff = np.log10(nwlen / buffdur) flo = pow(10,0.5*(np.log10(30) + np.log10(2e4)) - 0.5*newdiff) fhi = pow(10,0.5*(np.log10(30) + np.log10(2e4)) + 0.5*newdiff) basewarn += f"\t - Increase the sound frequency range eg ({np.floor(flo):.0f} to {np.ceil(fhi):.0f} Hz)\n" warnings.warn(basewarn + f"\t - Rebin spectra more coarsely \n") # only warn once per instance self.freqwarn = False # length of buffer and therefore IFFT in this case new_nlen = sstream.buffers._nsamp_buff # hardcode phase randomisation for now phases = 2*np.pi*np.random.random(new_nlen) # iterate through buffers and spectra nolap = nspec-1 buffsize = sstream.buffers._nsamp_buff for i in range(nspec): # print(buffsize) buffs = self.spectrum_to_signal(spectrum[i], phases, new_nlen, mindx, maxdx, interp_type) # print(sstream.buffers._nsamp_buff, buffs.size) sstream.buffers.buffs_tile[i] = buffs[:sstream.buffers._nsamp_buff] if i == nolap: continue # print(sstream.buffers.buffs_tile[i][0], sstream.buffers.buffs_tile[i][-1]) sstream.buffers.buffs_olap[i][buffsize//2:] = sstream.buffers.buffs_tile[i][:buffsize//2] sstream.buffers.buffs_olap[i][:buffsize//2] = sstream.buffers.buffs_tile[i][buffsize//2:] if params['regen_phases']: # regenerate randomised phases if doing so phases = 2*np.pi*np.random.random(new_nlen) sstream.consolidate_buffers() sstream.values /= abs(sstream.values).max() # get volume envelope env = self.envelope(sstream.samples, params) if params['volume_lfo']['use']: env *= np.clip(1.-self.lfo(sstream.samples, sstream.sampfracs, params, 'volume')*0.5, 0, 1) pindex = np.zeros(samples.size) if callable(params['pitch_shift']): pindex += params['pitch_shift'](sstream.sampfracs)/12. elif params['pitch_shift'] != 0: pindex += params['pitch_shift']/12. if params['pitch_lfo']['use']: pindex += self.lfo(samples, sstream.sampfracs, params, 'pitch')/12. if np.any(pindex): sampfunc = interp1d(samples, sstream.values, bounds_error=False, fill_value = (0.,0.), assume_sorted=True) newsamp = np.cumsum(pow(2., pindex)) sstream.values = sampfunc(newsamp) # apply volume normalisation or modulation (TO DO: envelope, pre or post filter?) sstream.values *= utils.const_or_evo(params['volume'], sstream.sampfracs) * env # filter stream if params['filter'] == "on": if hasattr(params['cutoff'], "__iter__"): # if static cutoff, use minimum buffer count sstream.bufferize(sstream.length/4) else: # 30 ms buffer (hardcoded for now) sstream.bufferize(0.03) sstream.filt_sweep(getattr(filters, params['filter_type']), utils.const_or_evo_func(params['cutoff'])) return sstream
[docs] def gen_chord(stream, chordname, rootoctv=3): """DEPRECATED CODE: generate chord over entire stream given chord name and optional octave of root note """ frqs = notes.parse_chord(chordname, rootoctv) frqsamp = frqs/stream.samprate for f in frqsamp: stream.values += detuned_saw(stream.samples, f)
[docs] def detuned_saw(samples, freqsamp, oscdets=[1,1.005,0.995]): """DEPRECATED CODE: Three oscillator sawtooth wave generator with slight detuning for texture """ saw = lambda freqsamp, samp: 1-((samples*(freqsamp)/2) % 2) signal = np.zeros(samples.size) for det in oscdets: freq = freqsamp*det signal += saw(freq, samples+freq*np.random.random()) return signal
[docs] def legacy_env(t, dur,a,d,s,r): """ DEPRECATED CODE: older function for generating envelopes """ att = lambda t: t/a dgrad = (1-s)/d dec = lambda t: (a-t)*dgrad + 1 sus = lambda t: s funcs = [att, dec, sus] conds = [t<a, np.logical_and(t>a, t<(d+a)), t > (a+d)] vol = np.piecewise(np.clip(t, 0, dur), conds, funcs) rel = np.clip(np.exp((dur-t)/r),0, 1) print(a,d,s,r) return vol * rel
if __name__ == "__main__": # test volume envelope t = np.linspace(0.,11,500) dur = 9 a = 1.4 d = 2 s = 0.7 r = 1 env = legacy_env(t, dur,a,d,s,r) plt.plot(t, env) plt.show()