Spaces:
Sleeping
Sleeping
| // --------------------------------------------------------------------------- | |
| // 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 = ['<div class="legend-title">What the categories mean:</div>']; | |
| data.labels.forEach(label => { | |
| const meta = data.meta[label]; | |
| parts.push(`<div class="legend-item">${meta.icon} <strong>${label}</strong> — ${meta.description}</div>`); | |
| }); | |
| 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 = ` | |
| <p class="umux-question">${qNum}. ${item}</p> | |
| <div class="radio-group umux-scale" data-qnum="${qNum}"> | |
| ${[1, 2, 3, 4, 5, 6, 7].map(v => ` | |
| <div class="radio-pill"> | |
| <input type="radio" name="umux_q${qNum}" id="umux-${qNum}-${v}" value="${v}"> | |
| <label for="umux-${qNum}-${v}">${v}</label> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <div class="umux-anchors"><span>Strongly disagree</span><span>Strongly agree</span></div> | |
| `; | |
| 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); | |
| } | |
| }); |