Source code for strauss.generator

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

This submodule handles the actual generation of sound for the
sonfication, after parametrisation 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)
"""

from . import stream
from . import notes
from . import presets
from . import utilities as utils
from . import filters
import numpy as np
import glob
import copy
import scipy
from scipy.io import wavfile
from scipy.interpolate import interp1d
import matplotlib.pyplot as plt
import warnings

# 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.

def forward_loopsamp(s, start, end):
    delsamp = end-start
    return np.piecewise(s, [s < start, s >= start],
                        [lambda x: x, lambda x: (x-start)%(delsamp) + start])
def forward_back_loopsamp(s, start, end):
    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. 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) """ def __init__(self, params={}, samprate=48000): """universal generator initialisation""" 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'`. 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 ocillator, 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'`. 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) Todo: * Add other synthesiser types, aside from additive (e.g. FM, vector, wavetable)? """ def __init__(self, params=None, samprate=48000): # 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 parametrisation 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`. """ 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) self.generate = self.combine_oscs
[docs] def modify_preset(self, parameters, clear_oscs=True): """Synthesizer-specific wrapper for the modify_preset method. 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 notation (e.g. :obj:`'A4'`) Returns: tot (:obj:`array`-like): values for each sample """ tot = 0. if isinstance(f, str): # we want a numerical frequency to generate tone f = notes.parse_note(f) for osc in self.osclist: tot += osc(s,f) 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. 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'`. Args: sampfiles () 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) 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): # 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): self.sampdict = sampfiles if isinstance(sampfiles, str): wavs = glob.glob(sampfiles+"/*") self.sampdict = {} for w in wavs: note = w.split('/')[-1].split('_')[-1].split('.')[0] self.sampdict[note] = w self.load_samples()
[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 notation (e.g. :obj:`'A4'`). """ self.samples = {} self.samplens = {} for note in self.sampdict.keys(): 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 wavdat = np.mean(wavobj.data, axis=1) # 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] 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()