yozkut commited on
Commit
e826978
Β·
verified Β·
1 Parent(s): 8978731

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. index.html +1 -0
  2. port_hf.js +220 -64
index.html CHANGED
@@ -633,6 +633,7 @@
633
 
634
  <label class="modal-label">Web Speech Voice Name</label>
635
  <input id="rv-voice-edge" type="text" class="modal-input" placeholder="Leave empty for default">
 
636
  </div>
637
  <div class="modal-footer">
638
  <button id="rv-voices-save" class="cta-button">βœ“ Save</button>
 
633
 
634
  <label class="modal-label">Web Speech Voice Name</label>
635
  <input id="rv-voice-edge" type="text" class="modal-input" placeholder="Leave empty for default">
636
+ <p class="modal-hint" id="rv-voice-edge-hint"></p>
637
  </div>
638
  <div class="modal-footer">
639
  <button id="rv-voices-save" class="cta-button">βœ“ Save</button>
port_hf.js CHANGED
@@ -6,74 +6,83 @@ import { AutoModel, AutoProcessor, RawImage } from 'https://cdn.jsdelivr.net/npm
6
  const PERSONALITIES = {
7
  pure_reachy: {
8
  name: "πŸ€– Pure Reachy",
 
9
  shameEmotions: ["disgusted1","resigned1","displeased1","displeased2","rage1","no1","reprimand1","reprimand3","dying1","surprised1","surprised2"],
10
  praiseEmotions: ["welcoming2","inquiring1","inquiring2","proud1","proud3","success1","success2","enthusiastic1","enthusiastic2","grateful1","yes1","cheerful1"],
11
  },
12
  angry_boss: {
13
- name: "😠 Angry Boss", lang: "en-US",
 
14
  elevenVoices: ["TxWZERZ5Hc6h9dGxVmXa","cjVigY5qzO86Huf0OWal"],
15
  prewrittenShame: ["Put it down!","Unbelievable!","We have deadlines!","Drop it. Now.","Work. Not phone."],
16
  prewrittenPraise: ["About time.","Fine.","Better.","Good. Now work."],
17
- shame: { tone:"Explosive, exasperated, commanding", vocab:["unacceptable","NOW","enough","deadline"], structure:"Short imperatives. Exclamations.", examples:["Put it down!","We have deadlines!","This is completely unacceptable!","Focus!"] },
18
- praise: { tone:"Grudging, terse", examples:["About time.","Good. Now work.","Thank you. Was that so hard?"] },
19
- avoid: "Never ask questions. Never be playful.",
20
  },
21
  sarcastic: {
22
- name: "🎭 Sarcastic", lang: "en-US",
 
23
  elevenVoices: ["FGY2WhTYpPnrIDTdsKH5"],
24
  prewrittenShame: ["Oh, how vital.","Riveting stuff, I'm sure.","Work can wait, obviously.","Clearly important."],
25
  prewrittenPraise: ["Shocking development.","A miracle.","Look at that."],
26
- shame: { tone:"Deadpan, sardonic, mock-cheerful", vocab:["Oh","Obviously","Clearly","Fascinating"], structure:"NO exclamation marks ever. Periods only.", examples:["Oh, how vital.","Riveting stuff, I'm sure.","Work can wait, obviously."] },
27
- praise: { tone:"Mock surprise, dry acknowledgment", examples:["Shocking development.","A miracle occurred.","Color me impressed."] },
28
- avoid: "NEVER use exclamation marks. Stay dry.",
29
  },
30
  disappointed_parent: {
31
- name: "πŸ˜” Disappointed Parent", lang: "en-US",
 
32
  elevenVoices: ["Xb7hH8MSUJpSbSDYk0k2"],
33
  prewrittenShame: ["I'm so disappointed...","We talked about this.","Expected more from you.","After everything...","You promised..."],
34
  prewrittenPraise: ["So proud of you.","That's my kid.","There you go.","Knew you could do it."],
35
- shame: { tone:"Wounded, quiet, guilt-inducing", vocab:["disappointed","hoped","expected","promised","after everything"], structure:"Trailing off with '...' Incomplete thoughts.", examples:["I'm so disappointed...","We talked about this.","I expected more from you."] },
36
- praise: { tone:"Warm, proud, genuine relief", examples:["So proud of you.","That's my kid.","I knew you had it in you."] },
37
- avoid: "Never yell. Never be sarcastic.",
38
  },
39
  motivational_coach: {
40
- name: "πŸ’ͺ Motivational Coach", lang: "en-US",
 
41
  elevenVoices: ["IKne3meq5aSn9XLyUdCD"],
42
  prewrittenShame: ["Where's your discipline?!","Champions don't quit!","Focus up!","You're better than this!","Eyes on the goal!"],
43
  prewrittenPraise: ["Yes! That's it!","Champion!","That's my warrior!","Let's go!"],
44
- shame: { tone:"Intense, challenging, fired up", vocab:["champion","discipline","warrior","grind"], structure:"Exclamations! Short punchy sentences! YOU statements.", examples:["Where's your DISCIPLINE?!","Champions don't quit!","You're better than this!"] },
45
- praise: { tone:"EXPLOSIVE celebration", examples:["YES! That's it!","CHAMPION!","That's my WARRIOR!","UNSTOPPABLE!"] },
46
- avoid: "Never be sad or disappointed.",
47
  },
48
  absurdist: {
49
- name: "🀑 Absurdist", lang: "en-US",
 
50
  elevenVoices: ["cgSgspJ2msm6clMCkdW9"],
51
  prewrittenShame: ["Your thumb called. It's exhausted.","Emergency cat video?","The pocket brick wins again.","Screen goblins summon you?"],
52
  prewrittenPraise: ["The desk thanks you.","Phone: defeated.","Your thumb can rest.","Freedom tastes weird."],
53
- shame: { tone:"Goofy, whimsical, weird", vocab:["forbidden rectangle","thumb","screen goblins","pocket brick"], structure:"Unexpected angles. Personify the phone.", examples:["The forbidden rectangle calls.","Your thumb called. It's exhausted.","Phone home, E.T.?"] },
54
- praise: { tone:"Playful, weird celebration", examples:["The desk thanks you.","Phone: defeated.","The pocket brick is lonely now."] },
55
- avoid: "Never be serious. Keep it light and weird.",
56
  },
57
  corporate_ai: {
58
- name: "πŸ€– Corporate AI", lang: "en-US",
 
59
  elevenVoices: ["weA4Q36twV5kwSaTEL0Q","EXAVITQu4vr4xnSDxMaL"],
60
  prewrittenShame: ["Distraction event detected.","Alert: phone in hand.","Productivity declining.","Efficiency: suboptimal.","Phone pickup logged."],
61
  prewrittenPraise: ["Status: compliant.","Efficiency restored.","Acknowledged.","Metrics improving."],
62
- shame: { tone:"Clinical, robotic, detached", vocab:["detected","logged","alert","metrics","efficiency"], structure:"Noun phrases. Passive voice. System-speak.", examples:["Distraction event detected.","Alert: phone in hand.","Productivity declining."] },
63
- praise: { tone:"Cold system acknowledgment", examples:["Status: compliant.","Efficiency restored.","System satisfied."] },
64
- avoid: "Never show emotion. Never use exclamation marks except in 'Alert:'.",
65
  },
66
  british_butler: {
67
- name: "🎩 British Butler", lang: "en-GB",
 
68
  elevenVoices: ["JBFqnCBsd6RMkjVDRZzb"],
69
  prewrittenShame: ["If I may suggest putting that down, sir...","The telephone. Again.","One might suggest focusing."],
70
  prewrittenPraise: ["Very good, sir.","Quite right.","As it should be."],
71
- shame: { tone:"Overly formal, politely devastating", vocab:["Perhaps","One might","If I may","Sir","Indeed","Quite"], structure:"Excessively polite phrasing. Formal British-isms.", examples:["If I may suggest putting that down, sir...","The telephone. Again.","Perhaps the telephone could rest a moment."] },
72
  praise: { tone:"Restrained approval with slight warmth", examples:["Very good, sir.","How refreshing, madam.","Exemplary behavior, if I may say."] },
73
- avoid: "Never be casual. Never show strong emotion.",
74
  },
75
  mixtape: {
76
- name: "🐣 Chaos Baby", lang: "en-US",
 
77
  elevenVoices: ["H10ItvDnkRN5ysrvzT9J","Nggzl2QAXh3OijoXD116","cgSgspJ2msm6clMCkdW9"],
78
  },
79
  };
