first demo

This commit is contained in:
2025-07-20 01:59:08 +02:00
parent 544b5d2b1e
commit 85ffeb480d
11 changed files with 2561 additions and 22 deletions

476
scripts/mark-call.js Normal file
View 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;