929 lines
44 KiB
JavaScript
929 lines
44 KiB
JavaScript
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;
|
|
}
|
|
|
|
// Check if waveform should be shown
|
|
const showWaveform = import.meta.env.VITE_SHOW_WAVEFORM === 'true';
|
|
const allowSkip = import.meta.env.VITE_ALLOW_SKIP_BUTTONS === 'true';
|
|
|
|
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 the audio and <span class="key-icon space">SPACE</span> again to pause at the lifting point.
|
|
</li>
|
|
<li>
|
|
You may adjust the lifting point by clicking and dragging the red line on the playback indicator.
|
|
</li>
|
|
<li>
|
|
Once you have paused at the lifting point, press <span class="key-icon">ENTER</span> to confirm your selection before submitting.
|
|
</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>
|
|
|
|
${allowSkip ? `
|
|
<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;">
|
|
${showWaveform ? `
|
|
<!-- 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>
|
|
` : `
|
|
<!-- Simple time display with invisible scrubbing area when waveform is disabled -->
|
|
<div style="margin-bottom: 20px;">
|
|
<div style="display: flex; justify-content: center; font-size: 14px; color: #666; margin-bottom: 10px;">
|
|
<span>Current time: <span id="current-time">0:00</span> / <span id="end-time">0:00</span></span>
|
|
</div>
|
|
<!-- Invisible scrubbing area -->
|
|
<div id="scrub-area" style="
|
|
position: relative;
|
|
width: 100%;
|
|
height: 40px;
|
|
background: transparent;
|
|
border: 2px dashed #ccc;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #999;
|
|
font-size: 12px;
|
|
">
|
|
Click and drag the red line to adjust the lifting point.
|
|
<div id="scrub-indicator" style="
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 2px;
|
|
height: 100%;
|
|
background-color: #ff4444;
|
|
z-index: 10;
|
|
"></div>
|
|
</div>
|
|
</div>
|
|
`}
|
|
|
|
<!-- Progress Bar (kept as fallback) -->
|
|
<div style="margin-bottom: 20px; display: ${showWaveform ? 'none' : 'block'};" 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;
|
|
">Confirm lifting 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 lifting 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, showWaveform);
|
|
}
|
|
|
|
setupMarkingEvents(recordingData, audioBlob, showWaveform = true) {
|
|
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 ? waveformCanvas.getContext('2d') : null;
|
|
|
|
// Scrubbing area elements (for when waveform is disabled)
|
|
const scrubArea = document.getElementById('scrub-area');
|
|
const scrubIndicator = document.getElementById('scrub-indicator');
|
|
|
|
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 = () => {
|
|
if (!waveformCanvas || !ctx) {
|
|
console.error('Canvas or context not available');
|
|
return false;
|
|
}
|
|
|
|
const rect = waveformCanvas.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) {
|
|
console.warn('Canvas has zero dimensions, retrying...');
|
|
return false;
|
|
}
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvasWidth = rect.width * dpr;
|
|
canvasHeight = rect.height * dpr;
|
|
|
|
waveformCanvas.width = canvasWidth;
|
|
waveformCanvas.height = canvasHeight;
|
|
ctx.scale(dpr, dpr);
|
|
return true;
|
|
};
|
|
|
|
// Generate waveform data from audio
|
|
const generateWaveform = async () => {
|
|
try {
|
|
// Ensure canvas is properly set up
|
|
if (!setupCanvas()) {
|
|
console.error('Failed to setup canvas for waveform');
|
|
throw new Error('Canvas setup failed');
|
|
}
|
|
|
|
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
|
|
|
|
// Guard against invalid sample count
|
|
if (samples <= 0) {
|
|
throw new Error('Invalid canvas width for waveform generation');
|
|
}
|
|
|
|
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;
|
|
|
|
// Use the container's logical dimensions for consistent rendering
|
|
const rect = waveformCanvas.getBoundingClientRect();
|
|
const logicalWidth = rect.width;
|
|
const logicalHeight = rect.height;
|
|
|
|
// Clear the entire canvas using its actual dimensions
|
|
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
|
|
ctx.fillStyle = '#e0e0e0';
|
|
ctx.fillRect(0, 0, logicalWidth, logicalHeight);
|
|
|
|
// Guard against invalid dimensions
|
|
if (logicalWidth <= 0 || logicalHeight <= 0 || waveformData.length === 0) return;
|
|
|
|
const barWidth = logicalWidth / waveformData.length;
|
|
const maxAmplitude = Math.max(...waveformData);
|
|
|
|
// Guard against invalid amplitude data
|
|
if (maxAmplitude <= 0) return;
|
|
|
|
ctx.fillStyle = '#007cba';
|
|
for (let i = 0; i < waveformData.length; i++) {
|
|
const barHeight = (waveformData[i] / maxAmplitude) * logicalHeight * 0.8;
|
|
const x = i * barWidth;
|
|
const y = (logicalHeight - 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;
|
|
|
|
if (currentTimeDisplay) {
|
|
currentTimeDisplay.textContent = formatTime(currentTime);
|
|
}
|
|
|
|
// Update progress bar (fallback)
|
|
if (progressBar) {
|
|
progressBar.style.width = `${progress}%`;
|
|
}
|
|
|
|
// Update waveform playback indicator (only if not dragging)
|
|
if (showWaveform && 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 scrub area indicator when waveform is disabled
|
|
if (!showWaveform && scrubIndicator && scrubArea && !isDragging && audioDuration > 0) {
|
|
const rect = scrubArea.getBoundingClientRect();
|
|
const indicatorPosition = (progress / 100) * rect.width;
|
|
scrubIndicator.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;">✓ Lifting point confirmed at: ${formatPreciseTime(liftPointTime)}</p>
|
|
<p style="margin: 5px 0; font-size: 14px; color: #2c3e50;">Preview completed. Press Submit to continue, <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);
|
|
console.log('Audio duration isFinite:', isFinite(audio.duration), 'Audio duration !== Infinity:', audio.duration !== Infinity);
|
|
|
|
// Use stored duration if available and valid
|
|
if (storedDuration && isFinite(storedDuration) && storedDuration > 0) {
|
|
audioDuration = storedDuration;
|
|
if (startTimeDisplay) startTimeDisplay.textContent = '0:00';
|
|
if (endTimeDisplay) endTimeDisplay.textContent = formatTime(audioDuration);
|
|
updateDisplay();
|
|
updateSubmitButton();
|
|
// Initialize waveform only if enabled
|
|
if (showWaveform) {
|
|
generateWaveform();
|
|
}
|
|
console.log('Using stored duration:', audioDuration);
|
|
return;
|
|
}
|
|
|
|
// Otherwise try to get duration from audio element (reject Infinity)
|
|
if (audio.duration && isFinite(audio.duration) && audio.duration > 0 && audio.duration !== Infinity) {
|
|
audioDuration = audio.duration;
|
|
if (startTimeDisplay) startTimeDisplay.textContent = '0:00';
|
|
if (endTimeDisplay) endTimeDisplay.textContent = formatTime(audioDuration);
|
|
updateDisplay();
|
|
updateSubmitButton();
|
|
// Initialize waveform only if enabled
|
|
if (showWaveform) {
|
|
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;
|
|
if (startTimeDisplay) startTimeDisplay.textContent = '0:00';
|
|
if (endTimeDisplay) endTimeDisplay.textContent = formatTime(audioDuration);
|
|
updateDisplay();
|
|
updateSubmitButton();
|
|
// Initialize waveform even with estimated duration (only if enabled)
|
|
if (showWaveform) {
|
|
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 (only if waveform is enabled)
|
|
if (showWaveform) {
|
|
window.addEventListener('resize', () => {
|
|
if (waveformData) {
|
|
if (setupCanvas()) {
|
|
drawWaveform();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mouse event handlers for dragging the playback indicator
|
|
const handleMouseDown = (e) => {
|
|
isDragging = true;
|
|
wasPlayingBeforeDrag = isPlaying;
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
}
|
|
if (showWaveform && playbackIndicator) {
|
|
playbackIndicator.style.cursor = 'grabbing';
|
|
}
|
|
|
|
e.preventDefault();
|
|
handleMouseMove(e); // Immediately update position
|
|
};
|
|
|
|
const handleMouseMove = (e) => {
|
|
if (!isDragging || audioDuration === 0) return;
|
|
|
|
let rect, indicator;
|
|
if (showWaveform && waveformCanvas) {
|
|
rect = waveformCanvas.getBoundingClientRect();
|
|
indicator = playbackIndicator;
|
|
} else if (!showWaveform && scrubArea) {
|
|
rect = scrubArea.getBoundingClientRect();
|
|
indicator = scrubIndicator;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
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
|
|
if (indicator) {
|
|
indicator.style.left = `${x}px`;
|
|
}
|
|
|
|
// Update time display
|
|
if (currentTimeDisplay) {
|
|
currentTimeDisplay.textContent = formatTime(newTime);
|
|
}
|
|
|
|
// Update audio position
|
|
seekTo(newTime);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!isDragging) return;
|
|
|
|
isDragging = false;
|
|
if (showWaveform && playbackIndicator) {
|
|
playbackIndicator.style.cursor = 'grab';
|
|
}
|
|
|
|
// Resume playback if it was playing before drag
|
|
if (wasPlayingBeforeDrag) {
|
|
audio.play();
|
|
}
|
|
};
|
|
|
|
// Add event listeners for dragging
|
|
if (showWaveform && playbackIndicator) {
|
|
playbackIndicator.addEventListener('mousedown', handleMouseDown);
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
} else if (!showWaveform && scrubIndicator) {
|
|
scrubIndicator.addEventListener('mousedown', handleMouseDown);
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
// Also allow clicking anywhere to seek
|
|
if (showWaveform && waveformCanvas) {
|
|
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);
|
|
});
|
|
} else if (!showWaveform && scrubArea) {
|
|
scrubArea.addEventListener('click', (e) => {
|
|
if (isDragging) return; // Don't handle click if we're dragging
|
|
|
|
const rect = scrubArea.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) - only if waveform is enabled
|
|
if (showWaveform && skipBackBtn) {
|
|
skipBackBtn.addEventListener('click', () => {
|
|
const skipAmount = 0.1; // 0.1 seconds
|
|
audio.currentTime = Math.max(0, audio.currentTime - skipAmount);
|
|
updateDisplay();
|
|
});
|
|
}
|
|
|
|
if (showWaveform && skipForwardBtn) {
|
|
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':
|
|
if (showWaveform) {
|
|
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':
|
|
if (showWaveform) {
|
|
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; |