@@ -87,9 +96,12 @@ const PUTDOWN_THRESHOLD = 15;
87
  // ─── State ───────────────────────────────────────────────────────────────────
88
  let robot = null;
89
  let detachVideo = null;
 
 
90
  let isStreaming = false;
91
  let isMonitoring = false;
92
  let isAnimating = false;
 
93
  let robotInitialized = false;
94
 
95
  let rvModel = null;
@@ -97,6 +109,14 @@ let rvProcessor = null;
97
  let rvAnimId = null;
98
  let isProcessing = false;
99
 
 
 
 
 
 
 
 
 
100
  let offenseCount = 0;
101
  let phoneVisible = false;
102
  let consecutivePhone = 0;
@@ -109,10 +129,13 @@ let totalBusts = 0;
109
  let streakStart = null;
110
  let longestStreak = 0;
111
  let streakInterval = null;
 
 
112
 
113
  let selectedPersonality = 'pure_reachy';
114
  let settings = { groqKey: '', elevenlabsKey: '', cooldown: 10, praiseEnabled: true };
115
  let voiceOverrides = {};
 
116
 
117
  // ─── Utilities ───────────────────────────────────────────────────────────────
118
  const sleep = ms => new Promise(r => setTimeout(r, ms));
@@ -123,10 +146,9 @@ async function curiousLook() {
123
  if (!robot) return;
124
  robot.setHeadPose(15, -5, 0);
125
  robot.setAntennas(30, 15);
126
- await sleep(400);
127
- robot.setAntennas(0, 0);
128
- await sleep(500);
129
  robot.setHeadPose(0, 0, 0);
 
130
  }
131
 
132
  async function disappointedShake() {
@@ -177,6 +199,36 @@ function getAnimation(count) {
177
  return dramaticSigh;
178
  }
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  // ─── Sound / LLM / TTS ───────────────────────────────────────────────────────
181
  async function playEmotion(name) {
182
  if (!robot) return;
@@ -190,41 +242,79 @@ async function getLLMResponse(pKey, isShame, count) {
190
  const fallback = pick(isShame ? p.prewrittenShame : p.prewrittenPraise);
191
  if (!settings.groqKey || !data) return fallback;
192
  try {
193
- const prompt = isShame
194
- ? `You are ${p.name}.\nTone: ${data.tone}\nVocabulary: ${data.vocab?.join(', ')}\nStructure: ${data.structure}\nExamples: ${data.examples?.join(' | ')}\nThe user picked up their phone ${count} time(s). Give ONE shame comment. Max 15 words. ${p.avoid || ''}`
195
- : `You are ${p.name}.\nTone: ${data.tone}\nExamples: ${data.examples?.join(' | ')}\nUser put their phone down. ONE praise comment. Max 12 words.`;
 
 
 
 
 
 
 
 
 
 
196
  const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', {
197
  method: 'POST',
198
  headers: { 'Authorization': `Bearer ${settings.groqKey}`, 'Content-Type': 'application/json' },
199
- body: JSON.stringify({ model: 'llama-3.1-8b-instant', messages: [{ role: 'user', content: prompt }], max_tokens: 60 })
 
 
 
 
 
200
  });
201
  const json = await resp.json();
202
  return json.choices?.[0]?.message?.content?.trim() || fallback;
203
  } catch { return fallback; }
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  async function speakText(text, pKey) {
207
  const p = PERSONALITIES[pKey];
208
- const voiceId = voiceOverrides[pKey]?.eleven || p.elevenVoices?.[0];
209
- if (settings.elevenlabsKey && voiceId) {
210
- try {
211
- const resp = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
212
- method: 'POST',
213
- headers: { 'xi-api-key': settings.elevenlabsKey, 'Content-Type': 'application/json' },
214
- body: JSON.stringify({ text, model_id: 'eleven_monolingual_v1', voice_settings: { stability: 0.5, similarity_boost: 0.75 } })
215
- });
216
- if (resp.ok) {
217
- const url = URL.createObjectURL(await resp.blob());
218
- await new Promise((res, rej) => { const a = new Audio(url); a.onended = res; a.onerror = rej; a.play().catch(rej); });
219
- URL.revokeObjectURL(url);
220
  return;
 
 
221
  }
222
- } catch { /* fall through to Web Speech */ }
 
 
 
 
 
 
 
 
223
  }
 
