nextmarte Claude Opus 4.8 commited on
Commit
19657cc
·
1 Parent(s): 2bdcfe3

live mode: stop self-hearing, prioritize user questions, kill repetition/hallucination

Browse files

- pause speech recognition while Iris speaks (no more transcribing its own TTS)
- a spoken question mutes ambient alerts for 12s (the question always wins)
- 5s cooldown after each alert -> a listening gap so questions can land
- client-side near-duplicate dedup (Jaccard >= 0.5) drops repeated alerts
- live alerts fire ONLY on a newly-arrived person; objects/text/colors are
answered on demand (accurate), not auto-narrated (the small VLM hallucinates)
- add run.sh local launcher (frees the port, warmup on)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +27 -13
  2. frontend/app.js +67 -47
app.py CHANGED
@@ -96,18 +96,18 @@ def describe(image: FileData, audio: FileData | None = None, lang: str = "pt",
96
 
97
  # Live mode: describe ONLY new/relevant things, sparingly (avoid verbosity).
98
  WATCH_PT = (
99
- "Você observa o ambiente para uma pessoa cega, com PARCIMÔNIA. "
100
- "Coisas DITAS recentemente (não repita nenhuma): \"{prev}\". "
101
- "Olhe a cena AGORA. Se algo NOVO e relevante apareceuque ainda NÃO foi dito "
102
- "(uma pessoa, um objeto trazido, um texto, um obstáculo próximo) — diga em UMA "
103
- "frase curta o que é. Se nada de novo relevante apareceu, responda APENAS: NADA."
104
  )
105
  WATCH_EN = (
106
- "You watch the environment for a blind person, SPARINGLY. "
107
- "Already said recently (do not repeat any): \"{prev}\". "
108
- "Look at the scene NOW. If something NEW and relevant appeared — not already said "
109
- "(a person, an object, text, a nearby obstacle) — say it in ONE short sentence. "
110
- "If nothing new and relevant appeared, reply ONLY the word: NONE."
111
  )
112
  _QUIET = {"nada", "none", "nenhum", "nothing", "sem novidade", "no change"}
113
 
@@ -118,15 +118,29 @@ def is_quiet(answer: str) -> bool:
118
  return (not low) or low in _QUIET
119
 
120
 
 
 
 
 
 
 
 
 
121
  @app.api(name="watch")
122
- def watch(image: FileData, prev: str = "", lang: str = "pt") -> dict:
123
- """Live mode. First call (no history) describes the scene as a baseline;
124
- after that, speak ONLY when something new/relevant entered (else stay quiet)."""
 
125
  t0 = time.time()
126
  prev = (prev or "").strip()
 
127
  if not prev:
128
  # baseline: describe the current scene so the user hears the surroundings
129
  answer = vlm.describe(_path(image), lang=lang)
 
 
 
 
130
  else:
131
  tmpl = WATCH_EN if lang == "en" else WATCH_PT
132
  sys = tmpl.format(prev=prev)
 
96
 
97
  # Live mode: describe ONLY new/relevant things, sparingly (avoid verbosity).
98
  WATCH_PT = (
99
+ "Você observa o ambiente para uma pessoa cega. "
100
+ "Coisas ditas (não repita): \"{prev}\". "
101
+ "Olhe a cena AGORA. Se apareceu QUALQUER objeto ou pessoa novo mesmo pequeno "
102
+ "(óculos, celular, copo, papel, comida) — diga em UMA frase curta o que é. "
103
+ " responda NADA se a cena estiver basicamente igual ao que foi dito."
104
  )
105
  WATCH_EN = (
106
+ "You watch the environment for a blind person. "
107
+ "Already said (do not repeat): \"{prev}\". "
108
+ "Look at the scene NOW. If ANY new object or person appeared — even small "
109
+ "(glasses, a phone, a cup, paper, food) — say what it is in ONE short sentence. "
110
+ "Only reply NONE if the scene is basically the same as what was already said."
111
  )
112
  _QUIET = {"nada", "none", "nenhum", "nothing", "sem novidade", "no change"}
113
 
 
118
  return (not low) or low in _QUIET
119
 
120
 
121
+ HINT_PT = ("Uma pessoa entrou no campo de visão. Em UMA frase de no máximo 12 palavras, "
122
+ "avise de forma útil para um cego (ex.: 'Há alguém à sua frente.'). Descreva só o "
123
+ "que você vê com CERTEZA; não invente; não repita o que já foi dito: \"{prev}\".")
124
+ HINT_EN = ("A person entered the field of view. In ONE sentence of at most 12 words, alert a "
125
+ "blind person usefully (e.g. 'Someone is in front of you.'). Describe only what you "
126
+ "see for CERTAIN; do not invent; do not repeat what was already said: \"{prev}\".")
127
+
128
+
129
  @app.api(name="watch")
130
+ def watch(image: FileData, prev: str = "", lang: str = "pt", hint: str = "") -> dict:
131
+ """Live mode. First call (no history) describes the scene as a baseline. When the
132
+ in-browser detector flags a new object (`hint`), describe it (trust the gate).
133
+ Otherwise (pixel-diff fallback) speak ONLY if something new entered, else stay quiet."""
134
  t0 = time.time()
135
  prev = (prev or "").strip()
136
+ hint = (hint or "").strip()
137
  if not prev:
138
  # baseline: describe the current scene so the user hears the surroundings
139
  answer = vlm.describe(_path(image), lang=lang)
140
+ elif hint:
141
+ # the detector already decided something new is here -> just describe it
142
+ tmpl = HINT_EN if lang == "en" else HINT_PT
143
+ answer = vlm.describe(_path(image), lang=lang, system=tmpl.format(hint=hint, prev=prev))
144
  else:
145
  tmpl = WATCH_EN if lang == "en" else WATCH_PT
146
  sys = tmpl.format(prev=prev)
frontend/app.js CHANGED
@@ -135,7 +135,7 @@ async function send(frame, audio, qtext = "") {
135
  let url = a && a.url;
136
  if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
137
  if (url) {
138
- srMuted = true;
139
  player.src = url;
140
  try { await player.play(); } catch (err) { console.error("play:", err); }
141
  } else { resetSoon(); }
@@ -147,21 +147,26 @@ async function send(frame, audio, qtext = "") {
147
  busy = false;
148
  }
149
  }
150
- function resetSoon() { setTimeout(() => { srMuted = false; if (!busy) setState("", t("idle")); }, 600); }
151
- player.addEventListener("ended", () => { srMuted = false; if (!busy) setState("", t("idle")); });
152
 
153
  // ---- first-run onboarding: default to the browser language, speak it, offer to switch ----
154
  function onboard() {
155
- onboarded = true; mode = "normal";
156
- try { localStorage.setItem("iris_onboarded", "1"); } catch (e) {}
157
- hintEl.textContent = t("hint");
158
- updateLabels();
159
  const u = new SpeechSynthesisUtterance(t("welcomeAsk"));
160
  u.lang = lang === "pt" ? "pt-BR" : "en-US";
 
 
 
 
 
 
 
 
 
161
  u.onend = listenForLangChoice; // after the welcome, listen for a language switch
162
- setState("speaking", t("idle"));
163
  try { speechSynthesis.cancel(); speechSynthesis.speak(u); }
164
- catch (e) { listenForLangChoice(); }
165
  }
166
 
167
  function listenForLangChoice() {
@@ -203,9 +208,11 @@ function handleCommand(cmd) {
203
  if (cmd === "live_on") setLive(true);
204
  else if (cmd === "live_off") setLive(false);
205
  }
206
- const LIVE_INTERVAL = 2500; // check the scene every 2.5s
207
- const LIVE_THRESHOLD = 14; // mean per-pixel change (0-255) to trigger the VLM
 
208
  let lastSig = null, history = []; // recent descriptions (anti-repetition)
 
209
 
210
  // in-browser object detection gates the live mode (semantic change, not pixels).
211
  // Falls back to the cheap pixel-diff if the detector can't load.
@@ -213,6 +220,7 @@ let detector = null, detectorTried = false;
213
  let seenClasses = new Map(); // class name -> last tick index it was seen
214
  let tickN = 0;
215
  const CLASS_TTL = 4; // ticks a class is remembered before it can re-trigger
 
216
 
217
  async function loadDetector() {
218
  if (detector || detectorTried || !window.cocoSsd) return;
@@ -244,7 +252,7 @@ function setLive(on) {
244
  }
245
 
246
  // ---- hands-free continuous listening (live mode) via Web Speech API ----
247
- let recog = null, listening = false, srMuted = false;
248
  function setupRecognition() {
249
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
250
  if (!SR) { console.warn("SpeechRecognition not supported in this browser"); return null; }
@@ -252,11 +260,11 @@ function setupRecognition() {
252
  r.continuous = true;
253
  r.interimResults = false;
254
  r.onresult = (e) => {
255
- if (srMuted || busy) return;
256
  const txt = e.results[e.results.length - 1][0].transcript.trim();
257
  if (txt) onVoice(txt);
258
  };
259
- r.onend = () => { if (listening) { try { r.start(); } catch (e) {} } }; // keep alive
260
  r.onerror = (e) => { console.log("SR:", e.error); };
261
  return r;
262
  }
@@ -271,8 +279,12 @@ function stopListening() {
271
  listening = false;
272
  if (recog) { try { recog.stop(); } catch (e) {} }
273
  }
274
- async function onVoice(txt) {
275
- if (busy) return;
 
 
 
 
276
  const frame = await grabFrame();
277
  if (frame) send(frame, null, txt); // text question (from browser speech recognition)
278
  }
@@ -297,52 +309,59 @@ function changeAmount(a, b) {
297
 
298
  async function liveTick() {
299
  if (busy || mode !== "normal" || !liveOn || !player.paused) return;
 
 
300
  tickN++;
301
 
302
- // semantic gate: only wake the VLM when a NEW object/person class appears
303
- if (detector && cam.videoWidth) {
304
- let preds = null;
305
- try { preds = await detector.detect(cam, 10); } catch (e) {}
306
- if (preds) {
307
- let novel = false;
308
- for (const p of preds) {
309
- if (p.score < 0.55) continue;
310
- const last = seenClasses.get(p.class);
311
- if (last === undefined || tickN - last > CLASS_TTL) novel = true;
312
- seenClasses.set(p.class, tickN);
313
- }
314
- for (const [c, n] of seenClasses) if (tickN - n > CLASS_TTL) seenClasses.delete(c);
315
- if (novel) {
316
- const frame = await grabFrame();
317
- if (frame) sendWatch(frame);
318
- }
319
- return;
320
  }
 
 
 
 
 
 
321
  }
 
322
 
323
- // fallback: cheap pixel-diff gate
324
- const sig = frameSignature();
325
- if (!sig) return;
326
- if (lastSig && changeAmount(lastSig, sig) < LIVE_THRESHOLD) return; // static scene -> stay quiet
327
- lastSig = sig;
328
- const frame = await grabFrame();
329
- if (frame) sendWatch(frame);
 
 
 
 
330
  }
331
 
332
- async function sendWatch(frame) {
333
  if (busy) return;
334
  busy = true;
335
  try {
336
- const result = await client.predict("/watch", { image: handle_file(frame), prev: history.join(" · "), lang });
337
  const out = Array.isArray(result.data) ? result.data[0] : result.data;
338
- if (out && out.speak) {
339
- if (out.answer) { history.push(out.answer); if (history.length > 5) history.shift(); }
340
- answerEl.textContent = out.answer || "";
341
  setState("speaking", "");
342
  const a = out.audio;
343
  let url = a && a.url;
344
  if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
345
- if (url) { srMuted = true; player.src = url; try { await player.play(); } catch (e) { console.error("play:", e); } }
346
  }
347
  } catch (e) { console.error("watch:", e); }
348
  finally { busy = false; }
@@ -466,6 +485,7 @@ function updateLabels() {
466
  updateLabels();
467
  hintEl.textContent = onboarded ? t("hint") : "";
468
  setState("", onboarded ? t("idle") : t("startTap"));
 
469
  await startCamera();
470
  loadDetector(); // preload the object detector in the background (non-blocking)
471
  try { client = await Client.connect(window.location.origin); console.log("Iris connected"); }
 
135
  let url = a && a.url;
136
  if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
137
  if (url) {
138
+ pauseSR();
139
  player.src = url;
140
  try { await player.play(); } catch (err) { console.error("play:", err); }
141
  } else { resetSoon(); }
 
147
  busy = false;
148
  }
149
  }
150
+ function resetSoon() { setTimeout(() => { resumeSR(); if (!busy) setState("", t("idle")); }, 600); }
151
+ player.addEventListener("ended", () => { setTimeout(resumeSR, 700); if (!busy) setState("", t("idle")); });
152
 
153
  // ---- first-run onboarding: default to the browser language, speak it, offer to switch ----
154
  function onboard() {
155
+ if (onboarded) return;
 
 
 
156
  const u = new SpeechSynthesisUtterance(t("welcomeAsk"));
157
  u.lang = lang === "pt" ? "pt-BR" : "en-US";
158
+ // mark onboarded only when speech actually STARTS — so if the browser blocks
159
+ // autoplay on load, the mode stays "onboarding" and the first tap triggers it.
160
+ u.onstart = () => {
161
+ onboarded = true; mode = "normal";
162
+ try { localStorage.setItem("iris_onboarded", "1"); } catch (e) {}
163
+ hintEl.textContent = t("hint");
164
+ updateLabels();
165
+ setState("speaking", t("idle"));
166
+ };
167
  u.onend = listenForLangChoice; // after the welcome, listen for a language switch
 
168
  try { speechSynthesis.cancel(); speechSynthesis.speak(u); }
169
+ catch (e) { /* will retry on the first tap */ }
170
  }
171
 
172
  function listenForLangChoice() {
 
208
  if (cmd === "live_on") setLive(true);
209
  else if (cmd === "live_off") setLive(false);
210
  }
211
+ const LIVE_INTERVAL = 3500; // check the scene every 3.5s
212
+ const LIVE_THRESHOLD = 15; // mean per-pixel change (0-255) to trigger the VLM
213
+ const LIVE_COOLDOWN = 5000; // quiet window after an auto-alert -> gap for the user to ask
214
  let lastSig = null, history = []; // recent descriptions (anti-repetition)
215
+ let suppressLiveUntil = 0; // pause auto-alerts after an alert OR a user question (their question wins)
216
 
217
  // in-browser object detection gates the live mode (semantic change, not pixels).
218
  // Falls back to the cheap pixel-diff if the detector can't load.
 
220
  let seenClasses = new Map(); // class name -> last tick index it was seen
221
  let tickN = 0;
222
  const CLASS_TTL = 4; // ticks a class is remembered before it can re-trigger
223
+ const PERSON_GONE = 4; // ticks a person must be absent before a re-entry counts as new (~14s)
224
 
225
  async function loadDetector() {
226
  if (detector || detectorTried || !window.cocoSsd) return;
 
252
  }
253
 
254
  // ---- hands-free continuous listening (live mode) via Web Speech API ----
255
+ let recog = null, listening = false, srPaused = false;
256
  function setupRecognition() {
257
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
258
  if (!SR) { console.warn("SpeechRecognition not supported in this browser"); return null; }
 
260
  r.continuous = true;
261
  r.interimResults = false;
262
  r.onresult = (e) => {
263
+ if (srPaused || busy) return;
264
  const txt = e.results[e.results.length - 1][0].transcript.trim();
265
  if (txt) onVoice(txt);
266
  };
267
+ r.onend = () => { if (listening && !srPaused) { try { r.start(); } catch (e) {} } }; // keep alive
268
  r.onerror = (e) => { console.log("SR:", e.error); };
269
  return r;
270
  }
 
279
  listening = false;
280
  if (recog) { try { recog.stop(); } catch (e) {} }
281
  }
282
+ // stop the recognizer WHILE Iris speaks (so it never transcribes its own voice)
283
+ function pauseSR() { srPaused = true; if (recog) { try { recog.stop(); } catch (e) {} } }
284
+ function resumeSR() { srPaused = false; if (listening && recog) { try { recog.start(); } catch (e) {} } }
285
+ async function onVoice(txt, tries = 0) {
286
+ suppressLiveUntil = performance.now() + 12000; // the user's question wins: mute auto-alerts for a while
287
+ if (busy) { if (tries < 3) setTimeout(() => onVoice(txt, tries + 1), 700); return; } // retry if a watch is in flight
288
  const frame = await grabFrame();
289
  if (frame) send(frame, null, txt); // text question (from browser speech recognition)
290
  }
 
309
 
310
  async function liveTick() {
311
  if (busy || mode !== "normal" || !liveOn || !player.paused) return;
312
+ if (performance.now() < suppressLiveUntil) return; // user just asked / we just spoke -> stay quiet, let them talk
313
+ if (!detector || !cam.videoWidth) return; // alerts are person-arrival only -> need the detector
314
  tickN++;
315
 
316
+ // Live alerts fire ONLY when a NEW person enters (per the spec: rare ambient alerts, not narration).
317
+ // Re-describing objects/movement makes the small VLM hallucinate and repeat; objects/text/colors are
318
+ // answered on demand (the user asks), which is accurate. So we gate strictly on a fresh "person".
319
+ let personNew = false;
320
+ try {
321
+ const preds = await detector.detect(cam, 5);
322
+ let hasPerson = false;
323
+ for (const p of preds) if (p.class === "person" && p.score >= 0.6) hasPerson = true;
324
+ if (hasPerson) {
325
+ const last = seenClasses.get("person");
326
+ if (last === undefined || tickN - last > PERSON_GONE) personNew = true; // absent a while -> a real arrival
327
+ seenClasses.set("person", tickN);
 
 
 
 
 
 
328
  }
329
+ } catch (e) { return; }
330
+
331
+ if (personNew) {
332
+ suppressLiveUntil = performance.now() + LIVE_COOLDOWN; // one alert, then a quiet gap to ask
333
+ const frame = await grabFrame();
334
+ if (frame) sendWatch(frame, "person");
335
  }
336
+ }
337
 
338
+ // word-set similarity (accent-insensitive) — the model often re-emits a near-identical
339
+ // line, so we drop it client-side instead of trusting the "do not repeat" prompt.
340
+ function words(s) {
341
+ return (s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
342
+ .replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((w) => w.length > 2); // NFD + strip diacritics
343
+ }
344
+ function tooSimilar(a, b) {
345
+ const A = new Set(words(a)), B = new Set(words(b));
346
+ if (!A.size || !B.size) return false;
347
+ let inter = 0; for (const w of A) if (B.has(w)) inter++;
348
+ return inter / (A.size + B.size - inter) >= 0.5; // Jaccard >= 0.5 -> basically the same alert
349
  }
350
 
351
+ async function sendWatch(frame, hint = "") {
352
  if (busy) return;
353
  busy = true;
354
  try {
355
+ const result = await client.predict("/watch", { image: handle_file(frame), prev: history.join(" · "), lang, hint });
356
  const out = Array.isArray(result.data) ? result.data[0] : result.data;
357
+ if (out && out.speak && out.answer && !history.some((h) => tooSimilar(h, out.answer))) {
358
+ history.push(out.answer); if (history.length > 5) history.shift();
359
+ answerEl.textContent = out.answer;
360
  setState("speaking", "");
361
  const a = out.audio;
362
  let url = a && a.url;
363
  if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
364
+ if (url) { pauseSR(); player.src = url; try { await player.play(); } catch (e) { console.error("play:", e); } }
365
  }
366
  } catch (e) { console.error("watch:", e); }
367
  finally { busy = false; }
 
485
  updateLabels();
486
  hintEl.textContent = onboarded ? t("hint") : "";
487
  setState("", onboarded ? t("idle") : t("startTap"));
488
+ if (!onboarded) onboard(); // try to speak the welcome on load (works where the browser allows it)
489
  await startCamera();
490
  loadDetector(); // preload the object detector in the background (non-blocking)
491
  try { client = await Client.connect(window.location.origin); console.log("Iris connected"); }