Skip to content

Commit

Permalink
new .name attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
DrMarc committed Aug 8, 2024
1 parent 0095d80 commit 991b74f
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 81 deletions.
72 changes: 45 additions & 27 deletions slab/binaural.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ class Binaural(Sound):
data (slab.Signal | numpy.ndarray | list | str): see documentation of slab.Sound for details. the `data` must
have either one or two channels. If it has one, that channel is duplicated
samplerate (int): samplerate in Hz, must only be specified when creating an instance from an array.
name (str): A string label for the Sound object. The inbuilt sound generating functions will automatically
set .name to the name of the method used. Useful for logging during experiments.
Attributes:
.left: the first data channel, containing the sound for the left ear.
.right: the second data channel, containing the sound for the right ear
.data: the data-array of the Sound object which has the shape `n_samples` x `n_channels`.
.n_channels: the number of channels in `data`. Must be 2 for a binaural sound.
.n_samples: the number of samples in `data`. Equals `duration` * `samplerate`.
.duration: the duration of the sound in seconds. Equals `n_samples` / `samplerate`.
"""
.name: string label of the sound.
"""
# instance properties
def _set_left(self, other):
if hasattr(other, 'samplerate'): # probably an slab object
Expand All @@ -44,33 +48,34 @@ def _set_right(self, other):
right = property(fget=lambda self: Sound(self.channel(1)), fset=_set_right,
doc='The right channel for a stereo sound.')

def __init__(self, data, samplerate=None):
def __init__(self, data, samplerate=None, name='unnamed'):
if isinstance(data, (Sound, Signal)):
self.name = data.name
if data.n_channels == 1: # if there is only one channel, duplicate it.
self.data = numpy.tile(data.data, 2)
elif data.n_channels == 2:
self.data = data.data
else:
raise ValueError("Data must have one or two channel!")
self.samplerate = data.samplerate
elif isinstance(data, (list, tuple)):
elif isinstance(data, (list, tuple)): # list of Sounds
if isinstance(data[0], (Sound, Signal)):
if data[0].n_samples != data[1].n_samples:
raise ValueError('Sounds must have same number of samples!')
if data[0].samplerate != data[1].samplerate:
raise ValueError('Sounds must have same samplerate!')
super().__init__([data[0].data[:, 0], data[1].data[:, 0]], data[0].samplerate)
else:
super().__init__(data, samplerate)
elif isinstance(data, str):
super().__init__([data[0].data[:, 0], data[1].data[:, 0]], data[0].samplerate, name=data[0].name)
else: # list of samples
super().__init__(data, samplerate, name=name)
elif isinstance(data, str): # file name
super().__init__(data, samplerate)
if self.n_channels == 1:
self.data = numpy.tile(self.data, 2) # duplicate channel if monaural file
else:
super().__init__(data, samplerate)
else: # anything but Sound, list, or file name
super().__init__(data, samplerate, name=name)
if self.n_channels == 1:
self.data = numpy.tile(self.data, 2) # duplicate channel if monaural file
if self.n_channels != 2:
if self.n_channels != 2: # bail if unable to enforce 2 channels
ValueError('Binaural sounds must have two channels!')

def itd(self, duration=None, max_lag=0.001):
Expand All @@ -96,7 +101,9 @@ def itd(self, duration=None, max_lag=0.001):
"""
if duration is None:
return self._get_itd(max_lag)
return self._apply_itd(duration)
out = copy.deepcopy(self)
out.name = f'{str(duration)}-itd_{self.name}'
return out._apply_itd(duration)

def _get_itd(self, max_lag):
max_lag = Sound.in_samples(max_lag, self.samplerate)
Expand Down Expand Up @@ -135,11 +142,12 @@ def ild(self, dB=None):
"""
if dB is None:
return self.right.level - self.left.level
new = copy.deepcopy(self) # so that we can return a new sound
out = copy.deepcopy(self) # so that we can return a new sound
level = numpy.mean(self.level)
new_levels = (level - dB/2, level + dB/2)
new.level = new_levels
return new
out_levels = (level - dB/2, level + dB/2)
out.level = out_levels
out.name = f'{str(dB)}-ild_{self.name}'
return out