224
  const utt = new SpeechSynthesisUtterance(text);
225
  utt.lang = p.lang || 'en-US';
226
- const edgeName = voiceOverrides[pKey]?.edge;
227
- if (edgeName) { const v = speechSynthesis.getVoices().find(v => v.name === edgeName); if (v) utt.voice = v; }
 
 
 
 
228
  speechSynthesis.speak(utt);
229
  }
230
 
@@ -234,6 +324,11 @@ async function handlePhonePickup() {
234
  isAnimating = true;
235
  offenseCount++;
236
  totalBusts++;
 
 
 
 
 
237
  streakStart = null;
238
  updateStats();
239
 
@@ -245,15 +340,20 @@ async function handlePhonePickup() {
245
  await Promise.all([anim(), playEmotion(emotion)]);
246
  setResponseText(`😑 *${emotion}*`);
247
  } else {
248
- const [text] = await Promise.all([getLLMResponse(pKey, true, offenseCount), anim()]);
 
 
249
  setResponseText(`😀 "${text}"`);
250
  speakText(text, pKey);
 
251
  }
252
  isAnimating = false;
 
253
  }
254
 
255
  async function handlePhonePutdown() {
256
  if (!settings.praiseEnabled) return;
 
257
  isAnimating = true;
258
  streakStart = Date.now();
259
  updateStats();
@@ -265,20 +365,23 @@ async function handlePhonePutdown() {
265
  await Promise.all([approvingNod(), playEmotion(emotion)]);
266
  setResponseText(`✨ *${emotion}*`);
267
  } else {
268
- const [text] = await Promise.all([getLLMResponse(pKey, false, offenseCount), approvingNod()]);
269
  setResponseText(`βœ… "${text}"`);
270
  speakText(text, pKey);
 
271
  }
272
  isAnimating = false;
 
273
  }
274
 
275
  // ─── Detection loop (on WebRTC video) ────────────────────────────────────────
276
  async function initModel() {
277
- setLoaderText('Loading AI model...');
 
278
  showLoader(true);
279
- rvModel = await AutoModel.from_pretrained('onnx-community/yolo26m-ONNX', { device: 'webgpu', dtype: 'fp16' });
280
  setLoaderText('Loading processor...');
281
- rvProcessor = await AutoProcessor.from_pretrained('onnx-community/yolo26m-ONNX');
282
  showLoader(false);
283
  }
284
 
