first demo
This commit is contained in:
476
scripts/record-call.js
Normal file
476
scripts/record-call.js
Normal file
@@ -0,0 +1,476 @@
|
||||
import { ParameterType } from 'jspsych';
|
||||
|
||||
const info = {
|
||||
name: "record-call",
|
||||
parameters: {
|
||||
stimulus: {
|
||||
type: ParameterType.STRING,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class jsPsychRecordCall {
|
||||
constructor(jsPsych) {
|
||||
this.jsPsych = jsPsych;
|
||||
this.recorded_data_chunks = [];
|
||||
this.recording = false;
|
||||
this.recorder = null;
|
||||
this.current_recording = null;
|
||||
this.audio_duration = null;
|
||||
}
|
||||
static {
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
trial(display_element, trial) {
|
||||
try {
|
||||
this.recorder = this.jsPsych.pluginAPI.getMicrophoneRecorder();
|
||||
if (!this.recorder) {
|
||||
throw new Error("Microphone not initialized");
|
||||
}
|
||||
this.setupRecordingEvents();
|
||||
} catch (error) {
|
||||
console.error("Microphone setup failed:", error);
|
||||
display_element.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<p style="color: red;">Microphone access is required for this experiment.</p>
|
||||
<p>Please refresh the page and allow microphone access when prompted.</p>
|
||||
<button onclick="location.reload()" style="
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">Refresh Page</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
display_element.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<div style="background-color: #f0f0f0; padding: 15px; margin-bottom: 20px; border-radius: 8px; text-align: left; max-width: 600px; margin-left: auto; margin-right: auto;">
|
||||
<p style="margin: 0; font-weight: bold; font-size: 16px;">Imagine two people lifting something heavy together. In your culture, is there a typical phrase, word or utterance that is used in such a situation? Please record one such a phrase, word or utterance!</p>
|
||||
</div>
|
||||
${trial.stimulus ? `<p>${trial.stimulus}</p>` : ''}
|
||||
<button id="record-button" style="
|
||||
padding: 15px 30px;
|
||||
font-size: 16px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
">Hold to Record</button>
|
||||
<div id="recording-status" style="margin: 10px; font-weight: bold;"></div>
|
||||
<div id="recording-controls" style="display: none; margin: 20px;">
|
||||
<audio id="playback-audio" controls style="display: block; margin: 10px auto;"></audio>
|
||||
<button id="re-record-button" style="
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
">Re-record</button>
|
||||
<button id="accept-recording-button" style="
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
">Accept Recording</button>
|
||||
</div>
|
||||
<div id="questions-section" style="display: none; margin: 20px; text-align: left; max-width: 600px; margin-left: auto; margin-right: auto;">
|
||||
<audio id="final-playback-audio" controls style="display: block; margin: 10px auto;"></audio>
|
||||
<h3 style="text-align: center; margin-bottom: 20px;">Please answer the following questions:</h3>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="spelling-input" style="display: block; margin-bottom: 5px; font-weight: bold;">How would you spell what you have just recorded?</label>
|
||||
<input type="text" id="spelling-input" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="language-select" style="display: block; margin-bottom: 5px; font-weight: bold;">What language is it in?</label>
|
||||
<select id="language-select" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" required>
|
||||
<option value="">Select a language...</option>
|
||||
<option value="English">English</option>
|
||||
<option value="Spanish">Spanish</option>
|
||||
<option value="French">French</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Italian">Italian</option>
|
||||
<option value="Portuguese">Portuguese</option>
|
||||
<option value="Russian">Russian</option>
|
||||
<option value="Chinese">Chinese</option>
|
||||
<option value="Japanese">Japanese</option>
|
||||
<option value="Korean">Korean</option>
|
||||
<option value="Arabic">Arabic</option>
|
||||
<option value="Hindi">Hindi</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="other-language-section" style="margin-bottom: 15px; display: none;">
|
||||
<label for="other-language-input" style="display: block; margin-bottom: 5px; font-weight: bold;">Please specify the language:</label>
|
||||
<input type="text" id="other-language-input" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div id="translation-section" style="margin-bottom: 15px; display: none;">
|
||||
<label for="translation-input" style="display: block; margin-bottom: 5px; font-weight: bold;">How would you translate it to English?</label>
|
||||
<input type="text" id="translation-input" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="meaning-input" style="display: block; margin-bottom: 5px; font-weight: bold;">Does it have a meaning? If so, write it here in English:</label>
|
||||
<input type="text" id="meaning-input" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="Write the meaning or 'No meaning' if it doesn't have one">
|
||||
</div>
|
||||
|
||||
<button id="submit-answers-button" style="
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
opacity: 0.5;
|
||||
" disabled>Submit Answers</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupButtonEvents(display_element, trial);
|
||||
}
|
||||
|
||||
setupRecordingEvents() {
|
||||
this.data_available_handler = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.recorded_data_chunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.stop_event_handler = () => {
|
||||
const data = new Blob(this.recorded_data_chunks, {type: "audio/ogg"});
|
||||
this.current_recording = data;
|
||||
|
||||
const audioUrl = URL.createObjectURL(data);
|
||||
const audioElement = document.getElementById("playback-audio");
|
||||
audioElement.src = audioUrl;
|
||||
|
||||
// Force playback to load metadata and get exact duration
|
||||
document.getElementById("recording-status").textContent = "Processing recording...";
|
||||
document.getElementById("record-button").style.display = "none";
|
||||
|
||||
const loadAudioMetadata = () => {
|
||||
let metadataLoaded = false;
|
||||
|
||||
const showControls = () => {
|
||||
if (metadataLoaded) return;
|
||||
metadataLoaded = true;
|
||||
|
||||
// Clean up
|
||||
audioElement.muted = false;
|
||||
audioElement.pause();
|
||||
audioElement.currentTime = 0;
|
||||
|
||||
// Show controls
|
||||
document.getElementById("recording-controls").style.display = "block";
|
||||
document.getElementById("record-button").style.display = "none";
|
||||
document.getElementById("recording-status").textContent = "Recording complete. Listen and choose:";
|
||||
|
||||
console.log('Final audio duration:', this.audio_duration);
|
||||
};
|
||||
|
||||
// Multiple event handlers to catch metadata loading
|
||||
const onMetadataEvent = () => {
|
||||
if (audioElement.duration && isFinite(audioElement.duration) && audioElement.duration > 0) {
|
||||
this.audio_duration = audioElement.duration;
|
||||
console.log('Audio duration loaded via metadata:', this.audio_duration);
|
||||
showControls();
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlayEvent = () => {
|
||||
if (audioElement.duration && isFinite(audioElement.duration) && audioElement.duration > 0) {
|
||||
this.audio_duration = audioElement.duration;
|
||||
console.log('Audio duration loaded via canplay:', this.audio_duration);
|
||||
showControls();
|
||||
}
|
||||
};
|
||||
|
||||
audioElement.addEventListener('loadedmetadata', onMetadataEvent);
|
||||
audioElement.addEventListener('loadeddata', onMetadataEvent);
|
||||
audioElement.addEventListener('canplay', onCanPlayEvent);
|
||||
|
||||
// Mute and try to load
|
||||
audioElement.muted = true;
|
||||
audioElement.currentTime = 0;
|
||||
audioElement.load();
|
||||
|
||||
// Fallback: if metadata doesn't load within 2 seconds, continue anyway
|
||||
setTimeout(() => {
|
||||
if (!metadataLoaded) {
|
||||
console.warn('Metadata loading timeout, continuing without duration');
|
||||
showControls();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Try playing after a short delay if metadata isn't loaded
|
||||
setTimeout(() => {
|
||||
if (!metadataLoaded && !this.audio_duration) {
|
||||
console.log('Attempting to play audio to force metadata loading');
|
||||
audioElement.play().catch(e => {
|
||||
console.log('Auto-play prevented:', e);
|
||||
// If play fails, just continue
|
||||
if (!metadataLoaded) {
|
||||
showControls();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
loadAudioMetadata();
|
||||
};
|
||||
|
||||
this.start_event_handler = (e) => {
|
||||
this.recorded_data_chunks.length = 0;
|
||||
this.recorder_start_time = e.timeStamp;
|
||||
};
|
||||
|
||||
this.recorder.addEventListener("dataavailable", this.data_available_handler);
|
||||
this.recorder.addEventListener("stop", this.stop_event_handler);
|
||||
this.recorder.addEventListener("start", this.start_event_handler);
|
||||
}
|
||||
|
||||
setupButtonEvents(display_element, trial) {
|
||||
const recordButton = document.getElementById("record-button");
|
||||
const reRecordButton = document.getElementById("re-record-button");
|
||||
const acceptButton = document.getElementById("accept-recording-button");
|
||||
const statusDiv = document.getElementById("recording-status");
|
||||
|
||||
recordButton.addEventListener("mousedown", () => {
|
||||
if (!this.recording) {
|
||||
this.startRecording();
|
||||
recordButton.textContent = "Recording... (Release to stop)";
|
||||
recordButton.style.backgroundColor = "#f44336";
|
||||
statusDiv.textContent = "Recording in progress...";
|
||||
}
|
||||
});
|
||||
|
||||
recordButton.addEventListener("mouseup", () => {
|
||||
if (this.recording) {
|
||||
this.stopRecording();
|
||||
recordButton.textContent = "Hold to Record";
|
||||
recordButton.style.backgroundColor = "#007cba";
|
||||
statusDiv.textContent = "Processing recording...";
|
||||
}
|
||||
});
|
||||
|
||||
recordButton.addEventListener("mouseleave", () => {
|
||||
if (this.recording) {
|
||||
this.stopRecording();
|
||||
recordButton.textContent = "Hold to Record";
|
||||
recordButton.style.backgroundColor = "#007cba";
|
||||
statusDiv.textContent = "Processing recording...";
|
||||
}
|
||||
});
|
||||
|
||||
reRecordButton.addEventListener("click", () => {
|
||||
document.getElementById("recording-controls").style.display = "none";
|
||||
document.getElementById("record-button").style.display = "inline-block";
|
||||
statusDiv.textContent = "";
|
||||
this.current_recording = null;
|
||||
});
|
||||
|
||||
acceptButton.addEventListener("click", () => {
|
||||
document.getElementById("recording-controls").style.display = "none";
|
||||
document.getElementById("questions-section").style.display = "block";
|
||||
|
||||
const finalAudio = document.getElementById("final-playback-audio");
|
||||
const originalAudio = document.getElementById("playback-audio");
|
||||
finalAudio.src = originalAudio.src;
|
||||
|
||||
document.getElementById("recording-status").textContent = "Please answer all questions to continue:";
|
||||
|
||||
this.setupQuestionEvents(trial);
|
||||
});
|
||||
}
|
||||
|
||||
setupQuestionEvents(trial) {
|
||||
const spellingInput = document.getElementById("spelling-input");
|
||||
const languageSelect = document.getElementById("language-select");
|
||||
const otherLanguageSection = document.getElementById("other-language-section");
|
||||
const otherLanguageInput = document.getElementById("other-language-input");
|
||||
const translationSection = document.getElementById("translation-section");
|
||||
const translationInput = document.getElementById("translation-input");
|
||||
const meaningInput = document.getElementById("meaning-input");
|
||||
const submitButton = document.getElementById("submit-answers-button");
|
||||
|
||||
const validateForm = () => {
|
||||
const spelling = spellingInput.value.trim();
|
||||
const language = languageSelect.value;
|
||||
const otherLanguage = otherLanguageInput.value.trim();
|
||||
const meaning = meaningInput.value.trim();
|
||||
const needsOtherLanguage = language === "Other";
|
||||
const needsTranslation = language && language !== "English";
|
||||
const translation = translationInput.value.trim();
|
||||
|
||||
const isValid = spelling && language && meaning &&
|
||||
(!needsOtherLanguage || otherLanguage) &&
|
||||
(!needsTranslation || translation);
|
||||
|
||||
submitButton.disabled = !isValid;
|
||||
submitButton.style.opacity = isValid ? "1" : "0.5";
|
||||
submitButton.style.cursor = isValid ? "pointer" : "not-allowed";
|
||||
};
|
||||
|
||||
languageSelect.addEventListener("change", () => {
|
||||
const selectedLanguage = languageSelect.value;
|
||||
const isEnglish = selectedLanguage === "English";
|
||||
const isOther = selectedLanguage === "Other";
|
||||
|
||||
translationSection.style.display = isEnglish ? "none" : "block";
|
||||
otherLanguageSection.style.display = isOther ? "block" : "none";
|
||||
|
||||
if (isEnglish) {
|
||||
translationInput.value = "";
|
||||
}
|
||||
if (!isOther) {
|
||||
otherLanguageInput.value = "";
|
||||
}
|
||||
validateForm();
|
||||
});
|
||||
|
||||
[spellingInput, languageSelect, otherLanguageInput, translationInput, meaningInput].forEach(element => {
|
||||
element.addEventListener("input", validateForm);
|
||||
element.addEventListener("change", validateForm);
|
||||
});
|
||||
|
||||
submitButton.addEventListener("click", () => {
|
||||
if (!submitButton.disabled) {
|
||||
const finalLanguage = languageSelect.value === "Other" ?
|
||||
otherLanguageInput.value.trim() :
|
||||
languageSelect.value;
|
||||
|
||||
this.endTrialWithAnswers(trial, {
|
||||
spelling: spellingInput.value.trim(),
|
||||
language: finalLanguage,
|
||||
translation: translationInput.value.trim() || null,
|
||||
meaning: meaningInput.value.trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startRecording() {
|
||||
try {
|
||||
this.recording = true;
|
||||
this.recorder.start();
|
||||
this.stimulus_start_time = Date.now();
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
document.getElementById("recording-status").textContent = "Recording failed. Please refresh and try again.";
|
||||
this.recording = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
if (this.recording) {
|
||||
this.recording = false;
|
||||
// Add a small buffer to prevent clipping
|
||||
setTimeout(() => {
|
||||
this.recorder.stop();
|
||||
}, 200); // 200ms buffer
|
||||
}
|
||||
}
|
||||
|
||||
endTrialWithAnswers(trial, answers) {
|
||||
this.recorder.removeEventListener("dataavailable", this.data_available_handler);
|
||||
this.recorder.removeEventListener("start", this.start_event_handler);
|
||||
this.recorder.removeEventListener("stop", this.stop_event_handler);
|
||||
|
||||
this.jsPsych.pluginAPI.clearAllTimeouts();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const response = reader.result.split(",")[1];
|
||||
|
||||
let trial_data = {
|
||||
rt: this.stimulus_start_time ? Date.now() - this.stimulus_start_time : null,
|
||||
stimulus: trial.stimulus,
|
||||
response: response,
|
||||
estimated_stimulus_onset: this.recorder_start_time ? Math.round(this.stimulus_start_time - this.recorder_start_time) : null,
|
||||
spelling: answers.spelling,
|
||||
language: answers.language,
|
||||
translation: answers.translation,
|
||||
meaning: answers.meaning,
|
||||
audio_duration: this.audio_duration
|
||||
};
|
||||
|
||||
this.jsPsych.finishTrial(trial_data);
|
||||
});
|
||||
|
||||
if (this.current_recording) {
|
||||
reader.readAsDataURL(this.current_recording);
|
||||
} else {
|
||||
this.jsPsych.finishTrial({
|
||||
rt: null,
|
||||
stimulus: trial.stimulus,
|
||||
response: null,
|
||||
estimated_stimulus_onset: null,
|
||||
spelling: answers.spelling,
|
||||
language: answers.language,
|
||||
translation: answers.translation,
|
||||
meaning: answers.meaning,
|
||||
audio_duration: this.audio_duration
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
endTrial(display_element, trial) {
|
||||
this.recorder.removeEventListener("dataavailable", this.data_available_handler);
|
||||
this.recorder.removeEventListener("start", this.start_event_handler);
|
||||
this.recorder.removeEventListener("stop", this.stop_event_handler);
|
||||
|
||||
this.jsPsych.pluginAPI.clearAllTimeouts();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const response = reader.result.split(",")[1];
|
||||
|
||||
let trial_data = {
|
||||
rt: this.stimulus_start_time ? Date.now() - this.stimulus_start_time : null,
|
||||
stimulus: trial.stimulus,
|
||||
response: response,
|
||||
estimated_stimulus_onset: this.recorder_start_time ? Math.round(this.stimulus_start_time - this.recorder_start_time) : null,
|
||||
};
|
||||
|
||||
this.jsPsych.finishTrial(trial_data);
|
||||
});
|
||||
|
||||
if (this.current_recording) {
|
||||
reader.readAsDataURL(this.current_recording);
|
||||
} else {
|
||||
this.jsPsych.finishTrial({
|
||||
rt: null,
|
||||
stimulus: trial.stimulus,
|
||||
response: null,
|
||||
estimated_stimulus_onset: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default jsPsychRecordCall;
|
||||
Reference in New Issue
Block a user