@jspsych/plugin-audio-button-response 1.2.0 → 2.0.1

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.
package/src/index.ts CHANGED
@@ -1,312 +1,309 @@
1
+ import autoBind from "auto-bind";
1
2
  import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
2
3
 
4
+ import { AudioPlayerInterface } from "../../jspsych/src/modules/plugin-api/AudioPlayer";
5
+ import { version } from "../package.json";
6
+
3
7
  const info = <const>{
4
8
  name: "audio-button-response",
9
+ version: version,
5
10
  parameters: {
6
- /** The audio to be played. */
11
+ /** Path to audio file to be played. */
7
12
  stimulus: {
8
13
  type: ParameterType.AUDIO,
9
- pretty_name: "Stimulus",
10
14
  default: undefined,
11
15
  },
12
- /** Array containing the label(s) for the button(s). */
16
+ /** Labels for the buttons. Each different string in the array will generate a different button. */
13
17
  choices: {
14
18
  type: ParameterType.STRING,
15
- pretty_name: "Choices",
16
19
  default: undefined,
17
20
  array: true,
18
21
  },
19
- /** The HTML for creating button. Can create own style. Use the "%choice%" string to indicate where the label from the choices parameter should be inserted. */
22
+ /**
23
+ * A function that generates the HTML for each button in the `choices` array. The function gets the string
24
+ * and index of the item in the `choices` array and should return valid HTML. If you want to use different
25
+ * markup for each button, you can do that by using a conditional on either parameter. The default parameter
26
+ * returns a button element with the text label of the choice.
27
+ */
20
28
  button_html: {
21
- type: ParameterType.HTML_STRING,
22
- pretty_name: "Button HTML",
23
- default: '<button class="jspsych-btn">%choice%</button>',
24
- array: true,
29
+ type: ParameterType.FUNCTION,
30
+ default: function (choice: string, choice_index: number) {
31
+ return `<button class="jspsych-btn">${choice}</button>`;
32
+ },
25
33
  },
26
- /** Any content here will be displayed below the stimulus. */
34
+ /** This string can contain HTML markup. Any content here will be displayed below the stimulus. The intention
35
+ * is that it can be used to provide a reminder about the action the participant is supposed to take
36
+ * (e.g., which key to press). */
27
37
  prompt: {
28
38
  type: ParameterType.HTML_STRING,
29
- pretty_name: "Prompt",
30
39
  default: null,
31
40
  },
32
- /** The maximum duration to wait for a response. */
41
+ /** How long to wait for the participant to make a response before ending the trial in milliseconds. If the
42
+ * participant fails to make a response before this timer is reached, the participant's response will be
43
+ * recorded as null for the trial and the trial will end. If the value of this parameter is null, the trial
44
+ * will wait for a response indefinitely */
33
45
  trial_duration: {
34
46
  type: ParameterType.INT,
35
- pretty_name: "Trial duration",
36
47
  default: null,
37
48
  },
38
- /** Vertical margin of button. */
39
- margin_vertical: {
49
+ /** Setting to `'grid'` will make the container element have the CSS property `display: grid` and enable the
50
+ * use of `grid_rows` and `grid_columns`. Setting to `'flex'` will make the container element have the CSS
51
+ * property `display: flex`. You can customize how the buttons are laid out by adding inline CSS in the `button_html` parameter.
52
+ */
53
+ button_layout: {
40
54
  type: ParameterType.STRING,
41
- pretty_name: "Margin vertical",
42
- default: "0px",
55
+ default: "grid",
43
56
  },
44
- /** Horizontal margin of button. */
45
- margin_horizontal: {
46
- type: ParameterType.STRING,
47
- pretty_name: "Margin horizontal",
48
- default: "8px",
57
+ /** The number of rows in the button grid. Only applicable when `button_layout` is set to `'grid'`. If null, the
58
+ * number of rows will be determined automatically based on the number of buttons and the number of columns.
59
+ */
60
+ grid_rows: {
61
+ type: ParameterType.INT,
62
+ default: 1,
49
63
  },
50
- /** If true, the trial will end when user makes a response. */
64
+ /** The number of columns in the button grid. Only applicable when `button_layout` is set to `'grid'`.
65
+ * If null, the number of columns will be determined automatically based on the number of buttons and the
66
+ * number of rows.
67
+ */
68
+ grid_columns: {
69
+ type: ParameterType.INT,
70
+ default: null,
71
+ },
72
+ /** If true, then the trial will end whenever the participant makes a response (assuming they make their
73
+ * response before the cutoff specified by the `trial_duration` parameter). If false, then the trial will
74
+ * continue until the value for `trial_duration` is reached. You can set this parameter to `false` to force
75
+ * the participant to listen to the stimulus for a fixed amount of time, even if they respond before the time is complete. */
51
76
  response_ends_trial: {
52
77
  type: ParameterType.BOOL,
53
- pretty_name: "Response ends trial",
54
78
  default: true,
55
79
  },
56
- /** If true, then the trial will end as soon as the audio file finishes playing. */
80
+ /** If true, then the trial will end as soon as the audio file finishes playing. */
57
81
  trial_ends_after_audio: {
58
82
  type: ParameterType.BOOL,
59
- pretty_name: "Trial ends after audio",
60
83
  default: false,
61
84
  },
62
85
  /**
63
- * If true, then responses are allowed while the audio is playing.
64
- * If false, then the audio must finish playing before a response is accepted.
86
+ * If true, then responses are allowed while the audio is playing. If false, then the audio must finish
87
+ * playing before the button choices are enabled and a response is accepted. Once the audio has played
88
+ * all the way through, the buttons are enabled and a response is allowed (including while the audio is
89
+ * being re-played via on-screen playback controls).
65
90
  */
66
91
  response_allowed_while_playing: {
67
92
  type: ParameterType.BOOL,
68
- pretty_name: "Response allowed while playing",
69
93
  default: true,
70
94
  },
71
- /** The delay of enabling button */
95
+ /** How long the button will delay enabling in milliseconds. If `response_allowed_while_playing` is `true`,
96
+ * the timer will start immediately. If it is `false`, the timer will start at the end of the audio. */
72
97
  enable_button_after: {
73
98
  type: ParameterType.INT,
74
- pretty_name: "Enable button after",
75
99
  default: 0,
76
100
  },
77
101
  },
102
+ data: {
103
+ /** The response time in milliseconds for the participant to make a response. The time is measured from
104
+ * when the stimulus first began playing until the participant's response.*/
105
+ rt: {
106
+ type: ParameterType.INT,
107
+ },
108
+ /** Indicates which button the participant pressed. The first button in the `choices` array is 0, the second is 1, and so on. */
109
+ response: {
110
+ type: ParameterType.INT,
111
+ },
112
+ },
78
113
  };
79
114
 
80
115
  type Info = typeof info;
81
116
 
82
117
  /**
83
- * **audio-button-response**
84
- *
85
- * jsPsych plugin for playing an audio file and getting a button response
86
- *
118
+ * If the browser supports it, audio files are played using the WebAudio API. This allows for reasonably precise
119
+ * timing of the playback. The timing of responses generated is measured against the WebAudio specific clock,
120
+ * improving the measurement of response times. If the browser does not support the WebAudio API, then the audio file is
121
+ * played with HTML5 audio.
122
+
123
+ * Audio files can be automatically preloaded by jsPsych using the [`preload` plugin](preload.md). However, if
124
+ * you are using timeline variables or another dynamic method to specify the audio stimulus, you will need
125
+ * to [manually preload](../overview/media-preloading.md#manual-preloading) the audio.
126
+
127
+ * The trial can end when the participant responds, when the audio file has finished playing, or if the participant
128
+ * has failed to respond within a fixed length of time. You can also prevent a button response from being made before the
129
+ * audio has finished playing.
130
+ *
87
131
  * @author Kristin Diep
88
- * @see {@link https://www.jspsych.org/plugins/jspsych-audio-button-response/ audio-button-response plugin documentation on jspsych.org}
132
+ * @see {@link https://www.jspsych.org/latest/plugins/audio-button-response/ audio-button-response plugin documentation on jspsych.org}
89
133
  */
90
134
  class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
91
135
  static info = info;
92
- private audio;
93
-
94
- constructor(private jsPsych: JsPsych) {}
95
-
96
- trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
97
- // hold the .resolve() function from the Promise that ends the trial
98
- let trial_complete;
136
+ private audio: AudioPlayerInterface;
137
+ private params: TrialType<Info>;
138
+ private buttonElements: HTMLElement[] = [];
139
+ private display: HTMLElement;
140
+ private response: { rt: number; button: number } = { rt: null, button: null };
141
+ private context: AudioContext;
142
+ private startTime: number;
143
+ private trial_complete: (trial_data: { rt: number; stimulus: string; response: number }) => void;
144
+
145
+ constructor(private jsPsych: JsPsych) {
146
+ autoBind(this);
147
+ }
99
148
 
149
+ async trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
150
+ this.params = trial;
151
+ this.display = display_element;
100
152
  // setup stimulus
101
- var context = this.jsPsych.pluginAPI.audioContext();
102
-
103
- // store response
104
- var response = {
105
- rt: null,
106
- button: null,
107
- };
108
-
109
- // record webaudio context start time
110
- var startTime;
153
+ this.context = this.jsPsych.pluginAPI.audioContext();
111
154
 
112
155
  // load audio file
113
- this.jsPsych.pluginAPI
114
- .getAudioBuffer(trial.stimulus)
115
- .then((buffer) => {
116
- if (context !== null) {
117
- this.audio = context.createBufferSource();
118
- this.audio.buffer = buffer;
119
- this.audio.connect(context.destination);
120
- } else {
121
- this.audio = buffer;
122
- this.audio.currentTime = 0;
123
- }
124
- setupTrial();
125
- })
126
- .catch((err) => {
127
- console.error(
128
- `Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`
129
- );
130
- console.error(err);
131
- });
132
-
133
- const setupTrial = () => {
134
- // set up end event if trial needs it
135
- if (trial.trial_ends_after_audio) {
136
- this.audio.addEventListener("ended", end_trial);
137
- }
138
-
139
- // enable buttons after audio ends if necessary
140
- if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
141
- this.audio.addEventListener("ended", enable_buttons);
142
- }
143
-
144
- //display buttons
145
- var buttons = [];
146
- if (Array.isArray(trial.button_html)) {
147
- if (trial.button_html.length == trial.choices.length) {
148
- buttons = trial.button_html;
149
- } else {
150
- console.error(
151
- "Error in audio-button-response plugin. The length of the button_html array does not equal the length of the choices array"
152
- );
153
- }
154
- } else {
155
- for (var i = 0; i < trial.choices.length; i++) {
156
- buttons.push(trial.button_html);
157
- }
158
- }
159
-
160
- var html = '<div id="jspsych-audio-button-response-btngroup">';
161
- for (var i = 0; i < trial.choices.length; i++) {
162
- var str = buttons[i].replace(/%choice%/g, trial.choices[i]);
163
- html +=
164
- '<div class="jspsych-audio-button-response-button" style="cursor: pointer; display: inline-block; margin:' +
165
- trial.margin_vertical +
166
- " " +
167
- trial.margin_horizontal +
168
- '" id="jspsych-audio-button-response-button-' +
169
- i +
170
- '" data-choice="' +
171
- i +
172
- '">' +
173
- str +
174
- "</div>";
175
- }
176
- html += "</div>";
177
-
178
- //show prompt if there is one
179
- if (trial.prompt !== null) {
180
- html += trial.prompt;
181
- }
182
-
183
- display_element.innerHTML = html;
156
+ this.audio = await this.jsPsych.pluginAPI.getAudioPlayer(trial.stimulus);
184
157
 
185
- if (trial.response_allowed_while_playing) {
186
- disable_buttons();
187
- enable_buttons();
188
- } else {
189
- disable_buttons();
190
- }
191
-
192
- // start time
193
- startTime = performance.now();
158
+ // set up end event if trial needs it
159
+ if (trial.trial_ends_after_audio) {
160
+ this.audio.addEventListener("ended", this.end_trial);
161
+ }
194
162
 
195
- // start audio
196
- if (context !== null) {
197
- startTime = context.currentTime;
198
- this.audio.start(startTime);
199
- } else {
200
- this.audio.play();
201
- }
163
+ // enable buttons after audio ends if necessary
164
+ if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
165
+ this.audio.addEventListener("ended", this.enable_buttons);
166
+ }
202
167
 
203
- // end trial if time limit is set
204
- if (trial.trial_duration !== null) {
205
- this.jsPsych.pluginAPI.setTimeout(() => {
206
- end_trial();
207
- }, trial.trial_duration);
168
+ // Display buttons
169
+ const buttonGroupElement = document.createElement("div");
170
+ buttonGroupElement.id = "jspsych-audio-button-response-btngroup";
171
+ if (trial.button_layout === "grid") {
172
+ buttonGroupElement.classList.add("jspsych-btn-group-grid");
173
+ if (trial.grid_rows === null && trial.grid_columns === null) {
174
+ throw new Error(
175
+ "You cannot set `grid_rows` to `null` without providing a value for `grid_columns`."
176
+ );
208
177
  }
178
+ const n_cols =
179
+ trial.grid_columns === null
180
+ ? Math.ceil(trial.choices.length / trial.grid_rows)
181
+ : trial.grid_columns;
182
+ const n_rows =
183
+ trial.grid_rows === null
184
+ ? Math.ceil(trial.choices.length / trial.grid_columns)
185
+ : trial.grid_rows;
186
+ buttonGroupElement.style.gridTemplateColumns = `repeat(${n_cols}, 1fr)`;
187
+ buttonGroupElement.style.gridTemplateRows = `repeat(${n_rows}, 1fr)`;
188
+ } else if (trial.button_layout === "flex") {
189
+ buttonGroupElement.classList.add("jspsych-btn-group-flex");
190
+ }
209
191
 
210
- on_load();
211
- };
192
+ for (const [choiceIndex, choice] of trial.choices.entries()) {
193
+ buttonGroupElement.insertAdjacentHTML("beforeend", trial.button_html(choice, choiceIndex));
194
+ const buttonElement = buttonGroupElement.lastChild as HTMLElement;
195
+ buttonElement.dataset.choice = choiceIndex.toString();
196
+ buttonElement.addEventListener("click", () => {
197
+ this.after_response(choiceIndex);
198
+ });
199
+ this.buttonElements.push(buttonElement);
200
+ }
212
201
 
213
- // function to handle responses by the subject
214
- function after_response(choice) {
215
- // measure rt
216
- var endTime = performance.now();
217
- var rt = Math.round(endTime - startTime);
218
- if (context !== null) {
219
- endTime = context.currentTime;
220
- rt = Math.round((endTime - startTime) * 1000);
221
- }
222
- response.button = parseInt(choice);
223
- response.rt = rt;
202
+ display_element.appendChild(buttonGroupElement);
224
203
 
225
- // disable all the buttons after a response
226
- disable_buttons();
204
+ // Show prompt if there is one
205
+ if (trial.prompt !== null) {
206
+ display_element.insertAdjacentHTML("beforeend", trial.prompt);
207
+ }
227
208
 
228
- if (trial.response_ends_trial) {
229
- end_trial();
209
+ if (trial.response_allowed_while_playing) {
210
+ if (trial.enable_button_after > 0) {
211
+ this.disable_buttons();
212
+ this.enable_buttons();
230
213
  }
214
+ } else {
215
+ this.disable_buttons();
231
216
  }
232
217
 
233
- // function to end trial when it is time
234
- const end_trial = () => {
235
- // kill any remaining setTimeout handlers
236
- this.jsPsych.pluginAPI.clearAllTimeouts();
218
+ // end trial if time limit is set
219
+ if (trial.trial_duration !== null) {
220
+ this.jsPsych.pluginAPI.setTimeout(() => {
221
+ this.end_trial();
222
+ }, trial.trial_duration);
223
+ }
237
224
 
238
- // stop the audio file if it is playing
239
- // remove end event listeners if they exist
240
- if (context !== null) {
241
- this.audio.stop();
242
- } else {
243
- this.audio.pause();
244
- }
225
+ on_load();
245
226
 
246
- this.audio.removeEventListener("ended", end_trial);
247
- this.audio.removeEventListener("ended", enable_buttons);
227
+ // start time
228
+ this.startTime = performance.now();
229
+ if (this.context !== null) {
230
+ this.startTime = this.context.currentTime;
231
+ }
248
232
 
249
- // gather the data to store for the trial
250
- var trial_data = {
251
- rt: response.rt,
252
- stimulus: trial.stimulus,
253
- response: response.button,
254
- };
233
+ // start audio
234
+ this.audio.play();
255
235
 
256
- // clear the display
257
- display_element.innerHTML = "";
236
+ return new Promise((resolve) => {
237
+ // hold the .resolve() function from the Promise that ends the trial
238
+ this.trial_complete = resolve;
239
+ });
240
+ }
258
241
 
259
- // move on to the next trial
260
- this.jsPsych.finishTrial(trial_data);
242
+ private disable_buttons = () => {
243
+ for (const button of this.buttonElements) {
244
+ button.setAttribute("disabled", "disabled");
245
+ }
246
+ };
261
247
 
262
- trial_complete();
263
- };
248
+ private enable_buttons_without_delay = () => {
249
+ for (const button of this.buttonElements) {
250
+ button.removeAttribute("disabled");
251
+ }
252
+ };
264
253
 
265
- const enable_buttons_with_delay = (delay: number) => {
266
- this.jsPsych.pluginAPI.setTimeout(enable_buttons_without_delay, delay);
267
- };
254
+ private enable_buttons_with_delay = (delay: number) => {
255
+ this.jsPsych.pluginAPI.setTimeout(this.enable_buttons_without_delay, delay);
256
+ };
268
257
 
269
- function button_response(e) {
270
- var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
271
- after_response(choice);
258
+ private enable_buttons() {
259
+ if (this.params.enable_button_after > 0) {
260
+ this.enable_buttons_with_delay(this.params.enable_button_after);
261
+ } else {
262
+ this.enable_buttons_without_delay();
272
263
  }
264
+ }
273
265
 
274
- function disable_buttons() {
275
- var btns = document.querySelectorAll(".jspsych-audio-button-response-button");
276
- for (var i = 0; i < btns.length; i++) {
277
- var btn_el = btns[i].querySelector("button");
278
- if (btn_el) {
279
- btn_el.disabled = true;
280
- }
281
- btns[i].removeEventListener("click", button_response);
282
- }
266
+ // function to handle responses by the subject
267
+ private after_response = (choice) => {
268
+ // measure rt
269
+ var endTime = performance.now();
270
+ var rt = Math.round(endTime - this.startTime);
271
+ if (this.context !== null) {
272
+ endTime = this.context.currentTime;
273
+ rt = Math.round((endTime - this.startTime) * 1000);
283
274
  }
275
+ this.response.button = parseInt(choice);
276
+ this.response.rt = rt;
284
277
 
285
- function enable_buttons_without_delay() {
286
- var btns = document.querySelectorAll(".jspsych-audio-button-response-button");
287
- for (var i = 0; i < btns.length; i++) {
288
- var btn_el = btns[i].querySelector("button");
289
- if (btn_el) {
290
- btn_el.disabled = false;
291
- }
292
- btns[i].addEventListener("click", button_response);
293
- }
294
- }
278
+ // disable all the buttons after a response
279
+ this.disable_buttons();
295
280
 
296
- function enable_buttons() {
297
- if (trial.enable_button_after > 0) {
298
- enable_buttons_with_delay(trial.enable_button_after);
299
- } else {
300
- enable_buttons_without_delay();
301
- }
281
+ if (this.params.response_ends_trial) {
282
+ this.end_trial();
302
283
  }
284
+ };
285
+
286
+ // method to end trial when it is time
287
+ private end_trial = () => {
288
+ // stop the audio file if it is playing
289
+ this.audio.stop();
290
+
291
+ // remove end event listeners if they exist
292
+ this.audio.removeEventListener("ended", this.end_trial);
293
+ this.audio.removeEventListener("ended", this.enable_buttons);
294
+
295
+ // gather the data to store for the trial
296
+ var trial_data = {
297
+ rt: this.response.rt,
298
+ stimulus: this.params.stimulus,
299
+ response: this.response.button,
300
+ };
303
301
 
304
- return new Promise((resolve) => {
305
- trial_complete = resolve;
306
- });
307
- }
302
+ // move on to the next trial
303
+ this.trial_complete(trial_data);
304
+ };
308
305
 
309
- simulate(
306
+ async simulate(
310
307
  trial: TrialType<Info>,
311
308
  simulation_mode,
312
309
  simulation_options: any,
@@ -351,7 +348,9 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
351
348
  const respond = () => {
352
349
  if (data.rt !== null) {
353
350
  this.jsPsych.pluginAPI.clickTarget(
354
- display_element.querySelector(`div[data-choice="${data.response}"] button`),
351
+ display_element.querySelector(
352
+ `#jspsych-audio-button-response-btngroup [data-choice="${data.response}"]`
353
+ ),
355
354
  data.rt
356
355
  );
357
356
  }