Files
work-calls-corpus/scripts/mark-call.js
2025-07-22 17:03:06 +02:00

792 lines
37 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>
Press <span class="key-icon space">SPACE</span> to play/pause the audio and <span class="key-icon">ENTER</span> to mark the lift point.
</li>
</ul>
<h2 class="text-base font-semibold text-black">Keyboard shortcuts:</h2>
<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">ENTER</span> Mark lift point</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;">
<!-- Waveform Display -->
<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="position: relative; width: 100%; height: 120px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;">
<canvas id="waveform-canvas" style="width: 100%; height: 100%; display: block;"></canvas>
<div id="playback-indicator" style="
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
background-color: #ff4444;
z-index: 10;
cursor: grab;
"></div>
</div>
</div>
<!-- Progress Bar (kept as fallback) -->
<div style="margin-bottom: 20px; display: none;" id="fallback-progress">
<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');
// Waveform elements
const waveformCanvas = document.getElementById('waveform-canvas');
const playbackIndicator = document.getElementById('playback-indicator');
const ctx = waveformCanvas.getContext('2d');
let audioDuration = 0;
let liftPointTime = null;
let isPlaying = false;
let waveformData = null;
let canvasWidth = 0;
let canvasHeight = 0;
let isDragging = false;
let wasPlayingBeforeDrag = 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`;
};
// Setup canvas dimensions
const setupCanvas = () => {
const rect = waveformCanvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvasWidth = rect.width * dpr;
canvasHeight = rect.height * dpr;
waveformCanvas.width = canvasWidth;
waveformCanvas.height = canvasHeight;
ctx.scale(dpr, dpr);
};
// Generate waveform data from audio
const generateWaveform = async () => {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const rawData = audioBuffer.getChannelData(0);
const samples = Math.floor(canvasWidth / 2); // Sample every 2 pixels
const blockSize = Math.floor(rawData.length / samples);
const filteredData = [];
for (let i = 0; i < samples; i++) {
let blockStart = blockSize * i;
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(rawData[blockStart + j]);
}
filteredData.push(sum / blockSize);
}
waveformData = filteredData;
drawWaveform();
} catch (error) {
console.error('Error generating waveform:', error);
// Fallback to progress bar if waveform fails
document.getElementById('fallback-progress').style.display = 'block';
waveformCanvas.parentElement.style.display = 'none';
}
};
// Draw waveform on canvas
const drawWaveform = () => {
if (!waveformData || !ctx) return;
const rect = waveformCanvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#e0e0e0';
ctx.fillRect(0, 0, width, height);
const barWidth = width / waveformData.length;
const maxAmplitude = Math.max(...waveformData);
ctx.fillStyle = '#007cba';
for (let i = 0; i < waveformData.length; i++) {
const barHeight = (waveformData[i] / maxAmplitude) * height * 0.8;
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight);
}
};
// Seek to specific time
const seekTo = (seconds) => {
if (audioDuration > 0) {
audio.currentTime = Math.max(0, Math.min(seconds, audioDuration));
updateDisplay();
}
};
// Update display function
const updateDisplay = () => {
const currentTime = audio.currentTime;
const progress = audioDuration > 0 ? (currentTime / audioDuration * 100) : 0;
currentTimeDisplay.textContent = formatTime(currentTime);
// Update progress bar (fallback)
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
// Update waveform playback indicator (only if not dragging)
if (playbackIndicator && waveformCanvas && !isDragging && audioDuration > 0) {
const rect = waveformCanvas.getBoundingClientRect();
const indicatorPosition = (progress / 100) * rect.width;
playbackIndicator.style.left = `${Math.max(0, Math.min(indicatorPosition, rect.width - 2))}px`;
}
};
// 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();
// Initialize waveform
setupCanvas();
generateWaveform();
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();
// Initialize waveform
setupCanvas();
generateWaveform();
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();
// Initialize waveform even with estimated duration
setupCanvas();
generateWaveform();
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();
// Handle window resize to redraw waveform
window.addEventListener('resize', () => {
if (waveformData) {
setupCanvas();
drawWaveform();
}
});
// Mouse event handlers for dragging the playback indicator
const handleMouseDown = (e) => {
isDragging = true;
wasPlayingBeforeDrag = isPlaying;
if (isPlaying) {
audio.pause();
}
playbackIndicator.style.cursor = 'grabbing';
e.preventDefault();
handleMouseMove(e); // Immediately update position
};
const handleMouseMove = (e) => {
if (!isDragging || !waveformCanvas || audioDuration === 0) return;
const rect = waveformCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width - 2));
const progress = x / rect.width;
const newTime = progress * audioDuration;
// Update indicator position immediately
playbackIndicator.style.left = `${x}px`;
// Update time display
currentTimeDisplay.textContent = formatTime(newTime);
// Update audio position
seekTo(newTime);
};
const handleMouseUp = () => {
if (!isDragging) return;
isDragging = false;
playbackIndicator.style.cursor = 'grab';
// Resume playback if it was playing before drag
if (wasPlayingBeforeDrag) {
audio.play();
}
};
// Add event listeners for dragging
playbackIndicator.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Also allow clicking anywhere on the waveform to seek
waveformCanvas.addEventListener('click', (e) => {
if (isDragging) return; // Don't handle click if we're dragging
const rect = waveformCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width - 2));
const progress = x / rect.width;
const newTime = progress * audioDuration;
seekTo(newTime);
});
// 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;