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 = `

No recording found from the previous trial.

`; 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 = `

Error loading audio data.

`; return; } display_element.innerHTML = `

Listen to your recording and mark when you would lift:

You can use keyboard controls (space and arrow keys) to play and skip the recording.

Adjust playback speed for more precise control.

Speed:
Current position: 0:00s
Duration: 0:00 Progress: 0%
Keyboard shortcuts: Spacebar = Play/Pause | ← → = Skip Back/Forward | Enter = Mark Lift Point
`; // 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;