def itd_ramp(self, from_itd=-6e-4, to_itd=6e-4):
"""
Expand All @@ -158,7 +166,7 @@ def itd_ramp(self, from_itd=-6e-4, to_itd=6e-4):
moving = sig.itd_ramp(from_itd=-0.001, to_itd=0.01)
moving.play()
"""
new = copy.deepcopy(self)
out = copy.deepcopy(self)
# make the ITD ramps
left_ramp = numpy.linspace(from_itd / 2, to_itd / 2, self.n_samples)
right_ramp = numpy.linspace(-from_itd / 2, -to_itd / 2, self.n_samples)
Expand All @@ -168,9 +176,10 @@ def itd_ramp(self, from_itd=-6e-4, to_itd=6e-4):
filter_length = self.n_samples // 16 * 2 # 1/8th of n_samples, always even
else:
raise ValueError('Signal too short! (min 512 samples)')
new = new.delay(duration=left_ramp, channel=0, filter_length=filter_length)
new = new.delay(duration=right_ramp, channel=1, filter_length=filter_length)
return new
out = out.delay(duration=left_ramp, channel=0, filter_length=filter_length)
out = out.delay(duration=right_ramp, channel=1, filter_length=filter_length)
out.name = f'ild-ramp_{self.name}'
return out

def ild_ramp(self, from_ild=-50, to_ild=50):
"""
Expand All @@ -190,16 +199,17 @@ def ild_ramp(self, from_ild=-50, to_ild=50):
moving = sig.ild_ramp(from_ild=-10, to_ild=10)
move.play()
"""
new = self.ild(0) # set ild to zero
out = self.ild(0) # set ild to zero
# make ramps
left_ramp = numpy.linspace(-from_ild / 2, -to_ild / 2, self.n_samples)
right_ramp = numpy.linspace(from_ild / 2, to_ild / 2, self.n_samples)
left_ramp = 10**(left_ramp/20.)
right_ramp = 10**(right_ramp/20.)
# multiply channels with ramps
new.data[:, 0] *= left_ramp
new.data[:, 1] *= right_ramp
return new
out.data[:, 0] *= left_ramp
out.data[:, 1] *= right_ramp
out.name = f'ild-ramp_{self.name}'
return out

@staticmethod
def azimuth_to_itd(azimuth, frequency=2000, head_radius=8.75):
Expand Down Expand Up @@ -275,6 +285,7 @@ def at_azimuth(self, azimuth=0, ils=None):
itd = Binaural.azimuth_to_itd(azimuth, frequency=centroid)
ild = Binaural.azimuth_to_ild(azimuth, frequency=centroid, ils=ils)
out = self.itd(duration=itd)
out.name = f'{azimuth}-azi_{self.name}'
return out.ild(dB=ild)

def externalize(self, hrtf=None):
Expand All @@ -301,9 +312,10 @@ def externalize(self, hrtf=None):
# if sound and HRTF has different samplerates, resample the sound, apply the HRTF, and resample back:
resampled_signal = resampled_signal.resample(hrtf.data[0].samplerate) # resample to hrtf rate
filt = Filter(10**(h/20), fir='TF', samplerate=hrtf.data[0].samplerate)
filtered_signal = filt.apply(resampled_signal)
filtered_signal = filtered_signal.resample(self.samplerate)
return filtered_signal
out = filt.apply(resampled_signal)
out = out.resample(self.samplerate)
out.name = f'externalized_{self.name}'
return out

@staticmethod
def make_interaural_level_spectrum(hrtf=None):
Expand Down Expand Up @@ -397,6 +409,7 @@ def interaural_level_spectrum(self, azimuth, ils=None):
out_left = Filter.collapse_subbands(subbands_left, filter_bank=fbank)
out_right = Filter.collapse_subbands(subbands_right, filter_bank=fbank)
out = Binaural([out_left, out_right])
out.name = f'ils_{self.name}'
return out.resample(samplerate=original_samplerate)

def drr(self, winlength=0.0025):
Expand Down Expand Up @@ -465,6 +478,7 @@ def whitenoise(kind='diotic', **kwargs):
out.left = out.right
else:
raise ValueError("kind must be 'dichotic' or 'diotic'.")
out.name = f'{kind}-{out.name}'
return out

