// ---------------------------------------------------------------------------
// 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);
}
});