""" The :obj:`sources` submodule: representing data as sound sources.
This submodule deals with the mapping of input datasets to the parameters
controlling sound in the eventual sonification.
Attributes:
mappable (:obj:`list(str)`): List of strings indicating possible
sonification parameters to which data can be mapped.
evolvable (:obj:`list(str)`): List of strings indicating the subset of
`mappable` parameters that can be evolved continuosly for an
individual Source.
param_limits (:obj:`list(tuple)`): List of tuples indicating the default
numerical ranges bounding corresponding mappable parameter
(e.g. 0-1 for volume).
param_lim_dict (:obj:`dict`): Dictionary combining `mappable` (keys) and
`param_limits` (items).
Todo:
* Store mappable, evolvable and parameter ranges in YAML files (cleaner).
* Specialised Event and Object child classes (eg. spectralisation).
"""
import numpy as np
import pandas as pd
from scipy.interpolate import interp1d
import matplotlib.pyplot as plt
from .utilities import rescale_values
mappable = ['polar',
'azimuth',
'theta',
'phi',
'volume',
'pitch',
'time',
'cutoff',
'time_evo',
'pitch_shift',
'volume_envelope/A',
'volume_envelope/D',
'volume_envelope/S',
'volume_envelope/R',
'volume_lfo/freq',
'volume_lfo/freq_shift',
'volume_lfo/amount',
'pitch_lfo/freq',
'pitch_lfo/freq_shift',
'pitch_lfo/amount']
evolvable = ['polar',
'azimuth',
'theta',
'phi',
'volume',
'cutoff',
'time_evo',
'pitch_shift',
'volume_lfo/freq',
'volume_lfo/freq_shift',
'volume_lfo/amount',
'pitch_lfo/freq',
'pitch_lfo/freq_shift',
'pitch_lfo/amount']
param_limits = [(0,1),#np.pi),
(0,1),#2*np.pi),
(0,1),#np.pi),
(0,1),#2*np.pi),
(0,1),
(0,1),
(0,1),
(0,1),
(0,1),
(0,24),
(1e-2, 10),
(1e-2, 10),
(0,1),
(1e-2, 10),
(1,12),
(0,3),
(0,2),
(1,12),
(0,3),
(0,1)]
param_lim_dict = dict(zip(mappable, param_limits))
[docs]
class Source:
""" Generic source class defining common methods/attributes
`Source` and its child classes represent the input data, and its
mapping to sonification parameters.
Note:
`Source` isn't used directly, instead use child classes
`Events` or `Objects`.
Args:
mapped_quantities (:obj:`list(str)`): The subset of parameters to
which data will be mapped.
Raises:
UnrecognisedProperty: if `mapped_quantities` entry not in `mappable`.
"""
def __init__(self, mapped_quantities):
# check these are all mappable parameters
for q in mapped_quantities:
if q not in mappable:
raise UnrecognisedProperty(
f"Property \"{q}\" is not recognised")
if ('theta' in mapped_quantities) and ('polar' in mapped_quantities):
raise Exception(
"\"theta\" and \"polar\" cannot be combined as " \
"these represent the same quantity: \"theta\" and " \
"\"phi\" are deprecated and will be replaced with \"polar\"" \
" and \"azimuth\" in a future version.")
if ('phi' in mapped_quantities) and ('azimuth' in mapped_quantities):
raise Exception(
"\"phi\" and \"azimuth\" cannot be combined as " \
"these represent the same quantity: \"theta\" and " \
"\"phi\" are deprecated and will be replaced with \"polar\"" \
" and \"azimuth\" in a future version.")
# initialise common structures
self.mapped_quantities = mapped_quantities
self.raw_mapping = {}
self.mapping = {}
self.mapping_evo = {}
[docs]
def apply_mapping_functions(self, map_funcs={}, map_lims={}, param_lims={}):
""" Taking input data and mapping to parameters.
This function does the bulk of the work for `Source` classes,
taking each input data variable and applying the mapping
function (x' = x by default), descaling by the x' upper and
lower limits and rescaling to the sonification parameter
limits. These values are stored for non-evolving parameters,
while for evolving properties are converted to interpolation
functions.
Args:
map_funcs (:obj:`dict`, optional): dict with keys that must be
a subset self.mapped_quantities. Entries are then
function-like objects for converting input data
(e.g. taking log of a data set). If not provided,
each conversion function is assumed to be f(x) = x.
map_lims (:obj:`dict`, optional): dict with keys that must be
a subset self.mapped_quantities. Entries are
tuples indicating the lower (index 0) and upper (index
1) limits on the converted input data
values. numerical values indicate absolute limits,
while strings indicate percentiles
[e.g. ('10','95')]. converted data values are clipped
to these limits. If not provided, (0,1) is assumed.
param_lims (:obj:`dict`, optional): dict with keys that
must be a subset self.mapped_quantities. Entries are
tuples indicating the lower (index 0) and upper (index
1) limits of the mapped sonification parameters. The
map_lims ranges are resaled to these ranges to give
the parameter values. If not provided, the default
param_lim_dict values are taken.
Note:
There is special behaviour for the `polar` and `azimuth`
parameters, to ensure shortest angular distance when
interpolating across the 0-2pi and 0-pi boundaries.
"""
for key in self.mapped_quantities:
rawvals = self.raw_mapping[key]
# apply mapping functions if specified
if key in map_funcs:
mapvals = map_funcs[key](rawvals)
else:
mapvals = rawvals
# set mapping limits if specified
if key in map_lims:
vallims = map_lims[key]
else:
vallims = (0,1)
# set parameter limits if specified
if key in param_lims:
plims = param_lims[key]
else:
plims = param_lim_dict[key]
lims = []
# scale mapped values within limits if specified
for l in vallims:
if isinstance(l, str):
# string values notate percentile limits
pc = float(l)
buff = 1
if pc > 100:
buff = pc/100.
pc = 100
lim = np.percentile(np.hstack([mapvals]), pc)*buff
lims.append(lim)
else:
# numerical values notate absolute limits
lims.append(l)
# limit mapped values from 0 to 1 NOTE: do we want to mix and match const and evo?
if hasattr(mapvals[0], "__iter__"):
self.mapping[key] = []
for i in range(self.n_sources):
scaledvals = rescale_values(mapvals[i], lims, plims)
self.mapping[key].append(scaledvals)
else:
scaledvals = rescale_values(np.array(mapvals), lims, plims)
self.mapping[key] = list(scaledvals)
# finally, iterate through sources and interpolate evo functions
for key in self.mapping:
if key == "time_evo":
continue
elif hasattr(self.mapping[key][0], "__iter__"):
# print(key, self.mapping[key][0])
for i in range(self.n_sources):
if key not in evolvable:
raise Exception(f"Mapping error: Parameter \"{key}\" cannot be evolved.")
x = self.mapping["time_evo"][i]
y = self.mapping[key][i]
if key == "phi" or key == "azimuth":
# special case: shortest angular distance
# between phi points is always assumed
ydiff = np.diff(y)
discont_bdx = abs(ydiff) > 0.5
for j in range(discont_bdx.sum()):
xpre = x[:-1][discont_bdx][j]
ysense = np.sign(ydiff[discont_bdx][j])
y[x > xpre] -= ysense
self.mapping[key][i] = interp1d(x,y, bounds_error=False,
fill_value=(y[0],y[-1]))
[docs]
class Events(Source):
""" Represent data as time-discrete events.
Child class of `Source`, for `Event`-type sources. Each `Event` is
discrete in `time` with single data values mapped to each
sonification parameter.
"""
[docs]
def fromfile(self, datafile, coldict):
"""Take input data from ASCII file
Args:
datafile (:obj:`str`): path to input data file
coldict (:obj:`dict`): keys are self.mapped_values, with
entries integer indexes for their corresponding column.
"""
data = np.genfromtxt(datafile)
for key in self.mapped_quantities:
self.raw_mapping[key] = data[:,coldict[key]]
self.n_sources = data.shape[0]
[docs]
def fromdict(self, datadict):
"""Take input data from dictionary
Args:
datadict (:obj:`dict`): keys are self.mapped_values, with
entries corresponding to the input data. Multiple
sources are provided as :obj:`lists`, with data for
each source corresponding to the values. Single
sources can be represented as single values.
"""
for key in self.mapped_quantities:
if key in datadict:
self.raw_mapping[key] = datadict[key]
else:
Exception(f"Mapped property {key} not in datadict.")
self.n_sources = datadict[key].shape[0]
[docs]
class Objects(Source):
""" Represent data as time-continuous objects.
Child class of `Source`. In addition to supporting single values
for each parameter (see `Events` class), objects also support
time evolution for `evolvable` parameters, given a `time-evo`
mapping.
Todo:
* implement :obj:`fromfile` method
"""
[docs]
def fromdict(self, datadict):
""" Take input data from dictionary
Args:
datadict (:obj:`dict`): keys are self.mapped_values, with
entries corresponding to the input data. Multiple
sources are provided as either :obj:`lists` or 2D
:obj:`numpy.array` objects, with each source
corresponding to the entries or columns respectively.
Single sources can be represented as single values or
1D :obj:`numpy.array` (for evolving parameters).
"""
for key in self.mapped_quantities:
if key in datadict:
d = datadict[key]
if (type(d) is not list) and (np.array(d).ndim <= 1):
self.raw_mapping[key] = [d]
else:
self.raw_mapping[key] = d
else:
Exception(f"Mapped property {key} not in datadict.")
self.n_sources = np.array(self.raw_mapping[key]).shape[0]
[docs]
class UnrecognisedProperty(Exception):
"Error raised when trying to map unrecognised parameters"
pass