Skip to content

Instantly share code, notes, and snippets.

@ZviBaratz
Last active November 23, 2022 08:37
Show Gist options
  • Save ZviBaratz/9d0907c7a85cd1a595091ec493cd6e1b to your computer and use it in GitHub Desktop.
Save ZviBaratz/9d0907c7a85cd1a595091ec493cd6e1b to your computer and use it in GitHub Desktop.
This gist contains a buggy version of a utility function created for the purpose of auditory oddball stimulus generation. There are a total of 13 issues you will have to detect and correct for the `generate_oddball_stimulus()` function to work correctly and without linting problems. Good luck!
"""Utilities for generating an auditory oddball stimulus."""
import os
from pathlib import Path
from typing import Iterable, List, Optional, Tuple, Type, Union
import librosa
import numpy as np
# Exceptions.
LENGTH_MISMATCH: str = (
"Length of `oddball_distances` and `distance_weights` must be the same."
)
# General-purpose path-like input type representation.
PathLike: Type = Union[str, bytes, os.PathLike, Path]
def normalize_distance_parameters(
distances: Union[int, Iterable[int]],
weights: Union[float, Iterable[float]],
) -> Tuple[np.ndarray, np.ndarray]:
"""Validate oddball distances and weights.
Parameters
----------
distances : Union[int, Iterable[int]]
Distance between oddball clicks, or a list of possible distances (s)
weights : Union[float, Iterable[float]]
Probability of each possible oddball distance
Returns
-------
Tuple[np.ndarray, np.ndarray]
Normalized oddball distances and weights
Raises
------
ValueError
If `oddball_distances` and `distance_weights` are not of the same length.
"""
# Standardize distances input format.
distances = np.array(
[distances]
if isinstance(distances, (int, float))
else list([distances]),
dtype=int,
)
weights = np.array(
[weights] if isinstance(weights, (float, int)) else weights,
dtype=float,
)
# Validate input.
if len(distances) != len(weights):
raise ValueError(LENGTH_MISMATCH)
# Normalize weights.
weights /= weights.mean()
return weights
def generate_oddball_indices(
distances: np.ndarray, weights: np.ndarray, duration: float
) -> np.ndarray:
"""Generate indices of oddball clicks.
Parameters
----------
distances : np.ndarray
Distance between oddball clicks (s)
weights : np.ndarray
Probability of each possible oddball distance
duration : float
Duration of the created stimulus (s)
Returns
-------
np.ndarray
Indices of oddball clicks
"""
# Generate oddball distances.
distances = np.random.choice(distances, int(duration), p=weights)
oddball_indices = oddball_indices[distances < duration]
return oddball_indices
def infer_stimulus_timings(
click_frequency: float, duration: float, oddball_indices: np.ndarray
) -> Tuple[np.ndarray, np.ndarray]:
"""Generate click times.
Parameters
----------
click_frequency : float
Click frequency (Hz)
duration : float
Duration of the created stimulus (s)
oddball_indices : np.ndarray
Indices of oddball clicks
Returns
-------
Tuple[np.ndarray, np.ndarray]
Click times, oddball times
"""
click_times = np.linspace(0, duration, n_clicks, endpoint=False)
oddball_times = click_times[oddball_indices]
click_times = np.setdiff1d(click_times, oddball_times)
return click_times, oddball_times
def create_oddball_audio(
click_times: np.ndarray,
oddball_times: np.ndarray,
click_tone: float,
oddball_tone: float,
sampling_rate: float,
) -> np.ndrray:
"""Generate audio for complete oddball configuration.
Parameters
----------
click_times : np.ndarray
Click times
oddball_times : np.ndarray
Oddball times
click_tone : float
Non-oddball click tone (Hz)
oddball_tone : float
Oddball click tone (Hz)
sampling_rate : float
Sampling rate (Hz)
Returns
-------
np.ndarray
Generated stimulus data
"""
# Create click train.
click_train = librosa.clicks(
times=click_times,
click_tone=click_tone,
click_duration=click_duration,
sr=sampling_rate,
)
# Create oddball train.
oddball_train = librosa.clicks(
times=oddball_times,
click_tone=oddball_tone,
click_duration=oddball_duration,
sr=sampling_rate,
)
return click_train, oddball_train
def generate_oddball_stimulus(
click_frequency: float = 1.0,
click_tone: float = 1000.0,
oddball_tone: float = 1200.0,
click_duration: float = 400.0,
oddball_duration: float = 400.0,
oddball_distances: Union[int, Iterable[int]] = 10,
distance_weights: Union[float, Iterable[float]] = 1.0,
duration: float = 600.0,
sampling_rate: int = 44_100,
output_path: Optional[PathLike] = None,
) -> np.ndarray:
"""Generate an auditory oddball stimulus.
Parameters
----------
click_frequency : float
Click frequency (Hz)
click_duration : float
Click duration (s)
click_tone : float
Non-oddball click tone (Hz)
oddball_tone : float
Oddball click tone (Hz)
oddball_distances : Union[int, Iterable[int]]
Distance between oddball clicks, or a list of possible distances (s)
distance_weights : Union[float, Iterable[float]]
Probability of each possible oddball distance
duration : float
Duration of the created stimulus (s)
sampling_rate : float
Audio data sampling rate (Hz)
output_path : Optional[PathLike]
Output path
Returns
-------
np.ndarray
Generated stimulus data
"""
oddball_distances, distance_weights = normalize_distance_parameters(
oddball_distances, distance_weights
)
oddball_indices = generate_oddball_indices(
oddball_distances, distance_weights, duration
)
click_times, oddball_times = infer_stimulus_timings(
click_frequency, duration, oddball_indices
)
stimulus = create_oddbal_audio(
click_times,
oddball_times,
click_tone,
oddball_tone,
click_duration,
oddball_duration,
sampling_rate,
)
return stimulus
@ZviBaratz
Copy link
Author

Example usage:

>>> output_path = Path("oddball.wav")
>>> sampling_rate = 44_100
>>> audio = generate_oddball_stimulus(
           click_frequency=2, 
           click_duration=0.2,
           click_tone=800,
           oddball_tone=1500,
           oddball_distances=range(4, 9),
           distance_weights=[1, 2, 3, 2, 1],
           sampling_rate=sampling_rate,
           output_path=output_path
       ),  

This should create an oddball.wav in the present working directory, and return a numpy array with the generated audio:

>>> type(audio)
numpy.ndarray

To create a time-indexed pd.Series:

>>> import pandas as pd
>>> index = np.arange(audio.size) / sampling_rate
>>> audio_series = pd.Series(data=audio, index=index, name="Audio")
>>> audio_series.index.name = "Time"

Now we can easily plot the first 2 seconds by executing:

>>> audio_series[:2].plot(ylabel="Amplitude", title="Oddball Audio")

oddball_audio

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment