// --------------------------------------------------------------------------- // DECLARE User Study -- client-side logic // Handles: consent/demographics submission, real microphone recording via the // MediaRecorder API, submitting recordings to the Flask backend, the two-part // judgement step, and the UMUX feedback questionnaire. // --------------------------------------------------------------------------- const screens = { consent: document.getElementById('consent-screen'), trial: document.getElementById('trial-screen'), umux: document.getElementById('umux-screen'), end: document.getElementById('end-screen'), }; const errorBanner = document.getElementById('error-banner'); let sessionId = null; let totalTrials = 0; let currentTrialIndex = 0; let entityApplicable = false; let intentLegendHtml = ''; let mediaStream = null; let mediaRecorder = null; let audioChunks = []; let isRecording = false; function showScreen(name) { Object.values(screens).forEach(el => el.classList.add('hidden')); screens[name].classList.remove('hidden'); } function showError(message) { errorBanner.textContent = message; errorBanner.classList.remove('hidden'); window.scrollTo({ top: 0, behavior: 'smooth' }); } function clearError() { errorBanner.classList.add('hidden'); } function getRadioValue(name) { const el = document.querySelector(`input[name="${name}"]:checked`); return el ? el.value : null; } function clearRadioGroup(name) { document.querySelectorAll(`input[name="${name}"]`).forEach(el => { el.checked = false; }); } // --------------------------------------------------------------------------- // Intent legend -- fetched once, reused every trial // --------------------------------------------------------------------------- async function loadIntentLegend() { const res = await fetch('/api/intent-legend'); const data = await res.json(); const parts = ['
What the categories mean:
']; data.labels.forEach(label => { const meta = data.meta[label]; parts.push(`
${meta.icon} ${label} — ${meta.description}
`); }); intentLegendHtml = parts.join(''); } // --------------------------------------------------------------------------- // Consent -> session start // --------------------------------------------------------------------------- document.getElementById('start-btn').addEventListener('click', async () => { clearError(); const gender = getRadioValue('gender'); const age_group = getRadioValue('age_group'); const first_language = document.getElementById('lang-input').value.trim(); const environment = getRadioValue('environment'); const consent = document.getElementById('consent-check').checked; if (!consent) { showError("Please confirm you've read and agree to the consent statement."); return; } if (!gender || !age_group) { showError('Please select your gender and age group before starting.'); return; } let data; try { const res = await fetch('/api/session/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gender, age_group, first_language, environment, consent }), }); data = await res.json(); if (!res.ok) { showError(data.error || 'Could not start session.'); return; } } catch (err) { console.error('Session start failed:', err); showError('Error starting session (check browser console for details): ' + err.message); return; } sessionId = data.session_id; totalTrials = data.total_trials; currentTrialIndex = data.trial_index; try { await loadIntentLegend(); } catch (err) { console.error('Loading intent legend failed:', err); showError('Error loading category legend (check browser console for details): ' + err.message); return; } try { renderTrial(data.prompt_text); showScreen('trial'); } catch (err) { console.error('Rendering trial screen failed:', err); showError('Error displaying the trial screen (check browser console for details): ' + err.message); } }); // --------------------------------------------------------------------------- // Trial rendering // --------------------------------------------------------------------------- function renderTrial(promptText) { document.getElementById('progress-label').textContent = `COMMAND ${currentTrialIndex + 1} OF ${totalTrials}`; document.getElementById('progress-fill').style.width = `${((currentTrialIndex + 1) / totalTrials) * 100}%`; document.getElementById('prompt-text').textContent = `"${promptText}"`; document.getElementById('rec-zone').classList.remove('hidden'); document.getElementById('analyzing-zone').classList.add('hidden'); document.getElementById('result-zone').classList.add('hidden'); const btn = document.getElementById('rec-btn'); btn.classList.remove('recording'); btn.textContent = '●'; btn.disabled = false; document.getElementById('rec-hint').textContent = 'Click to record'; clearRadioGroup('intent_judge'); clearRadioGroup('entity_judge'); document.getElementById('entity-judge-block').classList.add('hidden'); } // --------------------------------------------------------------------------- // Recording -- real microphone capture via MediaRecorder // --------------------------------------------------------------------------- document.getElementById('rec-btn').addEventListener('click', async () => { if (!isRecording) { await startRecording(); } else { stopRecording(); } }); async function startRecording() { clearError(); try { mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { showError('Microphone access is required to participate. Please allow microphone access and try again.'); return; } audioChunks = []; mediaRecorder = new MediaRecorder(mediaStream); mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = handleRecordingStopped; mediaRecorder.start(); isRecording = true; const btn = document.getElementById('rec-btn'); btn.classList.add('recording'); btn.textContent = '■'; document.getElementById('rec-hint').textContent = 'Recording… click to stop'; } function stopRecording() { if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); } if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); // release mic indicator between trials } isRecording = false; } async function handleRecordingStopped() { let blob; try { // Deliberately no `type` option here -- some WebKit/Safari versions return // an unusual or malformed mediaRecorder.mimeType value, and passing that // into Blob's type throws "The string did not match the expected pattern". // We don't need a specific type: the server converts via ffmpeg, which // detects the real format from file content, not from this label. blob = new Blob(audioChunks); } catch (err) { showError('Could not process the recording: ' + err.message); document.getElementById('analyzing-zone').classList.add('hidden'); document.getElementById('rec-zone').classList.remove('hidden'); return; } document.getElementById('rec-zone').classList.add('hidden'); document.getElementById('analyzing-zone').classList.remove('hidden'); try { const formData = new FormData(); formData.append('session_id', sessionId); formData.append('audio', blob, 'recording.webm'); const res = await fetch('/api/trial/submit', { method: 'POST', body: formData }); const data = await res.json(); document.getElementById('analyzing-zone').classList.add('hidden'); if (!res.ok) { showError(data.error || 'Could not process recording.'); document.getElementById('rec-zone').classList.remove('hidden'); return; } showPrediction(data); } catch (err) { document.getElementById('analyzing-zone').classList.add('hidden'); document.getElementById('rec-zone').classList.remove('hidden'); console.error('Trial submit failed:', err); showError('Error submitting recording (check browser console): ' + err.message); } } function showPrediction(data) { entityApplicable = data.entity_applicable; document.getElementById('prediction-intent').textContent = `${data.icon} ${data.intent}`; document.getElementById('prediction-intent').classList.remove('empty'); const entityEl = document.getElementById('prediction-entity'); if (entityApplicable) { entityEl.textContent = data.entity; entityEl.classList.remove('empty'); } else { entityEl.textContent = 'none detected'; entityEl.classList.add('empty'); } document.getElementById('timing-caption').textContent = `Processed in ${data.elapsed_seconds.toFixed(2)}s`; document.getElementById('legend-box').innerHTML = intentLegendHtml; document.getElementById('entity-judge-block').classList.toggle('hidden', !entityApplicable); document.getElementById('result-zone').classList.remove('hidden'); } // --------------------------------------------------------------------------- // Judgement confirmation // --------------------------------------------------------------------------- document.getElementById('confirm-btn').addEventListener('click', async () => { clearError(); const intentAnswer = getRadioValue('intent_judge'); const entityAnswer = getRadioValue('entity_judge'); if (!intentAnswer) { showError('Please answer whether the command type was correct.'); return; } if (entityApplicable && !entityAnswer) { showError('Please answer whether the detected item was correct.'); return; } try { const res = await fetch('/api/trial/judge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, intent_correct: intentAnswer, entity_correct: entityApplicable ? entityAnswer : null, }), }); const data = await res.json(); if (!res.ok) { showError(data.error || 'Could not record judgement.'); return; } if (data.session_complete) { await startUmux(); showScreen('umux'); } else { currentTrialIndex = data.trial_index; totalTrials = data.total_trials; renderTrial(data.prompt_text); } } catch (err) { console.error('Judgement submit failed:', err); showError('Error recording judgement (check browser console): ' + err.message); } }); // --------------------------------------------------------------------------- // UMUX feedback (validated 4-item SUS short-form, Finstad 2010) // --------------------------------------------------------------------------- async function startUmux() { const res = await fetch('/api/umux/items'); const data = await res.json(); const container = document.getElementById('umux-items'); container.innerHTML = ''; data.items.forEach((item, i) => { const qNum = i + 1; const wrap = document.createElement('div'); wrap.className = 'umux-item'; wrap.innerHTML = `

${qNum}. ${item}

${[1, 2, 3, 4, 5, 6, 7].map(v => `
`).join('')}
Strongly disagreeStrongly agree
`; container.appendChild(wrap); }); } document.getElementById('umux-submit-btn').addEventListener('click', async () => { clearError(); const responses = [1, 2, 3, 4].map(q => getRadioValue(`umux_q${q}`)); if (responses.some(r => r === null)) { showError('Please answer all 4 questions before submitting.'); return; } try { const res = await fetch('/api/umux/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, responses: responses.map(Number) }), }); const data = await res.json(); if (!res.ok) { showError(data.error || 'Could not submit feedback.'); return; } showScreen('end'); } catch (err) { console.error('UMUX submit failed:', err); showError('Error submitting feedback (check browser console): ' + err.message); } });