Trials (2)¶
Source: demos/trial_2
This demo follows on from the previous Trial demo. Its key feature is programmatically
generating audio stimuli. Instead of manually creating a folder of audio stimuli
in advance, the experimenter instead defines a custom function, in this case
synth_prosody
, which is called to generate stimuli.
The stimulus set is specified in the form of a list of Nodes. The Node is a core concept in PsyNet that typically corresponds to some kind of ‘stimulus generator’. We define a collection of Nodes using a so-called list comprehension. List comprehensions are a special feature of Python that are really great for creating exhaustive combinations of experimental parameters. For example, the following part of the list comprehension makes sure that Nodes are created in all combinations of five frequency gradients and three start frequencies.
for frequency_gradient in [-100, -50, 0, 50, 100]
for start_frequency in [-100, 0, 100]
Each Node is linked to a Cached Function Asset. Assets correspond to files that are managed by PsyNet. A Function Asset is an Asset that is generated by a function; a Cached Function Asset is a Function Asset whose results are cached to avoid unnecessary resource usage. Typically the outputs will be stored on a web server and the caching will serve to avoid running the functions and uploading the files, both of which can otherwise be time-consuming. When you run an experiment, PsyNet will automatically check the status of these Assets and perform any computations or uploads that are necessary.
Source: demos/trial_2/experiment.py
import random
import psynet.experiment
from psynet.asset import CachedFunctionAsset, LocalStorage, S3Storage # noqa
from psynet.bot import Bot
from psynet.consent import NoConsent
from psynet.modular_page import AudioPrompt, ModularPage, PushButtonControl
from psynet.page import SuccessfulEndPage
from psynet.timeline import Module, Timeline, for_loop
from psynet.trial import Node, Trial
from .custom_synth import synth_prosody
def synth_stimulus(path, frequencies):
synth_prosody(vector=frequencies, output_path=path)
NODES = [
Node(
definition={
"frequency_gradient": frequency_gradient,
"start_frequency": start_frequency,
"frequencies": [start_frequency + i * frequency_gradient for i in range(5)],
},
assets={
"stimulus": CachedFunctionAsset(
function=synth_stimulus,
extension=".wav",
)
},
)
for frequency_gradient in [-100, -50, 0, 50, 100]
for start_frequency in [-100, 0, 100]
]
class RateTrial(Trial):
time_estimate = 5
def show_trial(self, experiment, participant):
return ModularPage(
"audio_rating",
AudioPrompt(
self.node.assets["stimulus"],
text="How happy is the following word?",
),
PushButtonControl(
["Not at all", "A little", "Very much"],
),
)
audio_ratings = Module(
"audio_ratings",
for_loop(
label="Deliver 5 random samples from the stimulus set",
iterate_over=lambda nodes: random.sample(nodes, 5),
logic=lambda node: RateTrial.cue(node),
time_estimate_per_iteration=RateTrial.time_estimate,
expected_repetitions=5,
),
nodes=NODES,
)
class Exp(psynet.experiment.Experiment):
label = "Simple trial demo (2)"
asset_storage = LocalStorage()
# asset_storage = S3Storage("psynet-tests", "static-audio")
timeline = Timeline(
NoConsent(),
audio_ratings,
SuccessfulEndPage(),
)
def test_check_bot(self, bot: Bot, **kwargs):
assert len(bot.alive_trials) == 5
Source: demos/trial_2/custom_synth.py
import os
import numpy as np
def synth_prosody(vector, output_path):
"""
Synthesises a stimulus.
Parameters
----------
vector : list
A vector of parameters as produced by the Gibbs sampler,
for example:
::
[144.11735609, 159.17558762, 232.15967799, 298.43893329, 348.34553954]
output_path : str
The output path for the generated file.
"""
assert len(vector) == 5
times = np.array([0.0, 0.090453, 0.18091, 0.27136, 0.36181])
freqs = np.array(vector)
x = np.column_stack((times, freqs))
effects = [{"name": "fade-out", "duration": 0.01}]
synth_batch(
[x],
[output_path],
"synth_files/audio/3c_sp5_cr_su_i10_g00_bier_no-b_flat_235Hz_no-sil.wav",
prepend_path="synth_files/audio/2b_sp5_cr_su_i10_g00_bier_b-only.wav",
append_path="synth_files/audio/silence.wav",
effects=effects,
)
def synth_batch(
BPFs,
filenames,
baseline_audio_path,
prepend_path=None,
append_path=None,
reference_tone=235,
man_step_size=0.01,
man_min_F0=75,
man_max_F0=600,
effects=[],
):
"""
Create stimuli based on BPFs
Parameters:
BPFs (list): List of numpy matrices the first column is time, the second column pitch change in cents
filenames (list): Filenames of synthesized files
baseline_audio_path (str): Filepath to baseline
prepend_path (str): name of the wav file to prepend to the audio
append_path (str): name of the wav file to append to the audio
reference_tone (int): default 235 Hz
man_step_size (float): The pitch tracking window size
man_min_F0 (float): The pitch floor
man_max_F0 (float): The pitch ceiling
effects (list): List of dictionaries that describe effects applied to the baseline_audio_path
"""
from parselmouth import Sound
from parselmouth.praat import call
from scipy.io.wavfile import write as write_wav
# Do some checks
supported_effects = ["fade-out"]
if not all(
["name" in e.keys() and e["name"] in supported_effects for e in effects]
):
raise ValueError(
"Your effect must have a name. Currently we only support the following effects: %s"
% ", ".join(supported_effects)
)
if len(BPFs) != len(filenames):
raise ValueError("Need to be of same length!")
if append_path is not None and not os.path.exists(append_path):
raise FileNotFoundError("Specified `append_path` not found on this system")
if prepend_path is not None and not os.path.exists(prepend_path):
raise FileNotFoundError("Specified `prepend_path` not found on this system")
if not os.path.exists(baseline_audio_path):
raise FileNotFoundError(
"Specified `baseline_audio_path` not found on this system"
)
def cent2herz(ct, base=reference_tone):
"""Converts deviation in cents to a value in Hertz"""
st = ct / 100
semi1 = np.log(np.power(2, 1 / 12))
return np.exp(st * semi1) * base
# Load the sound
sound = Sound(baseline_audio_path)
if prepend_path is not None:
pre_sound = Sound(prepend_path)
if append_path is not None:
app_sound = Sound(append_path)
# Create a manipulation object
manipulation = call(sound, "To Manipulation", man_step_size, man_min_F0, man_max_F0)
# Extract the pitch tier
pitch_tier = call(manipulation, "Extract pitch tier")
for BPF_idx, BPF in enumerate(BPFs):
# Make sure the pitch Tier is empty
call(pitch_tier, "Remove points between", sound.xmin, sound.xmax)
# Convert cents to Hertz
BPF[:, 1] = [cent2herz(ct) for ct in BPF[:, 1]]
# Populate the pitch tier
for point_idx in range(BPF.shape[0]):
call(pitch_tier, "Add point", BPF[point_idx, 0], BPF[point_idx, 1])
# Use it in the manipulation object
call([pitch_tier, manipulation], "Replace pitch tier")
# Synthesize it
synth_main = call(manipulation, "Get resynthesis (overlap-add)")
# Assuming all effects are applied to the main file
for effect in effects:
if effect["name"] == "fade-out":
if "duration" in effect.keys():
call(
synth_main,
"Fade out",
1,
synth_main.xmax - effect["duration"],
effect["duration"],
"yes",
)
# Concatenate it
if prepend_path is not None or append_path is not None:
sounds = []
if prepend_path is not None:
sounds.append(pre_sound)
sounds.append(synth_main)
if append_path is not None:
sounds.append(app_sound)
synth_main = call(sounds, "Concatenate")
filepath = filenames[BPF_idx]
write_wav(filepath, int(synth_main.sampling_frequency), synth_main.values.T)
call(synth_main, "Save as WAV file", filepath)