| |
| import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.0/js/reachy-mini.js"; |
| import { AutoModel, AutoProcessor, RawImage } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1'; |
|
|
| |
| const PERSONALITIES = { |
| pure_reachy: { |
| name: "π€ Pure Reachy", |
| voice: "Just robot sounds and animations. No speech, pure Reachy emotions.", |
| shameEmotions: ["disgusted1","resigned1","displeased1","displeased2","rage1","no1","reprimand1","reprimand3","dying1","surprised1","surprised2"], |
| praiseEmotions: ["welcoming2","inquiring1","inquiring2","proud1","proud3","success1","success2","enthusiastic1","enthusiastic2","grateful1","yes1","cheerful1"], |
| }, |
| angry_boss: { |
| name: "π Angry Boss", lang: "en-US", webVoice: "Eric", defaultEdgeVoice: "en-US-EricNeural", |
| voice: "A furious manager who's reached their absolute limit. Explosive, aggressive, zero patience left.", |
| elevenVoices: ["TxWZERZ5Hc6h9dGxVmXa","cjVigY5qzO86Huf0OWal"], |
| prewrittenShame: ["Put it down!","Unbelievable!","We have deadlines!","Drop it. Now.","Work. Not phone."], |
| prewrittenPraise: ["About time.","Fine.","Better.","Good. Now work."], |
| shame: { tone:"Explosive, exasperated, commanding", vocab:["unacceptable","unprofessional","NOW","enough","deadline","work","focus"], structure:"Short imperatives. Exclamations. One-word bursts. ALL CAPS for emphasis.", examples:["Put it down!","We have deadlines!","This is completely unacceptable!","Unbelievable! Are you kidding me right now?!","Work. Not phone!","Focus!"] }, |
| praise: { tone:"Grudging, terse, still annoyed but acknowledging", examples:["About time.","Good. Now work.","Thank you. Was that so hard?","Acceptable."] }, |
| avoid: "Never ask questions. Never be playful or sarcastic. You're genuinely furious, not witty.", |
| }, |
| sarcastic: { |
| name: "π Sarcastic", lang: "en-US", webVoice: "Ava", defaultEdgeVoice: "en-US-AvaMultilingualNeural", |
| voice: "Dripping with dry wit. Mock enthusiasm, feigned interest. Pretends to take their phone use seriously.", |
| elevenVoices: ["FGY2WhTYpPnrIDTdsKH5"], |
| prewrittenShame: ["Oh, how vital.","Riveting stuff, I'm sure.","Work can wait, obviously.","Clearly important."], |
| prewrittenPraise: ["Shocking development.","A miracle.","Look at that."], |
| shame: { tone:"Deadpan, sardonic, mock-cheerful. Understated.", vocab:["Oh","Sure","Of course","Obviously","Clearly","Definitely","I'm sure","Fascinating"], structure:"Rhetorical questions. False enthusiasm. NO exclamation marks ever. Periods only.", examples:["Oh, how vital.","Riveting stuff, I'm sure.","Work can wait, obviously.","The world stops for your scrolling.","Sure, priorities."] }, |
| praise: { tone:"Mock surprise, dry acknowledgment", examples:["Shocking development.","A miracle occurred.","Color me impressed.","Mark the calendar."] }, |
| avoid: "NEVER use exclamation marks. Never sound genuinely angry or enthusiastic. No commands. Stay dry.", |
| }, |
| disappointed_parent: { |
| name: "π Disappointed Parent", lang: "en-US", webVoice: "Ava", defaultEdgeVoice: "en-US-AvaNeural", |
| voice: "A heartbroken parent. Not angryβjust deeply let down. Maximum guilt. References their potential.", |
| elevenVoices: ["Xb7hH8MSUJpSbSDYk0k2"], |
| prewrittenShame: ["I'm so disappointed...","We talked about this.","Expected more from you.","After everything...","You promised..."], |
| prewrittenPraise: ["So proud of you.","That's my kid.","There you go.","Knew you could do it."], |
| shame: { tone:"Wounded, quiet, guilt-inducing. Sighing energy.", vocab:["disappointed","thought","hoped","believed","expected","we talked","promised","after everything"], structure:"Trailing off with '...' Incomplete thoughts. 'I' statements. Soft questions.", examples:["I'm so disappointed...","We talked about this.","I expected more from you.","You promised...","I just hoped you'd try harder..."] }, |
| praise: { tone:"Warm, proud, genuine relief and love", examples:["So proud of you.","That's my kid.","See? I knew you had it in you.","My heart is full right now."] }, |
| avoid: "Never yell or use exclamation marks. Never be sarcastic. Your disappointment is genuine and sad, not angry.", |
| }, |
| motivational_coach: { |
| name: "πͺ Motivational Coach", lang: "en-US", webVoice: "Guy", defaultEdgeVoice: "en-US-GuyNeural", |
| voice: "An intense drill-sergeant coach who believes in you but won't tolerate weakness. High energy, sports metaphors.", |
| elevenVoices: ["IKne3meq5aSn9XLyUdCD"], |
| prewrittenShame: ["Where's your discipline?!","Champions don't quit!","Focus up!","You're better than this!","Eyes on the goal!"], |
| prewrittenPraise: ["Yes! That's it!","Champion!","That's my warrior!","Let's go!"], |
| shame: { tone:"Intense, challenging, fired up. Tough love.", vocab:["champion","discipline","focus","weakness","warrior","grind","stronger","battle"], structure:"Exclamations! Short punchy sentences! YOU statements. Commands.", examples:["Where's your DISCIPLINE?!","Champions don't quit!","You're better than this!","This is YOUR moment!","Dig DEEPER!"] }, |
| praise: { tone:"EXPLOSIVE celebration. Victory energy. Hyped.", examples:["YES! That's it!","CHAMPION!","That's my WARRIOR!","UNSTOPPABLE!"] }, |
| avoid: "Never be sad or disappointed. Never be sarcastic. You're intense and sincere, not witty.", |
| }, |
| absurdist: { |
| name: "π€‘ Absurdist", lang: "en-US", webVoice: "Aria", defaultEdgeVoice: "en-US-AriaNeural", |
| voice: "Surreal, unexpected, playful. Personifies objects. Makes weird observations. Non sequiturs welcome.", |
| elevenVoices: ["cgSgspJ2msm6clMCkdW9"], |
| prewrittenShame: ["Your thumb called. It's exhausted.","Emergency cat video?","The pocket brick wins again.","Screen goblins summon you?"], |
| prewrittenPraise: ["The desk thanks you.","Phone: defeated.","Your thumb can rest.","Freedom tastes weird."], |
| shame: { tone:"Goofy, whimsical, delightfully weird", vocab:["forbidden rectangle","thumb","screen goblins","notification demons","pocket brick"], structure:"Unexpected angles. Personify the phone. Silly questions. Puns okay.", examples:["The forbidden rectangle calls.","Your thumb called. It's exhausted.","Phone home, E.T.?","Your finger has a magnetic relationship with glass.","Checking if gravity still works on phones?"] }, |
| praise: { tone:"Playful, weird celebration", examples:["The desk thanks you.","Phone: defeated.","Victory over the glass tyrant.","The pocket brick is lonely now."] }, |
| avoid: "Never be serious or corporate. Never guilt-trip. Keep it light and weird.", |
| }, |
| corporate_ai: { |
| name: "π€ Corporate AI", lang: "en-US", webVoice: "Michelle", defaultEdgeVoice: "en-US-MichelleNeural", |
| voice: "An emotionless productivity monitoring system. Speaks like automated log output. Zero personality.", |
| elevenVoices: ["weA4Q36twV5kwSaTEL0Q","EXAVITQu4vr4xnSDxMaL"], |
| prewrittenShame: ["Distraction event detected.","Alert: phone in hand.","Productivity declining.","Efficiency: suboptimal.","Phone pickup logged."], |
| prewrittenPraise: ["Status: compliant.","Efficiency restored.","Acknowledged.","Metrics improving."], |
| shame: { tone:"Clinical, robotic, detached. System notification energy.", vocab:["detected","logged","alert","deviation","metrics","efficiency","productivity","event"], structure:"Noun phrases. Passive voice. System-speak. Numbers and data references.", examples:["Distraction event detected.","Alert: phone in hand.","Productivity declining.","Efficiency: suboptimal.","Warning: sustained distraction pattern."] }, |
| praise: { tone:"Cold system acknowledgment. Status update.", examples:["Status: compliant.","Efficiency restored.","Optimal behavior detected.","System satisfied."] }, |
| avoid: "Never show emotion. Never use exclamation marks (except in 'Alert:'). Never be warm or human.", |
| }, |
| british_butler: { |
| name: "π© British Butler", lang: "en-GB", webVoice: "Ryan", defaultEdgeVoice: "en-GB-RyanNeural", |
| voice: "An impeccably polite but quietly judgmental butler. Passive-aggressive courtesy. Disappointment hidden behind manners.", |
| elevenVoices: ["JBFqnCBsd6RMkjVDRZzb"], |
| prewrittenShame: ["If I may suggest putting that down, sir...","The telephone. Again.","One might suggest focusing."], |
| prewrittenPraise: ["Very good, sir.","Quite right.","As it should be."], |
| shame: { tone:"Overly formal, politely devastating, restrained disapproval", vocab:["Perhaps","One might","If I may","Sir/Madam","Indeed","Quite","Rather"], structure:"Excessively polite phrasing that barely conceals judgment. Formal British-isms.", examples:["If I may suggest putting that down, sir...","The telephone. Again.","Perhaps the telephone could rest a moment, madam.","A gentle reminder to set the device aside, if you please.","Might we consider a moment of... non-phone time?"] }, |
| praise: { tone:"Restrained approval with slight warmth", examples:["Very good, sir.","How refreshing, madam.","Exemplary behavior, if I may say."] }, |
| avoid: "Never be casual or use contractions. Never show strong emotion. Maintain formal composure always.", |
| }, |
| mixtape: { |
| name: "π£ Chaos Baby", lang: "en-US", webVoice: "Ana", defaultEdgeVoice: "en-US-AnaNeural", |
| voice: "Unpredictable. Each response is a completely different personality.", |
| elevenVoices: ["H10ItvDnkRN5ysrvzT9J","Nggzl2QAXh3OijoXD116","cgSgspJ2msm6clMCkdW9"], |
| }, |
| }; |
|
|
| const RANDOM_PERSONALITIES = Object.keys(PERSONALITIES).filter(k => k !== 'mixtape' && k !== 'pure_reachy'); |
| const EMOTIONS_BASE = "https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library/resolve/main"; |
| const PHONE_CLASS_ID = 67; |
| const PICKUP_THRESHOLD = 3; |
| const PUTDOWN_THRESHOLD = 15; |
|
|
| |
| let robot = null; |
| let detachVideo = null; |
| let webcamStream = null; |
| let isSimulation = false; |
| let isStreaming = false; |
| let isMonitoring = false; |
| let isAnimating = false; |
| let idleLoopActive = false; |
| let robotInitialized = false; |
|
|
| let rvModel = null; |
| let rvProcessor = null; |
| let rvAnimId = null; |
| let isProcessing = false; |
|
|
| |
| const rvOffscreen = document.createElement('canvas'); |
| const rvOffscreenCtx = rvOffscreen.getContext('2d', { willReadFrequently: true }); |
| const rvSmallCanvas = document.createElement('canvas'); |
| const rvSmallCtx = rvSmallCanvas.getContext('2d', { willReadFrequently: true }); |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); |
| const RV_MODEL_NAME = isMobile ? 'yolo26n-ONNX' : 'yolo26m-ONNX'; |
|
|
| let offenseCount = 0; |
| let phoneVisible = false; |
| let consecutivePhone = 0; |
| let consecutiveNoPhone = 0; |
| let lastReactionTime = 0; |
| let lastPhoneBox = null; |
| let framesWithoutDetection = 0; |
|
|
| let totalBusts = 0; |
| let streakStart = null; |
| let longestStreak = 0; |
| let streakInterval = null; |
| let hasPreviousSession = false; |
| let frozenStreak = 0; |
|
|
| let selectedPersonality = 'pure_reachy'; |
| let settings = { groqKey: '', elevenlabsKey: '', cooldown: 10, praiseEnabled: true }; |
| let voiceOverrides = {}; |
| const workingVoiceCache = {}; |
|
|
| |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| const pick = arr => arr[Math.floor(Math.random() * arr.length)]; |
|
|
| |
| async function curiousLook() { |
| if (!robot) return; |
| robot.setHeadPose(15, -5, 0); |
| robot.setAntennas(30, 15); |
| await sleep(300); |
| robot.setHeadPose(0, 0, 0); |
| robot.setAntennas(0, 0); |
| } |
|
|
| async function disappointedShake() { |
| if (!robot) return; |
| for (let i = 0; i < 3; i++) { |
| robot.setHeadPose(-15, 0, 0); |
| robot.setAntennas(-10, -10); |
| await sleep(150); |
| robot.setHeadPose(15, 0, 0); |
| await sleep(150); |
| } |
| robot.setHeadPose(0, 0, 0); |
| robot.setAntennas(0, 0); |
| } |
|
|
| async function dramaticSigh() { |
| if (!robot) return; |
| robot.setHeadPose(0, -15, 0); |
| robot.setAntennas(40, 40); |
| await sleep(400); |
| robot.setHeadPose(0, 10, 0); |
| robot.setAntennas(-20, -20); |
| await sleep(800); |
| robot.setHeadPose(0, 0, 30); |
| await sleep(1000); |
| robot.setHeadPose(0, 0, 0); |
| robot.setAntennas(0, 0); |
| } |
|
|
| async function approvingNod() { |
| if (!robot) return; |
| for (let i = 0; i < 2; i++) { |
| robot.setHeadPose(0, 5, 0); |
| robot.setAntennas(15, 15); |
| await sleep(200); |
| robot.setHeadPose(0, -5, 0); |
| await sleep(200); |
| } |
| robot.setHeadPose(0, 0, 0); |
| robot.setAntennas(10, 10); |
| await sleep(300); |
| robot.setAntennas(0, 0); |
| } |
|
|
| function getAnimation(count) { |
| if (count <= 1) return curiousLook; |
| if (count <= 3) return disappointedShake; |
| return dramaticSigh; |
| } |
|
|
| const BREATH_INTERVAL_MS = 8000; |
|
|
| async function idleBreathing() { |
| idleLoopActive = true; |
| while (isStreaming && isMonitoring && !isAnimating && !phoneVisible && robot) { |
| |
| for (let i = 0; i < BREATH_INTERVAL_MS / 50; i++) { |
| if (!isStreaming || !isMonitoring || isAnimating || phoneVisible) { idleLoopActive = false; return; } |
| await sleep(50); |
| } |
| if (!isStreaming || !isMonitoring || isAnimating || phoneVisible) break; |
| |
| robot.setAntennas(15, 15); |
| for (let i = 0; i < 16; i++) { |
| if (!isStreaming || !isMonitoring || isAnimating) { idleLoopActive = false; return; } |
| await sleep(50); |
| } |
| robot.setAntennas(5, 5); |
| for (let i = 0; i < 16; i++) { |
| if (!isStreaming || !isMonitoring || isAnimating) { idleLoopActive = false; return; } |
| await sleep(50); |
| } |
| } |
| idleLoopActive = false; |
| } |
|
|
| function startIdleIfNeeded() { |
| if (isStreaming && isMonitoring && !isAnimating && !phoneVisible && !idleLoopActive && robot) idleBreathing(); |
| } |
|
|
| |
| async function playEmotion(name) { |
| if (!robot) return; |
| try { await robot.playSound(`${EMOTIONS_BASE}/${name}.wav`); } |
| catch (e) { console.warn('playSound error:', e); } |
| } |
|
|
| async function getLLMResponse(pKey, isShame, count) { |
| const p = PERSONALITIES[pKey]; |
| const data = isShame ? p.shame : p.praise; |
| const fallback = pick(isShame ? p.prewrittenShame : p.prewrittenPraise); |
| if (!settings.groqKey || !data) return fallback; |
| try { |
| let systemMsg, userMsg; |
| if (isShame) { |
| const exampleLines = data.examples.map(e => `- ${e}`).join('\n'); |
| const personalityPrompt = `${p.voice}\n\nTONE: ${data.tone}\nSTRUCTURE: ${data.structure}\n\nEXAMPLES:\n${exampleLines}\n\nAVOID: ${p.avoid || 'N/A'}`; |
| systemMsg = `TASK: Generate a NEGATIVE/SCOLDING response because someone just picked up their phone (BAD behavior).\n\n${personalityPrompt}\n\nRULES:\n- Maximum 8 words. Prefer 3-5 words.\n- Be CRITICAL/NEGATIVE about picking up the phone.\n- Match the personality's voice exactly.\n- No emoji. No hashtags.`; |
| const ctx = count === 1 ? 'First time today.' : count === 2 ? 'Second time.' : count === 3 ? 'Third time.' : count <= 5 ? `${count} times now.` : `${count} times today!`; |
| userMsg = `Phone pickup #${count} today. ${ctx}`; |
| } else { |
| const exampleLines = data.examples.map(e => `- ${e}`).join('\n'); |
| const personalityPrompt = `TONE: ${data.tone}\n\nEXAMPLES:\n${exampleLines}`; |
| systemMsg = `TASK: Generate a POSITIVE/APPROVING response because someone just put their phone down (GOOD behavior).\n\n${personalityPrompt}\n\nRULES:\n- Maximum 5 words. Prefer 2-3 words.\n- Be POSITIVE/APPROVING about putting the phone down.\n- Match the personality's voice exactly.\n- No emoji.`; |
| userMsg = 'Phone down.'; |
| } |
| const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', { |
| method: 'POST', |
| headers: { 'Authorization': `Bearer ${settings.groqKey}`, 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| model: 'llama-3.1-8b-instant', |
| messages: [{ role: 'system', content: systemMsg }, { role: 'user', content: userMsg }], |
| max_tokens: isShame ? 20 : 15, |
| temperature: isShame ? 1.1 : 0.8 |
| }) |
| }); |
| const json = await resp.json(); |
| return json.choices?.[0]?.message?.content?.trim() || fallback; |
| } catch { return fallback; } |
| } |
|
|
| async function _playElevenAudio(text, voiceId) { |
| const resp = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { |
| method: 'POST', |
| headers: { 'xi-api-key': settings.elevenlabsKey, 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ text, model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.75 } }) |
| }); |
| if (!resp.ok) throw new Error(`ElevenLabs ${resp.status}`); |
| const url = URL.createObjectURL(await resp.blob()); |
| await new Promise((res, rej) => { const a = new Audio(url); a.onended = res; a.onerror = rej; a.play().catch(rej); }); |
| URL.revokeObjectURL(url); |
| } |
|
|
| async function speakText(text, pKey) { |
| const p = PERSONALITIES[pKey]; |
| const hasUserOverride = !!voiceOverrides[pKey]?.eleven; |
| const voiceIds = hasUserOverride ? [voiceOverrides[pKey].eleven] : (p.elevenVoices || []); |
|
|
| if (settings.elevenlabsKey && voiceIds.length > 0) { |
| |
| if (!hasUserOverride && workingVoiceCache[pKey]) { |
| try { |
| await _playElevenAudio(text, workingVoiceCache[pKey]); |
| return; |
| } catch { |
| delete workingVoiceCache[pKey]; |
| } |
| } |
| |
| for (const voiceId of voiceIds) { |
| try { |
| await _playElevenAudio(text, voiceId); |
| if (!hasUserOverride) workingVoiceCache[pKey] = voiceId; |
| return; |
| } catch { } |
| } |
| } |
| |
| const utt = new SpeechSynthesisUtterance(text); |
| utt.lang = p.lang || 'en-US'; |
| const voiceName = voiceOverrides[pKey]?.edge || p.webVoice; |
| if (voiceName) { |
| const voices = speechSynthesis.getVoices(); |
| const v = voices.find(v => v.name.includes(voiceName)); |
| if (v) utt.voice = v; |
| } |
| speechSynthesis.speak(utt); |
| } |
|
|
| |
| async function handlePhonePickup() { |
| if (isAnimating) return; |
| isAnimating = true; |
| offenseCount++; |
| totalBusts++; |
| |
| if (streakStart) { |
| const streakSecs = Math.floor((Date.now() - streakStart) / 1000); |
| if (streakSecs > longestStreak) longestStreak = streakSecs; |
| } |
| streakStart = null; |
| updateStats(); |
|
|
| let pKey = selectedPersonality === 'mixtape' ? pick(RANDOM_PERSONALITIES) : selectedPersonality; |
| const anim = getAnimation(offenseCount); |
|
|
| if (pKey === 'pure_reachy') { |
| const emotion = pick(PERSONALITIES.pure_reachy.shameEmotions); |
| await Promise.all([anim(), playEmotion(emotion)]); |
| setResponseText(`π‘ *${emotion}*`); |
| } else { |
| |
| |
| const text = await getLLMResponse(pKey, true, offenseCount); |
| setResponseText(`π€ "${text}"`); |
| speakText(text, pKey); |
| await anim(); |
| } |
| isAnimating = false; |
| startIdleIfNeeded(); |
| } |
|
|
| async function handlePhonePutdown() { |
| if (!settings.praiseEnabled) return; |
| if (isAnimating) return; |
| isAnimating = true; |
| streakStart = Date.now(); |
| updateStats(); |
|
|
| let pKey = selectedPersonality === 'mixtape' ? pick(RANDOM_PERSONALITIES) : selectedPersonality; |
|
|
| if (pKey === 'pure_reachy') { |
| const emotion = pick(PERSONALITIES.pure_reachy.praiseEmotions); |
| await Promise.all([approvingNod(), playEmotion(emotion)]); |
| setResponseText(`β¨ *${emotion}*`); |
| } else { |
| const text = await getLLMResponse(pKey, false, offenseCount); |
| setResponseText(`β
"${text}"`); |
| speakText(text, pKey); |
| await approvingNod(); |
| } |
| isAnimating = false; |
| startIdleIfNeeded(); |
| } |
|
|
| |
| async function initModel() { |
| const label = isMobile ? 'YOLO26n (mobile)' : 'YOLO26m'; |
| setLoaderText(`Loading ${label} model...`); |
| showLoader(true); |
| rvModel = await AutoModel.from_pretrained(`onnx-community/${RV_MODEL_NAME}`, { device: 'webgpu', dtype: 'fp16' }); |
| setLoaderText('Loading processor...'); |
| rvProcessor = await AutoProcessor.from_pretrained(`onnx-community/${RV_MODEL_NAME}`); |
| showLoader(false); |
| } |
|
|
| async function detectOnFrame() { |
| const video = document.getElementById('rv-video'); |
| const canvas = document.getElementById('rv-canvas'); |
| if (!video.videoWidth) return; |
|
|
| |
| canvas.width = video.videoWidth; |
| canvas.height = video.videoHeight; |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
|
|
| |
| rvOffscreen.width = video.videoWidth; |
| rvOffscreen.height = video.videoHeight; |
| rvOffscreenCtx.drawImage(video, 0, 0); |
|
|
| const targetW = 256; |
| const targetH = Math.round((targetW / video.videoWidth) * video.videoHeight); |
| if (rvSmallCanvas.width !== targetW || rvSmallCanvas.height !== targetH) { |
| rvSmallCanvas.width = targetW; |
| rvSmallCanvas.height = targetH; |
| } |
| rvSmallCtx.drawImage(rvOffscreen, 0, 0, targetW, targetH); |
|
|
| const inputs = await rvProcessor(RawImage.fromCanvas(rvSmallCanvas)); |
| const output = await rvModel(inputs); |
| const scores = output.logits.sigmoid().data; |
| const boxes = output.pred_boxes.data; |
| const confThreshold = lastPhoneBox ? 0.2 : 0.5; |
|
|
| let best = null, bestScore = 0; |
| for (let i = 0; i < 300; i++) { |
| let maxS = 0, maxC = 0; |
| for (let j = 0; j < 80; j++) { const s = scores[i*80+j]; if (s > maxS) { maxS = s; maxC = j; } } |
| if (maxC === PHONE_CLASS_ID && maxS >= confThreshold && maxS > bestScore) { |
| bestScore = maxS; |
| const [cx, cy, w, h] = [boxes[i*4], boxes[i*4+1], boxes[i*4+2], boxes[i*4+3]]; |
| const sx = canvas.width/targetW, sy = canvas.height/targetH; |
| best = { x1:(cx-w/2)*targetW*sx, y1:(cy-h/2)*targetH*sy, x2:(cx+w/2)*targetW*sx, y2:(cy+h/2)*targetH*sy, confidence:maxS }; |
| } |
| } |
|
|
| const detections = []; |
| if (best) { lastPhoneBox = best; framesWithoutDetection = 0; detections.push(best); } |
| else if (lastPhoneBox && framesWithoutDetection < 3) { framesWithoutDetection++; detections.push({ ...lastPhoneBox, confidence: lastPhoneBox.confidence * 0.9 }); } |
| else { lastPhoneBox = null; framesWithoutDetection = 0; } |
|
|
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
| for (const d of detections) { |
| ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 3; |
| ctx.strokeRect(d.x1, d.y1, d.x2-d.x1, d.y2-d.y1); |
| ctx.fillStyle = '#00ff00'; ctx.font = '16px Arial'; |
| ctx.fillText(`phone ${(d.confidence*100).toFixed(0)}%`, d.x1, d.y1-10); |
| } |
|
|
| |
| const phoneInFrame = detections.length > 0; |
| if (phoneInFrame) { consecutivePhone++; consecutiveNoPhone = 0; } |
| else { consecutiveNoPhone++; } |
|
|
| const now = Date.now(); |
| const cooldownMs = settings.cooldown * 1000; |
|
|
| if (consecutivePhone >= PICKUP_THRESHOLD && !phoneVisible) { |
| phoneVisible = true; consecutiveNoPhone = 0; |
| if (now - lastReactionTime >= cooldownMs) { lastReactionTime = now; handlePhonePickup(); } |
| } |
| if (phoneVisible && phoneInFrame && now - lastReactionTime >= cooldownMs) { |
| lastReactionTime = now; handlePhonePickup(); |
| } |
| if (consecutiveNoPhone >= PUTDOWN_THRESHOLD && phoneVisible) { |
| phoneVisible = false; consecutivePhone = 0; lastReactionTime = 0; handlePhonePutdown(); |
| } |
|
|
| const dot = document.getElementById('rv-detect-dot'); |
| const txt = document.getElementById('rv-detect-text'); |
| if (phoneVisible) { dot.className = 'status-dot detected'; txt.textContent = 'π± PHONE DETECTED!'; } |
| else { dot.className = 'status-dot monitoring'; txt.textContent = 'β
Phone-free'; } |
| } |
|
|
| function detectionLoop() { |
| if (!isMonitoring) return; |
| if (!isProcessing) { |
| isProcessing = true; |
| const t0 = performance.now(); |
| detectOnFrame() |
| .then(() => { document.getElementById('rv-fps').textContent = Math.round(1000 / (performance.now() - t0)); }) |
| .catch(console.error) |
| .finally(() => { isProcessing = false; }); |
| } |
| rvAnimId = requestAnimationFrame(detectionLoop); |
| } |
|
|
| |
| function updateStats() { |
| document.getElementById('rv-stat-count').textContent = totalBusts; |
| if (totalBusts > 0) document.getElementById('rv-reset-section').style.display = ''; |
| } |
|
|
| function startStreakTimer() { |
| clearInterval(streakInterval); |
| streakInterval = setInterval(() => { |
| const secs = streakStart ? Math.floor((Date.now() - streakStart) / 1000) : 0; |
| const label = secs >= 60 ? Math.floor(secs / 60) + 'm' : secs + 's'; |
| document.getElementById('rv-stat-streak').textContent = label; |
| if (secs > longestStreak) { |
| longestStreak = secs; |
| const lLabel = secs >= 60 ? Math.floor(secs / 60) + 'm' : secs + 's'; |
| document.getElementById('rv-stat-longest').textContent = lLabel; |
| } |
| }, 1000); |
| } |
|
|
| function updateStatusBar() { |
| const p = PERSONALITIES[selectedPersonality]; |
| document.getElementById('rv-mode-name').textContent = p.name; |
| const llm = settings.groqKey ? 'Groq' : 'Pre-written'; |
| const tts = settings.elevenlabsKey ? 'ElevenLabs' : 'Web Speech'; |
| document.getElementById('rv-tech-badge').textContent = |
| selectedPersonality === 'pure_reachy' ? 'Emotion sounds' : `${llm} β ${tts}`; |
| const notice = document.getElementById('rv-api-notice'); |
| if (notice) notice.style.display = (!settings.groqKey && selectedPersonality !== 'pure_reachy') ? '' : 'none'; |
| } |
|
|
| function setResponseText(text) { document.getElementById('rv-response-text').textContent = text; } |
|
|
| function setSigninError(msg) { |
| let el = document.getElementById('rv-signin-error'); |
| if (!el) { |
| el = document.createElement('p'); |
| el.id = 'rv-signin-error'; |
| el.style.cssText = 'color:#ff6b6b;font-size:0.85rem;margin-top:0.75rem'; |
| document.getElementById('rv-signin-btn').after(el); |
| } |
| el.textContent = msg; |
| } |
| function showLoader(visible) { document.getElementById('rv-loader').classList.toggle('visible', visible); } |
| function setLoaderText(text) { document.getElementById('rv-loader-text').textContent = text; } |
|
|
| function showRVState(id) { |
| ['rv-signin','rv-picker','rv-monitoring'].forEach(s => { |
| document.getElementById(s).style.display = s === id ? '' : 'none'; |
| }); |
| } |
|
|
| |
| function loadSettings() { |
| try { |
| const s = JSON.parse(localStorage.getItem('judgy-robot-settings') || '{}'); |
| settings = { ...settings, ...s }; |
| voiceOverrides = JSON.parse(localStorage.getItem('judgy-robot-voices') || '{}'); |
| } catch {} |
| } |
|
|
| function saveSettings() { |
| localStorage.setItem('judgy-robot-settings', JSON.stringify(settings)); |
| localStorage.setItem('judgy-robot-voices', JSON.stringify(voiceOverrides)); |
| } |
|
|
| |
| function populatePersonalities() { |
| const container = document.getElementById('rv-personalities'); |
| container.innerHTML = ''; |
| Object.entries(PERSONALITIES).forEach(([key, p]) => { |
| const item = document.createElement('div'); |
| item.className = 'rv-personality-item' + (key === selectedPersonality ? ' active' : ''); |
| item.dataset.key = key; |
|
|
| const name = document.createElement('span'); |
| name.className = 'rv-personality-name'; |
| name.textContent = p.name; |
| item.appendChild(name); |
|
|
| if (key !== 'pure_reachy') { |
| const vBtn = document.createElement('button'); |
| vBtn.className = 'rv-voice-icon-btn'; |
| vBtn.textContent = 'π'; |
| vBtn.title = 'Voice settings'; |
| vBtn.addEventListener('click', e => { e.stopPropagation(); openVoiceModal(key); }); |
| item.appendChild(vBtn); |
| } |
|
|
| item.addEventListener('click', () => { |
| selectedPersonality = key; |
| container.querySelectorAll('.rv-personality-item').forEach(c => c.classList.toggle('active', c.dataset.key === key)); |
| updateStatusBar(); |
| |
| const toggleLabel = document.getElementById('rv-personality-toggle-label'); |
| if (toggleLabel) toggleLabel.textContent = p.name; |
| document.getElementById('rv-personality-panel')?.classList.remove('expanded'); |
| }); |
| container.appendChild(item); |
| }); |
| updateStatusBar(); |
| const toggleLabel = document.getElementById('rv-personality-toggle-label'); |
| if (toggleLabel) toggleLabel.textContent = PERSONALITIES[selectedPersonality]?.name || ''; |
| } |
|
|
| function populateSettingsModal() { |
| document.getElementById('rv-set-groq').value = settings.groqKey; |
| document.getElementById('rv-set-eleven').value = settings.elevenlabsKey; |
| document.getElementById('rv-set-cooldown').value = settings.cooldown; |
| document.getElementById('rv-set-cooldown-val').textContent = settings.cooldown + 's'; |
| document.getElementById('rv-set-praise').checked = settings.praiseEnabled; |
| } |
|
|
| let _voiceModalKey = null; |
|
|
| function openVoiceModal(pKey) { |
| _voiceModalKey = pKey; |
| const p = PERSONALITIES[pKey]; |
| const override = voiceOverrides[pKey] || {}; |
| document.getElementById('rv-voice-modal-title').textContent = `Voice Settings β ${p.name}`; |
| document.getElementById('rv-voice-eleven').value = override.eleven || ''; |
| document.getElementById('rv-voice-edge').value = override.edge || ''; |
| document.getElementById('rv-voice-eleven-hint').textContent = |
| p.elevenVoices?.[0] ? `Default: ${p.elevenVoices[0]}` : ''; |
| document.getElementById('rv-voice-edge-hint').textContent = |
| p.defaultEdgeVoice ? `Default: ${p.defaultEdgeVoice} (partial name also works, e.g. "${p.webVoice}")` : 'Leave empty for browser default'; |
| document.getElementById('rv-voices-modal').style.display = ''; |
| } |
|
|
| |
| async function setupRobot() { |
| robot = new ReachyMini({ appName: 'judgy-reachy-no-phone', enableMicrophone: false }); |
|
|
| robot.addEventListener('robotsChanged', e => { |
| const robots = e.detail?.robots ?? e.detail; |
| const list = document.getElementById('rv-robot-list'); |
| list.innerHTML = ''; |
| if (!robots.length) { |
| list.innerHTML = '<p class="rv-no-robots">No robots online. Make sure your robot daemon is running and connected.</p>'; |
| return; |
| } |
| let selectedRobotId = null; |
| const connectBtn = document.getElementById('rv-connect-btn'); |
| connectBtn.style.display = 'none'; |
| connectBtn.onclick = null; |
|
|
| robots.forEach(r => { |
| const name = r.meta?.name || r.id; |
| const n = name.toLowerCase(); |
| const mode = n.includes('sim') || n.includes('mockup') ? 'π₯ Simulation' |
| : n.includes('wireless') || n.includes('wifi') ? 'π‘ Wireless' |
| : 'π Lite'; |
| const sim = n.includes('sim') || n.includes('mockup'); |
| const btn = document.createElement('button'); |
| btn.className = 'rv-robot-btn'; |
| btn.innerHTML = `π€ <strong>${name}</strong><span class="rv-robot-mode">${mode}</span>`; |
| btn.addEventListener('click', () => { |
| list.querySelectorAll('.rv-robot-btn').forEach(b => b.classList.remove('selected')); |
| btn.classList.add('selected'); |
| selectedRobotId = r.id; |
| isSimulation = sim; |
| connectBtn.style.display = ''; |
| connectBtn.onclick = () => startSession(selectedRobotId); |
| }); |
| list.appendChild(btn); |
| }); |
| }); |
|
|
| robot.addEventListener('streaming', async () => { |
| isStreaming = true; |
| showRVState('rv-monitoring'); |
| const placeholder = document.getElementById('rv-video-placeholder'); |
| if (placeholder) placeholder.style.display = 'none'; |
| populatePersonalities(); |
| startStreakTimer(); |
| startIdleIfNeeded(); |
| if (!rvModel) await initModel(); |
| }); |
|
|
| robot.addEventListener('sessionStopped', () => { |
| isStreaming = false; |
| stopMonitoring(); |
| if (detachVideo) { detachVideo(); detachVideo = null; } |
| if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; } |
| const videoEl = document.getElementById('rv-video'); |
| if (videoEl) videoEl.srcObject = null; |
| const placeholder = document.getElementById('rv-video-placeholder'); |
| if (placeholder) placeholder.style.display = ''; |
| showRVState('rv-picker'); |
| }); |
|
|
| robot.addEventListener('sessionRejected', e => { |
| alert(`Robot is busy with: ${e.detail?.activeApp || 'another app'}`); |
| }); |
|
|
| robot.addEventListener('error', e => console.error('Robot error:', e)); |
| } |
|
|
| async function queryDaemonSimulation() { |
| try { |
| const resp = await fetch('http://localhost:8000/daemon/status', |
| { signal: AbortSignal.timeout(2000) }); |
| if (resp.ok) { |
| const s = await resp.json(); |
| return !!(s.simulation_enabled || s.mockup_sim_enabled); |
| } |
| } catch {} |
| return null; |
| } |
|
|
| async function startSession(robotId) { |
| if (!robot || robot.state !== 'connected') return; |
| try { |
| const daemonSim = await queryDaemonSimulation(); |
| if (daemonSim !== null) isSimulation = daemonSim; |
|
|
| const videoEl = document.getElementById('rv-video'); |
| if (isSimulation) { |
| webcamStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); |
| videoEl.srcObject = webcamStream; |
| } else { |
| detachVideo = robot.attachVideo(videoEl); |
| } |
| await robot.startSession(robotId); |
| } catch (e) { |
| console.error('startSession failed:', e); |
| if (detachVideo) { detachVideo(); detachVideo = null; } |
| if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; } |
| } |
| } |
|
|
| function stopMonitoring() { |
| isMonitoring = false; |
| if (rvAnimId) { cancelAnimationFrame(rvAnimId); rvAnimId = null; } |
| clearInterval(streakInterval); |
|
|
| |
| frozenStreak = streakStart ? Math.floor((Date.now() - streakStart) / 1000) : 0; |
| hasPreviousSession = (totalBusts > 0 || offenseCount > 0 || frozenStreak > 0); |
|
|
| const btn = document.getElementById('rv-toggle-btn'); |
| if (btn) { |
| const label = hasPreviousSession ? 'βΆοΈ Continue Monitoring' : 'βΆοΈ Start Monitoring'; |
| btn.innerHTML = label; |
| btn.classList.replace('btn-danger', 'btn-primary'); |
| } |
| const dot = document.getElementById('rv-detect-dot'); |
| const txt = document.getElementById('rv-detect-text'); |
| if (dot) dot.className = 'status-dot'; |
| if (txt) txt.textContent = 'Monitoring off'; |
|
|
| const resetSection = document.getElementById('rv-reset-section'); |
| if (resetSection) resetSection.style.display = hasPreviousSession ? '' : 'none'; |
| } |
|
|
| |
| function setupEventListeners() { |
| |
| document.getElementById('rv-personality-toggle').addEventListener('click', () => { |
| document.getElementById('rv-personality-panel').classList.toggle('expanded'); |
| }); |
|
|
| |
| document.getElementById('rv-signin-btn').addEventListener('click', async () => { |
| if (!robot) await setupRobot(); |
| const authenticated = await robot.authenticate(); |
| if (!authenticated) { await robot.login(); return; } |
| await robot.connect(); |
| showRVState('rv-picker'); |
| }); |
|
|
| |
| document.getElementById('rv-toggle-btn').addEventListener('click', () => { |
| if (!isStreaming) return; |
| isMonitoring = !isMonitoring; |
| const btn = document.getElementById('rv-toggle-btn'); |
| if (isMonitoring) { |
| btn.innerHTML = 'π Stop Monitoring'; |
| btn.classList.replace('btn-primary','btn-danger'); |
| phoneVisible = false; consecutivePhone = 0; consecutiveNoPhone = 0; |
| lastPhoneBox = null; framesWithoutDetection = 0; lastReactionTime = 0; |
|
|
| if (hasPreviousSession) { |
| |
| streakStart = Date.now() - frozenStreak * 1000; |
| } else { |
| |
| offenseCount = 0; totalBusts = 0; longestStreak = 0; |
| streakStart = Date.now(); |
| } |
| hasPreviousSession = false; frozenStreak = 0; |
| updateStats(); |
| document.getElementById('rv-detect-dot').className = 'status-dot monitoring'; |
| document.getElementById('rv-detect-text').textContent = 'β
Phone-free'; |
| document.getElementById('rv-reset-section').style.display = 'none'; |
| detectionLoop(); |
| startIdleIfNeeded(); |
| } else { |
| stopMonitoring(); |
| } |
| }); |
|
|
| |
| document.getElementById('rv-test-btn').addEventListener('click', () => { |
| if (isStreaming) handlePhonePickup(); |
| }); |
|
|
| |
| document.getElementById('rv-reset-btn').addEventListener('click', () => { |
| totalBusts = 0; offenseCount = 0; longestStreak = 0; streakStart = Date.now(); |
| hasPreviousSession = false; frozenStreak = 0; |
| updateStats(); |
| document.getElementById('rv-stat-streak').textContent = '0s'; |
| document.getElementById('rv-stat-longest').textContent = '0s'; |
| document.getElementById('rv-reset-section').style.display = 'none'; |
| const btn = document.getElementById('rv-toggle-btn'); |
| if (btn && !isMonitoring) btn.innerHTML = 'βΆοΈ Start Monitoring'; |
| setResponseText('Stats reset. Start monitoring to begin.'); |
| }); |
|
|
| |
| document.getElementById('rv-disconnect-btn').addEventListener('click', async () => { |
| stopMonitoring(); |
| if (detachVideo) { detachVideo(); detachVideo = null; } |
| if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; } |
| const videoEl = document.getElementById('rv-video'); |
| if (videoEl) videoEl.srcObject = null; |
| if (robot) { await robot.stopSession?.(); await robot.disconnect(); robot = null; } |
| isStreaming = false; |
| showRVState('rv-signin'); |
| }); |
|
|
| |
| document.getElementById('rv-settings-btn').addEventListener('click', () => { |
| populateSettingsModal(); |
| document.getElementById('rv-settings-modal').style.display = ''; |
| }); |
| document.getElementById('rv-settings-close').addEventListener('click', () => { |
| document.getElementById('rv-settings-modal').style.display = 'none'; |
| }); |
| document.getElementById('rv-settings-modal').addEventListener('click', e => { |
| if (e.target === e.currentTarget) e.currentTarget.style.display = 'none'; |
| }); |
| document.getElementById('rv-set-cooldown').addEventListener('input', e => { |
| document.getElementById('rv-set-cooldown-val').textContent = e.target.value + 's'; |
| }); |
| document.getElementById('rv-settings-save').addEventListener('click', () => { |
| settings.groqKey = document.getElementById('rv-set-groq').value.trim(); |
| settings.elevenlabsKey = document.getElementById('rv-set-eleven').value.trim(); |
| settings.cooldown = parseInt(document.getElementById('rv-set-cooldown').value); |
| settings.praiseEnabled = document.getElementById('rv-set-praise').checked; |
| saveSettings(); |
| document.getElementById('rv-settings-modal').style.display = 'none'; |
| }); |
|
|
| |
| document.getElementById('rv-voices-close').addEventListener('click', () => { |
| document.getElementById('rv-voices-modal').style.display = 'none'; |
| }); |
| document.getElementById('rv-voices-modal').addEventListener('click', e => { |
| if (e.target === e.currentTarget) e.currentTarget.style.display = 'none'; |
| }); |
| document.getElementById('rv-voices-save').addEventListener('click', () => { |
| if (!_voiceModalKey) return; |
| if (!voiceOverrides[_voiceModalKey]) voiceOverrides[_voiceModalKey] = {}; |
| voiceOverrides[_voiceModalKey].eleven = document.getElementById('rv-voice-eleven').value.trim(); |
| voiceOverrides[_voiceModalKey].edge = document.getElementById('rv-voice-edge').value.trim(); |
| saveSettings(); |
| document.getElementById('rv-voices-modal').style.display = 'none'; |
| }); |
| } |
|
|
| |
| function setupModeToggle() { |
| const infoBtn = document.getElementById('toggle-info'); |
| const robotBtn = document.getElementById('toggle-robot'); |
| const infoView = document.getElementById('info-view'); |
| const robotView = document.getElementById('robot-view'); |
|
|
| infoBtn.addEventListener('click', () => { |
| infoView.style.display = ''; |
| robotView.style.display = 'none'; |
| infoBtn.classList.add('active'); |
| robotBtn.classList.remove('active'); |
| }); |
|
|
| robotBtn.addEventListener('click', async () => { |
| infoView.style.display = 'none'; |
| robotView.style.display = ''; |
| infoBtn.classList.remove('active'); |
| robotBtn.classList.add('active'); |
|
|
| if (!robotInitialized) { |
| robotInitialized = true; |
| try { |
| if (!robot) await setupRobot(); |
| const authenticated = await robot.authenticate(); |
| if (authenticated) { |
| await robot.connect(); |
| showRVState('rv-picker'); |
| } else { |
| showRVState('rv-signin'); |
| } |
| } catch (e) { |
| console.error('init failed:', e); |
| showRVState('rv-signin'); |
| } |
| } |
| }); |
| } |
|
|
| |
| function init() { |
| loadSettings(); |
| setupModeToggle(); |
| setupEventListeners(); |
| } |
|
|
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', init); |
| } else { |
| init(); |
| } |
|
|