/** * jspsych-audio-audio-response * Matt Jaquiery, Feb 2018 (https://github.com/mjaquiery) * Becky Gilbert, Apr 2020 (https://github.com/becky-gilbert) * Hannah Small, 2020/07/07 (https://github.com/hesmall) * added in browser checking and mic checking using this code: https://experiments.ppls.ed.ac.uk/ -- Hannah Small, 2020/07/07 * added option to manually end recording on each trial * * plugin for displaying an audio stimulus and getting an audio response (records audio only after the audio stimulus ends) * * documentation: docs.jspsych.org * **/ jsPsych.plugins["audio-audio-response"] = (function() { var plugin = {}; plugin.info = { name: 'audio-audio-response', description: 'Present a string and retrieve an audio response.', parameters: { audio_stimulus: { type: jsPsych.plugins.parameterType.AUDIO, pretty_name: 'Stimulus', default: undefined, description: 'The audio file to be played' }, visual_stimulus: { type: jsPsych.plugins.parameterType.HTML_STRING, pretty_name: 'Stimulus', default: undefined, description: 'Any visual stimulus to be displayed' }, audio_stimulus: { type: jsPsych.plugins.parameterType.AUDIO, pretty_name: 'Stimulus', default: undefined, description: 'The audio file to be played' }, buffer_length: { type: jsPsych.plugins.parameterType.INT, pretty_name: 'Buffer length', default: 4000, description: 'Length of the audio buffer.' }, postprocessing: { type: jsPsych.plugins.parameterType.FUNCTION, pretty_name: 'Postprocessing function', default: function(data) { return new Promise(function(resolve) { const blob = new Blob(data, { type: 'audio/webm' }); // create URL, which is used to replay the audio file (if allow_playback is true) let url = URL.createObjectURL(blob); var reader = new window.FileReader(); reader.readAsDataURL(blob); const readerPromise = new Promise(function(resolveReader) { reader.onloadend = function() { // Create base64 string, which is used to save the audio data in JSON/CSV format. // This has to go inside of a Promise so that the base64 data is converted before the // higher-level data processing Promise is resolved (since that will pass the base64 // data to the onRecordingFinish function). var base64 = reader.result; base64 = base64.split(',')[1]; resolveReader(base64); }; }); readerPromise.then(function(base64) { // After the base64 string has been created we can resolve the higher-level Promise, // which pass both the base64 data and the URL to the onRecordingFinish function. var processed_data = {url: url, str: base64}; resolve(processed_data); }); }); }, description: 'Function to execute on the audio data prior to saving. '+ 'This function takes the audio data as an argument, '+ 'and returns an object with keys called "str" and "url". '+ 'The str and url values are saved in the trial data as "audio_data" and "audio_url". '+ 'The url value is used as the audio source to replay the recording if allow_playback is true. '+ 'By default, the str value is a base64 string which can be saved in the JSON/CSV data and '+ 'later converted back into an audio file. '+ 'This parameter can be used to pass a custom function that saves the file using a different '+ 'method/format and generates an ID that relates this file to the trial data. '+ 'The custom postprocessing function must return an object with "str" and "url" keys. '+ 'The url value must be a valid audio source, which is used if allow_playback is true. '+ 'The str value can be null.' }, allow_playback: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: 'Allow playback', default: true, description: 'Whether to allow the participant to play back their '+ 'recording and re-record if unhappy.' }, recording_light: { type: jsPsych.plugins.parameterType.HTML_STRING, pretty_name: 'Recording light', default: '
', description: 'HTML to display while recording is in progress.' }, recording_light_off: { type: jsPsych.plugins.parameterType.HTML_STRING, pretty_name: 'Recording light (off state)', default: '', description: 'HTML to display while recording is not in progress.' }, prompt: { type: jsPsych.plugins.parameterType.STRING, pretty_name: 'Prompt', default: null, description: 'Any content here will be displayed under the button.' }, stimulus_duration: { type: jsPsych.plugins.parameterType.INT, pretty_name: 'Stimulus duration', default: null, description: 'How long to show the stimulus.' }, margin_vertical: { type: jsPsych.plugins.parameterType.STRING, pretty_name: 'Margin vertical', default: '0px', description: 'The vertical margin of the button.' }, margin_horizontal: { type: jsPsych.plugins.parameterType.STRING, pretty_name: 'Margin horizontal', default: '8px', description: 'The horizontal margin of the button.' }, response_ends_trial: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: 'Response ends trial', default: false, description: 'If true, then trial will end when user responds.' }, wait_for_mic_approval: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: 'Wait for mic approval', default: false, description: 'If true, the trial will not start until the participant approves the browser mic request.' }, enable_mic_message: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: 'Will allow pop-up for participant to enable microphone', default: false, description: 'If true, will allow the browser mic request. This should be done before recording any audio!' }, manually_end_recording: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: 'Subject will manually end their recording', default: false, description: 'If true, the subject will have to press a key to stop recording and continue.' }, manually_end_recording_key: { type: jsPsych.plugins.parameterType.KEYCODE, pretty_name: 'Key to manually end recording', default: jsPsych.ALL_KEYS, description: 'The key to end recording on any given trial, default is any key.' } } }; plugin.trial = function(display_element, trial) { if(typeof trial.audio_stimulus === 'undefined'){ console.error('Required parameter "audio_stimulus" missing in audio-audio-response'); } let playbackElements = []; // store response let response = { rt: null, audio_data: null, key: null }; let recorder = null; let start_time = null; //setup stimulus var context = jsPsych.pluginAPI.audioContext(); if(context !== null){ var source = context.createBufferSource(); source.buffer = jsPsych.pluginAPI.getAudioBuffer(trial.audio_stimulus); source.connect(context.destination); } else { var audio = jsPsych.pluginAPI.getAudioBuffer(trial.audio_stimulus); audio.currentTime = 0; } let html = ''+message+'
'+body.innerHTML;//defines the style of error messages throw error; }; //either starts handlerFunction if access to microphone is enabeled or catches that it is blocked and calls errorQuit function if(trial.enable_mic_message){ navigator.mediaDevices.getUserMedia({audio:true}) .then(stream => {handlerFunction(stream)}) .catch(error => {errorQuit(declined_audio_message)}); }else{ recorder = new MediaRecorder(stream) stream = recorder.stream handlerFunction(stream) } //function that catches incompatibility with MediaRecorder (e.g. in Safari or Edge) function handlerFunction(stream) { try { recorder = new MediaRecorder(stream); recorder.data = []; recorder.wrapUp = false; recorder.ondataavailable = function(e) { // add stream data to chunks chunks.push(e.data); if (recorder.wrapUp) { if (typeof trial.postprocessing !== 'undefined') { trial.postprocessing(chunks) .then(function(processedData) { onRecordingFinish(processedData); }); } else { // should never fire - trial.postprocessing should use the default function if // not passed in via trial parameters onRecordingFinish(chunks); } } }; // start recording with 1 second time between receiving 'ondataavailable' events recorder.start(1000); if(trial.manually_end_recording == false){ // setTimeout to stop recording after 4 seconds setTimeout(function() { // this will trigger one final 'ondataavailable' event and set recorder state to 'inactive' recorder.stop(); recorder.wrapUp = true; }, trial.buffer_length); }else{ //wait for response from keyboard to end recording var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ callback_function: after_response, valid_responses: trial.manually_end_recording_key, rt_method: 'performance', persist: false, allow_held_key: false }); } } catch(error) { errorQuit(wrong_browser_message); }; } // navigator.mediaDevices.getUserMedia({audio:true}); //recorder = new MediaRecorder(stream); //recorder.data = []; //recorder.wrapUp = false; } var after_response = function(info){ // after a valid response, the stimulus will have the CSS class 'responded' // which can be used to provide visual feedback that a response was recorded //display_element.querySelector('#jspsych-audio-audio-response-stimulus').className += ' responded'; // only record the first response if (response.key == null) { response = info; } // this will trigger one final 'ondataavailable' event and set recorder state to 'inactive' recorder.stop(); recorder.wrapUp = true; } function showPlaybackTools(data) { // Audio Player let playerDiv = display_element.querySelector('#jspsych-audio-audio-response-audio-container'); let url; if (data instanceof Blob) { const blob = new Blob(data, { type: 'audio/webm' }); url = (URL.createObjectURL(blob)); } else { url = data; } let player = playerDiv.querySelector('#jspsych-audio-audio-response-audio'); player.src = url; player.style.visibility = "visible"; // Okay/rerecord buttons let buttonDiv = document.querySelector('#jspsych-audio-audio-response-buttons'); let rerecord = buttonDiv.querySelector('#jspsych-audio-audio-response-rerecord'); let okay = buttonDiv.querySelector('#jspsych-audio-audio-response-okay'); rerecord.style.visibility = 'visible'; okay.style.visibility = 'visible'; // Save ids of things we want to hide later: playbackElements = [player.id, okay.id, rerecord.id]; } function onRecordingFinish(data) { // switch to the off visual indicator let light = document.querySelector('#jspsych-audio-audio-response-recording-container'); if (light !== null) light.innerHTML = trial.recording_light_off; // measure rt let end_time = performance.now(); let rt = end_time - start_time; response.audio_data = data.str; response.audio_url = data.url; response.rt = rt; if (trial.response_ends_trial) { end_trial(); } else if (trial.allow_playback) { // only allow playback if response doesn't end trial showPlaybackTools(response.audio_url); } else { // fallback in case response_ends_trial and allow_playback are both false, // which would mean the trial never ends end_trial(); } } // function to end trial when it is time function end_trial() { // kill any remaining setTimeout handlers jsPsych.pluginAPI.clearAllTimeouts(); //kill keyboard listeners jsPsych.pluginAPI.cancelAllKeyboardResponses(); //stop and clear audio if(context !== null){ source.stop(); source.onended = function() { } } else { audio.pause(); audio.removeEventListener('ended', end_trial); } // gather the data to store for the trial let trial_data = { "rt": response.rt, "stimulus": trial.stimulus, "audio_data": response.audio_data, "key_press": response.key }; // clear the display display_element.innerHTML = ''; // move on to the next trial jsPsych.finishTrial(trial_data); } if (trial.wait_for_mic_approval) { start_recording(); } else { start_trial(); } }; return plugin; })();