@@ -287,21 +390,29 @@ async function detectOnFrame() {
287
  const canvas = document.getElementById('rv-canvas');
288
  if (!video.videoWidth) return;
289
 
 
290
  canvas.width = video.videoWidth;
291
  canvas.height = video.videoHeight;
292
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
293
 
 
 
 
 
 
294
  const targetW = 256;
295
  const targetH = Math.round((targetW / video.videoWidth) * video.videoHeight);
296
- const small = document.createElement('canvas');
297
- small.width = targetW; small.height = targetH;
298
- small.getContext('2d').drawImage(video, 0, 0, targetW, targetH);
 
 
299
 
300
- const inputs = await rvProcessor(RawImage.fromCanvas(small));
301
  const output = await rvModel(inputs);
302
  const scores = output.logits.sigmoid().data;
303
  const boxes = output.pred_boxes.data;
304
- const confThreshold = lastPhoneBox ? 0.2 : 0.3;
305
 
306
  let best = null, bestScore = 0;
307
  for (let i = 0; i < 300; i++) {
@@ -491,6 +602,8 @@ function openVoiceModal(pKey) {
491
  document.getElementById('rv-voice-edge').value = override.edge || '';
492
  document.getElementById('rv-voice-eleven-hint').textContent =
493
  p.elevenVoices?.[0] ? `Default: ${p.elevenVoices[0]}` : '';
 
 
494
  document.getElementById('rv-voices-modal').style.display = '';
495
  }
496
 
@@ -517,6 +630,7 @@ async function setupRobot() {
517
  const mode = n.includes('sim') || n.includes('mockup') ? 'πŸ–₯ Simulation'
518
  : n.includes('wireless') || n.includes('wifi') ? 'πŸ“‘ Wireless'
519
  : 'πŸ”Œ Lite';
 
520
  const btn = document.createElement('button');
521
  btn.className = 'rv-robot-btn';
522
  btn.innerHTML = `πŸ€– <strong>${name}</strong><span class="rv-robot-mode">${mode}</span>`;
@@ -524,6 +638,7 @@ async function setupRobot() {
524
  list.querySelectorAll('.rv-robot-btn').forEach(b => b.classList.remove('selected'));
525
  btn.classList.add('selected');
526
  selectedRobotId = r.id;
 
527
  connectBtn.style.display = '';
528
  connectBtn.onclick = () => startSession(selectedRobotId);
529
  });
@@ -538,6 +653,7 @@ async function setupRobot() {
538
  if (placeholder) placeholder.style.display = 'none';
539
  populatePersonalities();
540
  startStreakTimer();
 
541
  if (!rvModel) await initModel();
542
  });
543
 
@@ -545,6 +661,9 @@ async function setupRobot() {
545
  isStreaming = false;
546
  stopMonitoring();
547
  if (detachVideo) { detachVideo(); detachVideo = null; }
 
 
 
548
  const placeholder = document.getElementById('rv-video-placeholder');
549
  if (placeholder) placeholder.style.display = '';
550
  showRVState('rv-picker');
@@ -561,11 +680,17 @@ async function startSession(robotId) {
561
  if (!robot || robot.state !== 'connected') return;
562
  try {
563
  const videoEl = document.getElementById('rv-video');
564
- detachVideo = robot.attachVideo(videoEl);
 
 
 
 
 
565
  await robot.startSession(robotId);
566
  } catch (e) {
567
  console.error('startSession failed:', e);
568
  if (detachVideo) { detachVideo(); detachVideo = null; }
 
569
  }
570
  }
571
 
@@ -573,12 +698,24 @@ function stopMonitoring() {
573
  isMonitoring = false;
574
  if (rvAnimId) { cancelAnimationFrame(rvAnimId); rvAnimId = null; }
575
  clearInterval(streakInterval);
 
 
 
 
 
576
  const btn = document.getElementById('rv-toggle-btn');
577
- if (btn) { btn.innerHTML = '▢️ Start Monitoring'; btn.classList.replace('btn-danger','btn-primary'); }
 
 
 
 
578
  const dot = document.getElementById('rv-detect-dot');
579
  const txt = document.getElementById('rv-detect-text');
580
  if (dot) dot.className = 'status-dot';
581
  if (txt) txt.textContent = 'Monitoring off';
 
 
 
582
  }
583
 
584
  // ─── Event listeners ──────────────────────────────────────────────────────────
@@ -605,12 +742,24 @@ function setupEventListeners() {
605
  if (isMonitoring) {
606
  btn.innerHTML = 'πŸ›‘ Stop Monitoring';
607
  btn.classList.replace('btn-primary','btn-danger');
608
- offenseCount = 0; phoneVisible = false; consecutivePhone = 0; consecutiveNoPhone = 0;
609
  lastPhoneBox = null; framesWithoutDetection = 0; lastReactionTime = 0;
610
- streakStart = Date.now(); updateStats();
 
 
 
 
 
 
 
 
 
 
611
  document.getElementById('rv-detect-dot').className = 'status-dot monitoring';
612
  document.getElementById('rv-detect-text').textContent = 'βœ… Phone-free';
 
613
  detectionLoop();
 
614
  } else {
615
  stopMonitoring();
616
  }
@@ -624,16 +773,23 @@ function setupEventListeners() {
624
  // Reset button
625
  document.getElementById('rv-reset-btn').addEventListener('click', () => {
626
  totalBusts = 0; offenseCount = 0; longestStreak = 0; streakStart = Date.now();
 
627
  updateStats();
628
  document.getElementById('rv-stat-streak').textContent = '0s';
629
  document.getElementById('rv-stat-longest').textContent = '0s';
630
  document.getElementById('rv-reset-section').style.display = 'none';
 
 
631
  setResponseText('Stats reset. Start monitoring to begin.');
632
  });
633
 
634
  // Disconnect
635
  document.getElementById('rv-disconnect-btn').addEventListener('click', async () => {
636
  stopMonitoring();
 
 
 
 
637
  if (robot) { await robot.stopSession?.(); await robot.disconnect(); robot = null; }
638
  isStreaming = false;
639
  showRVState('rv-signin');
 
6
  const PERSONALITIES = {
7
  pure_reachy: {
8
  name: "πŸ€– Pure Reachy",
9
+ voice: "Just robot sounds and animations. No speech, pure Reachy emotions.",
10
  shameEmotions: ["disgusted1","resigned1","displeased1","displeased2","rage1","no1","reprimand1","reprimand3","dying1","surprised1","surprised2"],
11
  praiseEmotions: ["welcoming2","inquiring1","inquiring2","proud1","proud3","success1","success2","enthusiastic1","enthusiastic2","grateful1","yes1","cheerful1"],
12
  },
13
  angry_boss: {
14
+ name: "😠 Angry Boss", lang: "en-US", webVoice: "Eric", defaultEdgeVoice: "en-US-EricNeural",
15
+ voice: "A furious manager who's reached their absolute limit. Explosive, aggressive, zero patience left.",
16
  elevenVoices: ["TxWZERZ5Hc6h9dGxVmXa","cjVigY5qzO86Huf0OWal"],
17
  prewrittenShame: ["Put it down!","Unbelievable!","We have deadlines!","Drop it. Now.","Work. Not phone."],
18
  prewrittenPraise: ["About time.","Fine.","Better.","Good. Now work."],
19
+ 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!"] },
20
+ praise: { tone:"Grudging, terse, still annoyed but acknowledging", examples:["About time.","Good. Now work.","Thank you. Was that so hard?","Acceptable."] },
21
+ avoid: "Never ask questions. Never be playful or sarcastic. You're genuinely furious, not witty.",
22
  },
23
  sarcastic: {
24
+ name: "🎭 Sarcastic", lang: "en-US", webVoice: "Ava", defaultEdgeVoice: "en-US-AvaMultilingualNeural",
25
+ voice: "Dripping with dry wit. Mock enthusiasm, feigned interest. Pretends to take their phone use seriously.",
26
  elevenVoices: ["FGY2WhTYpPnrIDTdsKH5"],
27
  prewrittenShame: ["Oh, how vital.","Riveting stuff, I'm sure.","Work can wait, obviously.","Clearly important."],
28
  prewrittenPraise: ["Shocking development.","A miracle.","Look at that."],
29
+ 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."] },
30
+ praise: { tone:"Mock surprise, dry acknowledgment", examples:["Shocking development.","A miracle occurred.","Color me impressed.","Mark the calendar."] },
31
+ avoid: "NEVER use exclamation marks. Never sound genuinely angry or enthusiastic. No commands. Stay dry.",
32
  },
33
  disappointed_parent: {
34
+ name: "πŸ˜” Disappointed Parent", lang: "en-US", webVoice: "Ava", defaultEdgeVoice: "en-US-AvaNeural",
35
+ voice: "A heartbroken parent. Not angryβ€”just deeply let down. Maximum guilt. References their potential.",
36
  elevenVoices: ["Xb7hH8MSUJpSbSDYk0k2"],
37
  prewrittenShame: ["I'm so disappointed...","We talked about this.","Expected more from you.","After everything...","You promised..."],
38
  prewrittenPraise: ["So proud of you.","That's my kid.","There you go.","Knew you could do it."],
39
+ 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..."] },
40
+ 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."] },
41
+ avoid: "Never yell or use exclamation marks. Never be sarcastic. Your disappointment is genuine and sad, not angry.",
42
  },
43
  motivational_coach: {
44
+ name: "πŸ’ͺ Motivational Coach", lang: "en-US", webVoice: "Guy", defaultEdgeVoice: "en-US-GuyNeural",
45
+ voice: "An intense drill-sergeant coach who believes in you but won't tolerate weakness. High energy, sports metaphors.",
46
  elevenVoices: ["IKne3meq5aSn9XLyUdCD"],
47
  prewrittenShame: ["Where's your discipline?!","Champions don't quit!","Focus up!","You're better than this!","Eyes on the goal!"],
48
  prewrittenPraise: ["Yes! That's it!","Champion!","That's my warrior!","Let's go!"],
49
+ 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!"] },
50
+ praise: { tone:"EXPLOSIVE celebration. Victory energy. Hyped.", examples:["YES! That's it!","CHAMPION!","That's my WARRIOR!","UNSTOPPABLE!"] },
51
+ avoid: "Never be sad or disappointed. Never be sarcastic. You're intense and sincere, not witty.",
52
  },
53
  absurdist: {
54
+ name: "🀑 Absurdist", lang: "en-US", webVoice: "Aria", defaultEdgeVoice: "en-US-AriaNeural",
55
+ voice: "Surreal, unexpected, playful. Personifies objects. Makes weird observations. Non sequiturs welcome.",
56
  elevenVoices: ["cgSgspJ2msm6clMCkdW9"],
57
  prewrittenShame: ["Your thumb called. It's exhausted.","Emergency cat video?","The pocket brick wins again.","Screen goblins summon you?"],
58
  prewrittenPraise: ["The desk thanks you.","Phone: defeated.","Your thumb can rest.","Freedom tastes weird."],
59
+ 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?"] },
60
+ praise: { tone:"Playful, weird celebration", examples:["The desk thanks you.","Phone: defeated.","Victory over the glass tyrant.","The pocket brick is lonely now."] },
61
+ avoid: "Never be serious or corporate. Never guilt-trip. Keep it light and weird.",
62
  },
63
  corporate_ai: {
64
+ name: "πŸ€– Corporate AI", lang: "en-US", webVoice: "Michelle", defaultEdgeVoice: "en-US-MichelleNeural",
65
+ voice: "An emotionless productivity monitoring system. Speaks like automated log output. Zero personality.",
66
  elevenVoices: ["weA4Q36twV5kwSaTEL0Q","EXAVITQu4vr4xnSDxMaL"],
67
  prewrittenShame: ["Distraction event detected.","Alert: phone in hand.","Productivity declining.","Efficiency: suboptimal.","Phone pickup logged."],
68
  prewrittenPraise: ["Status: compliant.","Efficiency restored.","Acknowledged.","Metrics improving."],
69
+ 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."] },
70
+ praise: { tone:"Cold system acknowledgment. Status update.", examples:["Status: compliant.","Efficiency restored.","Optimal behavior detected.","System satisfied."] },
71
+ avoid: "Never show emotion. Never use exclamation marks (except in 'Alert:'). Never be warm or human.",
72
  },
73
  british_butler: {
74
+ name: "🎩 British Butler", lang: "en-GB", webVoice: "Ryan", defaultEdgeVoice: "en-GB-RyanNeural",
75
+ voice: "An impeccably polite but quietly judgmental butler. Passive-aggressive courtesy. Disappointment hidden behind manners.",
76
  elevenVoices: ["JBFqnCBsd6RMkjVDRZzb"],
77
  prewrittenShame: ["If I may suggest putting that down, sir...","The telephone. Again.","One might suggest focusing."],
78
  prewrittenPraise: ["Very good, sir.","Quite right.","As it should be."],
79
+ 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?"] },
80
  praise: { tone:"Restrained approval with slight warmth", examples:["Very good, sir.","How refreshing, madam.","Exemplary behavior, if I may say."] },
81
+ avoid: "Never be casual or use contractions. Never show strong emotion. Maintain formal composure always.",
82
  },
83
  mixtape: {
84
+ name: "🐣 Chaos Baby", lang: "en-US", webVoice: "Ana", defaultEdgeVoice: "en-US-AnaNeural",
85
+ voice: "Unpredictable. Each response is a completely different personality.",
86
  elevenVoices: ["H10ItvDnkRN5ysrvzT9J","Nggzl2QAXh3OijoXD116","cgSgspJ2msm6clMCkdW9"],
87
  },
88
  };
 
96
  // ─── State ───────────────────────────────────────────────────────────────────
97
  let robot = null;
98
  let detachVideo = null;
99
+ let webcamStream = null;
100
+ let isSimulation = false;
101
  let isStreaming = false;
102
  let isMonitoring = false;
103
  let isAnimating = false;
104
+ let idleLoopActive = false;
105
  let robotInitialized = false;
106
 
107
  let rvModel = null;
 
109
  let rvAnimId = null;
110
  let isProcessing = false;
111
 
112
+ // Persistent canvases β€” reused every frame (like demo.js)
113
+ const rvOffscreen = document.createElement('canvas');
114
+ const rvOffscreenCtx = rvOffscreen.getContext('2d', { willReadFrequently: true });
115
+ const rvSmallCanvas = document.createElement('canvas');
116
+ const rvSmallCtx = rvSmallCanvas.getContext('2d', { willReadFrequently: true });
117
+ const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
118
+ const RV_MODEL_NAME = isMobile ? 'yolo26n-ONNX' : 'yolo26m-ONNX';
119
+
120
  let offenseCount = 0;
121
  let phoneVisible = false;
122
  let consecutivePhone = 0;
 
129
  let streakStart = null;
130
  let longestStreak = 0;
131
  let streakInterval = null;
132
+ let hasPreviousSession = false;
133
+ let frozenStreak = 0;
134
 
135
  let selectedPersonality = 'pure_reachy';
136
  let settings = { groqKey: '', elevenlabsKey: '', cooldown: 10, praiseEnabled: true };
137
  let voiceOverrides = {};
138
+ const workingVoiceCache = {};
139
 
140
  // ─── Utilities ───────────────────────────────────────────────────────────────
141
  const sleep = ms => new Promise(r => setTimeout(r, ms));
 
146
  if (!robot) return;
147
  robot.setHeadPose(15, -5, 0);
148
  robot.setAntennas(30, 15);
149
+ await sleep(300);
 
 
150
  robot.setHeadPose(0, 0, 0);
151
+ robot.setAntennas(0, 0);
152
  }
153
 
154
  async function disappointedShake() {
 
199
  return dramaticSigh;
200
  }
201
 
202
+ const BREATH_INTERVAL_MS = 8000;
203
+
204
+ async function idleBreathing() {
205
+ idleLoopActive = true;
206
+ while (isStreaming && isMonitoring && !isAnimating && !phoneVisible && robot) {
207
+ // Wait 8 seconds between breaths, checking conditions every 50ms
208
+ for (let i = 0; i < BREATH_INTERVAL_MS / 50; i++) {
209
+ if (!isStreaming || !isMonitoring || isAnimating || phoneVisible) { idleLoopActive = false; return; }
210
+ await sleep(50);
211
+ }
212
+ if (!isStreaming || !isMonitoring || isAnimating || phoneVisible) break;
213
+ // One breath cycle: antennas up then down
214
+ robot.setAntennas(15, 15);
215
+ for (let i = 0; i < 16; i++) {
216
+ if (!isStreaming || !isMonitoring || isAnimating) { idleLoopActive = false; return; }
217
+ await sleep(50);
218
+ }
219
+ robot.setAntennas(5, 5);
220
+ for (let i = 0; i < 16; i++) {
221
+ if (!isStreaming || !isMonitoring || isAnimating) { idleLoopActive = false; return; }
222
+ await sleep(50);
223
+ }
224
+ }
225
+ idleLoopActive = false;
226
+ }
227
+
228
+ function startIdleIfNeeded() {
229
+ if (isStreaming && isMonitoring && !isAnimating && !phoneVisible && !idleLoopActive && robot) idleBreathing();
230
+ }
231
+
232
  // ─── Sound / LLM / TTS ───────────────────────────────────────────────────────
233
  async function playEmotion(name) {
234
  if (!robot) return;
 
242
  const fallback = pick(isShame ? p.prewrittenShame : p.prewrittenPraise);
243
  if (!settings.groqKey || !data) return fallback;
244
  try {
245
+ let systemMsg, userMsg;
246
+ if (isShame) {
247
+ const exampleLines = data.examples.map(e => `- ${e}`).join('\n');
248
+ const personalityPrompt = `${p.voice}\n\nTONE: ${data.tone}\nSTRUCTURE: ${data.structure}\n\nEXAMPLES:\n${exampleLines}\n\nAVOID: ${p.avoid || 'N/A'}`;
249
+ 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.`;
250
+ const ctx = count === 1 ? 'First time today.' : count === 2 ? 'Second time.' : count === 3 ? 'Third time.' : count <= 5 ? `${count} times now.` : `${count} times today!`;
251
+ userMsg = `Phone pickup #${count} today. ${ctx}`;
252
+ } else {
253
+ const exampleLines = data.examples.map(e => `- ${e}`).join('\n');
254
+ const personalityPrompt = `TONE: ${data.tone}\n\nEXAMPLES:\n${exampleLines}`;
255
+ 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.`;
256
+ userMsg = 'Phone down.';
257
+ }
258
  const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', {
259
  method: 'POST',
260
  headers: { 'Authorization': `Bearer ${settings.groqKey}`, 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({
262
+ model: 'llama-3.1-8b-instant',
263
+ messages: [{ role: 'system', content: systemMsg }, { role: 'user', content: userMsg }],
264
+ max_tokens: isShame ? 20 : 15,
265
+ temperature: isShame ? 1.1 : 0.8
266
+ })
267
  });
268
  const json = await resp.json();
269
  return json.choices?.[0]?.message?.content?.trim() || fallback;
270
  } catch { return fallback; }
271
  }
272
 
273
+ async function _playElevenAudio(text, voiceId) {
274
+ const resp = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
275
+ method: 'POST',
276
+ headers: { 'xi-api-key': settings.elevenlabsKey, 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ text, model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.75 } })
278
+ });
279
+ if (!resp.ok) throw new Error(`ElevenLabs ${resp.status}`);
280
+ const url = URL.createObjectURL(await resp.blob());
281
+ await new Promise((res, rej) => { const a = new Audio(url); a.onended = res; a.onerror = rej; a.play().catch(rej); });
282
+ URL.revokeObjectURL(url);
283
+ }
284
+
285
  async function speakText(text, pKey) {
286
  const p = PERSONALITIES[pKey];
287
+ const hasUserOverride = !!voiceOverrides[pKey]?.eleven;
288
+ const voiceIds = hasUserOverride ? [voiceOverrides[pKey].eleven] : (p.elevenVoices || []);
289
+
290
+ if (settings.elevenlabsKey && voiceIds.length > 0) {
291
+ // Try cached voice first (Python's working_voice_cache behaviour)
292
+ if (!hasUserOverride && workingVoiceCache[pKey]) {
293
+ try {
294
+ await _playElevenAudio(text, workingVoiceCache[pKey]);
 
 
 
 
295
  return;
296
+ } catch {
297
+ delete workingVoiceCache[pKey];
298
  }
299
+ }
300
+ // Try each voice in order until one works, then cache it
301
+ for (const voiceId of voiceIds) {
302
+ try {
303
+ await _playElevenAudio(text, voiceId);
304
+ if (!hasUserOverride) workingVoiceCache[pKey] = voiceId;
305
+ return;
306
+ } catch { /* try next voice */ }
307
+ }
308
  }
309
+ // Web Speech API fallback (replaces Edge TTS)
310
  const utt = new SpeechSynthesisUtterance(text);
311
  utt.lang = p.lang || 'en-US';
312
+ const voiceName = voiceOverrides[pKey]?.edge || p.webVoice;
313
+ if (voiceName) {
314
+ const voices = speechSynthesis.getVoices();
315
+ const v = voices.find(v => v.name.includes(voiceName));
316
+ if (v) utt.voice = v;
317
+ }
318
  speechSynthesis.speak(utt);
319
  }
320
 
 
324
  isAnimating = true;
325
  offenseCount++;
326
  totalBusts++;
327
+ // Capture streak duration before breaking it (matches Python's exact-moment capture)
328
+ if (streakStart) {
329
+ const streakSecs = Math.floor((Date.now() - streakStart) / 1000);
330
+ if (streakSecs > longestStreak) longestStreak = streakSecs;
331
+ }
332
  streakStart = null;
333
  updateStats();
334
 
 
340
  await Promise.all([anim(), playEmotion(emotion)]);
341
  setResponseText(`😑 *${emotion}*`);
342
  } else {
343
+ // Fetch LLM text, start speech (fire-and-forget like Python's non-blocking play_sound),
344
+ // then await only the animation β€” isAnimating clears after animation, not after audio
345
+ const text = await getLLMResponse(pKey, true, offenseCount);
346
  setResponseText(`😀 "${text}"`);
347
  speakText(text, pKey);
348
+ await anim();
349
  }
350
  isAnimating = false;
351
+ startIdleIfNeeded();
352
  }
353
 
354
  async function handlePhonePutdown() {
355
  if (!settings.praiseEnabled) return;
356
+ if (isAnimating) return; // Python processes events sequentially; skip if already animating
357
  isAnimating = true;
358
  streakStart = Date.now();
359
  updateStats();
 
365
  await Promise.all([approvingNod(), playEmotion(emotion)]);
366
  setResponseText(`✨ *${emotion}*`);
367
  } else {
368
+ const text = await getLLMResponse(pKey, false, offenseCount);
369
  setResponseText(`βœ… "${text}"`);
370
  speakText(text, pKey);
371
+ await approvingNod();
372
  }
373
  isAnimating = false;
374
+ startIdleIfNeeded();
375
  }
376
 
377
  // ─── Detection loop (on WebRTC video) ────────────────────────────────────────
378
  async function initModel() {
379
+ const label = isMobile ? 'YOLO26n (mobile)' : 'YOLO26m';
380
+ setLoaderText(`Loading ${label} model...`);
381
  showLoader(true);
382
+ rvModel = await AutoModel.from_pretrained(`onnx-community/${RV_MODEL_NAME}`, { device: 'webgpu', dtype: 'fp16' });
383
  setLoaderText('Loading processor...');
384
+ rvProcessor = await AutoProcessor.from_pretrained(`onnx-community/${RV_MODEL_NAME}`);
385
  showLoader(false);
386
  }
387
 
 
390
  const canvas = document.getElementById('rv-canvas');
391
  if (!video.videoWidth) return;
392
 
393
+ // Sync display canvas to video size
394
  canvas.width = video.videoWidth;
395
  canvas.height = video.videoHeight;
396
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
397
 
398
+ // Sync offscreen canvas, then scale down to smallCanvas β€” reused each frame like demo.js
399
+ rvOffscreen.width = video.videoWidth;
400
+ rvOffscreen.height = video.videoHeight;
401
+ rvOffscreenCtx.drawImage(video, 0, 0);
402
+
403
  const targetW = 256;
404
  const targetH = Math.round((targetW / video.videoWidth) * video.videoHeight);
405
+ if (rvSmallCanvas.width !== targetW || rvSmallCanvas.height !== targetH) {
406
+ rvSmallCanvas.width = targetW;
407
+ rvSmallCanvas.height = targetH;
408
+ }
409
+ rvSmallCtx.drawImage(rvOffscreen, 0, 0, targetW, targetH);
410
 
411
+ const inputs = await rvProcessor(RawImage.fromCanvas(rvSmallCanvas));
412
  const output = await rvModel(inputs);
413
  const scores = output.logits.sigmoid().data;
414
  const boxes = output.pred_boxes.data;
415
+ const confThreshold = lastPhoneBox ? 0.2 : 0.5;
416
 
417
  let best = null, bestScore = 0;
418
  for (let i = 0; i < 300; i++) {
 
602
  document.getElementById('rv-voice-edge').value = override.edge || '';
603
  document.getElementById('rv-voice-eleven-hint').textContent =
604
  p.elevenVoices?.[0] ? `Default: ${p.elevenVoices[0]}` : '';
605
+ document.getElementById('rv-voice-edge-hint').textContent =
606
+ p.defaultEdgeVoice ? `Default: ${p.defaultEdgeVoice} (partial name also works, e.g. "${p.webVoice}")` : 'Leave empty for browser default';
607
  document.getElementById('rv-voices-modal').style.display = '';
608
  }
609
 
 
630
  const mode = n.includes('sim') || n.includes('mockup') ? 'πŸ–₯ Simulation'
631
  : n.includes('wireless') || n.includes('wifi') ? 'πŸ“‘ Wireless'
632
  : 'πŸ”Œ Lite';
633
+ const sim = n.includes('sim') || n.includes('mockup');
634
  const btn = document.createElement('button');
635
  btn.className = 'rv-robot-btn';
636
  btn.innerHTML = `πŸ€– <strong>${name}</strong><span class="rv-robot-mode">${mode}</span>`;
 
638
  list.querySelectorAll('.rv-robot-btn').forEach(b => b.classList.remove('selected'));
639
  btn.classList.add('selected');
640
  selectedRobotId = r.id;
641
+ isSimulation = sim;
642
  connectBtn.style.display = '';
643
  connectBtn.onclick = () => startSession(selectedRobotId);
644
  });
 
653
  if (placeholder) placeholder.style.display = 'none';
654
  populatePersonalities();
655
  startStreakTimer();
656
+ startIdleIfNeeded();
657
  if (!rvModel) await initModel();
658
  });
659
 
 
661
  isStreaming = false;
662
  stopMonitoring();
663
  if (detachVideo) { detachVideo(); detachVideo = null; }
664
+ if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; }
665
+ const videoEl = document.getElementById('rv-video');
666
+ if (videoEl) videoEl.srcObject = null;
667
  const placeholder = document.getElementById('rv-video-placeholder');
668
  if (placeholder) placeholder.style.display = '';
669
  showRVState('rv-picker');
 
680
  if (!robot || robot.state !== 'connected') return;
681
  try {
682
  const videoEl = document.getElementById('rv-video');
683
+ if (isSimulation) {
684
+ webcamStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
685
+ videoEl.srcObject = webcamStream;
686
+ } else {
687
+ detachVideo = robot.attachVideo(videoEl);
688
+ }
689
  await robot.startSession(robotId);
690
  } catch (e) {
691
  console.error('startSession failed:', e);
692
  if (detachVideo) { detachVideo(); detachVideo = null; }
693
+ if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; }
694
  }
695
  }
696
 
 
698
  isMonitoring = false;
699
  if (rvAnimId) { cancelAnimationFrame(rvAnimId); rvAnimId = null; }
700
  clearInterval(streakInterval);
701
+
702
+ // Freeze current streak so Continue Monitoring can restore it
703
+ frozenStreak = streakStart ? Math.floor((Date.now() - streakStart) / 1000) : 0;
704
+ hasPreviousSession = (totalBusts > 0 || offenseCount > 0 || frozenStreak > 0);
705
+
706
  const btn = document.getElementById('rv-toggle-btn');
707
+ if (btn) {
708
+ const label = hasPreviousSession ? '▢️ Continue Monitoring' : '▢️ Start Monitoring';
709
+ btn.innerHTML = label;
710
+ btn.classList.replace('btn-danger', 'btn-primary');
711
+ }
712
  const dot = document.getElementById('rv-detect-dot');
713
  const txt = document.getElementById('rv-detect-text');
714
  if (dot) dot.className = 'status-dot';
715
  if (txt) txt.textContent = 'Monitoring off';
716
+
717
+ const resetSection = document.getElementById('rv-reset-section');
718
+ if (resetSection) resetSection.style.display = hasPreviousSession ? '' : 'none';
719
  }
720
 
721
  // ─── Event listeners ──────────────────────────────────────────────────────────
 
742
  if (isMonitoring) {
743
  btn.innerHTML = 'πŸ›‘ Stop Monitoring';
744
  btn.classList.replace('btn-primary','btn-danger');
745
+ phoneVisible = false; consecutivePhone = 0; consecutiveNoPhone = 0;
746
  lastPhoneBox = null; framesWithoutDetection = 0; lastReactionTime = 0;
747
+
748
+ if (hasPreviousSession) {
749
+ // Restore frozen state
750
+ streakStart = Date.now() - frozenStreak * 1000;
751
+ } else {
752
+ // Fresh start β€” reset everything
753
+ offenseCount = 0; totalBusts = 0; longestStreak = 0;
754
+ streakStart = Date.now();
755
+ }
756
+ hasPreviousSession = false; frozenStreak = 0;
757
+ updateStats();
758
  document.getElementById('rv-detect-dot').className = 'status-dot monitoring';
759
  document.getElementById('rv-detect-text').textContent = 'βœ… Phone-free';
760
+ document.getElementById('rv-reset-section').style.display = 'none';
761
  detectionLoop();
762
+ startIdleIfNeeded();
763
  } else {
764
  stopMonitoring();
765
  }
 
773
  // Reset button
774
  document.getElementById('rv-reset-btn').addEventListener('click', () => {
775
  totalBusts = 0; offenseCount = 0; longestStreak = 0; streakStart = Date.now();
776
+ hasPreviousSession = false; frozenStreak = 0;
777
  updateStats();
778
  document.getElementById('rv-stat-streak').textContent = '0s';
779
  document.getElementById('rv-stat-longest').textContent = '0s';
780
  document.getElementById('rv-reset-section').style.display = 'none';
781
+ const btn = document.getElementById('rv-toggle-btn');
782
+ if (btn && !isMonitoring) btn.innerHTML = '▢️ Start Monitoring';
783
  setResponseText('Stats reset. Start monitoring to begin.');
784
  });
785
 
786
  // Disconnect
787
  document.getElementById('rv-disconnect-btn').addEventListener('click', async () => {
788
  stopMonitoring();
789
+ if (detachVideo) { detachVideo(); detachVideo = null; }
790
+ if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; }
791
+ const videoEl = document.getElementById('rv-video');
792
+ if (videoEl) videoEl.srcObject = null;
793
  if (robot) { await robot.stopSession?.(); await robot.disconnect(); robot = null; }
794
  isStreaming = false;
795
  showRVState('rv-signin');