Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions examples/jspsych-html-video-response.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@
allow_playback: true
}

// optional version that shows how to save the video both locally and via an API endpoint
// NOTE: you have to have your own API endpoint to test that feature here.
// const record_and_save_via_api = {
// type: jsPsychHtmlVideoResponse,
// stimulus: `<div style="width:100vw; height:100vh; position: relative;">
// <div style="width:20px; height:20px; border-radius: 20px; background-color:red; position: absolute; top:10%; left:10%; transform: translate(-50%, -50%);"></div>
// </div>`,
// show_done_button: false,
// recording_duration: 2000,
// allow_playback: true,
// save_locally: true,
// // save_via_api: "https://yourserver.com/upload",
// }

jsPsych.run([init_camera, record]);

</script>
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-html-audio-response/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The html-audio-response plugin displays HTML content and records audio from the

The audio data is recorded in base 64 format, which is a text representation of the audio that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of audio needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below).

In addition to the default base64 saving, this plugin now supports:
- **Server upload** via the `save_via_api` parameter, which uploads the audio file to a specified API endpoint. A loading spinner and customizable `upload_wait_message` are shown during upload.
- **Local file saving** via the `save_locally` parameter, which saves the audio file directly to the participant's default Downloads folder with a random UUID filename.

These options can be used individually or together, and if neither is enabled, the original base64 behavior is preserved.

## Examples

Several example experiments and plugin demonstrations are available in the `/examples` folder.
Expand Down
191 changes: 173 additions & 18 deletions packages/plugin-html-audio-response/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ const info = <const>{
type: ParameterType.BOOL,
default: false,
},
/** If a string is provided (e.g., "https://yourserver.com/upload"), the recorded audio will be uploaded
* directly to the specified API endpoint as an audio file (e.g., .webm, .wav, .mp3). A reference ID returned
* by the server (in a field named `ref_id` in the JSON response) will be stored under `response`.
* If `null` or empty, no upload is performed. */
save_via_api: {
type: ParameterType.STRING,
default: null,
},
/** If `true`, the recorded audio will be saved locally using a randomly generated filename
* (e.g., "a8sjw93kd.webm"). The browser saves to the user's default Downloads folder. */
save_locally: {
type: ParameterType.BOOL,
default: false,
},
/** A message shown to participants while the audio file is being uploaded to a remote server
* (i.e., when `save_via_api` is set to a valid API endpoint). During this time, a loading spinner
* will appear along with this message. */
upload_wait_message: {
type: ParameterType.STRING,
default: "Uploading data...",
},
},
data: {
/** The time, since the onset of the stimulus, for the participant to click the done button. If the button is not clicked (or not enabled), then `rt` will be `null`. */
Expand Down Expand Up @@ -121,6 +142,8 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
private stop_event_handler;
private data_available_handler;
private recorded_data_chunks = [];
private audio_data: Blob;
private audio_extension: string;

constructor(private jsPsych: JsPsych) {}

Expand Down Expand Up @@ -151,7 +174,9 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
}

private hideStimulus(display_element: HTMLElement) {
const el: HTMLElement = display_element.querySelector("#jspsych-html-audio-response-stimulus");
const el: HTMLElement = display_element.querySelector(
"#jspsych-html-audio-response-stimulus"
);
if (el) {
el.style.visibility = "hidden";
}
Expand All @@ -174,6 +199,102 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
}
}

private showLoadingStateSpinner(display_element, trial) {
const html = `
<style>
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #2b2b2a;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<p>${trial.upload_wait_message}</p>
<div class="spinner"></div>
`;
display_element.innerHTML = html;
}

private async saveAudioData(
display_element: HTMLElement,
trial: TrialType<Info>
): Promise<void> {
const isApi =
typeof trial.save_via_api === "string" &&
trial.save_via_api.trim() !== "";
const wantsLocalSave = trial.save_locally === true;

// UUID-style filename
const randomId = Math.random().toString(36).slice(2, 11);
const filename = `${randomId}.${this.audio_extension}`;

// API upload if requested
if (isApi) {
const formData = new FormData();
// Server should accept the field name "audio" (adjust if your server expects a different name)
formData.append("audio", this.audio_data, filename);

try {
this.showLoadingStateSpinner(display_element, trial);
const response = await fetch(trial.save_via_api, {
method: "POST",
body: formData,
});

if (!response.ok) {
const text = await response.text();
throw new Error(
`Upload failed (${response.status}): ${text.slice(0, 300)}`
);
}

let result: any;
try {
result = await response.json();
} catch {
const text = await response.text();
throw new Error(`Expected JSON but got: ${text.slice(0, 300)}`);
}

this.response = result.ref_id ?? "Uploaded with no ref_id";
} catch (error) {
console.error("Error uploading audio:", error);
this.response = "Error uploading audio: " + error;
}
}

// Local download if requested
if (wantsLocalSave) {
const a = document.createElement("a");
a.style.display = "none";
a.href = this.audio_url;
a.download = filename; // browser saves to Downloads
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

if (!isApi) {
this.response = filename;
}
}

// Fallback: base64-encoded into trial data
if (!isApi && !wantsLocalSave) {
this.response = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
const base64 = (reader.result as string).split(",")[1];
resolve(base64);
});
reader.readAsDataURL(this.audio_data);
});
}
}

