FocusPal / script.js
NCTCMumbai's picture
Upload 4 files
79f4712 verified
Raw History Blame Contribute Delete
29.2 kB
/* ==========================================================================
FocusPal — AI powered study companion
Runs entirely in the browser. Nothing is uploaded.
Pipeline:
webcam frame -> MediaPipe Face Landmarker (478 points + 52 blendshapes)
-> geometry (head yaw / pitch / roll, eye openness, face scale)
-> six live parameters, each scored 0-100
-> weighted overall focus score for the whole session
========================================================================== */
import { FaceLandmarker, FilesetResolver }
from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs";
/* ---------------------------------------------------------------- helpers */
const $ = (s) => document.querySelector(s);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const median = (arr) => {
if (!arr.length) return 0;
const s = [...arr].sort((x, y) => x - y);
return s[Math.floor(s.length / 2)];
};
/** how far past "tol" a deviation is, as 0..1 (1 = at or beyond "max") */
const over = (d, tol, max) => clamp((d - tol) / Math.max(max - tol, 1e-6), 0, 1);
/** exponential smoothing that is frame-rate independent */
const smooth = (prev, next, dt, tau) => prev + (next - prev) * (1 - Math.exp(-dt / tau));
/* ------------------------------------------------------- landmark indices */
const NOSE = 1, CHIN = 152, BROW = 10, L_FACE = 234, R_FACE = 454;
const L_OUT = 33, L_IN = 133, L_UP = 159, L_LO = 145;
const R_IN = 362, R_OUT = 263, R_UP = 386, R_LO = 374;
/* -------------------------------------------------------- the parameters */
const METERS = [
{ id: 'task', name: 'Eyes on the book', hint: 'Looking where you set up' },
{ id: 'steady', name: 'No looking away', hint: 'Head not turning aside' },
{ id: 'aloud', name: 'Reading aloud', hint: 'Voice heard while reading' },
{ id: 'alert', name: 'Alertness', hint: 'Eyes open, not drowsy' },
{ id: 'posture', name: 'Posture', hint: 'Sitting up, not slouching' },
{ id: 'present', name: 'At the desk', hint: 'Face visible to camera' },
];
const TASK_NAME = {
read: 'Eyes on the book',
write: 'Eyes on the page',
screen: 'Eyes on the screen',
};
/* weight of each parameter in the overall score, per study mode */
const WEIGHTS = {
read: { task:.28, steady:.16, aloud:.20, alert:.12, posture:.12, present:.12 },
write: { task:.34, steady:.20, aloud:0, alert:.14, posture:.16, present:.16 },
screen: { task:.32, steady:.22, aloud:0, alert:.16, posture:.14, present:.16 },
};
/* strictness multiplies every tolerance: bigger = more forgiving */
const STRICT = { gentle: 1.5, normal: 1.0, strict: 0.72 };
const FIRM = [
'Eyes back on your work.',
'You are drifting. Focus now.',
'Stop looking away. Back to studying.',
'Head down. Keep reading.',
'That is a distraction. Return to your book.',
];
const CALM = [
'Your energy is dipping. Sit up straight.',
'Shoulders back, chin up. You have got this.',
'You look tired. Two minutes of rest will help.',
'Straighten your back. Protect your spine.',
];
const ALOUD_LINES = [
'I cannot hear you. Read out loud.',
'Use your voice. Reading aloud helps you remember.',
];
/* ---------------------------------------------------------------- DOM refs */
const video = $('#cam');
const canvas = $('#overlay');
const ctx = canvas.getContext('2d');
const veil = $('#veil');
const veilT = $('#veil-title');
const veilS = $('#veil-sub');
const veilC = $('#veil-count');
const nudge = $('#nudge');
const nudgeT = $('#nudge-text');
const statusPill = $('#status-pill');
const statusText = $('#status-text');
const digits = () => document.querySelectorAll('#clock .digit');
const ringFill = $('#ring-fill');
const RING_LEN = 2 * Math.PI * 52;
/* ------------------------------------------------------------------ state */
let landmarker = null;
let stream = null, audioStream = null;
let analyser = null, audioData = null, audioCtx = null;
let running = false, phase = 'idle'; // idle | cal1 | cal2 | live
let mode = 'read';
let rafId = 0, lastTs = -1, lastFrame = 0;
let sessionStart = 0, lastClock = '';
const cal = {
neutralYaw:0, taskYaw:0, taskPitch:0, taskGaze:0, taskGazeH:0,
taskCenterY:0, taskScale:0, taskRoll:0, earOpen:0.28, noise:0.004,
};
let samples = [];
/* live (smoothed) values and session accumulators for each parameter */
const M = {};
METERS.forEach(m => { M[m.id] = { live: 100, sum: 0, time: 0 }; });
let overallLive = 100, overallSum = 0, overallTime = 0;
let streakNow = 0, streakBest = 0;
const timers = { away:0, off:0, closed:0, slouch:0, absent:0, silent:0 };
const cooldown = {};
let lastSpoke = 0;
const counts = { distract:0, drowsy:0, slouch:0, away:0 };
let blinkTimes = [], eyeWasClosed = false, closedStart = 0;
let speakBuf = [], prevJaw = 0, mouthMove = 0;
let logCount = 0;
/* =========================================================== build the UI */
function buildMeters() {
const box = $('#meters');
box.innerHTML = '';
METERS.forEach(m => {
const el = document.createElement('div');
el.className = 'meter';
el.id = 'meter-' + m.id;
el.innerHTML = `
<div class="meter-top">
<span class="meter-name">${m.id === 'task' ? TASK_NAME[mode] : m.name}</span>
<span class="meter-now" id="now-${m.id}">--</span>
</div>
<div class="meter-bar"><div class="meter-fill" id="fill-${m.id}"></div></div>
<div class="meter-foot"><span>${m.hint}</span><span>session avg <b id="avg-${m.id}">--</b></span></div>`;
box.appendChild(el);
});
refreshMeterState();
}
function refreshMeterState() {
const w = WEIGHTS[mode];
METERS.forEach(m => {
const el = $('#meter-' + m.id);
el.classList.toggle('off', w[m.id] === 0 || (m.id === 'aloud' && !$('#opt-mic').checked));
});
$('#meter-task .meter-name').textContent = TASK_NAME[mode];
}
const barColor = v => v >= 78 ? 'var(--green)' : v >= 55 ? 'var(--mark)' : 'var(--red)';
/* ================================================================ controls */
$('#modes').addEventListener('click', e => {
const b = e.target.closest('.mode');
if (!b || running) return;
document.querySelectorAll('.mode').forEach(x => x.classList.remove('is-on'));
b.classList.add('is-on');
mode = b.dataset.mode;
refreshMeterState();
});
$('#opt-mic').addEventListener('change', refreshMeterState);
$('#btn-start').addEventListener('click', start);
$('#btn-end').addEventListener('click', () => { stopCamera(); showReport(); });
$('#btn-reset').addEventListener('click', () => { stopCamera(); hardReset(); });
$('#btn-close-sheet').addEventListener('click', () => { $('#sheet-bg').hidden = true; hardReset(); });
function setStatus(text, kind) {
statusText.textContent = text;
statusPill.className = 'pill pill-' + kind;
}
/* =================================================== start / stop / reset */
async function start() {
$('#btn-start').disabled = true;
try {
setStatus('Loading model…', 'cal');
veilT.textContent = 'Waking up FocusPal';
veilS.textContent = 'Loading the face tracking model. This takes a few seconds the first time.';
if (!landmarker) landmarker = await loadModel();
stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' },
audio: false,
});
video.srcObject = stream;
await video.play();
if ($('#opt-mic').checked && mode === 'read') await startMic();
hardReset(true);
running = true;
$('#btn-end').disabled = false;
$('#btn-start').disabled = true;
document.querySelectorAll('.mode').forEach(b => b.disabled = true);
beginCalibration();
lastFrame = performance.now();
rafId = requestAnimationFrame(loop);
} catch (err) {
console.error(err);
$('#btn-start').disabled = false;
setStatus('Camera blocked', 'warn');
veilT.textContent = 'FocusPal cannot see anything';
veilS.textContent = 'Allow camera access for this page and press Start again. If this page is inside a frame, open it in its own tab first.';
}
}
async function loadModel() {
const files = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm');
const opts = {
baseOptions: {
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
delegate: 'GPU',
},
runningMode: 'VIDEO',
numFaces: 1,
outputFaceBlendshapes: true,
};
try { return await FaceLandmarker.createFromOptions(files, opts); }
catch { opts.baseOptions.delegate = 'CPU'; return await FaceLandmarker.createFromOptions(files, opts); }
}
async function startMic() {
try {
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const src = audioCtx.createMediaStreamSource(audioStream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
audioData = new Float32Array(analyser.fftSize);
src.connect(analyser);
} catch { analyser = null; }
}
function stopCamera() {
running = false;
cancelAnimationFrame(rafId);
stream?.getTracks().forEach(t => t.stop());
audioStream?.getTracks().forEach(t => t.stop());
audioCtx?.close().catch(() => {});
stream = audioStream = analyser = audioCtx = null;
video.srcObject = null;
speechSynthesis.cancel();
ctx.clearRect(0, 0, canvas.width, canvas.height);
$('#btn-end').disabled = true;
$('#btn-start').disabled = false;
document.querySelectorAll('.mode').forEach(b => b.disabled = false);
}
function hardReset(keepVeilQuiet = false) {
phase = 'idle';
METERS.forEach(m => { M[m.id] = { live: 100, sum: 0, time: 0 }; });
overallLive = 100; overallSum = 0; overallTime = 0;
streakNow = streakBest = 0;
Object.keys(timers).forEach(k => timers[k] = 0);
Object.keys(counts).forEach(k => counts[k] = 0);
Object.keys(cooldown).forEach(k => delete cooldown[k]);
blinkTimes = []; speakBuf = []; samples = [];
eyeWasClosed = false; lastTs = -1; sessionStart = 0; lastClock = '';
logCount = 0;
lastPaint = 0;
paintClock(0);
$('#score-num').textContent = '--';
$('#score-live').textContent = '--';
ringFill.style.strokeDashoffset = RING_LEN;
$('#t-distract').textContent = '0';
$('#t-drowsy').textContent = '0';
$('#t-slouch').textContent = '0';
$('#t-streak').textContent = '0s';
METERS.forEach(m => {
$('#now-' + m.id).textContent = '--';
$('#avg-' + m.id).textContent = '--';
$('#fill-' + m.id).style.width = '0%';
});
$('#log').innerHTML = '<li class="log-empty">Nothing yet. Start a session and this page fills itself in.</li>';
hideNudge();
if (!keepVeilQuiet) {
setStatus('Camera off', 'idle');
veil.hidden = false;
veilT.textContent = 'Ready when you are';
veilS.textContent = 'Sit at your desk with your book or notebook, then press Start session.';
veilC.textContent = '';
}
}
/* ============================================================ calibration */
let calLeft = 0;
function beginCalibration() {
phase = 'cal1';
samples = []; calLeft = 4;
veil.hidden = false;
setStatus('Setting up', 'cal');
veilT.textContent = 'Step 1 — sit the way you normally sit';
veilS.textContent = 'Look straight at the camera and stay still.';
}
function beginCalibration2() {
applyCal1();
phase = 'cal2';
samples = []; calLeft = 4;
const what = mode === 'screen' ? 'your screen' : mode === 'write' ? 'your notebook' : 'your book';
veilT.textContent = 'Step 2 — now look at ' + what;
veilS.textContent = 'Hold exactly the position you will study in. FocusPal learns this as "on task".';
}
function applyCal1() {
cal.neutralYaw = median(samples.map(s => s.yaw));
cal.earOpen = Math.max(median(samples.map(s => s.ear)), 0.16);
cal.noise = Math.max(median(samples.map(s => s.rms)), 0.0015);
}
function applyCal2() {
cal.taskYaw = median(samples.map(s => s.yaw));
cal.taskPitch = median(samples.map(s => s.pitch));
cal.taskGaze = median(samples.map(s => s.gazeV));
cal.taskGazeH = median(samples.map(s => s.gazeH));
cal.taskCenterY = median(samples.map(s => s.cy));
cal.taskScale = Math.max(median(samples.map(s => s.scale)), 0.05);
cal.taskRoll = median(samples.map(s => s.roll));
phase = 'live';
veil.hidden = true;
sessionStart = performance.now();
setStatus('Session live', 'live');
addLog('Session started. FocusPal is watching.', 'good');
speak('Session started. Stay focused.', false);
}
/* ================================================================== loop */
function loop() {
if (!running) return;
rafId = requestAnimationFrame(loop);
if (video.readyState < 2) return;
const now = performance.now();
const dt = clamp((now - lastFrame) / 1000, 0, 0.25);
lastFrame = now;
let ts = now;
if (ts <= lastTs) ts = lastTs + 1;
lastTs = ts;
const res = landmarker.detectForVideo(video, ts);
const lm = res.faceLandmarks?.[0];
const bs = readBlendshapes(res);
if (canvas.width !== video.videoWidth) {
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const f = lm ? geometry(lm, bs) : null;
const rms = readMic();
if (phase === 'cal1' || phase === 'cal2') {
if (f) {
samples.push({ ...f, rms });
calLeft -= dt;
veilC.textContent = Math.max(1, Math.ceil(calLeft));
drawFace(f, '#FFD34D');
if (calLeft <= 0 && samples.length > 10) {
phase === 'cal1' ? beginCalibration2() : applyCal2();
}
} else {
veilC.textContent = '·'; // countdown pauses until a face is found
}
return;
}
if (phase !== 'live') return;
analyse(f, bs, rms, dt, now);
paintClock(now - sessionStart);
}
/* ---------------------------------------------- read blendshape scores */
function readBlendshapes(res) {
const out = {};
const cats = res.faceBlendshapes?.[0]?.categories;
if (cats) for (const c of cats) out[c.categoryName] = c.score;
return out;
}
/* ------------------------------------------ turn landmarks into geometry */
function geometry(lm, bs) {
const nose = lm[NOSE], chin = lm[CHIN], brow = lm[BROW];
const lf = lm[L_FACE], rf = lm[R_FACE];
const lo = lm[L_OUT], li = lm[L_IN], ro = lm[R_OUT], ri = lm[R_IN];
const faceW = Math.abs(rf.x - lf.x) || 1e-6;
const faceH = Math.abs(chin.y - brow.y) || 1e-6;
// yaw: where the nose sits between the two cheek edges (0.5 = facing camera)
const yaw = (nose.x - lf.x) / faceW;
// pitch: eye-line to nose distance shrinks as the head tips down
const eyeY = (lo.y + li.y + ro.y + ri.y) / 4;
const pitch = (nose.y - eyeY) / faceH;
// roll: tilt angle of the eye line (head resting on a hand)
const roll = Math.atan2(ro.y - lo.y, ro.x - lo.x) * 180 / Math.PI;
// eye aspect ratio, averaged over both eyes
const ear = (
Math.abs(lm[L_UP].y - lm[L_LO].y) / (Math.abs(li.x - lo.x) || 1e-6) +
Math.abs(lm[R_UP].y - lm[R_LO].y) / (Math.abs(ro.x - ri.x) || 1e-6)
) / 2;
const gazeV = ((bs.eyeLookDownLeft || 0) + (bs.eyeLookDownRight || 0)) / 2
- ((bs.eyeLookUpLeft || 0) + (bs.eyeLookUpRight || 0)) / 2;
const gazeH = Math.max(bs.eyeLookOutLeft || 0, bs.eyeLookOutRight || 0);
return {
yaw, pitch, roll, ear, gazeV, gazeH,
cy: (brow.y + chin.y) / 2,
scale: faceH,
box: { x: lf.x, y: brow.y, w: faceW, h: (chin.y - brow.y) * 1.18 },
jaw: bs.jawOpen || 0,
blink: ((bs.eyeBlinkLeft || 0) + (bs.eyeBlinkRight || 0)) / 2,
lm,
};
}
function readMic() {
if (!analyser) return 0;
analyser.getFloatTimeDomainData(audioData);
let sum = 0;
for (let i = 0; i < audioData.length; i++) sum += audioData[i] * audioData[i];
return Math.sqrt(sum / audioData.length);
}
/* ================================================== the scoring engine */
function analyse(f, bs, rms, dt, now) {
const S = STRICT[$('#strictness').value];
const w = WEIGHTS[mode];
const micOn = analyser && mode === 'read' && $('#opt-mic').checked;
/* ---- presence ------------------------------------------------------- */
const presentRaw = f ? 100 : 0;
M.present.live = smooth(M.present.live, presentRaw, dt, 0.35);
if (!f) {
timers.absent += dt;
if (timers.absent > 4) fire('absent', 'You left the desk.', 'calm', now, false);
accumulate(['present'], dt);
overallLive = 0.55 * M.present.live; // absence still costs the session
overallStep(overallLive, dt);
streakNow = 0;
paintAll(now);
return;
}
timers.absent = 0;
/* ---- on-task gaze ---------------------------------------------------- */
const dYaw = Math.abs(f.yaw - cal.taskYaw);
const dPitch = Math.abs(f.pitch - cal.taskPitch);
const dGazeV = Math.abs(f.gazeV - cal.taskGaze);
const dGazeH = Math.abs(f.gazeH - cal.taskGazeH);
const taskOff = Math.max(
over(dYaw, 0.055 * S, 0.145 * S) * 0.9,
over(dPitch, 0.030 * S, 0.090 * S),
over(dGazeV, 0.190 * S, 0.520 * S) * 0.8,
);
M.task.live = smooth(M.task.live, 100 * (1 - taskOff), dt, 0.5);
/* ---- looking away sideways ------------------------------------------- */
const steadyOff = Math.max(
over(dYaw, 0.055 * S, 0.145 * S),
over(dGazeH, 0.260 * S, 0.680 * S) * 0.7,
);
M.steady.live = smooth(M.steady.live, 100 * (1 - steadyOff), dt, 0.5);
/* ---- alertness -------------------------------------------------------- */
const earNorm = f.ear / cal.earOpen;
const closed = earNorm < 0.62 || f.blink > 0.55;
if (closed) {
if (!eyeWasClosed) { eyeWasClosed = true; closedStart = now; }
timers.closed += dt;
} else {
if (eyeWasClosed && now - closedStart < 500) blinkTimes.push(now);
eyeWasClosed = false; timers.closed = 0;
}
blinkTimes = blinkTimes.filter(t => now - t < 60000);
const blinkRate = blinkTimes.length;
const alertRaw = 100
- 100 * over(timers.closed, 0.35 * S, 1.6 * S)
- 30 * over(blinkRate, 30 * S, 55 * S);
M.alert.live = smooth(M.alert.live, clamp(alertRaw, 0, 100), dt, 0.6);
/* ---- posture ---------------------------------------------------------- */
const drop = f.cy - cal.taskCenterY; // face sinking in frame
const lean = f.scale / cal.taskScale - 1; // leaning too close
const tilt = Math.abs(f.roll - cal.taskRoll); // head on a hand
const postureOff = Math.max(
over(drop, 0.035 * S, 0.120 * S),
over(lean, 0.170 * S, 0.480 * S),
over(tilt, 7.0 * S, 22.0 * S),
);
M.posture.live = smooth(M.posture.live, 100 * (1 - postureOff), dt, 0.8);
/* ---- reading aloud ---------------------------------------------------- */
mouthMove = smooth(mouthMove, Math.abs(f.jaw - prevJaw) * 30, dt, 0.4);
prevJaw = f.jaw;
if (micOn) {
const voiced = rms > Math.max(cal.noise * 2.2, cal.noise + 0.004);
const speaking = voiced && (f.jaw > 0.045 || mouthMove > 0.12);
speakBuf.push({ t: now, on: speaking });
speakBuf = speakBuf.filter(s => now - s.t < 20000);
const frac = speakBuf.filter(s => s.on).length / Math.max(speakBuf.length, 1);
const warmUp = now - sessionStart < 10000;
M.aloud.live = warmUp ? 100
: smooth(M.aloud.live, clamp(frac / 0.40, 0, 1) * 100, dt, 1.2);
timers.silent = speaking ? 0 : timers.silent + dt;
} else {
M.aloud.live = 100;
}
/* ---- combine into one live score ------------------------------------- */
let total = 0, wsum = 0;
for (const m of METERS) {
const weight = (m.id === 'aloud' && !micOn) ? 0 : w[m.id];
if (!weight) continue;
total += M[m.id].live * weight; wsum += weight;
}
overallLive = wsum ? total / wsum : 100;
const active = METERS.map(m => m.id)
.filter(id => (id === 'aloud' ? micOn : w[id] > 0));
accumulate(active, dt);
overallStep(overallLive, dt);
/* ---- focus streak ----------------------------------------------------- */
if (overallLive >= 75) { streakNow += dt; streakBest = Math.max(streakBest, streakNow); }
else streakNow = 0;
/* ---- events + the voice coach ---------------------------------------- */
timers.away = steadyOff > 0.60 ? timers.away + dt : 0;
timers.off = taskOff > 0.55 ? timers.off + dt : 0;
timers.slouch = postureOff > 0.60 ? timers.slouch + dt : 0;
if (timers.away > 2.2 * S)
fire('away', pick(FIRM), 'firm', now, true, 'Looked away from your work');
else if (timers.off > 3.6 * S)
fire('off', pick(FIRM), 'firm', now, true, 'Not looking at your work');
if (timers.closed > 1.3 * S)
fire('drowsy', pick(CALM), 'calm', now, true, 'Eyes closing — drowsy');
if (timers.slouch > 6 * S)
fire('slouch', pick(CALM), 'calm', now, true, 'Slouching over the desk');
if (micOn && timers.silent > 14 * S)
fire('silent', pick(ALOUD_LINES), 'calm', now, true, 'Reading silently, not aloud');
drawFace(f, overallLive >= 75 ? '#37D6A0' : overallLive >= 55 ? '#FFD34D' : '#FF5468');
paintAll(now);
}
function accumulate(ids, dt) {
for (const id of ids) { M[id].sum += M[id].live * dt; M[id].time += dt; }
}
function overallStep(v, dt) { overallSum += v * dt; overallTime += dt; }
const avg = (id) => M[id].time > 0 ? M[id].sum / M[id].time : null;
const pick = (a) => a[Math.floor(Math.random() * a.length)];
/* ------------------------------------------------------- event dispatch */
const TIMER_OF = { away:'away', off:'off', slouch:'slouch', silent:'silent', drowsy:'closed', absent:'absent' };
function fire(key, line, tone, now, countIt = true, logLine = null) {
if (cooldown[key] && now - cooldown[key] < 12000) return;
cooldown[key] = now;
timers[TIMER_OF[key]] = 0;
if (countIt) {
if (key === 'away' || key === 'off') counts.distract++;
if (key === 'drowsy') counts.drowsy++;
if (key === 'slouch') counts.slouch++;
}
if (key === 'absent') counts.away++;
addLog(logLine || line, tone === 'firm' ? 'bad' : 'calm');
showNudge(line, tone);
speak(line, tone === 'firm');
if ($('#opt-beep').checked) beep(tone === 'firm');
}
/* ------------------------------------------------------------- feedback */
let nudgeTimer = 0;
function showNudge(text, tone) {
nudgeT.textContent = text;
nudge.className = 'nudge show' + (tone === 'firm' ? '' : ' calm');
clearTimeout(nudgeTimer);
nudgeTimer = setTimeout(hideNudge, 4000);
}
function hideNudge() { nudge.className = 'nudge'; }
function speak(text, firm) {
if (!$('#opt-voice').checked || !('speechSynthesis' in window)) return;
const now = performance.now();
if (now - lastSpoke < 6000) return;
lastSpoke = now;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.rate = firm ? 1.08 : 0.92;
u.pitch = firm ? 0.85 : 1.12;
u.volume = 1;
speechSynthesis.speak(u);
}
function beep(firm) {
try {
const a = new (window.AudioContext || window.webkitAudioContext)();
const o = a.createOscillator(), g = a.createGain();
o.connect(g); g.connect(a.destination);
o.type = firm ? 'square' : 'sine';
o.frequency.value = firm ? 720 : 480;
g.gain.setValueAtTime(0.18, a.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, a.currentTime + 0.35);
o.start(); o.stop(a.currentTime + 0.36);
setTimeout(() => a.close().catch(() => {}), 600);
} catch {}
}
function addLog(text, kind) {
const list = $('#log');
list.querySelector('.log-empty')?.remove();
const li = document.createElement('li');
li.className = kind === 'bad' ? '' : kind === 'good' ? 'good' : 'calm';
const el = sessionStart ? performance.now() - sessionStart : 0;
li.innerHTML = `<time>${mmss(el)}</time><span></span>`;
li.querySelector('span').textContent = text;
list.prepend(li);
if (++logCount > 60) list.lastElementChild?.remove();
}
/* ----------------------------------------------------------- the canvas */
function drawFace(f, colour) {
const W = canvas.width, H = canvas.height;
const x = f.box.x * W, y = f.box.y * H, w = f.box.w * W, h = f.box.h * H;
ctx.strokeStyle = colour; ctx.lineWidth = 3;
const r = 14;
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); ctx.stroke();
if ($('#opt-mesh').checked) {
ctx.fillStyle = colour;
for (let i = 0; i < f.lm.length; i += 4)
ctx.fillRect(f.lm[i].x * W, f.lm[i].y * H, 1.6, 1.6);
}
}
/* ------------------------------------------------------------- painting */
let lastPaint = 0;
function paintAll(now = performance.now()) {
if (now - lastPaint < 100) return; // repaint at ~10 fps, not every frame
lastPaint = now;
for (const m of METERS) {
const v = Math.round(M[m.id].live);
$('#now-' + m.id).textContent = v;
const fill = $('#fill-' + m.id);
fill.style.width = v + '%';
fill.style.background = barColor(v);
const a = avg(m.id);
$('#avg-' + m.id).textContent = a === null ? '--' : Math.round(a);
}
const sess = overallTime > 0 ? overallSum / overallTime : 100;
$('#score-num').textContent = Math.round(sess);
$('#score-live').textContent = Math.round(overallLive);
ringFill.style.strokeDashoffset = RING_LEN * (1 - clamp(sess, 0, 100) / 100);
ringFill.style.stroke = barColor(sess);
$('#t-distract').textContent = counts.distract;
$('#t-drowsy').textContent = counts.drowsy;
$('#t-slouch').textContent = counts.slouch;
$('#t-streak').textContent = fmtStreak(streakBest);
setStatus(overallLive < 55 ? 'Distracted' : 'Session live',
overallLive < 55 ? 'warn' : 'live');
}
function fmtStreak(s) {
s = Math.floor(s);
return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's';
}
function mmss(ms) {
const t = Math.floor(ms / 1000);
return String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0');
}
/* ---------------------------------------------------------- block clock */
function paintClock(ms) {
const t = Math.floor(ms / 1000);
const str = [Math.floor(t / 3600), Math.floor(t / 60) % 60, t % 60]
.map(n => String(n).padStart(2, '0')).join('');
if (str === lastClock) return;
lastClock = str;
digits().forEach((d, i) => { if (d.textContent !== str[i]) d.textContent = str[i]; });
}
/* ============================================================== report */
function showReport() {
const sess = overallTime > 0 ? overallSum / overallTime : 0;
const score = Math.round(sess);
const mins = overallTime / 60;
const grade = score >= 85 ? 'Excellent focus'
: score >= 70 ? 'Good session'
: score >= 55 ? 'Fair — you can do better'
: 'Needs work';
$('#rp-title').textContent = grade;
$('#rp-line').textContent =
`You studied for ${mins < 1 ? 'less than a minute' : Math.round(mins) + ' minutes'} `
+ `and scored ${score} out of 100. Your longest unbroken stretch of focus was `
+ `${fmtStreak(streakBest)}.`;
const w = WEIGHTS[mode];
const rows = METERS
.filter(m => w[m.id] > 0 && M[m.id].time > 0)
.map(m => ({ m, v: Math.round(avg(m.id)) }));
$('#rp-stats').innerHTML =
rows.map(r => `<div><span>${r.m.id === 'task' ? TASK_NAME[mode] : r.m.name}</span><b>${r.v}</b></div>`).join('') +
`<div><span>Times distracted</span><b>${counts.distract}</b></div>` +
`<div><span>Drowsy moments</span><b>${counts.drowsy}</b></div>`;
const tips = [];
const weakest = [...rows].sort((a, b) => a.v - b.v)[0];
const TIP = {
task: 'Move your book directly in front of you so your eyes do not have to travel far.',
steady: 'Put your phone in another room. Most of your slips were sideways glances.',
aloud: 'Read the difficult paragraphs out loud — saying it helps you remember it.',
alert: 'Try 25 minutes of study then a 5 minute break. Sleep matters more than extra hours.',
posture:'Raise your book on a stand and keep both feet flat on the floor.',
present:'Keep water and stationery on the desk before you start, so you do not get up.',
};
if (weakest && weakest.v < 85) tips.push(TIP[weakest.m.id]);
if (counts.distract >= 4) tips.push('You broke focus ' + counts.distract + ' times. Aim for half that next session.');
if (counts.drowsy >= 2) tips.push('Drowsiness showed up more than once. Study earlier in the day if you can.');
if (streakBest < 300 && mins > 6) tips.push('Try to hold one unbroken 5 minute stretch before you check anything.');
if (!tips.length) tips.push('Strong session. Keep the same desk setup tomorrow and add five minutes.');
$('#rp-tips').innerHTML = tips.map(t => `<li></li>`).join('');
$('#rp-tips').querySelectorAll('li').forEach((li, i) => li.textContent = tips[i]);
$('#sheet-bg').hidden = false;
}
/* ================================================================= boot */
buildMeters();
paintClock(0);
ringFill.style.strokeDasharray = RING_LEN;
ringFill.style.strokeDashoffset = RING_LEN;