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>
- app.py +27 -13
- 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
|
| 100 |
-
"Coisas
|
| 101 |
-
"Olhe a cena AGORA. Se
|
| 102 |
-
"(
|
| 103 |
-
"
|
| 104 |
)
|
| 105 |
WATCH_EN = (
|
| 106 |
-
"You watch the environment for a blind person
|
| 107 |
-
"Already said
|
| 108 |
-
"Look at the scene NOW. If
|
| 109 |
-
"(a
|
| 110 |
-
"
|
| 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 |
-
|
|
|
|
| 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 já 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 |
+
"Só responda NADA se a cena estiver basicamente igual ao que já 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 |
-
|
| 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(() => {
|
| 151 |
-
player.addEventListener("ended", () => {
|
| 152 |
|
| 153 |
// ---- first-run onboarding: default to the browser language, speak it, offer to switch ----
|
| 154 |
function onboard() {
|
| 155 |
-
onboarded
|
| 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) {
|
| 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 =
|
| 207 |
-
const LIVE_THRESHOLD =
|
|
|
|
| 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,
|
| 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 (
|
| 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 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 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 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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) {
|
| 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"); }
|