private setupRecordingEvents(display_element, trial) {
this.data_available_handler = (e) => {
if (e.data.size > 0) {
Expand All @@ -182,15 +303,38 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
};

this.stop_event_handler = () => {
const data = new Blob(this.recorded_data_chunks, { type: this.recorded_data_chunks[0].type });
this.audio_url = URL.createObjectURL(data);
const reader = new FileReader();
reader.addEventListener("load", () => {
const base64 = (reader.result as string).split(",")[1];
this.response = base64;
this.load_resolver();
this.audio_data = new Blob(this.recorded_data_chunks, {
type: this.recorded_data_chunks[0].type,
});
reader.readAsDataURL(data);

this.audio_url = URL.createObjectURL(this.audio_data);

// extension from MIME type (e.g., "audio/webm;codecs=opus" → "webm")
const mimeType = this.audio_data.type.split("/");
if (mimeType.length > 1) {
const raw = mimeType[1].split(";")[0];
switch (raw) {
case "webm":
this.audio_extension = "webm";
break;
case "x-wav":
case "wav":
this.audio_extension = "wav";
break;
case "mpeg":
this.audio_extension = "mp3";
break;
case "ogg":
this.audio_extension = "ogg";
break;
default:
this.audio_extension = raw;
}
} else {
this.audio_extension = "webm";
}

this.load_resolver();
};

this.start_event_handler = (e) => {
Expand Down Expand Up @@ -226,7 +370,10 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
}
};

this.recorder.addEventListener("dataavailable", this.data_available_handler);
this.recorder.addEventListener(
"dataavailable",
this.data_available_handler
);

this.recorder.addEventListener("stop", this.stop_event_handler);

Expand All @@ -251,11 +398,13 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
<button id="continue" class="jspsych-btn">${trial.accept_button_label}</button>
`;

display_element.querySelector("#record-again").addEventListener("click", () => {
// release object url to save memory
URL.revokeObjectURL(this.audio_url);
this.startRecording();
});
display_element
.querySelector("#record-again")
.addEventListener("click", () => {
// release object url to save memory
URL.revokeObjectURL(this.audio_url);
this.startRecording();
});
display_element.querySelector("#continue").addEventListener("click", () => {
this.endTrial(display_element, trial);
});
Expand All @@ -264,10 +413,14 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
// audio.src =
}

private endTrial(display_element, trial) {
private async endTrial(display_element, trial) {
await this.saveAudioData(display_element, trial);
// clear recordering event handler

this.recorder.removeEventListener("dataavailable", this.data_available_handler);
this.recorder.removeEventListener(
"dataavailable",
this.data_available_handler
);
this.recorder.removeEventListener("start", this.start_event_handler);
this.recorder.removeEventListener("stop", this.stop_event_handler);

Expand All @@ -276,7 +429,9 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin<Info> {
rt: this.rt,
stimulus: trial.stimulus,
response: this.response,
estimated_stimulus_onset: Math.round(this.stimulus_start_time - this.recorder_start_time),
estimated_stimulus_onset: Math.round(
this.stimulus_start_time - this.recorder_start_time
),
};

if (trial.save_audio_url) {
Expand Down
12 changes: 11 additions & 1 deletion packages/plugin-html-video-response/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ jsPsych is a JavaScript framework for creating behavioral experiments that run i

The html-video-response plugin displays HTML content and records video from the participant via a webcam. In order to get access to the webcam, use the [initialize-camera plugin](https://www.jspsych.org/7.3/plugins/initialize-camera/) before this plugin.

The video data is recorded in base 64 format, which is a text representation of the video that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below).
The video data is by default recorded in base 64 format, which is a text representation of the video that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below).

Optionally, in addition to saving video data in the trial’s `response` field in base 64 format, this plugin can:
- **Upload recordings directly to a server API endpoint** via the `save_via_api` parameter.
- **Save recordings locally** to the participant’s default Downloads folder via the `save_locally` parameter.

Both options automatically generate a unique filename for each recording (e.g., `a7fj2sd9.webm`). If `save_via_api` is used, the plugin expects the server to return a JSON object containing a `ref_id` field, which will be stored in the trial data for linking the trial to the saved file.
If neither option is enabled, the plugin behaves as before, storing the base 64 string in the trial data.

Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below).

## Examples

Several example experiments and plugin demonstrations are available in the `/examples` folder.

After you've downloaded the [latest release](https://github.com/jspsych/jsPsych/releases), double-click on an example HTML file to run it in your web browser, and open it with a programming-friendly text editor to see how it works.

## Documentation
Expand Down
Loading