Files
work-calls-corpus/scripts/mark-call.js

610 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.key-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 24px;
padding: 2px 6px;
background: linear-gradient(145deg, #f0f0f0, #e0e0e0);
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.6);
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 11px;
font-weight: 600;
color: #333;
margin: 0 2px;
vertical-align: middle;
}
.key-icon.space {
min-width: 60px;
}
.key-icon i {
font-size: 12px;
}
</style>
<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;">
<div class="space-y-4 text-sm text-gray-700">
<p class="text-base font-semibold text-black">
🎧 Listen to your recording and mark where you would lift:
</p>
<ul class="list-disc list-inside space-y-2">
<li>
Pause the audio at the lift point and click
<span class="italic">'Mark this as lifting point'</span> or press
<span class="key-icon">ENTER</span> to save it.
</li>
<li>
You can use the keyboard or the buttons below to mark lift points.
</li>
<li>
Keyboard shortcuts:
<ul class="list-inside list-disc ml-5 mt-1 space-y-1 text-gray-600">
<li><span class="key-icon space">SPACE</span> Play/Pause</li>
<li><span class="key-icon"><i class="fas fa-arrow-left"></i></span> Skip back 0.1s</li>
<li><span class="key-icon"><i class="fas fa-arrow-right"></i></span> Skip forward 0.1s</li>
<li><span class="key-icon">ENTER</span> Mark lift point</li>
</ul>
</li>
<li>
Adjust playback speed if you need more precise control.
</li>
</ul>
</div>
<div style="max-width: 500px; margin: 0 auto; background: #f9f9f9; padding: 20px; border-radius: 10px;">
<div style="margin-bottom: 20px; text-align: center;">
<div style="margin-bottom: 15px; font-size: 14px; color: #666;">
Playing at 0.5x speed
</div>
<div style="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: 140px;
">▶ Play <span class="key-icon space">SPACE</span></button>
</div>
<!--
<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 0.1s <span class="key-icon"><i class="fas fa-arrow-left"></i></span></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 0.1s <span class="key-icon"><i class="fas fa-arrow-right"></i></span> ⏩</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="margin: 20px 0;">
<!-- Progress Bar -->
<div style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; font-size: 12px; color: #666; margin-bottom: 5px;">
<span id="start-time">0:00</span>
<span id="current-time">0:00</span>
<span id="end-time">0:00</span>
</div>
<div style="width: 100%; background-color: #ddd; border-radius: 10px; height: 8px; position: relative;">
<div id="progress-bar" style="background-color: #007cba; height: 100%; border-radius: 10px; width: 0%; transition: width 0.1s ease;"></div>
</div>
</div>
<div style="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 <span class="key-icon">ENTER</span></button>
</div>
</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');
// Progress bar elements
const startTimeDisplay = document.getElementById('start-time');
const currentTimeDisplay = document.getElementById('current-time');
const endTimeDisplay = document.getElementById('end-time');
const progressBar = document.getElementById('progress-bar');
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) : 0;
currentTimeDisplay.textContent = formatTime(currentTime);
progressBar.style.width = `${progress}%`;
};
// Update submit button state
let isPlaybackConfirmed = false;
const updateSubmitButton = () => {
if (liftPointTime !== null && isPlaybackConfirmed) {
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';
}
};
// Function to play back from marked point for confirmation
const playbackFromMarkedPoint = () => {
if (liftPointTime === null) return;
// Store current position to restore later
const originalPosition = audio.currentTime;
// Show confirmation message
markedPointDisplay.innerHTML = `
<div style="text-align: center; padding: 10px; background: #fff3cd; border: 1px solid #ffd93d; border-radius: 5px; margin: 10px 0;">
<p style="margin: 5px 0; color: #856404;">🔊 Playing back from marked point...</p>
<p style="margin: 5px 0; font-size: 14px; color: #856404;">If this sounds right, press Submit. Otherwise, choose a new mark point.</p>
</div>
`;
// Set audio to marked point and play
audio.currentTime = liftPointTime;
updateDisplay();
// Play for a few seconds (3 seconds or until end)
const playbackDuration = 3;
const endTime = Math.min(liftPointTime + playbackDuration, audioDuration);
audio.play();
// Set up event to stop playback after duration and restore position
const onTimeUpdate = () => {
if (audio.currentTime >= endTime) {
audio.pause();
audio.removeEventListener('timeupdate', onTimeUpdate);
// Restore original position
audio.currentTime = originalPosition;
updateDisplay();
// Update display to show confirmation
markedPointDisplay.innerHTML = `
<div style="text-align: center; padding: 10px; background: #d1edff; border: 1px solid #3498db; border-radius: 5px; margin: 10px 0;">
<p style="margin: 5px 0; color: #2c3e50;">✓ Lift point marked at: ${formatPreciseTime(liftPointTime)}</p>
<p style="margin: 5px 0; font-size: 14px; color: #2c3e50;">Preview completed. Press Submit to confirm, <span class="key-icon">ENTER</span> to play again, or mark a new point.</p>
</div>
`;
// Enable submit button
isPlaybackConfirmed = true;
updateSubmitButton();
}
};
audio.addEventListener('timeupdate', onTimeUpdate);
};
// 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;
startTimeDisplay.textContent = '0:00';
endTimeDisplay.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;
startTimeDisplay.textContent = '0:00';
endTimeDisplay.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;
startTimeDisplay.textContent = '0:00';
endTimeDisplay.textContent = formatTime(audioDuration);
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 (commented out - using fixed 0.5x speed)
/*
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 (0.1 second increments)
skipBackBtn.addEventListener('click', () => {
const skipAmount = 0.1; // 0.1 seconds
audio.currentTime = Math.max(0, audio.currentTime - skipAmount);
updateDisplay();
});
skipForwardBtn.addEventListener('click', () => {
const skipAmount = 0.1; // 0.1 seconds
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.innerHTML = '⏸ Pause <span class="key-icon space">SPACE</span>';
playPauseBtn.style.backgroundColor = '#f44336';
});
// Audio pause event
audio.addEventListener('pause', () => {
isPlaying = false;
playPauseBtn.innerHTML = '▶ Play <span class="key-icon space">SPACE</span>';
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 (0.1 seconds)
const skipBackAmount = 0.1;
audio.currentTime = Math.max(0, audio.currentTime - skipBackAmount);
updateDisplay();
break;
case 'ArrowRight':
e.preventDefault();
// Skip forward with right arrow (0.1 seconds)
const skipForwardAmount = 0.1;
audio.currentTime = Math.min(audioDuration, audio.currentTime + skipForwardAmount);
updateDisplay();
break;
case 'Enter':
e.preventDefault();
// Mark lift point with Enter
liftPointTime = audio.currentTime;
isPlaybackConfirmed = false;
updateSubmitButton();
playbackFromMarkedPoint();
break;
}
});
// Mark lift point button
markLiftPointBtn.addEventListener('click', () => {
liftPointTime = audio.currentTime;
isPlaybackConfirmed = false;
updateSubmitButton();
playbackFromMarkedPoint();
});
// 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;