@staticmethod
Expand All @@ -473,7 +487,9 @@ def pinknoise(kind='diotic', **kwargs):
Generate binaural pink noise. `kind`='diotic' produces the same noise samples in both channels,
`kind`='dichotic' produces uncorrelated noise. The rest is identical to `slab.Sound.pinknoise`.
"""
return Binaural.powerlawnoise(alpha=1, kind=kind, **kwargs)
out = Binaural.powerlawnoise(alpha=1, kind=kind, **kwargs)
out.name = f'{kind}-pinknoise'
return out

@staticmethod
def powerlawnoise(kind='diotic', **kwargs):
Expand All @@ -489,6 +505,7 @@ def powerlawnoise(kind='diotic', **kwargs):
out.left = out.right
else:
raise ValueError("kind must be 'dichotic' or 'diotic'.")
out.name = f'{kind}-{out.name}'
return out

@staticmethod
Expand All @@ -502,6 +519,7 @@ def irn(kind='diotic', **kwargs):
out.left = out.right
else:
raise ValueError("kind must be 'dichotic' or 'diotic'.")
out.name = f'{kind}-{out.name}'
return out

@staticmethod
Expand Down
17 changes: 12 additions & 5 deletions slab/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ class Signal:
it must have a .data attribute containing an array. If it's a list, the elements can be arrays or objects.
The output will be a multi-channel sound with each channel corresponding to an element of the list.
samplerate (int | None): the samplerate of the sound. If None, use the default samplerate.
name (str): a string label for the signal object. Default is 'unnamed'.
Attributes:
.duration: duration of the sound in seconds
.n_samples: duration of the sound in samples
.n_channels: number of channels in the sound
.times: list with the time point of each sample
.name: string label
Examples::
import slab, numpy
Expand All @@ -57,7 +59,7 @@ class Signal:
doc='The number of channels in the Signal.')

# __methods (class creation, printing, and slice functionality)
def __init__(self, data, samplerate=None):
def __init__(self, data, samplerate=None, name='unnamed'):
if hasattr(data, 'samplerate') and samplerate is not None:
warnings.warn('First argument has a samplerate property. Ignoring given samplerate.')
if isinstance(data, numpy.ndarray):
Expand Down Expand Up @@ -90,19 +92,24 @@ def __init__(self, data, samplerate=None):
self.samplerate = _default_samplerate
else:
self.samplerate = samplerate
if hasattr(data, 'name') and name == 'unnamed': # carry over name if source object has one and no new name provided
self.name = data.name
else:
self.name = name


def __repr__(self):
return f'{type(self)} (\n{repr(self.data)}\n{repr(self.samplerate)})'
return f'{type(self)} (\n{repr(self.data)}\n{repr(self.samplerate)}\n{repr(self.name)})'

def __str__(self):
return f'{type(self)} duration {self.duration}, samples {self.n_samples}, channels {self.n_channels},' \
return f'{type(self)} ({self.name}) duration {self.duration}, samples {self.n_samples}, channels {self.n_channels},' \
f'samplerate {self.samplerate}'

def _repr_html_(self):
'HTML image representation for Jupyter notebook support'
elipses = '\u2026'
class_name = str(type(self))[8:-2]
html = [f'<h4>{class_name} with samplerate = {self.samplerate}</h4>']
html = [f'<h4>{class_name} ({self.name}) with samplerate = {self.samplerate}</h4>']
html += ['<table><tr><th>#</th>']
samps, chans = self.data.shape
html += (f'<th>channel {j}</th>' for j in range(chans))
Expand Down Expand Up @@ -336,7 +343,7 @@ def _get_envelope(self, kind, cutoff):
envs = 20 * numpy.log10(envs) # convert amplitude to dB
elif not kind == 'gain':
raise ValueError('Kind must be either "gain" or "dB"!')
return Signal(envs, samplerate=self.samplerate)
return Signal(envs, samplerate=self.samplerate, name='envelope')

def _apply_envelope(self, envelope, times, kind):
# TODO: write tests for the new options!
Expand Down
Loading

0 comments on commit 991b74f

Please sign in to comment.