first demo
This commit is contained in:
476
scripts/mark-call.js
Normal file
476
scripts/mark-call.js
Normal file
@@ -0,0 +1,476 @@
|
||||
import { ParameterType } from 'jspsych';
|
||||
import html from '../utils/html.js';
|
||||
|
||||
const info = {
|
||||
name: "mark-call",
|
||||
parameters: {
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
class jsPsychMarkCall {
|
||||
constructor(jsPsych) {
|
||||
this.jsPsych = jsPsych;
|
||||
}
|
||||
static {
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
|
||||
trial(display_element, trial) {
|
||||
// Get the last recording from the previous trial
|
||||
const lastTrialData = this.jsPsych.data.getLastTrialData();
|
||||
const recordingData = lastTrialData.values()[0];
|
||||
|
||||
if (!recordingData || !recordingData.response) {
|
||||
display_element.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<p style="color: red;">No recording found from the previous trial.</p>
|
||||
<button onclick="this.jsPsych.finishTrial()" style="
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">Continue</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert base64 back to audio blob with error handling
|
||||
let audioData;
|
||||
let audioBlob;
|
||||
try {
|
||||
// Create blob from base64 data
|
||||
const byteCharacters = atob(recordingData.response);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
audioBlob = new Blob([byteArray], { type: 'audio/ogg' });
|
||||
audioData = URL.createObjectURL(audioBlob);
|
||||
console.log('Audio blob created, size:', audioBlob.size);
|
||||
} catch (error) {
|
||||
console.error('Error creating audio blob:', error);
|
||||
display_element.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<p style="color: red;">Error loading audio data.</p>
|
||||
<button onclick="this.jsPsych.finishTrial({})" style="
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">Continue</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
display_element.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<div style="margin: 20px 0;">
|
||||
<audio style="display: none;" id="playback-audio">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="margin: 30px 0;">
|
||||
<p style="font-weight: bold; margin-bottom: 15px;">Listen to your recording and mark when you would lift:</p>
|
||||
<p style="margin: 10px 0; font-size: 14px; color: #666;">You can use keyboard controls (space and arrow keys) to play and skip the recording.</p>
|
||||
<p style="margin: 10px 0; font-size: 14px; color: #666;">Adjust playback speed for more precise control.</p>
|
||||
|
||||
<div style="max-width: 500px; margin: 0 auto; background: #f9f9f9; padding: 20px; border-radius: 10px;">
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap;">
|
||||
<button id="play-pause-btn" style="
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
">▶ Play</button>
|
||||
|
||||
<div style="display: flex; gap: 5px; align-items: center;">
|
||||
<span style="font-size: 14px; color: #666;">Speed:</span>
|
||||
<button id="speed-025" class="speed-btn" data-speed="0.25" style="
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
background-color: #ddd;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
">0.25x</button>
|
||||
<button id="speed-05" class="speed-btn" data-speed="0.5" style="
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
">0.5x</button>
|
||||
<button id="speed-1" class="speed-btn" data-speed="1" style="
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
background-color: #ddd;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
">1x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap;">
|
||||
<button id="skip-back-btn" style="
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">⏪ Skip Back 5%</button>
|
||||
|
||||
<button id="skip-forward-btn" style="
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">Skip Forward 5% ⏩</button>
|
||||
</div>
|
||||
|
||||
<div id="current-time-display" style="
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #007cba;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
min-height: 25px;
|
||||
">Current position: 0:00s</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; color: #666; margin-bottom: 20px;">
|
||||
<span>Duration: <span id="duration-display">0:00</span></span>
|
||||
<span>Progress: <span id="progress-display">0%</span></span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: #888; text-align: center; margin-bottom: 15px; line-height: 1.4;">
|
||||
<strong>Keyboard shortcuts:</strong> Spacebar = Play/Pause | ← → = Skip Back/Forward | Enter = Mark Lift Point
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0; text-align: center;">
|
||||
<button id="mark-lift-point-btn" style="
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
">Mark This as Lift Point</button>
|
||||
</div>
|
||||
|
||||
<div id="marked-point-display" style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #e91e63;
|
||||
margin: 15px 0;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="submit-lift-point-btn" style="
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
">Submit Lift Point</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set the audio source after creating the element
|
||||
const audio = document.getElementById('playback-audio');
|
||||
audio.src = audioData;
|
||||
console.log('Audio source set to blob URL:', audioData);
|
||||
console.log('Audio blob size:', audioBlob.size, 'bytes');
|
||||
|
||||
this.setupMarkingEvents(recordingData, audioBlob);
|
||||
}
|
||||
|
||||
setupMarkingEvents(recordingData, audioBlob) {
|
||||
const audio = document.getElementById('playback-audio');
|
||||
const playPauseBtn = document.getElementById('play-pause-btn');
|
||||
const currentTimeDisplay = document.getElementById('current-time-display');
|
||||
const durationDisplay = document.getElementById('duration-display');
|
||||
const progressDisplay = document.getElementById('progress-display');
|
||||
const skipBackBtn = document.getElementById('skip-back-btn');
|
||||
const skipForwardBtn = document.getElementById('skip-forward-btn');
|
||||
const markLiftPointBtn = document.getElementById('mark-lift-point-btn');
|
||||
const markedPointDisplay = document.getElementById('marked-point-display');
|
||||
const submitButton = document.getElementById('submit-lift-point-btn');
|
||||
|
||||
let audioDuration = 0;
|
||||
let liftPointTime = null;
|
||||
let isPlaying = false;
|
||||
|
||||
// Format time as MM:SS
|
||||
const formatTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Format time with precision for lift point
|
||||
const formatPreciseTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(2);
|
||||
return `${minutes}:${secs.padStart(5, '0')}s`;
|
||||
};
|
||||
|
||||
// Update display function
|
||||
const updateDisplay = () => {
|
||||
const currentTime = audio.currentTime;
|
||||
const progress = audioDuration > 0 ? (currentTime / audioDuration * 100).toFixed(1) : 0;
|
||||
|
||||
currentTimeDisplay.textContent = `Current position: ${formatPreciseTime(currentTime)}`;
|
||||
progressDisplay.textContent = `${progress}%`;
|
||||
};
|
||||
|
||||
// Update submit button state
|
||||
const updateSubmitButton = () => {
|
||||
if (liftPointTime !== null) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.style.opacity = '1';
|
||||
submitButton.style.cursor = 'pointer';
|
||||
} else {
|
||||
submitButton.disabled = true;
|
||||
submitButton.style.opacity = '0.5';
|
||||
submitButton.style.cursor = 'not-allowed';
|
||||
}
|
||||
};
|
||||
|
||||
// Check if we have a stored duration from the recording
|
||||
const storedDuration = recordingData.audio_duration;
|
||||
|
||||
// Wait for audio to load with better error handling
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10; // Reduced since we might have stored duration
|
||||
|
||||
const setupAudioControls = () => {
|
||||
console.log(`Setup attempt ${retryCount + 1}, duration:`, audio.duration, 'ready state:', audio.readyState, 'stored duration:', storedDuration);
|
||||
|
||||
// Use stored duration if available and valid
|
||||
if (storedDuration && isFinite(storedDuration) && storedDuration > 0) {
|
||||
audioDuration = storedDuration;
|
||||
durationDisplay.textContent = formatTime(audioDuration);
|
||||
updateDisplay();
|
||||
updateSubmitButton();
|
||||
console.log('Using stored duration:', audioDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise try to get duration from audio element
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
audioDuration = audio.duration;
|
||||
durationDisplay.textContent = formatTime(audioDuration);
|
||||
updateDisplay();
|
||||
updateSubmitButton();
|
||||
console.log('Audio controls setup complete, duration:', audioDuration);
|
||||
} else if (retryCount < maxRetries) {
|
||||
// Retry after a short delay if duration is not available
|
||||
retryCount++;
|
||||
setTimeout(setupAudioControls, 100);
|
||||
} else {
|
||||
console.warn('Using fallback duration estimation');
|
||||
// Fallback: estimate duration based on blob size (rough approximation)
|
||||
const estimatedDuration = Math.max(1, audioBlob.size / 8000); // ~8KB per second rough estimate
|
||||
audioDuration = estimatedDuration;
|
||||
durationDisplay.textContent = formatTime(audioDuration) + ' (est)';
|
||||
updateDisplay();
|
||||
updateSubmitButton();
|
||||
console.log('Using estimated duration:', audioDuration);
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('loadedmetadata', setupAudioControls);
|
||||
audio.addEventListener('loadeddata', setupAudioControls);
|
||||
audio.addEventListener('canplay', setupAudioControls);
|
||||
|
||||
// Try to set up immediately if we have stored duration
|
||||
if (storedDuration) {
|
||||
setupAudioControls();
|
||||
}
|
||||
|
||||
// Add error handling for audio loading
|
||||
audio.addEventListener('error', (e) => {
|
||||
console.error('Audio loading error:', e);
|
||||
console.log('Trying fallback with data URL...');
|
||||
// Fallback to data URL if blob URL fails
|
||||
audio.src = `data:audio/ogg;base64,${recordingData.response}`;
|
||||
audio.load();
|
||||
});
|
||||
|
||||
// Set default playback speed to 0.5x
|
||||
audio.playbackRate = 0.5;
|
||||
|
||||
// Force load the audio
|
||||
audio.load();
|
||||
|
||||
// Speed control buttons
|
||||
const speedButtons = document.querySelectorAll('.speed-btn');
|
||||
speedButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const speed = parseFloat(btn.dataset.speed);
|
||||
audio.playbackRate = speed;
|
||||
|
||||
// Update button styles
|
||||
speedButtons.forEach(b => {
|
||||
b.style.backgroundColor = '#ddd';
|
||||
b.style.color = '#333';
|
||||
});
|
||||
btn.style.backgroundColor = '#007cba';
|
||||
btn.style.color = 'white';
|
||||
|
||||
console.log('Playback speed set to:', speed);
|
||||
});
|
||||
});
|
||||
|
||||
// Skip buttons (5% of total duration)
|
||||
skipBackBtn.addEventListener('click', () => {
|
||||
const skipAmount = audioDuration * 0.05; // 5% of duration
|
||||
audio.currentTime = Math.max(0, audio.currentTime - skipAmount);
|
||||
updateDisplay();
|
||||
});
|
||||
|
||||
skipForwardBtn.addEventListener('click', () => {
|
||||
const skipAmount = audioDuration * 0.05; // 5% of duration
|
||||
audio.currentTime = Math.min(audioDuration, audio.currentTime + skipAmount);
|
||||
updateDisplay();
|
||||
});
|
||||
|
||||
// Play/Pause button
|
||||
playPauseBtn.addEventListener('click', () => {
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
});
|
||||
|
||||
// Audio play event
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying = true;
|
||||
playPauseBtn.textContent = '⏸ Pause';
|
||||
playPauseBtn.style.backgroundColor = '#f44336';
|
||||
});
|
||||
|
||||
// Audio pause event
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying = false;
|
||||
playPauseBtn.textContent = '▶ Play';
|
||||
playPauseBtn.style.backgroundColor = '#4caf50';
|
||||
});
|
||||
|
||||
// Update display during playback
|
||||
audio.addEventListener('timeupdate', updateDisplay);
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Only handle keys if we're not typing in a text field
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.code) {
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
// Play/Pause with spacebar
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Skip back with left arrow
|
||||
const skipBackAmount = audioDuration * 0.05;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - skipBackAmount);
|
||||
updateDisplay();
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Skip forward with right arrow
|
||||
const skipForwardAmount = audioDuration * 0.05;
|
||||
audio.currentTime = Math.min(audioDuration, audio.currentTime + skipForwardAmount);
|
||||
updateDisplay();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
// Mark lift point with Enter
|
||||
liftPointTime = audio.currentTime;
|
||||
markedPointDisplay.textContent = `✓ Lift point marked at: ${formatPreciseTime(liftPointTime)}`;
|
||||
updateSubmitButton();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Mark lift point button
|
||||
markLiftPointBtn.addEventListener('click', () => {
|
||||
liftPointTime = audio.currentTime;
|
||||
markedPointDisplay.textContent = `✓ Lift point marked at: ${formatPreciseTime(liftPointTime)}`;
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Submit button
|
||||
submitButton.addEventListener('click', () => {
|
||||
if (liftPointTime === null) return;
|
||||
|
||||
const trialData = {
|
||||
lift_point_seconds: liftPointTime,
|
||||
lift_point_formatted: formatPreciseTime(liftPointTime),
|
||||
audio_duration: audioDuration,
|
||||
original_recording_data: {
|
||||
spelling: recordingData.spelling,
|
||||
language: recordingData.language,
|
||||
translation: recordingData.translation,
|
||||
meaning: recordingData.meaning,
|
||||
rt: recordingData.rt,
|
||||
stimulus: recordingData.stimulus
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up object URL to prevent memory leaks
|
||||
if (audio.src && audio.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audio.src);
|
||||
}
|
||||
|
||||
this.jsPsych.finishTrial(trialData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default jsPsychMarkCall;
|
||||
Reference in New Issue
Block a user