Spaces:
Running
Running
Download script.js from NCTCMumbai/FocusPal: direct link, hf CLI and curl.
- Browser
- Download file 29.2 kB
-
https://huggingface.co/spaces/NCTCMumbai/FocusPal/resolve/main/script.js
- Command line
-
hf download hf://spaces/NCTCMumbai/FocusPal/script.js
-
curl -L -o script.js https://huggingface.co/spaces/NCTCMumbai/FocusPal/resolve/main/script.js
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; | |