Source code for strauss.sources

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