""" 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()