MultiTaskBattery 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1703 @@
1
+ # Task Class definitions
2
+ # March 2021: First version: Ladan Shahshahani - Maedbh King - Suzanne Witt,
3
+ # Revised 2023: Bassel Arafat, Jorn Diedrichsen, Incé Husain
4
+ # Revised 2024: Caroline Nettekoven
5
+
6
+ from pathlib import Path
7
+ import pandas as pd
8
+ import numpy as np
9
+ import random
10
+ from psychopy import prefs
11
+ prefs.hardware['audioLib'] = ['sounddevice']
12
+ from psychopy import visual, sound, core, event
13
+ from psychopy.tools.monitorunittools import deg2pix
14
+ from pyglet.window import key
15
+ import MultiTaskBattery.utils as ut
16
+ from ast import literal_eval
17
+ from copy import deepcopy
18
+ from moviepy.audio.io.AudioFileClip import AudioFileClip
19
+ import gc
20
+ import math
21
+ import json
22
+
23
+
24
+ class Task:
25
+ """
26
+ Task: takes in inputs from run_experiment.py and methods
27
+ some methods are universal across all tasks. Those methods are included in the super class Task.
28
+ There are methods like display_instructions which are the same across most of the tasks.
29
+ There are, however, some tasks that have different instructions from the rest (like fingerSequence).
30
+ For those tasks, a display_instructions method is defined within the corresponding class which overrides
31
+ the universal display_instruction method.
32
+ Each of other classes runs a unique task given input from target files and from the Task class
33
+ (VisualSearch, SemanticPrediction, NBack, SocialPrediction, ActionObservation).
34
+ """
35
+
36
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
37
+
38
+ # Pointers to Screen and experimental constants
39
+ self.screen = screen
40
+ self.window = screen.window # Shortcut to window
41
+ self.const = const
42
+ self.ttl_clock = ttl_clock # This is a reference to the clock of the run
43
+ self.name = info['task_name']
44
+ self.descriptive_name = info['descriptive_name']
45
+ self.code = info['task_code']
46
+ self.task_file = info['task_file']
47
+ self.feedback_type = 'none'
48
+
49
+ # Set the instruction text height to a value defined in the constants, or use default - useful for smaller screens
50
+ self.const.instruction_text_height = getattr(self.const, 'instruction_text_height', None) or 1
51
+
52
+ # Width the instruction text wraps at, in degrees. PsychoPy's own default is
53
+ # narrow enough to break short lines such as "Left - Press [1] Right - Press [2]".
54
+ self.const.instruction_wrap_width = getattr(self.const, 'instruction_wrap_width', None) or 30
55
+
56
+ def init_task(self):
57
+ """
58
+ Initialize task - default is to read the target information into the trial_info dataframe
59
+ """
60
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
61
+
62
+ def display_instructions(self):
63
+ """
64
+ Displays the descriptive name of the task.
65
+ then instruction to press response key 1 for true and response key 2 for false.
66
+ Tasks with different instructions implement a new version to overwrite this default version
67
+ """
68
+ true_str = f"if True press {self.const.response_keys[1]}"
69
+ false_str = f"if False press {self.const.response_keys[2]}"
70
+
71
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {true_str} \n {false_str}"
72
+
73
+ # 3.2 display the instruction text
74
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
75
+ # instr.size = 0.8
76
+ instr_visual.draw()
77
+ self.window.flip()
78
+
79
+ def run(self):
80
+ """Loop over trials in the task object and collects data
81
+ Data will be stored in self.trial_data
82
+
83
+ Returns:
84
+ acc (float): Mean accuracy in proportion correct
85
+ rt (float): Mean reaction time in s
86
+ """
87
+ self.trial_data = [] # an empty list which will be appended with the responses from each trial
88
+
89
+ for i,trial in self.trial_info.iterrows():
90
+ t_data = trial.copy()
91
+ # Wait for the the start of next trial
92
+ t_data['real_start_time'],t_data['start_ttl'],t_data['start_ttl_time'] = self.ttl_clock.wait_until(self.start_time + trial.start_time )
93
+ # Run the trial
94
+ t_data = self.run_trial(t_data)
95
+ # Append the trial data
96
+ self.trial_data.append(t_data)
97
+ self.trial_data = pd.DataFrame(self.trial_data)
98
+ # Calculate the feedback for the run
99
+ acc = None
100
+ rt = None
101
+ if self.feedback_type[:3] == 'acc':
102
+ acc = self.trial_data['correct'].mean()
103
+ if self.feedback_type[-2:] == 'rt':
104
+ rt = self.trial_data['rt'].mean()
105
+ return acc,rt
106
+
107
+
108
+ def show_progress(self, seconds_left, show_last_seconds=5, height=1, width=10, x_pos=-5, y_pos=8):
109
+ """ Displays a progress bar for the Picture Sequence task
110
+
111
+ Args:
112
+ seconds_left (float):
113
+ The number of seconds remaining in the current trial.
114
+ If this value is greater than `show_last_seconds`, the progress bar is not shown.
115
+
116
+ show_last_seconds (float, optional):
117
+ The duration (in seconds) over which to display the progress bar at the end of a trial.
118
+ Default is 5 seconds. When `seconds_left` is less than this value, the progress bar appears.
119
+
120
+ height (float, optional):
121
+ The vertical size of the progress bar in PsychoPy window units. Default is 1.
122
+
123
+ width (float, optional):
124
+ The horizontal size of the progress bar in PsychoPy window units. Default is 10.
125
+
126
+ x_pos (float, optional):
127
+ The horizontal position of the center of the progress bar in window coordinates.
128
+ Negative values move it leftward. Default is -5.
129
+
130
+ y_pos (float, optional):
131
+ The vertical position of the center of the progress bar in window coordinates.
132
+ Positive values move it upward. Default is 8.
133
+ """
134
+ # If we are in the last five seconds of the trial, display the remaining time
135
+ if seconds_left < show_last_seconds:
136
+ progress = visual.Progress(
137
+ win=self.window,
138
+ progress=1-(seconds_left/show_last_seconds),
139
+ size=(width, height),
140
+ pos=(x_pos, y_pos),
141
+ backColor='blue',
142
+ barColor='black',
143
+ borderColor='black',
144
+ lineWidth=5,
145
+ )
146
+ progress.draw()
147
+
148
+ def wait_response(self, start_time, max_wait_time, show_last_seconds=None, current_stimuli=None):
149
+ """
150
+ waits for a response to be made and then returns the response
151
+
152
+ Args:
153
+ start_time (float): the time the RT-period started
154
+ max_wait_time (float): How long to wait maximally
155
+ show_last_seconds (float or None): If set, show a progress bar
156
+ during the final *show_last_seconds* of the response window.
157
+ Requires *current_stimuli* to be set so the display can be
158
+ redrawn each frame.
159
+ current_stimuli (optional): A single PsychoPy stimulus or a list of stimuli
160
+ to redraw each frame when the progress bar is active.
161
+ Returns:
162
+ key (str): the key that was pressed (1-4) (0 if no key was pressed)
163
+ rt (float): the reaction time (nan if no key was pressed)
164
+ """
165
+ response_made = False
166
+ key = 0
167
+ rt = np.nan
168
+
169
+ while (self.ttl_clock.get_time() - start_time <= max_wait_time) and not response_made:
170
+ self.ttl_clock.update()
171
+ if show_last_seconds is not None:
172
+ if isinstance(current_stimuli, (list, tuple)):
173
+ for stim in current_stimuli:
174
+ stim.draw()
175
+ else:
176
+ current_stimuli.draw()
177
+ seconds_left = max_wait_time - (self.ttl_clock.get_time() - start_time)
178
+ self.show_progress(seconds_left,
179
+ show_last_seconds=show_last_seconds,
180
+ y_pos=6)
181
+ self.window.flip()
182
+ keys=event.getKeys(keyList= self.const.response_keys, timeStamped=self.ttl_clock.clock)
183
+ if len(keys)>0:
184
+ response_made = True
185
+ key_char = keys[0][0]
186
+ key = self.const.response_keys.index(key_char) + 1
187
+ rt = keys[0][1] - start_time
188
+ return key, rt
189
+
190
+ def display_trial_feedback(self, give_feedback,correct_response):
191
+ """
192
+ display the feedback for the current trial at the position of fixation cross. Correct is a green check mark, incorrect a red cross.
193
+
194
+ Args:
195
+ give_feedback (bool):
196
+ If true, gives informative feedback - otherwise just shows fixation cross
197
+ correct_response (bool):
198
+ Response was correct?, False otherwise
199
+ """
200
+ if give_feedback:
201
+ if correct_response:
202
+ self.screen.check_mark('green')
203
+ else:
204
+ self.screen.error_cross('red')
205
+ else:
206
+ self.screen.fixation_cross('white')
207
+
208
+ def save_data(self, subj_id, run_num):
209
+ """Saves the data to the trial data file
210
+
211
+ Args:
212
+ subj_id (str): Subject id to determine name
213
+ run_num (int): Number of run - inserted as first column
214
+ """
215
+ self.trial_data.insert(0, 'run_num', [run_num]*len(self.trial_data))
216
+ trial_data_file = self.const.data_dir / subj_id / f"{subj_id}_task-{self.code}.tsv"
217
+ ut.append_data_to_file(trial_data_file, self.trial_data)
218
+
219
+ def screen_quit(self):
220
+ """ Checks for quit or escape key presses and quits the experiment if necessary """
221
+ keys = event.getKeys()
222
+ for key in keys:
223
+ if 'q' in key or 'esc' in key:
224
+ self.window.close()
225
+ core.quit()
226
+
227
+ def get_audio_from_movie(self, movie_path, sample_rate=48000):
228
+ """Seperates the audio from the movie file and returns the audio object (for better memory handling when playing movies with sound)
229
+
230
+ Args:
231
+ movie_path (str): Path and filename of the Movie file
232
+ sample_rate (int): Sampling rate of movie file, defaults to 48000.
233
+ Returns:
234
+ audio (sound.sound): Sound object
235
+ """
236
+
237
+ # Gets the movie audio
238
+ audio_clip = AudioFileClip(movie_path)
239
+ audio_array = audio_clip.to_soundarray(fps=sample_rate)
240
+ audio_clip.close()
241
+ audio = sound.Sound(audio_array,sampleRate=sample_rate, stereo=True)
242
+
243
+ return audio
244
+
245
+ class NBack(Task):
246
+
247
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
248
+ super().__init__(info, screen, ttl_clock, const, subj_id)
249
+ self.feedback_type = 'acc+rt'
250
+
251
+ def init_task(self):
252
+ """
253
+ Initialize task - default is to read the target information into the trial_info dataframe
254
+ """
255
+ trial_info_file = self.const.task_dir / self.name / self.task_file
256
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
257
+ self.stim = []
258
+ stim_width = self.trial_info['stim_width'].iloc[0] if 'stim_width' in self.trial_info.columns else 18.4
259
+ for stim in self.trial_info['stim']:
260
+ stim_path = ut.find_stim(self.const, self.name, stim)
261
+ img = visual.ImageStim(self.window, str(stim_path), size=(stim_width, None)) # width in deg
262
+ self.stim.append(img)
263
+ self.corr_key = [self.trial_info['key_nomatch'].iloc[0], self.trial_info['key_match'].iloc[0]]
264
+ self.n_back = self.infer_n_back()
265
+
266
+ def infer_n_back(self):
267
+ """ Derive the numeric n-back level (used only for the instruction text).
268
+
269
+ The level lives in the 'condition' column as an 'N-back' label (e.g.
270
+ '2-back'), so a hand-made task file only needs that one column. Defaults
271
+ to 2 if no usable 'condition' label is present.
272
+ """
273
+ if 'condition' in self.trial_info.columns:
274
+ head = str(self.trial_info['condition'].iloc[0]).split('-')[0].strip()
275
+ if head.isdigit():
276
+ return int(head)
277
+ return 2
278
+
279
+ def display_instructions(self):
280
+ """
281
+ displays the instruction for the task
282
+ """
283
+ str1 = f"Compare image to the one shown {self.n_back} previously"
284
+ str2 = f"if match, press {self.corr_key[1]}"
285
+ str3 = f"if no match, press {self.corr_key[0]}"
286
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
287
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
288
+ instr_visual.draw()
289
+ self.window.flip()
290
+
291
+ def run_trial(self,trial):
292
+ """Runs a single trial of the nback task (after it started)
293
+ Args:
294
+ trial (pd.Series):
295
+ Row of trial_info, with all information about the trial
296
+
297
+ Returns:
298
+ trial (pd.Series):
299
+ Row of trial_data with all response data added
300
+ """
301
+
302
+ # Flush any keys in buffer
303
+ event.clearEvents()
304
+
305
+ # display stimulus
306
+ self.stim[trial['trial_num']].draw()
307
+ self.window.flip()
308
+
309
+ # collect responses 0: no response 1-4: key pressed
310
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
311
+ trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
312
+
313
+ # display trial feedback
314
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
315
+ return trial
316
+
317
+ class Rest(Task):
318
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
319
+ super().__init__(info, screen, ttl_clock, const, subj_id)
320
+ self.name = 'rest'
321
+
322
+ def display_instructions(self): # overriding the display instruction routine from the parent
323
+ self.instruction_text = 'Rest: Fixate on the cross'
324
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
325
+ # instr.size = 0.8
326
+ instr_visual.draw()
327
+ self.window.flip()
328
+
329
+ def show_stim(self):
330
+ # show fixation cross
331
+ self.screen.fixation_cross()
332
+
333
+ def run_trial(self,trial):
334
+ # get current time (self.t0)
335
+ self.screen.fixation_cross()
336
+ self.ttl_clock.wait_until(self.start_time + trial['trial_dur'])
337
+ return trial
338
+
339
+ class VerbGeneration(Task):
340
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
341
+ super().__init__(info, screen, ttl_clock, const, subj_id)
342
+
343
+ def init_task(self):
344
+ """
345
+ Initialize task - default is to read the target information into the trial_info dataframe
346
+ """
347
+ trial_info_file = self.const.task_dir / self.name / self.task_file
348
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
349
+ self.trial_info['noun'] = self.trial_info['stim'].str.strip()
350
+
351
+ cue_labels = {'read': 'READ', 'generate': 'GENERATE'}
352
+ cue_colors = {'read': 'red', 'generate': 'green'}
353
+
354
+ def cue(self, condition):
355
+ """ Build the coloured condition label drawn just above the stimulus word. """
356
+ return visual.TextStim(self.window,
357
+ text=self.cue_labels.get(condition, condition.upper()),
358
+ pos=(0.0, 2.5), color=self.cue_colors.get(condition, 'black'),
359
+ units='deg', height=1, bold=True)
360
+
361
+ def display_instructions(self):
362
+ str1 = "A label at the top of the screen tells you what to do for each word:"
363
+ str2 = f"{self.cue_labels['read']} - silently read the word"
364
+ str3 = f"{self.cue_labels['generate']} - silently think of a verb that goes with it"
365
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
366
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
367
+ instr_visual.draw()
368
+ self.window.flip()
369
+
370
+ def show_stim(self, noun, condition, text_height=2):
371
+ """ Display the condition cue (top) and the word for a fixed time. """
372
+ stim = visual.TextStim(self.window, text=noun, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=text_height)
373
+ self.cue(condition).draw()
374
+ stim.draw()
375
+ self.window.flip()
376
+
377
+ def run_trial(self, trial):
378
+ """ Run a single trial of the VerbGeneration task. """
379
+
380
+ self.show_stim(trial['noun'], trial['condition'], trial.get('text_height', 2))
381
+
382
+ # wait for trial duration
383
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
384
+
385
+ # ITI: blank the word but keep the cue on, so the context never disappears
386
+ self.cue(trial['condition']).draw()
387
+ self.screen.fixation_cross(flip=False)
388
+ self.window.flip()
389
+ return trial
390
+
391
+ class TongueMovement(Task):
392
+ """
393
+ Tongue movement following Buckner et al., 2022! No particular feedback.
394
+ """
395
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
396
+ super().__init__(info, screen, ttl_clock, const, subj_id)
397
+
398
+ def display_instructions(self):
399
+ self.instruction_text = f"{self.descriptive_name} Task \n\n Move your tongue left to right touching your upper premolar teeth"
400
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
401
+ instr_visual.draw()
402
+ self.window.flip()
403
+
404
+ def run_trial(self, trial):
405
+ """ Run a single trial of the tonguemovement task. """
406
+
407
+ # draw fixation cross without flipping
408
+ self.screen.fixation_cross(flip=False)
409
+
410
+ # Check the trial_type and display the corresponding stimulus
411
+ if trial['trial_type'] == 'right':
412
+ # If trial_type is 'right', show the black circle around the fixation cross
413
+ circle_visual = visual.Circle(self.window, size=6, edges= 100, lineWidth = 20, fillColor=None, lineColor='black')
414
+ circle_visual.draw()
415
+
416
+ self.window.flip()
417
+
418
+ # wait for trial duration
419
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
420
+
421
+ # return to a plain fixation cross (no response, so no feedback)
422
+ self.screen.fixation_cross()
423
+
424
+ return trial
425
+
426
+ class MotorLocalizer(Task):
427
+ """
428
+ General motor localizer. Each row shows a condition label (e.g. 'hand',
429
+ 'foot', 'tongue') at the top of the screen in black, plus a central
430
+ fixation cross. A circle appears around the fixation when trial_type == 1
431
+ and is absent when trial_type == 0. The participant performs the movement
432
+ associated with the current condition.
433
+
434
+ The condition -> movement mapping (e.g. tongue = left-right, foot =
435
+ flex/extend) and the meaning of the circle appearing/disappearing are
436
+ explained to the participant during training and are deliberately NOT
437
+ encoded here: the code only reads the condition and trial_type and renders
438
+ them. No response is collected, so there is no feedback. Because the
439
+ condition can change within a block, a single block can localize several
440
+ body parts (e.g. tongue, hand and foot).
441
+ """
442
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
443
+ super().__init__(info, screen, ttl_clock, const, subj_id)
444
+ self.feedback_type = 'None'
445
+
446
+ def init_task(self):
447
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
448
+
449
+ def display_instructions(self):
450
+ self.instruction_text = f"{self.descriptive_name} Task \n\n Move the indicated body part as instructed"
451
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text,
452
+ height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
453
+ instr_visual.draw()
454
+ self.window.flip()
455
+
456
+ def run_trial(self, trial):
457
+ """ Run a single row of the motor-localizer task. """
458
+ # Condition label (body part) at the top of the screen, in black
459
+ cond_visual = visual.TextStim(self.window, text=str(trial['condition']), pos=(0.0, trial.get('label_y', 6.0)),
460
+ color=(-1, -1, -1), units='deg', height=trial.get('text_height', 1.5))
461
+ cond_visual.draw()
462
+
463
+ # Central fixation cross (drawn without flipping yet)
464
+ self.screen.fixation_cross(flip=False)
465
+
466
+ # Circle around the fixation only when trial_type == 1
467
+ if int(trial['trial_type']) == 1:
468
+ circle_visual = visual.Circle(self.window, size=6, edges=100, lineWidth=20,
469
+ fillColor=None, lineColor='black')
470
+ circle_visual.draw()
471
+
472
+ self.window.flip()
473
+
474
+ # hold this row for its duration (no response, so no feedback)
475
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
476
+ return trial
477
+
478
+ class AuditoryNarrative(Task):
479
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
480
+ super().__init__(info, screen, ttl_clock, const, subj_id)
481
+
482
+ def display_instructions(self):
483
+ self.instruction_text = f'{self.descriptive_name} Task\n\nListen to the narrative attentively.'
484
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
485
+ instr_visual.draw()
486
+ self.window.flip()
487
+
488
+ def run_trial(self, trial):
489
+ """ Run a single trial of the AuditoryNarrative task. """
490
+ self.screen.fixation_cross()
491
+
492
+ # Load and play audio stimulus for the current trial
493
+ audio_path = ut.find_stim(self.const, self.name, trial['stim'])
494
+ audio_stim = sound.Sound(str(audio_path))
495
+ audio_stim.play()
496
+
497
+ trial_dur = audio_stim.getDuration()
498
+
499
+ # wait for trial duration (fixation cross stays on screen during playback)
500
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
501
+
502
+ return trial
503
+
504
+
505
+ class SpatialNavigation(Task):
506
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
507
+ super().__init__(info, screen, ttl_clock, const, subj_id)
508
+
509
+ def init_task(self):
510
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
511
+
512
+ def display_instructions(self):
513
+ start_location = self.trial_info.iloc[0]['location_1']
514
+ end_location = self.trial_info.iloc[0]['location_2']
515
+
516
+ self.instruction_text = (f"{self.descriptive_name} Task \n\n"
517
+ f"Imagine walking around your childhood home\n"
518
+ f"Start in the {start_location} – end in the {end_location}\n"
519
+ f"Focus on the fixation cross")
520
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
521
+ instr_visual.draw()
522
+ self.window.flip()
523
+
524
+ def run_trial(self,trial):
525
+ # fixation cross stays on screen while the participant imagines the route (no response)
526
+ self.screen.fixation_cross()
527
+
528
+ # wait for trial duration
529
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
530
+
531
+ return trial
532
+
533
+ class TheoryOfMind(Task):
534
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
535
+ super().__init__(info, screen, ttl_clock, const, subj_id)
536
+ self.feedback_type = 'acc+rt'
537
+
538
+ def init_task(self):
539
+ """
540
+ Initialize task - default is to read the target information into the trial_info dataframe
541
+ """
542
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
543
+ self.corr_key = [self.trial_info['key_false'].iloc[0],self.trial_info['key_true'].iloc[0]]
544
+
545
+
546
+ def display_instructions(self):
547
+ """
548
+ displays the instruction for the task
549
+ """
550
+ task_name = visual.TextStim(self.window, text=f'{self.descriptive_name.capitalize()}', height=self.const.instruction_text_height, color=[-1, -1, -1], bold=True, pos=(0, 3))
551
+ task_name.draw()
552
+ str1 = f"You will read a story and decide if the answer to the question is True or False."
553
+ str2 = f"if true, press {self.corr_key[1]}"
554
+ str3 = f"if false, press {self.corr_key[0]}"
555
+ self.instruction_text = f"\n\n {str1} \n\n {str2} \n {str3}"
556
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
557
+ instr_visual.draw()
558
+ self.window.flip()
559
+
560
+ def run_trial(self, trial):
561
+ """ Runs a single trial of the Theory of Mind task """
562
+
563
+ event.clearEvents()
564
+
565
+ height = trial.get('text_height', 1.25)
566
+ wrapWidth = trial.get('wrap_width', 25)
567
+
568
+ # Display story
569
+ story_clean = ' '.join(trial['story'].split('\n'))
570
+ story_formatted = '.\n'.join(story_clean.split('. '))
571
+ story_stim = visual.TextStim(self.window, text=story_formatted, alignHoriz='center', wrapWidth=wrapWidth, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height)
572
+ story_stim.draw()
573
+ self.window.flip()
574
+
575
+ # wait until story duration
576
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['story_dur'])
577
+
578
+ # Flush any keys in buffer
579
+ event.clearEvents()
580
+
581
+ # Display question
582
+ question_stim = visual.TextStim(self.window, text=trial['question'], pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height, wrapWidth=wrapWidth)
583
+ question_stim.draw()
584
+ self.window.flip()
585
+
586
+ # collect responses 0: no response 1-4: key pressed
587
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['question_dur'])
588
+ trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
589
+
590
+ # display trial feedback
591
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
592
+
593
+ return trial
594
+
595
+ class PassageListening(Task):
596
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
597
+ super().__init__(info, screen, ttl_clock, const, subj_id)
598
+
599
+ def display_instructions(self):
600
+ self.instruction_text = f'{self.descriptive_name} Task \n\nListen to the audio attentively.'
601
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
602
+ instr_visual.draw()
603
+ self.window.flip()
604
+
605
+ def run_trial(self, trial):
606
+ """ Run a single trial of the task. """
607
+
608
+ self.screen.fixation_cross()
609
+
610
+ # Load and play audio stimulus for the current trial
611
+ audio_path = ut.find_stim(self.const, self.name, trial['stim'])
612
+ audio_stim = sound.Sound(str(audio_path))
613
+ audio_stim.play()
614
+
615
+ # wait for trial duration (fixation cross stays on screen during playback)
616
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
617
+
618
+ return trial
619
+
620
+ class ActionObservation(Task):
621
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
622
+ super().__init__(info, screen, ttl_clock, const, subj_id)
623
+
624
+ def display_instructions(self): # overriding the display instruction from the parent class
625
+ self.instruction_text = f"{self.descriptive_name} Task \n\n Keep your head still while watching the two clips. \n\n Try and remember the knot shown."
626
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
627
+ instr_visual.draw()
628
+ self.window.flip()
629
+
630
+ def run_trial(self, trial):
631
+ """ Runs a single trial of the ActionObservation task """
632
+ # Assuming that 'stim' column in trial contains the file name of the video clip
633
+ movie_file_name = trial['stim']
634
+
635
+ # Construct the movie file path
636
+ movie_path = ut.find_stim(self.const, self.name, 'clips', movie_file_name)
637
+
638
+ # Convert Path object to string for compatibility
639
+ movie_path_str = str(movie_path)
640
+
641
+ # Create a MovieStim3 object
642
+ # MovieStim sizes are in pixels, so convert the width (deg); height keeps the clip's aspect
643
+ movie_clip = visual.MovieStim(self.window, movie_path_str, loop=False,
644
+ size=(deg2pix(trial.get('stim_width', 18.4), self.window.monitor), None))
645
+
646
+ while movie_clip.isFinished == False:
647
+ movie_clip.play()
648
+ movie_clip.draw()
649
+ self.window.flip()
650
+ self.ttl_clock.update()
651
+
652
+ self.screen.fixation_cross()
653
+
654
+ # Flush memory: This is necessary for the script to be able to run more than 1 run. Presenting movies is very memory hungry, so do not remove!
655
+ movie_clip.unload()
656
+ gc.collect() # Collect garbarge
657
+
658
+ return trial
659
+
660
+ class DemandGrid(Task):
661
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
662
+ super().__init__(info, screen, ttl_clock, const, subj_id)
663
+ self.square_size = 1.5
664
+ self.feedback_type = 'acc+rt'
665
+
666
+ def init_task(self):
667
+ """
668
+ Initialize task - default is to read the target information into the trial_info dataframe
669
+ """
670
+ trial_info_file = self.const.task_dir / self.name / self.task_file
671
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
672
+ self.corr_key = [self.trial_info['key_left'].iloc[0],self.trial_info['key_right'].iloc[0]]
673
+
674
+ def display_instructions(self):
675
+ """
676
+ displays the instruction for the task
677
+ """
678
+ str1 = f"You will watch the sequence of boxes that light up and then choose the correct pattern"
679
+ str2 = f"if left, press {self.corr_key[0]}"
680
+ str3 = f"if right, press {self.corr_key[1]}"
681
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
682
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
683
+ instr_visual.draw()
684
+ self.window.flip()
685
+
686
+ def create_grid(self, sequence=None, position='center',grid_size=(3,4)):
687
+ """Creates the grid of squares for the DemandGrid task, lighting up specific squares blue if a sequence is given,
688
+ and positions the grid left, right, or center."""
689
+ # Calculate offsets based on the desired position
690
+ if position == 'left':
691
+ offset_x = -self.answer_offset
692
+ elif position == 'right':
693
+ offset_x = self.answer_offset
694
+ else: # center
695
+ offset_x = 0
696
+
697
+ # Center the grid vertically
698
+ offset_y = 0
699
+
700
+ grid = []
701
+ # Create and draw the grid
702
+ for i in range(grid_size[0]):
703
+ row = []
704
+ for j in range(grid_size[1]):
705
+ # Calculate position with the offsets
706
+ # i runs over rows (grid_size[0]), j over columns (grid_size[1])
707
+ square_x = (j - grid_size[1] / 2 + 0.5) * self.square_size + offset_x
708
+ square_y = (grid_size[0] / 2 - i - 0.5) * self.square_size + offset_y
709
+
710
+ # Determine the fill color based on the sequence
711
+ fill_color = 'blue' if sequence and (i, j) in sequence else 'white'
712
+
713
+ rect = visual.Rect(self.window, width=self.square_size, height=self.square_size,
714
+ pos=(square_x, square_y), lineWidth=3,
715
+ lineColor='black', fillColor=fill_color)
716
+ rect.draw()
717
+ row.append(rect)
718
+ grid.append(row)
719
+
720
+ return grid
721
+
722
+ def run_trial(self, trial):
723
+ """Runs a single trial of the DemandGrid task with two boxes lighting up at a time"""
724
+ # Draw the entire grid in its initial state
725
+ if 'grid_size' in trial:
726
+ grid_size = literal_eval(trial['grid_size'])
727
+ else:
728
+ grid_size = (3,4)
729
+
730
+ # Make the code adaptable to old DemandGrid implementation
731
+ if 'num_steps' in trial:
732
+ num_steps = trial['num_steps']
733
+ else:
734
+ num_steps = 3
735
+
736
+ step_dur = trial['sequence_dur']/num_steps
737
+ self.square_size = trial.get('square_size', 1.5)
738
+ self.answer_offset = trial.get('answer_offset', 5)
739
+ self.grid = self.create_grid(grid_size=grid_size)
740
+
741
+ # Display the sequence in steps
742
+ if 'original_sequence' in trial:
743
+ original_sequence = literal_eval(trial['original_sequence'])
744
+ else:
745
+ original_sequence = literal_eval(trial['grid_sequence'])
746
+
747
+ if 'num_steps' in trial: # new implementation
748
+ for i in range(num_steps):
749
+ step_sequence_name = f'original_step_{i+1}'
750
+ step_sequence = literal_eval(trial[step_sequence_name])
751
+
752
+ for tuple in step_sequence:
753
+ x, y = tuple
754
+ self.grid[x][y].fillColor = 'blue'
755
+
756
+ for row in self.grid:
757
+ for rect in row:
758
+ rect.draw()
759
+ self.window.flip()
760
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + step_dur)
761
+
762
+ for tuple in step_sequence:
763
+ x, y = tuple
764
+ self.grid[x][y].fillColor = 'white'
765
+
766
+ else: # old implementation
767
+ for i in range(0, len(original_sequence), 2): # Iterate in steps of 2
768
+ if i + 1 < len(original_sequence):
769
+ pair = [original_sequence[i], original_sequence[i + 1]]
770
+ else:
771
+ pair = [original_sequence[i]] # Handle odd-length sequences
772
+
773
+ # Highlight positions in the current pair
774
+ for x, y in pair:
775
+ self.grid[x][y].fillColor = 'blue'
776
+
777
+ # Draw and update the window
778
+ for row in self.grid:
779
+ for rect in row:
780
+ rect.draw()
781
+ self.window.flip()
782
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + step_dur)
783
+
784
+ # Reset colors after the pair
785
+ for x, y in pair:
786
+ self.grid[x][y].fillColor = 'white'
787
+
788
+ # Flush any keys in buffer
789
+ event.clearEvents()
790
+
791
+ # Determine which side the correct sequence will be displayed
792
+ correct_side = trial['correct_side']
793
+
794
+
795
+ # # Display the original and modified sequence on the left or right side
796
+ modified_sequence = literal_eval(trial['modified_sequence'])
797
+
798
+ original_grid = self.create_grid(sequence=original_sequence, position=correct_side, grid_size=grid_size)
799
+ modified_grid = self.create_grid(sequence=modified_sequence, position='left' if correct_side == 'right' else 'right', grid_size=grid_size)
800
+ self.window.flip()
801
+
802
+ # collect responses 0: no response 1-4: key pressed
803
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['question_dur'])
804
+ trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
805
+
806
+ # Provide feedback if necessary
807
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
808
+
809
+ return trial
810
+
811
+ class Reading(Task):
812
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
813
+ super().__init__(info, screen, ttl_clock, const, subj_id)
814
+
815
+ def display_instructions(self):
816
+ self.instruction_text = f'{self.descriptive_name} Task \n\n Read the shown text and press a button when the image of a hand pressing a button is displayed'
817
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
818
+ instr_visual.draw()
819
+ self.window.flip()
820
+
821
+ def run_trial(self, trial):
822
+ """ Run a single trial of the sentence reading task. """
823
+
824
+ # get sentence and split into words by space
825
+ sentence = trial['stim']
826
+ words = sentence.split()
827
+
828
+ #show words seqeuntially each for 450ms
829
+ for word in words:
830
+ word_stim = visual.TextStim(self.window, text=word, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=trial.get('text_height', 2))
831
+ word_stim.draw()
832
+ self.window.flip()
833
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.45)
834
+
835
+ event.clearEvents()
836
+
837
+ # show press button image
838
+ button_stim = visual.ImageStim(self.window, image=str(ut.find_stim(self.const, self.name, 'hand_press_transparent.png')), size=(trial.get('stim_width', 14.4), None))
839
+ button_stim.draw()
840
+ self.window.flip()
841
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), 0.4)
842
+
843
+ # show blank_transparent image
844
+ blank_stim = visual.ImageStim(self.window, image=str(ut.find_stim(self.const, self.name, 'blank_transparent.png')), size=(trial.get('stim_width', 14.4), None))
845
+ blank_stim.draw()
846
+ self.window.flip()
847
+
848
+ # flush any keys in buffer
849
+ event.clearEvents()
850
+
851
+ # not displaying crosshair to replicate the localizer
852
+
853
+ return trial
854
+
855
+ class OddBall(Task):
856
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
857
+ super().__init__(info, screen, ttl_clock, const, subj_id)
858
+ self.feedback_type = 'acc+rt'
859
+
860
+ def init_task(self):
861
+ """
862
+ Initialize task - default is to read the target information into the trial_info dataframe
863
+ """
864
+ trial_info_file = self.const.task_dir / self.name / self.task_file
865
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
866
+ self.press_key = self.trial_info['key_one'].iloc[0]
867
+
868
+ def display_instructions(self):
869
+ """
870
+ displays the instruction for the task
871
+ """
872
+ str1 = f"Press {self.press_key} when you see a red K"
873
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n"
874
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
875
+ instr_visual.draw()
876
+ self.window.flip()
877
+
878
+ def run_trial(self, trial):
879
+ """ Run a single trial of the oddball task. """
880
+
881
+ current_trial = trial['stim']
882
+
883
+ # show stem
884
+ if current_trial == 'red_K':
885
+ word_stim = visual.TextStim(self.window, text='K', pos=(0.0, 0.0), color='red', units='deg', height=trial.get('text_height', 1.5))
886
+ elif current_trial == 'black_K':
887
+ word_stim = visual.TextStim(self.window, text='K', pos=(0.0, 0.0), color='black', units='deg', height=trial.get('text_height', 1.5))
888
+ elif current_trial == 'red_O':
889
+ word_stim = visual.TextStim(self.window, text='O', pos=(0.0, 0.0), color='red', units='deg', height=trial.get('text_height', 1.5))
890
+ elif current_trial == 'black_O':
891
+ word_stim = visual.TextStim(self.window, text='O', pos=(0.0, 0.0), color='black', units='deg', height=trial.get('text_height', 1.5))
892
+
893
+ word_stim.draw()
894
+ self.window.flip()
895
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
896
+
897
+ # blank the letter to a fixation cross during the response window (no per-trial feedback)
898
+ self.screen.fixation_cross()
899
+
900
+ # Flush any keys in buffer
901
+ event.clearEvents()
902
+
903
+ # collect responses 0: no response 1-4: key pressed
904
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['iti_dur'])
905
+
906
+ # method 2 for accuracy for oddball
907
+ if trial['trial_type'] == 1:
908
+ trial['correct'] = True if trial['response']!= 0 else False
909
+ elif trial['trial_type'] == 0:
910
+ trial['correct'] = np.nan
911
+
912
+
913
+ return trial
914
+
915
+ class FingerSequence(Task):
916
+ """
917
+ Finger sequence task
918
+ """
919
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
920
+ super().__init__(info, screen, ttl_clock, const, subj_id)
921
+ self.feedback_type = 'acc+rt'
922
+
923
+ def init_task(self):
924
+ """
925
+ Initialize task - default is to read the target information into the trial_info dataframe
926
+ """
927
+ trial_info_file = self.const.task_dir / self.name / self.task_file
928
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
929
+
930
+
931
+ def display_instructions(self):
932
+ self.instruction_text = f"{self.descriptive_name} Task \n\n Using your four fingers, press the keys in the order shown on the screen\n Use all four fingers for this task"
933
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
934
+ instr_visual.draw()
935
+ self.window.flip()
936
+
937
+
938
+ def run_trial(self, trial):
939
+ """ Run a single trial of the finger sequence task. """
940
+ #clear buffer
941
+ event.clearEvents()
942
+
943
+ # Display the sequence
944
+ sequence = trial['stim'].split()
945
+
946
+ # Calculate the start position for the sequence and determine the spacing between numbers
947
+ num_items = len(sequence)
948
+ spacing = 2.0
949
+ start_x = -(num_items - 1) * spacing / 2
950
+
951
+ # Show the numbers in the sequence next to each other ( using the spacing and start_x calculated above)
952
+ for i, number in enumerate(sequence):
953
+ pos = (start_x + i * spacing, 0.0) # Horizontal position is adjusted based on index
954
+ stim = visual.TextStim(self.window, text=number, pos=pos, color='black', units='deg', height=trial.get('text_height', 1.5))
955
+ stim.draw()
956
+
957
+ self.window.flip()
958
+
959
+
960
+ sequence_start_time = self.ttl_clock.get_time() # Needed for knowing when to stop looking for key presses
961
+ digit_start_time = sequence_start_time # Updated with each key press for calculating RT
962
+
963
+ rt_list = np.full(num_items,np.nan)
964
+ correct_list = np.zeros((num_items,)) # List of booleans indicating whether each press was correct needed for overall trial accuracy
965
+ num_presses =0
966
+ # Initialize the color for each digit in the sequence as black
967
+ digit_colors = ['black'] * num_items
968
+ while self.ttl_clock.get_time() - sequence_start_time < trial['trial_dur'] and num_presses < num_items:
969
+ self.ttl_clock.update()
970
+
971
+ keys = event.getKeys(keyList=self.const.response_keys, timeStamped=self.ttl_clock.clock)
972
+ if keys:
973
+ key_char, key_press_time = keys[0]
974
+ key = self.const.response_keys.index(key_char) + 1
975
+ rt = key_press_time - digit_start_time
976
+ rt_list[num_presses]=rt
977
+ digit_start_time = key_press_time
978
+
979
+ # Check if key pressed is correct
980
+ correct_list[num_presses] = key == int(sequence[num_presses])
981
+
982
+ # Update color based on correctness
983
+ digit_colors[num_presses] = 'green' if correct_list[num_presses] else 'red'
984
+
985
+ num_presses += 1
986
+ # Draw all digits with their adjusted colors
987
+ for i, (number, color) in enumerate(zip(sequence, digit_colors)):
988
+ pos = (start_x + i * spacing, 0.0)
989
+ stim = visual.TextStim(self.window, text=number, pos=pos, color=color, units='deg', height=trial.get('text_height', 1.5))
990
+ stim.draw()
991
+
992
+ self.window.flip()
993
+
994
+ else:
995
+ # If the sequence is completed, wait until the end of the trial
996
+ self.ttl_clock.wait_until(sequence_start_time + trial['trial_dur'])
997
+
998
+ # if any press is wrong trial['correct'] needs to be false, this is for post trial feedback
999
+ trial['correct'] = correct_list.sum()/num_items
1000
+
1001
+ if np.all(np.isnan(rt_list)):
1002
+ # calculate mean rt across presses
1003
+ trial['rt'] = np.nan
1004
+
1005
+ else:
1006
+ trial['rt'] = np.nanmean(rt_list)
1007
+
1008
+ # display trial feedback (for whole trial)
1009
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct']== 1)
1010
+
1011
+ return trial
1012
+
1013
+ class FlexionExtension(Task):
1014
+ """
1015
+ Flexion extension of toes! No particular feedback.
1016
+ """
1017
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1018
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1019
+ self.feedback_type = 'None'
1020
+
1021
+ def init_task(self):
1022
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
1023
+
1024
+ def display_instructions(self):
1025
+ self.instruction_text = f"{self.descriptive_name} Task \n\n Flex and extend your right and left toes"
1026
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
1027
+ instr_visual.draw()
1028
+ self.window.flip()
1029
+
1030
+ def run_trial(self, trial):
1031
+ # Show this phase's cue ('flexion' or 'extension') for its duration; no response.
1032
+ stim = visual.TextStim(self.window, text=trial['stim'], pos=(0.0, 0.0),
1033
+ color=(-1, -1, -1), units='deg', height=trial.get('text_height', 1.5))
1034
+ stim.draw()
1035
+ self.window.flip()
1036
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
1037
+ return trial
1038
+
1039
+ class SemanticPrediction(Task):
1040
+
1041
+ """
1042
+ Read a sentence and decide if the last word of the sentence makes sense. Click "3" if the last word makes sense; click "4" if not. Be as accurate and fast as possible.
1043
+ """
1044
+
1045
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1046
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1047
+ self.feedback_type = 'acc+rt'
1048
+
1049
+ def init_task(self):
1050
+ """
1051
+ Initialize task - default is to read the target information into the trial_info dataframe
1052
+ """
1053
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
1054
+ self.corr_key = [self.trial_info['key_false'].iloc[0], self.trial_info['key_true'].iloc[0]]
1055
+
1056
+ def display_instructions(self):
1057
+ """
1058
+ displays the instruction for the task
1059
+ """
1060
+ str1 = f"You will read a sentence and decide if the last word makes sense."
1061
+ str2 = f"If it makes sense, press {self.corr_key[1]}"
1062
+ str3 = f"if it doesn't make sense, press {self.corr_key[0]}"
1063
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
1064
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
1065
+ instr_visual.draw()
1066
+ self.window.flip()
1067
+
1068
+ def run_trial(self, trial):
1069
+ """ Runs a single trial of the semantic prediction task """
1070
+
1071
+ height_word = trial.get('text_height', 2)
1072
+
1073
+ event.clearEvents()
1074
+
1075
+ # get sentence and split into words by space
1076
+ sentence = trial['sentence']
1077
+ words = sentence.split('|')
1078
+
1079
+ #show words seqeuntially each for 800ms
1080
+ for word in words:
1081
+ word_stim = visual.TextStim(self.window, text=word, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height_word)
1082
+ word_stim.draw()
1083
+ self.window.flip()
1084
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.8)
1085
+
1086
+ event.clearEvents()
1087
+
1088
+ # Fixation cross
1089
+ self.screen.fixation_cross()
1090
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.5)
1091
+ event.clearEvents()
1092
+
1093
+ # Display last word
1094
+ last_word_stim = visual.TextStim(self.window, text=trial['last_word'], pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height= height_word, wrapWidth=30)
1095
+ last_word_stim.draw()
1096
+ self.window.flip()
1097
+
1098
+ event.clearEvents()
1099
+
1100
+ # collect responses 0: no response 1-4: key pressed
1101
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['sentence_dur'])
1102
+ trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
1103
+
1104
+ # display trial feedback
1105
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
1106
+
1107
+ return trial
1108
+
1109
+
1110
+ class VisualSearch(Task):
1111
+
1112
+ """
1113
+ Look at a screen filled with shapes and identify whether an "L" is present. Click "3" if the "L" is present; click "4" if not. Be as accurate and fast as possible.
1114
+ """
1115
+
1116
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1117
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1118
+ self.feedback_type = 'acc'
1119
+
1120
+ def init_task(self):
1121
+ """
1122
+ Initialize task - default is to read the target information into the trial_info dataframe
1123
+ """
1124
+ trial_info_file = self.const.task_dir / self.name / self.task_file
1125
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
1126
+
1127
+ #counter initialized in order to read whether trial_type is true or not for each trial; used in generate_trial_stimuli
1128
+ self.trial_counter = 0
1129
+
1130
+ # Search area in deg (default 28 x 20: the x extremity of a large window is 14, y is 10, found by trial and error)
1131
+ screen_width = self.trial_info['search_width'].iloc[0] if 'search_width' in self.trial_info.columns else 28
1132
+ screen_height = self.trial_info['search_height'].iloc[0] if 'search_height' in self.trial_info.columns else 20
1133
+
1134
+ #define number of rows + col to split screen into 24 apertures
1135
+ num_rows = 4
1136
+ num_cols = 6
1137
+
1138
+ #Define aperture sizes + positions
1139
+ aperture_width = screen_width / num_cols
1140
+ aperture_height = screen_height / num_rows
1141
+
1142
+ positions_x = np.linspace(-screen_width / 2 + aperture_width / 2, screen_width / 2 - aperture_width / 2, num_cols)
1143
+ positions_y = np.linspace(-screen_height / 2 + aperture_height / 2, screen_height / 2 - aperture_height / 2, num_rows)
1144
+
1145
+ aperture_positions = []
1146
+
1147
+ for y in positions_y:
1148
+ for x in positions_x:
1149
+ aperture_positions.append((x, y))
1150
+
1151
+ self.apertures = []
1152
+ for pos in aperture_positions:
1153
+ apertures = visual.Aperture(self.window, size=40, shape = 'rectangle', pos=pos, units='norm')
1154
+ self.apertures.append(apertures)
1155
+
1156
+ def generate_trial_stimuli(self, num_stimuli):
1157
+
1158
+ stim_images = ['90.png','180.png','270.png','360.png']
1159
+
1160
+ self.stim = []
1161
+
1162
+ is_target = True #determines whether target will appear on screen
1163
+
1164
+ if self.trial_info['trial_type'][self.trial_counter] == 0:
1165
+ stim_images.remove('90.png') #sets a display with no target
1166
+ is_target = False
1167
+
1168
+ self.trial_counter+=1
1169
+
1170
+ randomly_select_apertures = random.sample(self.apertures, num_stimuli)
1171
+
1172
+ for aperture in randomly_select_apertures:
1173
+ if is_target:
1174
+ stim_current = stim_images[0] #sets a display with at least one target
1175
+ is_target = False
1176
+ else:
1177
+ stim_random_idx = random.randint(0, len(stim_images)-1) #chooses random stimuli from stim_images list
1178
+ stim_current = stim_images[stim_random_idx]
1179
+
1180
+ stim_path = ut.find_stim(self.const, self.name, stim_current)
1181
+ stimulus = visual.ImageStim(self.window, str(stim_path), size=(0.8,0.8))
1182
+ stimulus.setPos([aperture.pos[0], aperture.pos[1]]) #puts stimuli within apertures
1183
+ self.stim.append(stimulus) #creates list of all randomly selected stimuli
1184
+
1185
+ def display_instructions(self):
1186
+ """
1187
+ displays the instruction for the task
1188
+ """
1189
+
1190
+ self.corr_key = [self.trial_info['key_false'].iloc[0],self.trial_info['key_true'].iloc[0]]
1191
+
1192
+ str1 = f"You will survey a series of shapes and identify whether the letter ‘L’ is present."
1193
+ str2 = f"If 'L' is present, press {self.corr_key[1]}"
1194
+ str3 = f"if 'L' is not present, press {self.corr_key[0]}"
1195
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
1196
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
1197
+ instr_visual.draw()
1198
+ self.window.flip()
1199
+
1200
+ def run_trial(self,trial):
1201
+ """Runs a single trial of visual search task
1202
+ """
1203
+
1204
+ # Flush any keys in buffer
1205
+ event.clearEvents()
1206
+
1207
+ # Set size (difficulty) is stored as the condition, e.g. '8-items'.
1208
+ num_stimuli = int(str(trial['condition']).split('-')[0])
1209
+
1210
+ self.generate_trial_stimuli(num_stimuli)
1211
+
1212
+ self.screen.fixation_cross(flip=False) #show fixation cross in center of screen
1213
+
1214
+ # Display stimuli
1215
+ for stimulus in self.stim:
1216
+ stimulus.draw()
1217
+
1218
+ self.window.flip()
1219
+
1220
+ # collect responses
1221
+ trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
1222
+ trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
1223
+
1224
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
1225
+
1226
+ return trial
1227
+
1228
+
1229
+ class Movie(Task):
1230
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1231
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1232
+ self.name = 'movie'
1233
+
1234
+ def display_instructions(self):
1235
+ task_name = visual.TextStim(self.window, text=f'{self.descriptive_name.capitalize()}', height=self.const.instruction_text_height, color=[-1, -1, -1], bold=True, pos=(0, 3))
1236
+ task_name.draw()
1237
+
1238
+ self.instruction_text = f"\n\n You will watch short clips from a movie. Please keep your head still and pay attention to the screen."
1239
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width, pos=(0, 0))
1240
+ instr_visual.draw()
1241
+ self.window.flip()
1242
+
1243
+ def run_trial(self, trial):
1244
+ start = self.ttl_clock.get_time() # trial_dur counts from here, so loading the clip is included
1245
+ stim_width = deg2pix(trial.get('stim_width', 22), self.window.monitor) # MovieStim sizes are in pixels
1246
+
1247
+ # Get the file name
1248
+ movie_file_name = trial['stim']
1249
+
1250
+ # Construct the movie file path
1251
+ movie_path = ut.find_stim(self.const, self.name, 'clips', movie_file_name)
1252
+
1253
+ # Convert Path object to string for compatibility
1254
+ movie_path_str = str(movie_path)
1255
+
1256
+ # Create a MovieStim3 object
1257
+ movie_clip = visual.MovieStim(self.window, movie_path_str, loop=False, size=(stim_width, None), pos=(0, 0), noAudio=True)
1258
+
1259
+
1260
+ movie_clip.play()
1261
+ self.window.flip()
1262
+
1263
+ # play until the clip ends or trial_dur is up (so a shorter trial cuts a longer clip)
1264
+ while not movie_clip.isFinished and self.ttl_clock.get_time() - start < trial['trial_dur']:
1265
+ movie_clip.play()
1266
+ movie_clip.draw()
1267
+ self.window.flip()
1268
+ self.ttl_clock.update()
1269
+
1270
+ # Flush memory: This is necessary for the script to be able to run more than 1 run. Presenting movies is very memory hungry, so do not remove!
1271
+ movie_clip.unload()
1272
+ gc.collect() # Collect garbarge
1273
+
1274
+ return trial
1275
+
1276
+
1277
+ class Affective(Task):
1278
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1279
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1280
+ self.feedback_type = 'acc+rt'
1281
+
1282
+ def init_task(self):
1283
+ """
1284
+ Initialize task
1285
+ """
1286
+ trial_info_file = self.const.task_dir / self.name / self.task_file
1287
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
1288
+
1289
+ self.stim = []
1290
+ stim_width = self.trial_info['stim_width'].iloc[0] if 'stim_width' in self.trial_info.columns else 18.4
1291
+ for stim_file in self.trial_info['stim']:
1292
+ stim_path = ut.find_stim(self.const, self.name, stim_file)
1293
+ self.stim.append(visual.ImageStim(self.window, image=str(stim_path), size=(stim_width, None))) # width in deg
1294
+
1295
+ def display_instructions(self):
1296
+ """
1297
+ Display instructions for the affective task.
1298
+ """
1299
+ key_pleasant = self.trial_info['key_pleasant'].iloc[0]
1300
+ key_unpleasant = self.trial_info['key_unpleasant'].iloc[0]
1301
+
1302
+ self.instruction_text = (
1303
+ f"{self.descriptive_name} Task\n\n"
1304
+ f"Press {key_unpleasant} if the image is UNPLEASANT.\n"
1305
+ f"Press {key_pleasant} if the image is PLEASANT."
1306
+ )
1307
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
1308
+ instr_visual.draw()
1309
+ self.window.flip()
1310
+
1311
+ def run_trial(self, trial):
1312
+ """
1313
+ Run a single trial of the affective task.
1314
+ """
1315
+ event.clearEvents()
1316
+
1317
+ # Display image
1318
+ self.stim[trial['trial_num']].draw()
1319
+ self.window.flip()
1320
+
1321
+ # Wait for response
1322
+ trial['response'], trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
1323
+
1324
+ # Determine correct response
1325
+ correct_key = trial['key_pleasant'] if trial['trial_type'] == 2 else trial['key_unpleasant']
1326
+ trial['correct'] = (trial['response'] == correct_key)
1327
+
1328
+ # Show feedback
1329
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
1330
+
1331
+ return trial
1332
+
1333
+ class SerialReactionTime(Task):
1334
+ """
1335
+ Serial reaction time task
1336
+ """
1337
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1338
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1339
+
1340
+ def init_task(self):
1341
+ """
1342
+ Initialize task - default is to read the target information into the trial_info dataframe
1343
+ """
1344
+ trial_info_file = self.const.task_dir / self.name / self.task_file
1345
+ self.trial_info = pd.read_csv(trial_info_file, sep='\t')
1346
+
1347
+
1348
+ def display_instructions(self): # overriding the display instruction from the parent class
1349
+ self.instruction_text = f"Press the buttons that match the green boxes"
1350
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width, pos=(0, 0.3))
1351
+ instr_visual.draw()
1352
+ self.window.flip()
1353
+
1354
+ def run(self):
1355
+ """Override to show empty boxes during initial wait."""
1356
+ box_positions = [(-5, 2), (-3, 2), (3, 2), (5, 2)]
1357
+ self.boxes = [
1358
+ visual.Rect(
1359
+ self.window, width=2, height=2,
1360
+ pos=pos, fillColor='white',
1361
+ lineColor='black', units='deg'
1362
+ )
1363
+ for pos in box_positions
1364
+ ]
1365
+ for box in self.boxes:
1366
+ box.draw()
1367
+ self.window.flip()
1368
+ return super().run()
1369
+
1370
+ def run_trial(self, trial):
1371
+
1372
+ event.clearEvents()
1373
+
1374
+ trial_stim = int(trial['stim'])
1375
+ target_index = trial_stim - 1
1376
+
1377
+ # wait until absolute stimulus time
1378
+ stim_onset = self.ttl_clock.get_time()
1379
+
1380
+ # show stimulus
1381
+ for j, box in enumerate(self.boxes):
1382
+ box.fillColor = 'green' if j == target_index else 'white'
1383
+ box.draw()
1384
+ self.window.flip()
1385
+
1386
+ # keep green on for trial_dur
1387
+ self.ttl_clock.wait_until(stim_onset + trial['trial_dur'])
1388
+
1389
+ # back to white
1390
+ for box in self.boxes:
1391
+ box.fillColor = 'white'
1392
+ box.draw()
1393
+ self.window.flip()
1394
+
1395
+ # collect responses until absolute end_time
1396
+ responses = []
1397
+ while self.ttl_clock.get_time() < (stim_onset + trial['trial_dur'] + trial['iti_dur']):
1398
+ keys = event.getKeys(
1399
+ keyList=self.const.response_keys,
1400
+ timeStamped=self.ttl_clock.clock
1401
+ )
1402
+ for key, timestamp in keys:
1403
+ rt = timestamp - stim_onset
1404
+ responses.append((key, rt))
1405
+
1406
+ trial['responses'] = responses
1407
+ return trial
1408
+
1409
+ class FingerRhythmic(Task):
1410
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1411
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1412
+
1413
+ def init_task(self):
1414
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
1415
+ self.corr_key = [self.trial_info['key_one'].iloc[0]]
1416
+
1417
+ def display_instructions(self):
1418
+ """
1419
+ displays the instruction for the task
1420
+ """
1421
+
1422
+ str1 = f"Tap along to the tones using the {self.corr_key[0]} key."
1423
+ str2 = f"Keep tapping at the same pace when the tones stop."
1424
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2}"
1425
+ instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
1426
+ instr_visual.draw()
1427
+ self.window.flip()
1428
+
1429
+ def run_trial(self, trial):
1430
+ """ Runs a single trial of the Finger Rhythmic task """
1431
+
1432
+ event.clearEvents()
1433
+ txt = (f"New trial starts now") # this text shows when a new trials starts
1434
+ visual.TextStim(self.window, text=txt,height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width).draw()
1435
+ self.window.flip()
1436
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + 2)
1437
+
1438
+ self.screen.fixation_cross()
1439
+ event.clearEvents()
1440
+ clk = self.ttl_clock.clock
1441
+ t0 = clk.getTime() # trial anchor (TTL)
1442
+
1443
+ # --- Play FIRST tone now, then use THIS time as the grid anchor
1444
+ beep = sound.Sound(value=1000, secs=0.05, sampleRate=48000, stereo=True)
1445
+ beep.play()
1446
+ t_first = clk.getTime() # when we triggered the first tone (TTL)
1447
+ ioi = float(trial['ioi'])
1448
+ expected = [(t_first - t0) + i*ioi for i in range(12)] # expected, aligned to first tone
1449
+
1450
+ # Track beep times for timing verification (quiet by default)
1451
+ beep_times = [t_first]
1452
+
1453
+ taps_rel = []
1454
+
1455
+ # --- Remaining 11 tones by absolute TTL deadlines; collect keys in-between
1456
+ for i in range(1, 12):
1457
+ deadline = t_first + i*ioi
1458
+ while True:
1459
+ now = clk.getTime()
1460
+ if now >= deadline:
1461
+ # Create a new Sound object for each beep to ensure it plays
1462
+ beep = sound.Sound(value=1000, secs=0.05, sampleRate=48000, stereo=True)
1463
+ beep.play()
1464
+ beep_time = clk.getTime()
1465
+ beep_times.append(beep_time)
1466
+ break
1467
+ res = event.waitKeys(maxWait=deadline - now,
1468
+ keyList=self.const.response_keys,
1469
+ timeStamped=clk)
1470
+ if res:
1471
+ for _, ts in res:
1472
+ taps_rel.append(ts - t0)
1473
+
1474
+ # --- Silent phase: collect until absolute end_time
1475
+ end_abs = self.start_time + float(trial['end_time'])
1476
+ while True:
1477
+ now = clk.getTime()
1478
+ if now >= end_abs:
1479
+ break
1480
+ res = event.waitKeys(maxWait=end_abs - now,
1481
+ keyList=self.const.response_keys,
1482
+ timeStamped=clk)
1483
+ if res:
1484
+ for _, ts in res:
1485
+ taps_rel.append(ts - t0)
1486
+
1487
+ # --- Self-paced ISIs only (strictly after last tone onset)
1488
+ last_tone_t = expected[-1] # relative to t0
1489
+ self_taps = [t for t in taps_rel if t > last_tone_t]
1490
+ isis = np.diff(self_taps) if len(self_taps) > 1 else np.array([], float)
1491
+ # Accept a window scaled to the trial's IOI: a fixed 300-900 ms window
1492
+ # discards real taps and keeps implausible ones when ioi is not ~0.6.
1493
+ isis = isis[(isis >= max(0.15, 0.2 * ioi)) & (isis <= 2.0 * ioi)]
1494
+
1495
+ trial['iri_ms_mean'] = float(np.mean(isis) * 1000.0) if isis.size else np.nan
1496
+ trial['iri_ms_sd'] = float(np.std(isis) * 1000.0) if isis.size else np.nan
1497
+ trial['iris_ms_json'] = json.dumps((isis * 1000.0).tolist())
1498
+ trial['expected_rel_s_json'] = json.dumps([e for e in expected]) # seconds rel to t0
1499
+ trial['tap_rel_s_json'] = json.dumps(taps_rel)
1500
+ # Save beep times relative to trial start (t0)
1501
+ beep_times_rel = [bt - t0 for bt in beep_times]
1502
+ trial['beep_times_rel_s_json'] = json.dumps(beep_times_rel)
1503
+
1504
+ return trial
1505
+
1506
+
1507
+ class TimePerception(Task):
1508
+ def __init__(self, info, screen, ttl_clock, const, subj_id):
1509
+ super().__init__(info, screen, ttl_clock, const, subj_id)
1510
+ self.feedback_type = 'acc+rt'
1511
+
1512
+ def init_task(self):
1513
+ self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
1514
+ self.corr_key = [self.trial_info['key_one'].iloc[0], self.trial_info['key_two'].iloc[0]]
1515
+ # one tone for both modalities
1516
+ self.tone = sound.Sound(value=1000, secs=0.050, sampleRate=48000, stereo=True) # 1000 Hz, 50 ms
1517
+
1518
+ # PEST state per side
1519
+ mod = str(self.trial_info['modality'].iloc[0]).lower()
1520
+ if mod == 'time':
1521
+ # start ±120 ms from 400; step 40; min step 8; directions tracked
1522
+ self.stair = {
1523
+ 'shorter': {'curr': 280.0, 'step': 40.0, 'min_step': 8.0, 'last_dir': 0, 'same_dir': 0},
1524
+ 'longer': {'curr': 520.0, 'step': 40.0, 'min_step': 8.0, 'last_dir': 0, 'same_dir': 0},
1525
+ }
1526
+ else: # 'volume' (quieter/louder)
1527
+ # start ±1.62 dB from 73; step 1.08; min step 0.27
1528
+ self.stair = {
1529
+ 'quieter': {'curr': 71.38, 'step': 1.08, 'min_step': 0.27, 'last_dir': 0, 'same_dir': 0},
1530
+ 'louder': {'curr': 74.62, 'step': 1.08, 'min_step': 0.27, 'last_dir': 0, 'same_dir': 0},
1531
+ }
1532
+
1533
+ def display_instructions(self):
1534
+ mod = str(self.trial_info['modality'].iloc[0]).lower()
1535
+ if mod == 'time':
1536
+ str1 = f"You will hear two pairs of tones."
1537
+ str2 = f"Press [{self.corr_key[0]}] if the SECOND interval is shorter."
1538
+ str3 = f"Press [{self.corr_key[1]}] if the SECOND interval is longer."
1539
+ str4 = "The first pair is always the same."
1540
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3} \n {str4}"
1541
+
1542
+ else:
1543
+ str1 = f"You will hear two pairs of tones."
1544
+ str2 = f"Press [{self.corr_key[0]}] if the SECOND interval is quieter."
1545
+ str3 = f"Press [{self.corr_key[1]}] if the SECOND interval is louder."
1546
+ str4 = "The first pair is always the same."
1547
+ self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3} \n {str4}"
1548
+ visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width).draw()
1549
+ self.window.flip()
1550
+
1551
+ def run_trial(self, trial):
1552
+ event.clearEvents()
1553
+ clk = self.ttl_clock
1554
+ mod = str(trial['modality']).lower()
1555
+ side = str(trial['side']).lower()
1556
+
1557
+ # fixation
1558
+ self.screen.fixation_cross()
1559
+
1560
+ # --- Pair 1 (standard @ 0.7 amp, 400 ms gap) ---
1561
+ self.tone.setVolume(0.7)
1562
+ t1 = clk.get_time()
1563
+ self.tone.play()
1564
+ clk.wait_until(t1 + 0.050)
1565
+ clk.wait_until(t1 + 0.050 + 0.400)
1566
+ self.tone.play()
1567
+ clk.wait_until(t1 + 2*0.050 + 0.400)
1568
+
1569
+ # inter-pair gap
1570
+ clk.wait_until(clk.get_time() + 1.000)
1571
+
1572
+ # --- Pair 2 (comparison) ---
1573
+ st = self.stair[side] # PEST state for this side
1574
+
1575
+ if mod == 'time':
1576
+ # snap to 8 ms grid within side range
1577
+ if side == 'shorter':
1578
+ comp_ms = max(160, min(392, int(round(st['curr'] / 8.0) * 8)))
1579
+ else:
1580
+ comp_ms = max(408, min(640, int(round(st['curr'] / 8.0) * 8)))
1581
+
1582
+ t2 = clk.get_time()
1583
+ self.tone.setVolume(0.7)
1584
+ self.tone.play()
1585
+ clk.wait_until(t2 + 0.050)
1586
+ clk.wait_until(t2 + 0.050 + (comp_ms / 1000.0))
1587
+ self.tone.play()
1588
+ clk.wait_until(t2 + 2*0.050 + (comp_ms / 1000.0))
1589
+
1590
+ trial['comparison_ms'] = float(comp_ms)
1591
+ trial['stair_step_ms'] = float(st['step']) # log step used this trial
1592
+
1593
+ else: # volume (quieter/louder), grid 0.27 dB
1594
+ if side == 'quieter':
1595
+ comp_db = max(64.9, min(72.73, float(st['curr'])))
1596
+ else:
1597
+ comp_db = min(81.1, max(73.27, float(st['curr'])))
1598
+
1599
+ comp_amp = float(min(1.0, 0.7 * (10 ** ((comp_db - 73.0) / 20.0))))
1600
+
1601
+ t2 = clk.get_time()
1602
+ self.tone.setVolume(comp_amp)
1603
+ self.tone.play()
1604
+ clk.wait_until(t2 + 0.050)
1605
+ clk.wait_until(t2 + 0.050 + 0.400)
1606
+ self.tone.setVolume(comp_amp)
1607
+ self.tone.play()
1608
+ clk.wait_until(t2 + 2*0.050 + 0.400)
1609
+ self.tone.setVolume(1.0)
1610
+
1611
+ trial['comparison_dba'] = float(comp_db)
1612
+ trial['stair_step_dba'] = float(st['step']) # log step used this trial
1613
+
1614
+ # --- response window ---
1615
+ trial['response'], trial['rt'] = self.wait_response(clk.get_time(), float(trial['question_dur']))
1616
+ trial['correct'] = (trial['response'] == trial['trial_type'])
1617
+
1618
+ # --- PEST update (classic + border-safe): halve on reversal; double after two same-direction moves ---
1619
+ # Define movement: toward standard if correct, away if incorrect.
1620
+ # Use unified sign for direction comparison: toward = -1, away = +1.
1621
+ move_dir = (-1 if trial['correct'] else +1)
1622
+
1623
+ # Store the old level so we can tell if we actually moved after clamping.
1624
+ old_curr = st['curr']
1625
+
1626
+ # 1. Propose + clamp
1627
+ if mod == 'time':
1628
+ if side == 'shorter':
1629
+ # correct => toward standard => make interval longer => +step
1630
+ # incorrect => away => make interval even shorter => -step
1631
+ st['curr'] += (+st['step'] if trial['correct'] else -st['step'])
1632
+ # snap to 8 ms grid and clamp to that side's allowed range
1633
+ st['curr'] = float(int(round(st['curr'] / 8.0) * 8))
1634
+ if st['curr'] < 160.0:
1635
+ st['curr'] = 160.0
1636
+ if st['curr'] > 392.0:
1637
+ st['curr'] = 392.0
1638
+
1639
+ else: # side == 'longer'
1640
+ # correct => toward standard => make interval shorter => -step
1641
+ # incorrect => away => make interval even longer => +step
1642
+ st['curr'] += (-st['step'] if trial['correct'] else +st['step'])
1643
+ st['curr'] = float(int(round(st['curr'] / 8.0) * 8))
1644
+ if st['curr'] < 408.0:
1645
+ st['curr'] = 408.0
1646
+ if st['curr'] > 640.0:
1647
+ st['curr'] = 640.0
1648
+
1649
+ else:
1650
+ # volume
1651
+ if side == 'quieter':
1652
+ # correct => toward standard (louder) => +step in dB toward 73
1653
+ # incorrect => away (quieter) => -step
1654
+ st['curr'] += (+st['step'] if trial['correct'] else -st['step'])
1655
+ if st['curr'] < 64.9:
1656
+ st['curr'] = 64.9
1657
+ if st['curr'] > 72.73:
1658
+ st['curr'] = 72.73
1659
+
1660
+ else: # side == 'louder'
1661
+ # correct => toward standard (quieter) => -step
1662
+ # incorrect => away (louder) => +step
1663
+ st['curr'] += (-st['step'] if trial['correct'] else +st['step'])
1664
+ if st['curr'] < 73.27:
1665
+ st['curr'] = 73.27
1666
+ if st['curr'] > 81.1:
1667
+ st['curr'] = 81.1
1668
+
1669
+ # 2. Did we actually move?
1670
+ actually_moved = (st['curr'] != old_curr)
1671
+
1672
+ if actually_moved:
1673
+ # Normal PESt adaptation only if we escaped the boundary.
1674
+
1675
+ if st['last_dir'] != 0 and move_dir != st['last_dir']:
1676
+ # reversal -> halve step (but not below min_step), reset consecutive counter
1677
+ st['step'] = max(st['min_step'], st['step'] / 2.0)
1678
+ st['same_dir'] = 0
1679
+
1680
+ else:
1681
+ # same direction as last (or first informative move)
1682
+ if st['last_dir'] == 0 or move_dir == st['last_dir']:
1683
+ st['same_dir'] += 1
1684
+ else:
1685
+ # new direction but last_dir was 0 should already be covered above,
1686
+ # but keep a safe fallback
1687
+ st['same_dir'] = 1
1688
+
1689
+ # after two same-direction moves -> double step
1690
+ if st['same_dir'] >= 2:
1691
+ st['step'] = st['step'] * 2.0
1692
+ st['same_dir'] = 0 # require two more same-direction moves for next doubling
1693
+
1694
+ # update last_dir ONLY when a real move happened
1695
+ st['last_dir'] = move_dir
1696
+
1697
+ else:
1698
+ # We hit a border and got clamped. Do NOT adapt step. Do NOT change same_dir. Do NOT touch last_dir.
1699
+ pass
1700
+
1701
+ self.stair[side] = st
1702
+ self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
1703
+ return trial