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 
import warnings

mappable = ['polar',
            'azimuth',
            'theta',
            'phi',
            'volume',
            'pitch',
            'time',
            'cutoff',
            'time_evo',
            'spectrum',
            '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_shift',
             'volume_lfo/amount',
             '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,1),
                (0,24),
                (1e-2, 10),
                (1e-2, 10),
                (0,1),
                (1e-2, 10),
                (1,12),
                (0,3),
                (0,1),
                (1,12),
                (0,3),
                (0,2)]     

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`. Attributes: mapped_quantities (:obj:`list(str)`): The subset of parameters to which data will be mapped. raw_mapping (:obj:`dict`): Housing the input mapped parameters and data, with keys corresponding to :obj:`mapped_quantities`. mapping (:obj:`dict`): processed mapping :obj:`dict` rescaled to parameter ranges, or interpolation funtions for evolving parameters. Raises: UnrecognisedProperty: if `mapped_quantities` entry not in `mappable`. """ def __init__(self, mapped_quantities): """ Args: mapped_quantities (:obj:`list(str)`): The subset of parameters to which data will be mapped. """ # 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 = {}
[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 they 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 are used to 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): if '%' not in l: warnings.warn("Specifying percentiles without appending a '%' character " "(e.g. XX%) currently works but is deprecated for more " "explicit syntax.", stacklevel=2) else: l = l.strip('%') # string values notate percentile limits pc = float(l) buff = 1 sub = 0 if pc > 100: # if percentile over 100 we add buff = pc/100. pc = 100 sub = lims[0] lim = sub + (np.percentile(np.hstack([mapvals]), pc) - sub)*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 if key == "spectrum": # if hasattr(self.mapping[key][0][0], "__iter__"): # ^ in case we want to catch and pre process multi-spectra 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 = np.array(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