Poisson Clicks (Python)

Español (English below; by Google translate)

Es usual estudiar cognición numérica con estimulos visuales. Hay excepciones claro está, pero es usual. Los estimulos sonoros también se pueden usar para estudiar cognición numérica. Este post provee el código para generar clicks sonoros Poisson independientes en Python, siguiendo lo hecho por Brunton, et al, 2013.

English (by Google translate with some edits)

It is usual to study numerical cognition with visual stimuli. There are exceptions of course, but it is usual. Sound stimuli can also be used to study numerical cognition. This post provides the code to generate independent Poisson click sounds in Python, following what was done by Brunton, et al, 2013.

import numpy as np
import pandas as pd
import sounddevice as sd



def frames_to_samples(frames, *, hop_length=512, n_fft=None):
    """Converts frame indices to audio sample indices.
    NOTE: TAKEN FROM LIBROSA TO BE ABLE TO BE USED IN PSYCHOPY
    """

    offset = 0
    if n_fft is not None:
        offset = int(n_fft // 2)

    return (np.asanyarray(frames) * hop_length + offset).astype(int)

def time_to_samples(times, *, sr=22050):
    """Convert timestamps (in seconds) to sample indices.
    NOTE: TAKEN FROM LIBROSA TO BE ABLE TO BE USED IN PSYCHOPY
    """

    return (np.asanyarray(times) * sr).astype(int)

def clicks(
    *,
    times=None,
    frames=None,
    sr=22050,
    hop_length=512,
    click_freq=1000.0,
    click_duration=0.1,
    click=None,
    length=None,
):
    """Construct a "click track".
    NOTE: TAKEN FROM LIBROSA TO BE ABLE TO BE USED IN PSYCHOPY
    This returns a signal with the signal ``click`` sound placed at
    each specified time.
    """

    # Compute sample positions from time or frames
    if times is None:
        if frames is None:
            raise ValueError('either "times" or "frames" must be provided')
            
        positions = frames_to_samples(frames, hop_length=hop_length)
    else:
        # Convert times to positions
        positions = time_to_samples(times, sr=sr)

    if click is not None:
        # Check that we have a well-formed audio buffer
        util.valid_audio(click, mono=False)

    else:
        # Create default click signal
        if click_duration <= 0:
            raise ValueError("click_duration must be strictly positive")

        if click_freq <= 0:
            raise ValueError("click_freq must be strictly positive")

        angular_freq = 2 * np.pi * click_freq / float(sr)

        click = np.logspace(0, -10, num=int(np.round(sr * click_duration)), base=2.0)

        click *= np.sin(angular_freq * np.arange(len(click)))

    # Set default length
    if length is None:
        length = positions.max() + click.shape[-1]
    else:
        if length < 1:
            raise ValueError("length must be a positive integer")

        # Filter out any positions past the length boundary
        positions = positions[positions < length]

    # Pre-allocate click signal
    shape = list(click.shape)
    shape[-1] = length
    click_signal = np.zeros(shape, dtype=np.float32)

    # Place clicks
    for start in positions:
        # Compute the end-point of this click
        end = start + click.shape[-1]

        if end >= length:
            click_signal[..., start:] += click[..., : length - start]
        else:
            # Normally, just add a click here
            click_signal[..., start:end] += click

    return click_signal


def click_trains_brunton_et_al(poisson_max, poisson_left, duration_clicks): 
    #INPUTS:
        #poisson_max, poisson_left: positive scalar; parameters for the poissons (poisson_right is the complement)
        #duration_clicks: float in secs e.g. 3.4 would be an approximately 3.4 second click train (it will be longer because of the tail of zeros; see explanation below)
        
    #OUTPUTS:
        #number of clicks to the left and right
        #2D array with sinusoidal values for clicks, each column is a channel.
    
    sampling_rate = 44100
    click_dur = 0.01 #in seconds
    click_freq = 0.01*sampling_rate #1000 #in Hz
    alpha = 0.8 #Brunton et al 2013 supplemental used 0.8 i.e.: they wanted a distribution with 0.8 variance of poisson_max_actual (see normal below) (to make the task a bit harder)
    T = duration_clicks #desired duration of trials in seconds    
    poisson_right = poisson_max - poisson_left
    n_right = -1 #number of clicks
    n_left = -1
    while n_right<=0 or n_left<=0: #number of clicks can't be negative
        poisson_max_actual = np.max([1, np.random.poisson(T*poisson_max,1)[0]]) #random poisson number of clicks
        nR_minus_nL = np.random.normal(loc=T*(poisson_right-poisson_left), 
                                       scale=np.sqrt(alpha*T*(poisson_max)), size=1)
        n_right = (nR_minus_nL+poisson_max_actual)/2 #this is the solution for NR+NL = poisson_max_actual and NR-NL = nR_minus_nL
        n_left = poisson_max_actual - n_right 
    
    n_right = int(np.round(n_right))
    n_left = int(np.round(n_left))
    
    clicks_left = np.concatenate((np.ones(n_left), np.zeros(poisson_max_actual-n_left)))
    np.random.shuffle(clicks_left) #this mutates the actual array, so no update of the array is necessary
    clicks_right = np.concatenate((np.ones(n_right), np.zeros(poisson_max_actual-n_right)))
    np.random.shuffle(clicks_right)

    clicks_left_times = T*np.where(clicks_left==1)[0]/poisson_max_actual #in secs
    clicks_right_times = T*np.where(clicks_right==1)[0]/poisson_max_actual
    clicks_left_sound = clicks(times = clicks_left_times, sr = sampling_rate, 
                               click_duration = click_dur, click_freq = click_freq)
    clicks_right_sound = clicks(times = clicks_right_times, sr = sampling_rate, 
                                click_duration = click_dur, click_freq = click_freq)
    if clicks_left_sound.shape[0]>clicks_right_sound.shape[0]: 
        #equate size by appending zeros to the shortest array    
        clicks_right_sound = np.append(clicks_right_sound, np.zeros(clicks_left_sound.shape[0]-clicks_right_sound.shape[0])) 
    else:
        #equate size by appending zeros to the shortest array
        clicks_left_sound = np.append(clicks_left_sound, np.zeros(clicks_right_sound.shape[0]-clicks_left_sound.shape[0]))

    tail_zeros = np.zeros(np.array(np.round(0.05*sampling_rate), dtype = int))
    clicks_left_sound_A = np.append(clicks_left_sound, tail_zeros) #zeros at the end so that the clicks don't end abruptly
    clicks_right_sound_A = np.append(clicks_right_sound, tail_zeros) 
    clicks_stereo_sound_A = np.concatenate((np.reshape(clicks_left_sound, (clicks_left_sound.shape[0],1)), 
                                            np.reshape(clicks_right_sound, (clicks_right_sound.shape[0],1))), 
                                           axis=1)
    clicks_left_A = clicks_left.sum()
    clicks_right_A = clicks_right.sum()
    poisson_max_actual_A = poisson_max_actual
    
     
    return clicks_left_A, clicks_right_A, clicks_stereo_sound_A

#Example usage
pm = 20 #poisson_max clicks per second through both ears; not an actual max, it is the mean of a poisson; Brunton, et al, (2013) used 20 for humans, 40 for rats.
pl = 0.5*pm #poisson clicks that sound on the left speaker/headphone (Brunton et al 2013 use a 1:1 ratio for pl and pr)
dur = 3 #duration of the soundbit (in secs)
NL, NR, clicksLR = click_trains_brunton_et_al(pm, pl, dur) #NL: number of clicks on the left (not the same as pl), NR: clicks on the right, clicksLR: the array with the soundbit of right and left clicks
sd.play(clicksLR) #these should play the sound
Santiago Alonso-Díaz
Santiago Alonso-Díaz
Professor

My research interests include math cognition, bayesian cognition.