{"version":3,"file":"index.cjs","sources":["../package.json","../src/index.ts"],"sourcesContent":["{\n  \"name\": \"@jspsych/plugin-audio-button-response\",\n  \"version\": \"2.1.1\",\n  \"description\": \"jsPsych plugin for playing an audio file and getting a button response\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.cjs\",\n  \"exports\": {\n    \"import\": \"./dist/index.js\",\n    \"require\": \"./dist/index.cjs\"\n  },\n  \"typings\": \"dist/index.d.ts\",\n  \"unpkg\": \"dist/index.browser.min.js\",\n  \"files\": [\n    \"src\",\n    \"dist\"\n  ],\n  \"source\": \"src/index.ts\",\n  \"scripts\": {\n    \"test\": \"jest\",\n    \"test:watch\": \"npm test -- --watch\",\n    \"tsc\": \"tsc\",\n    \"build\": \"rollup --config\",\n    \"build:watch\": \"npm run build -- --watch\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/jspsych/jsPsych.git\",\n    \"directory\": \"packages/plugin-audio-button-response\"\n  },\n  \"author\": \"Kristin Diep\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/jspsych/jsPsych/issues\"\n  },\n  \"homepage\": \"https://www.jspsych.org/latest/plugins/audio-button-response\",\n  \"peerDependencies\": {\n    \"jspsych\": \">=7.1.0\"\n  },\n  \"devDependencies\": {\n    \"@jspsych/config\": \"^3.2.0\",\n    \"@jspsych/test-utils\": \"^1.2.0\"\n  }\n}\n","import autoBind from \"auto-bind\";\nimport { JsPsych, JsPsychPlugin, ParameterType, TrialType } from \"jspsych\";\n\nimport { AudioPlayerInterface } from \"../../jspsych/src/modules/plugin-api/AudioPlayer\";\nimport { version } from \"../package.json\";\n\nconst info = <const>{\n  name: \"audio-button-response\",\n  version: version,\n  parameters: {\n    /** Path to audio file to be played. */\n    stimulus: {\n      type: ParameterType.AUDIO,\n      default: undefined,\n    },\n    /** Labels for the buttons. Each different string in the array will generate a different button.  */\n    choices: {\n      type: ParameterType.STRING,\n      default: undefined,\n      array: true,\n    },\n    /**\n     * A function that generates the HTML for each button in the `choices` array. The function gets the string\n     * and index of the item in the `choices` array and should return valid HTML. If you want to use different\n     * markup for each button, you can do that by using a conditional on either parameter. The default parameter\n     * returns a button element with the text label of the choice.\n     */\n    button_html: {\n      type: ParameterType.FUNCTION,\n      default: function (choice: string, choice_index: number) {\n        return `<button class=\"jspsych-btn\">${choice}</button>`;\n      },\n    },\n    /** This string can contain HTML markup. Any content here will be displayed below the stimulus. The intention\n     *  is that it can be used to provide a reminder about the action the participant is supposed to take\n     * (e.g., which key to press). */\n    prompt: {\n      type: ParameterType.HTML_STRING,\n      default: null,\n    },\n    /** How long to wait for the participant to make a response before ending the trial in milliseconds. If the\n     * participant fails to make a response before this timer is reached, the participant's response will be\n     * recorded as null for the trial and the trial will end. If the value of this parameter is null, the trial\n     * will wait for a response indefinitely */\n    trial_duration: {\n      type: ParameterType.INT,\n      default: null,\n    },\n    /** Setting to `'grid'` will make the container element have the CSS property `display: grid` and enable the\n     * use of `grid_rows` and `grid_columns`. Setting to `'flex'` will make the container element have the CSS\n     * property `display: flex`. You can customize how the buttons are laid out by adding inline CSS in the `button_html` parameter.\n     */\n    button_layout: {\n      type: ParameterType.STRING,\n      default: \"grid\",\n    },\n    /** The number of rows in the button grid. Only applicable when `button_layout` is set to `'grid'`. If null, the\n     * number of rows will be determined automatically based on the number of buttons and the number of columns.\n     */\n    grid_rows: {\n      type: ParameterType.INT,\n      default: 1,\n    },\n    /** The number of columns in the button grid. Only applicable when `button_layout` is set to `'grid'`.\n     * If null, the number of columns will be determined automatically based on the number of buttons and the\n     * number of rows.\n     */\n    grid_columns: {\n      type: ParameterType.INT,\n      default: null,\n    },\n    /** If true, then the trial will end whenever the participant makes a response (assuming they make their\n     * response before the cutoff specified by the `trial_duration` parameter). If false, then the trial will\n     * continue until the value for `trial_duration` is reached. You can set this parameter to `false` to force\n     * the participant to listen to the stimulus for a fixed amount of time, even if they respond before the time is complete. */\n    response_ends_trial: {\n      type: ParameterType.BOOL,\n      default: true,\n    },\n    /** If true, then the trial will end as soon as the audio file finishes playing.  */\n    trial_ends_after_audio: {\n      type: ParameterType.BOOL,\n      default: false,\n    },\n    /**\n     * If true, then responses are allowed while the audio is playing. If false, then the audio must finish\n     * playing before the button choices are enabled and a response is accepted. Once the audio has played\n     * all the way through, the buttons are enabled and a response is allowed (including while the audio is\n     * being re-played via on-screen playback controls).\n     */\n    response_allowed_while_playing: {\n      type: ParameterType.BOOL,\n      default: true,\n    },\n    /** How long the button will delay enabling in milliseconds. If `response_allowed_while_playing` is `true`,\n     * the timer will start immediately. If it is `false`, the timer will start at the end of the audio. */\n    enable_button_after: {\n      type: ParameterType.INT,\n      default: 0,\n    },\n  },\n  data: {\n    /** The path of the audio file that was played. */\n    stimulus: {\n      type: ParameterType.STRING,\n    },\n    /** The response time in milliseconds for the participant to make a response. The time is measured from\n     * when the stimulus first began playing until the participant's response.*/\n    rt: {\n      type: ParameterType.INT,\n    },\n    /** Indicates which button the participant pressed. The first button in the `choices` array is 0, the second is 1, and so on. */\n    response: {\n      type: ParameterType.INT,\n    },\n  },\n  // prettier-ignore\n  citations: '__CITATIONS__',\n};\n\ntype Info = typeof info;\n\n/**\n * If the browser supports it, audio files are played using the WebAudio API. This allows for reasonably precise \n * timing of the playback. The timing of responses generated is measured against the WebAudio specific clock, \n * improving the measurement of response times. If the browser does not support the WebAudio API, then the audio file is \n * played with HTML5 audio. \n\n * Audio files can be automatically preloaded by jsPsych using the [`preload` plugin](preload.md). However, if \n * you are using timeline variables or another dynamic method to specify the audio stimulus, you will need \n * to [manually preload](../overview/media-preloading.md#manual-preloading) the audio.\n\n * The trial can end when the participant responds, when the audio file has finished playing, or if the participant \n * has failed to respond within a fixed length of time. You can also prevent a button response from being made before the \n * audio has finished playing.\n * \n * @author Kristin Diep\n * @see {@link https://www.jspsych.org/latest/plugins/audio-button-response/ audio-button-response plugin documentation on jspsych.org}\n */\nclass AudioButtonResponsePlugin implements JsPsychPlugin<Info> {\n  static info = info;\n  private audio: AudioPlayerInterface;\n  private params: TrialType<Info>;\n  private buttonElements: HTMLElement[] = [];\n  private display: HTMLElement;\n  private response: { rt: number; button: number } = { rt: null, button: null };\n  private context: AudioContext;\n  private startTime: number;\n  private trial_complete: (trial_data: { rt: number; stimulus: string; response: number }) => void;\n\n  constructor(private jsPsych: JsPsych) {\n    autoBind(this);\n  }\n\n  async trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {\n    this.params = trial;\n    this.display = display_element;\n    // setup stimulus\n    this.context = this.jsPsych.pluginAPI.audioContext();\n\n    // load audio file\n    this.audio = await this.jsPsych.pluginAPI.getAudioPlayer(trial.stimulus);\n\n    // set up end event if trial needs it\n    if (trial.trial_ends_after_audio) {\n      this.audio.addEventListener(\"ended\", this.end_trial);\n    }\n\n    // enable buttons after audio ends if necessary\n    if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {\n      this.audio.addEventListener(\"ended\", this.enable_buttons);\n    }\n\n    // Display buttons\n    const buttonGroupElement = document.createElement(\"div\");\n    buttonGroupElement.id = \"jspsych-audio-button-response-btngroup\";\n    if (trial.button_layout === \"grid\") {\n      buttonGroupElement.classList.add(\"jspsych-btn-group-grid\");\n      if (trial.grid_rows === null && trial.grid_columns === null) {\n        throw new Error(\n          \"You cannot set `grid_rows` to `null` without providing a value for `grid_columns`.\"\n        );\n      }\n      const n_cols =\n        trial.grid_columns === null\n          ? Math.ceil(trial.choices.length / trial.grid_rows)\n          : trial.grid_columns;\n      const n_rows =\n        trial.grid_rows === null\n          ? Math.ceil(trial.choices.length / trial.grid_columns)\n          : trial.grid_rows;\n      buttonGroupElement.style.gridTemplateColumns = `repeat(${n_cols}, 1fr)`;\n      buttonGroupElement.style.gridTemplateRows = `repeat(${n_rows}, 1fr)`;\n    } else if (trial.button_layout === \"flex\") {\n      buttonGroupElement.classList.add(\"jspsych-btn-group-flex\");\n    }\n\n    for (const [choiceIndex, choice] of trial.choices.entries()) {\n      buttonGroupElement.insertAdjacentHTML(\"beforeend\", trial.button_html(choice, choiceIndex));\n      const buttonElement = buttonGroupElement.lastChild as HTMLElement;\n      buttonElement.dataset.choice = choiceIndex.toString();\n      buttonElement.addEventListener(\"click\", () => {\n        this.after_response(choiceIndex);\n      });\n      this.buttonElements.push(buttonElement);\n    }\n\n    display_element.appendChild(buttonGroupElement);\n\n    // Show prompt if there is one\n    if (trial.prompt !== null) {\n      display_element.insertAdjacentHTML(\"beforeend\", trial.prompt);\n    }\n\n    if (trial.response_allowed_while_playing) {\n      if (trial.enable_button_after > 0) {\n        this.disable_buttons();\n        this.enable_buttons();\n      }\n    } else {\n      this.disable_buttons();\n    }\n\n    // end trial if time limit is set\n    if (trial.trial_duration !== null) {\n      this.jsPsych.pluginAPI.setTimeout(() => {\n        this.end_trial();\n      }, trial.trial_duration);\n    }\n\n    on_load();\n\n    // start time\n    this.startTime = performance.now();\n    if (this.context !== null) {\n      this.startTime = this.context.currentTime;\n    }\n\n    // start audio\n    this.audio.play();\n\n    return new Promise((resolve) => {\n      // hold the .resolve() function from the Promise that ends the trial\n      this.trial_complete = resolve;\n    });\n  }\n\n  private disable_buttons = () => {\n    for (const button of this.buttonElements) {\n      button.setAttribute(\"disabled\", \"disabled\");\n    }\n  };\n\n  private enable_buttons_without_delay = () => {\n    for (const button of this.buttonElements) {\n      button.removeAttribute(\"disabled\");\n    }\n  };\n\n  private enable_buttons_with_delay = (delay: number) => {\n    this.jsPsych.pluginAPI.setTimeout(this.enable_buttons_without_delay, delay);\n  };\n\n  private enable_buttons() {\n    if (this.params.enable_button_after > 0) {\n      this.enable_buttons_with_delay(this.params.enable_button_after);\n    } else {\n      this.enable_buttons_without_delay();\n    }\n  }\n\n  // function to handle responses by the subject\n  private after_response = (choice) => {\n    // measure rt\n    var endTime = performance.now();\n    var rt = Math.round(endTime - this.startTime);\n    if (this.context !== null) {\n      endTime = this.context.currentTime;\n      rt = Math.round((endTime - this.startTime) * 1000);\n    }\n    this.response.button = parseInt(choice);\n    this.response.rt = rt;\n\n    // disable all the buttons after a response\n    this.disable_buttons();\n\n    if (this.params.response_ends_trial) {\n      this.end_trial();\n    }\n  };\n\n  // method to end trial when it is time\n  private end_trial = () => {\n    // remove end event listeners if they exist\n    this.audio.removeEventListener(\"ended\", this.end_trial);\n    this.audio.removeEventListener(\"ended\", this.enable_buttons);\n\n    // stop the audio file if it is playing\n    this.audio.stop();\n\n    // gather the data to store for the trial\n    var trial_data = {\n      rt: this.response.rt,\n      stimulus: this.params.stimulus,\n      response: this.response.button,\n    };\n\n    // move on to the next trial\n    this.trial_complete(trial_data);\n  };\n\n  async simulate(\n    trial: TrialType<Info>,\n    simulation_mode,\n    simulation_options: any,\n    load_callback: () => void\n  ) {\n    if (simulation_mode == \"data-only\") {\n      load_callback();\n      this.simulate_data_only(trial, simulation_options);\n    }\n    if (simulation_mode == \"visual\") {\n      this.simulate_visual(trial, simulation_options, load_callback);\n    }\n  }\n\n  private create_simulation_data(trial: TrialType<Info>, simulation_options) {\n    const default_data = {\n      stimulus: trial.stimulus,\n      rt:\n        this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true) +\n        trial.enable_button_after,\n      response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),\n    };\n\n    const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);\n\n    this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);\n\n    return data;\n  }\n\n  private simulate_data_only(trial: TrialType<Info>, simulation_options) {\n    const data = this.create_simulation_data(trial, simulation_options);\n\n    this.jsPsych.finishTrial(data);\n  }\n\n  private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {\n    const data = this.create_simulation_data(trial, simulation_options);\n\n    const display_element = this.jsPsych.getDisplayElement();\n\n    const respond = () => {\n      if (data.rt !== null) {\n        this.jsPsych.pluginAPI.clickTarget(\n          display_element.querySelector(\n            `#jspsych-audio-button-response-btngroup [data-choice=\"${data.response}\"]`\n          ),\n          data.rt\n        );\n      }\n    };\n\n    this.trial(display_element, trial, () => {\n      load_callback();\n      if (!trial.response_allowed_while_playing) {\n        this.audio.addEventListener(\"ended\", respond);\n      } else {\n        respond();\n      }\n    });\n  }\n}\n\nexport default AudioButtonResponsePlugin;\n"],"names":[],"mappings":";;;;;AAEE,IAAW,OAAA,GAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ECmHA,SAAA,EAAA;AAAA;;GAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}