Source code for strauss.channels

""" :obj:`channels` submodule representing the output audio channels.

This submodule defines objects relevant to the output audio channels,
including the :obj:`audio_channels` which defines arrays of microphone
objects that are channeled to different speakers in the sonification
output.

"""

import numpy as np
import matplotlib.pyplot as plt
import warnings
import sys
from scipy.special import lpmv, factorial
[docs] class mic: """Microphone / sound detector object This class represents a microphone, or sound-detector, corresponding to a particular output channel for audio spatialisation in the sonicfication. Args: azimuth (:obj:`float`): Angular position of the microphone on the horizontal plane, from 0 to 2pi with 0.5 pi being to the left and 1.5 pi being to the right. In the special case of ambisonic, this is instead an index corresponding to the *Ambisonic Channel Number* (ACN) mic_type (:obj:`str`): Type of microphone, choose from :obj:`"directional"` (collects using a cardioid antenna pattern), :obj:`"omni"` (collects sound from all directions equally) and :obj:`"mute"` (collects no sound, useful for e.g. muting auxillary channels) label (:obj:`str`): A label for the mic channel (:obj:`int`) The index of the channel, corresponding to channel ordering in the output (starting from 0 e.g. stereo L=0, R=1) Raises: Exception: if mic_type not in allowed options """ def __init__(self, azimuth, mic_type="directional", label="C", channel=1, order=None, degree=None): self.azimuth = azimuth self.mic_type = mic_type self.label = label self.channel = channel if mic_type == "directional": self.antenna = lambda a, b=0.5*np.pi: 0.5*(1+np.cos(a-azimuth)*np.sin(b)) elif mic_type == "omni": self.antenna = lambda a, b=0.5*np.pi: a**0. elif mic_type == "mute": self.antenna = lambda a, b=0.5*np.pi: a*0. elif mic_type == "ambisonic": self.antenna = self._ambisonic_antenna(acn=azimuth) else: raise Exception(f"Mic type \"{mic_type}\" unknown.") def _ambisonic_antenna(self, acn): # get order and degree of spherical harmonic from ACN using ambiX standard # (see iem.kug.ac.at/fileadmin/media/iem/projects/2011/ # ambisonics11_nachbar_zotter_sontacchi_deleflie.pdf, footnote 5) # with modified l expression protecting against zero division in acn == 0 case. l = int(acn**0.5) m = acn - l*(l+1) mabs = abs(m) # normalise using SN3D standard (see iem.kug.ac.at/fileadmin/media/ # iem/projects/2011/ambisonics11_nachbar_zotter_sontacchi_deleflie.pdf, # equation 3) fctrl = factorial normSN3D = np.sqrt((2-(0**mabs)/4*np.pi) * fctrl(l-mabs)/fctrl(l+mabs)) # trig function to use, dependent on sense of m (see ref eq 2) if m < 0: tfunc = np.sin if m >= 0: tfunc = np.cos # return lambda function, indexing correct spherical harmonic and # normalising to SN3D normalisation (ref equation 2). # Note: additonal (-1)^mabs term needed to match ambiX given differing # assoc. Legendre polynom. definitions between ambiX and numpy. return lambda a, b=0.5*np.pi: normSN3D * pow(-1,mabs) * lpmv(mabs, l, np.cos(b)) * tfunc(mabs*a)
[docs] class audio_channels: """Representing output audio channels. Data object representing the output channels of the sonification for preset common audio setups, or a custom setup. Stores an array of :obj:`mic` objects for each output channel. Args: setup (:obj:`str`): Type of audio setup. Supported options are :obj:`"mono"`, :obj:`"stereo"`, :obj:`"5.1"` and :obj:`"7.1"`, or :obj:`"custom"`. custom_setup (:obj:`dict`): Dictionary defining a customised audio setup, containing keys for :obj:`"azimuths"`, :obj:`"types"` and :obj:`"labels"`, containing lists parameterising the first three arguments of the :class:`mic` object, respectively in the order of their channel index. Also optionally an forder list to unscramble any channel order scrambling done by ffmpeg processing in the sonification sae routines (this may need to be found empirically, awaiting better multichannel save routine). Raises: Exception: If custom requested but no parameters provided, or custom parameters provided without requesting a custom setup. """ def __init__(self, setup="stereo", custom_setup={}): ############################################## # Channel properties for preset audio setups ############################################## # mic angles in radians mono_azimuths = [0.] stereo_azimuths = [0.5*np.pi, 1.5*np.pi] fivepoint_azimuths = [1./3*np.pi, 5./3*np.pi, 0., 0., 2./3*np.pi, 4./3*np.pi] sevenpoint_azimuths = fivepoint_azimuths + stereo_azimuths # mic type, either omni(-directional) directional, # or muted (mute channels not used for spatialisation) mono_types = ["omni"] stereo_types = ["directional"] * 2 fivepoint_types = ["directional"] * 2 + \ ["mute"] * 2 + \ ["directional"] * 2 fivepoint_types = ["directional"] * 2 + \ ["mute"] * 2 + \ ["directional"] * 2 sevenpoint_types = ["directional"] * 2 + \ ["mute"] * 2 + \ ["directional"] * 4 # mic labels corresponding to each speaker mono_labels = ['C'] stereo_labels = ['L', 'R'] fivepoint_labels = ['FL', 'FR', 'FC', 'LF', 'SL', 'SR'] sevenpoint_labels = ['FL', 'FR', 'FC', 'LF', 'SL', 'SR', 'AL', 'AR'] #ffmpeg channel orders to correctly save combined files mono_forder = [0] stereo_forder = [0,1] fivepoint_forder = [1,2,0,3,4,5] sevenpoint_forder = [1,2,0,3,4,5,6,7] self.setup = setup if custom_setup and (setup != "custom"): warnings.warn("custom_setup variable non-empty, but not using custom setup. " \ "Did you mean to set setup=\"custom\"?") if (not bool(custom_setup)) and (setup == "custom"): raise Exception("Custom setup requested but custom_setup parameters empty. " \ "Please provide setup dictionary to custom_setup") if setup == "mono": self.setup_channels(mono_azimuths, mono_types, mono_labels) self.forder = mono_forder elif setup == "stereo": self.setup_channels(stereo_azimuths, stereo_types, stereo_labels) self.forder = stereo_forder elif setup == "5.1": self.setup_channels(fivepoint_azimuths, fivepoint_types, fivepoint_labels) self.forder = fivepoint_forder elif setup == "7.1": self.setup_channels(sevenpoint_azimuths, sevenpoint_types, sevenpoint_labels) self.forder = sevenpoint_forder elif setup[:-1] == 'ambiX': # i.e. ambiX3 => 3rd order ambisonics. nchan = np.sum(2*np.arange(int(setup[-1])+1).astype(int) + 1) labfunc = lambda a, b: str(a)+str(b) self.setup_channels(np.arange(nchan, dtype='int'), ['ambisonic']*nchan, list(map(labfunc, ['C']*nchan, range(nchan)))) elif setup == "custom": self.setup_channels(custom_setup['azimuths'], custom_setup['types'], custom_setup['labels']) if 'forder' in custom_setup: self.forder = self.custom_setup['forder'] else: self.forder = 'unknown' else: raise Exception(f"setup \"{setup}\" not understood")
[docs] def setup_channels(self, azimuths, types, labels, orders=None, degrees=None): """initialise audio channel setup for lists of properties Subroutine for setting up the audio_channels as arrays of :obj:`mic` objects, setting the :obj:`self.mics` list attribute to the :obj:`audio_channels` Args: azimuths (:obj:`list(float)`): list of :obj:`azimuth` values for :obj:`mic` object types (:obj:`list(float)`): list of :obj:`mic_types` values for :obj:`mic` object. labels (:obj:`list(float)`): list of :obj:`label` values for :obj:`mic` object """ self.azimuths = azimuths self.types = types self.labels = labels self.Nmics = len(azimuths) # Note: channel ordering important, sets output channel number self.channels = range(1, self.Nmics+1) self.mics = [] for i in range(self.Nmics): microphone = mic(azimuths[i], types[i], labels[i], self.channels[i], orders, degrees) self.mics.append(microphone)
[docs] def plot_antenna(self): """Plot antennae patterns for chosen audio setup Make a :obj:`matplotlib` figure object, representing a radial plot demonstrating the antennae patterns of each channel Returns: fig (:obj:`matplotlib.pyplot.figure`): figure object that can be shown or saved using the standard :obj:`matplotlib` routines. """ plot_azimuths = np.linspace(0, 2*np.pi, 100) normalised_volume = 0.*plot_azimuths # setup axes fig = plt.figure(figsize=(8,8)) ax = fig.subplots(subplot_kw={'projection': 'polar'}) shift = 0. for i in range(self.Nmics): labelpos = 1.2 microphone = self.mics[i] antenna = microphone.antenna(plot_azimuths) normalised_volume += antenna p = ax.plot(plot_azimuths, np.clip(antenna,0, np.inf)) p2 = ax.plot(plot_azimuths, np.clip(-antenna,0, np.inf), c=p[0].get_color(), ls =':') if np.all(microphone.antenna(plot_azimuths) == 0.): labelpos += shift shift -= 0.15 ax.scatter(microphone.azimuth, labelpos) ax.annotate(microphone.label+f" (ch. {microphone.channel})", (microphone.azimuth, labelpos), (0, 10), textcoords='offset points', ha='center') normalised_volume /= normalised_volume.max() plt.plot(plot_azimuths, normalised_volume, c='k', ls ='--', label='net amplitude') # configure axes ax = plt.gca() ax.set_theta_zero_location("N") ax.set_yticklabels([]) ax.set_xlabel('azimuth') ax.set_ylim(0,1.4) ax.legend(frameon=0) ax.grid(True) ax.set_title(f"Audio setup: {self.setup}") # return figure object to manipulate, show or save return fig
if __name__ == "__main__": # can use setup values, mono, stereo, 5.1, 7.1 or custom setup = "5.1" if len(sys.argv) > 1: # read setup type from command line argument setup = sys.argv[1] ac = audio_channels(setup=setup) fig = ac.plot_antenna() plt.show()