Marcus Ramalho Claude Opus 4.8 commited on
Commit ·
df6b3ac
1
Parent(s): 26dae50
Iris: hands-free live mode, money/bill reading, accessible UI, Qwen3-VL-2B
Browse files- live mode: visual-change watch (parsimonious) + continuous hands-free listening (Web Speech) + baseline scene description; double-tap or voice toggle
- voice commands robust to STT errors (fuzzy: 'modo ao fio' -> live_on)
- reads money/bills/medicine (prompt-tuned) ; EN/PT throughout incl. TTS voices
- accessible custom UI: SVG icons, big labelled buttons, high-contrast/larger-text, focus rings, ARIA, haptics
- language chosen by voice on first run
- swapped VLM to Qwen3-VL-2B (lighter, faster, strong OCR; all models <=4B -> Tiny Titan)
- code/comments/docstrings in English; polished README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README.md +50 -20
- app.py +106 -16
- core/gpu.py +5 -5
- core/stt.py +16 -4
- core/tts.py +17 -15
- core/vlm.py +53 -55
- frontend/app.js +305 -43
- frontend/index.html +39 -9
- frontend/style.css +86 -68
README.md
CHANGED
|
@@ -6,38 +6,68 @@ colorTo: orange
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
app_file: app.py
|
| 9 |
-
pinned:
|
| 10 |
license: apache-2.0
|
| 11 |
-
short_description: Your father's eyes, by voice. Describe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# Iris — your father's eyes, by voice
|
| 15 |
|
| 16 |
-
|
| 17 |
-
phone, point the camera, **tap to describe** what's in front of you or **hold to
|
| 18 |
-
ask a question** ("what color is this?", "read this label", "is anyone here?").
|
| 19 |
-
It answers out loud, in Portuguese or English.
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
##
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
## Not a mobility aid
|
| 35 |
-
Iris describes the environment and reads text. It is **not** an obstacle-avoidance
|
| 36 |
-
|
| 37 |
|
| 38 |
## Run locally
|
| 39 |
```bash
|
| 40 |
pip install -r requirements.txt
|
| 41 |
-
python app.py
|
| 42 |
-
# IRIS_WARMUP=1 pré-carrega os modelos
|
| 43 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
license: apache-2.0
|
| 11 |
+
short_description: Your father's eyes, by voice. Describe, read money & bills, ask.
|
| 12 |
+
tags:
|
| 13 |
+
- backyard-ai
|
| 14 |
+
- tiny-titan
|
| 15 |
+
- off-brand
|
| 16 |
+
- best-demo
|
| 17 |
+
- off-grid
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# 👁️ Iris — your father's eyes, by voice
|
| 21 |
|
| 22 |
+
> Built for the **Build Small Hackathon** · **Backyard AI** track · for my father, who is blind.
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
**Demo video:** _‹add link›_ · **Social post:** _‹add link›_
|
| 25 |
|
| 26 |
+
Iris is a voice-first assistant for blind and low-vision people. Open it on a phone,
|
| 27 |
+
point the camera, and Iris tells you what's around you — out loud, in your language.
|
| 28 |
+
There are no menus to find and no small buttons to hunt for: **the whole screen is
|
| 29 |
+
the button**, and in live mode Iris simply listens.
|
| 30 |
|
| 31 |
+
## What it does
|
| 32 |
+
- 👁️ **Describe** — tap anywhere: *"A table ahead with a mug on the right."*
|
| 33 |
+
- 🎤 **Ask, hands-free** — in live mode just speak: *"what color is this shirt?"*, *"read this label"*, *"is anyone here?"*
|
| 34 |
+
- 💵 **Read money & bills** — *"how much do I have?"* counts the banknotes; point at an electricity bill and it reads the **amount and due date**.
|
| 35 |
+
- 💊 **Read medicine** — reads the dose and instructions on a box, exactly as written.
|
| 36 |
+
- 📡 **Live mode** — double-tap (or say *"live mode"*): Iris describes the scene once, then **sparingly** announces *new* things that enter — without chattering.
|
| 37 |
|
| 38 |
+
## How to use it
|
| 39 |
+
- **Tap** anywhere → describe what's in front of you.
|
| 40 |
+
- **Hold** → ask a question (release to send).
|
| 41 |
+
- **Double-tap** → toggle live mode (hands-free listening + new-thing alerts). Say *"stop"* to turn it off.
|
| 42 |
+
- First run: **choose your language by voice** ("say your language"). Language & accessibility toggles sit in the top corners.
|
| 43 |
+
|
| 44 |
+
## Accessibility, treated as a first-class citizen
|
| 45 |
+
- Camera turns on by itself; **tap anywhere** to act — no precise targets.
|
| 46 |
+
- **Hands-free** continuous listening in live mode.
|
| 47 |
+
- Big, labelled buttons for low vision + a **high-contrast / larger-text** toggle.
|
| 48 |
+
- Keyboard focus rings, ARIA labels, haptic feedback; respects the OS *reduced-motion* and *more-contrast* settings.
|
| 49 |
+
|
| 50 |
+
## How it works — small models only, ≤ 32B total
|
| 51 |
+
| Stage | Model | Params |
|
| 52 |
+
|---|---|---|
|
| 53 |
+
| Speech-to-text | Whisper small (faster-whisper) | ~0.24B |
|
| 54 |
+
| Vision-language | **Qwen3-VL-2B-Instruct** | ~2B |
|
| 55 |
+
| Text-to-speech | Piper (pt_BR / en_US) | <1B |
|
| 56 |
+
|
| 57 |
+
**≈ 2.5B total** — comfortably under 32B, and **every model is ≤ 4B** (Tiny Titan).
|
| 58 |
+
Custom voice-first frontend via **`gr.Server`** (Off-Brand). Inference runs in-Space
|
| 59 |
+
on **ZeroGPU** — no third-party model APIs.
|
| 60 |
|
| 61 |
## Not a mobility aid
|
| 62 |
+
Iris describes the environment and reads text. It is **not** an obstacle-avoidance or
|
| 63 |
+
navigation device and must not be relied on for physical safety.
|
| 64 |
|
| 65 |
## Run locally
|
| 66 |
```bash
|
| 67 |
pip install -r requirements.txt
|
| 68 |
+
IRIS_WARMUP=1 python app.py # http://localhost:7860 (warmup preloads the models)
|
|
|
|
| 69 |
```
|
| 70 |
+
|
| 71 |
+
## Credits
|
| 72 |
+
Built by **Marcus Ramalho** for his father.
|
| 73 |
+
STT: OpenAI Whisper (via faster-whisper) · Vision: **Qwen3-VL** · TTS: **Piper** · UI: Gradio (`gr.Server`).
|
app.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
-
"""Iris —
|
| 2 |
|
| 3 |
-
gr.Server:
|
| 4 |
-
API
|
| 5 |
-
Pipeline: Whisper (STT) -> Qwen2.5-VL (descrição/VQA em PT) -> Piper (TTS).
|
| 6 |
"""
|
|
|
|
| 7 |
import os
|
|
|
|
| 8 |
import tempfile
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
|
@@ -22,22 +24,55 @@ app = Server(title="Iris")
|
|
| 22 |
|
| 23 |
|
| 24 |
def _path(f):
|
| 25 |
-
"""gr.Server
|
| 26 |
if f is None:
|
| 27 |
return None
|
| 28 |
return f["path"] if isinstance(f, dict) else f.path
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
@app.api(name="describe")
|
| 32 |
-
def describe(image: FileData, audio: FileData | None = None
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
if not answer.strip():
|
| 39 |
-
answer = "Não consegui descrever isso.
|
| 40 |
-
wav = tts.synthesize(answer)
|
| 41 |
print(f"[describe] q={question!r} a={answer!r}", flush=True)
|
| 42 |
return {
|
| 43 |
"question": question,
|
|
@@ -46,6 +81,61 @@ def describe(image: FileData, audio: FileData | None = None) -> dict:
|
|
| 46 |
}
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
app.mount("/static", StaticFiles(directory=str(HERE / "frontend")), name="static")
|
| 50 |
|
| 51 |
|
|
@@ -58,11 +148,11 @@ if __name__ == "__main__":
|
|
| 58 |
if os.environ.get("IRIS_WARMUP") == "1":
|
| 59 |
print("Warmup...", flush=True)
|
| 60 |
try:
|
| 61 |
-
vlm.describe(str(HERE.parent / "viability" / "samples" / "indoor.jpg"), "
|
| 62 |
-
stt.transcribe(tts.synthesize("
|
| 63 |
print("Warmup OK", flush=True)
|
| 64 |
except Exception as e:
|
| 65 |
-
print("Warmup
|
| 66 |
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860)))
|
| 67 |
app.launch(server_name="0.0.0.0", server_port=port, show_error=True,
|
| 68 |
allowed_paths=[tempfile.gettempdir()])
|
|
|
|
| 1 |
+
"""Iris — your father's eyes, by voice (HF Space, ZeroGPU).
|
| 2 |
|
| 3 |
+
gr.Server: serves a custom voice-first frontend (frontend/index.html) and exposes
|
| 4 |
+
the API endpoints. Pipeline: Whisper (STT) -> Qwen3-VL (description / VQA) -> Piper (TTS).
|
|
|
|
| 5 |
"""
|
| 6 |
+
import difflib
|
| 7 |
import os
|
| 8 |
+
import re
|
| 9 |
import tempfile
|
| 10 |
+
import unicodedata
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def _path(f):
|
| 27 |
+
"""gr.Server delivers FileData as a dict; accept dict or object."""
|
| 28 |
if f is None:
|
| 29 |
return None
|
| 30 |
return f["path"] if isinstance(f, dict) else f.path
|
| 31 |
|
| 32 |
|
| 33 |
+
# voice commands (live mode on/off) — robust to transcription errors (e.g. "modo ao fio")
|
| 34 |
+
_OFF_RE = re.compile(r"\b(pare|parar|para de|deslig\w*|cancela\w*|stop|turn off|"
|
| 35 |
+
r"silenci\w*|quieto|chega|modo manual|manual)\b")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _norm(s: str) -> str:
|
| 39 |
+
s = unicodedata.normalize("NFKD", s.lower())
|
| 40 |
+
s = "".join(c for c in s if not unicodedata.combining(c))
|
| 41 |
+
return s.strip().strip(".!?,").strip()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def detect_command(text: str):
|
| 45 |
+
"""Map a (possibly mis-transcribed) utterance to a live-mode command."""
|
| 46 |
+
if not text:
|
| 47 |
+
return None
|
| 48 |
+
t = _norm(text)
|
| 49 |
+
if _OFF_RE.search(t):
|
| 50 |
+
return "live_off"
|
| 51 |
+
on = ("ao vivo" in t or "ao fio" in t or "modo ao" in t or "descreva sempre" in t
|
| 52 |
+
or "descreva tudo" in t or "modo continuo" in t or "tempo real" in t
|
| 53 |
+
or "live" in t or "automatico" in t
|
| 54 |
+
or difflib.SequenceMatcher(None, t, "modo ao vivo").ratio() >= 0.7)
|
| 55 |
+
return "live_on" if on else None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
@app.api(name="describe")
|
| 59 |
+
def describe(image: FileData, audio: FileData | None = None, lang: str = "pt",
|
| 60 |
+
qtext: str = "") -> dict:
|
| 61 |
+
"""Camera frame + a question (as recorded audio OR as text from browser speech
|
| 62 |
+
recognition) -> a command OR a PT/EN description + audio."""
|
| 63 |
+
if qtext and qtext.strip():
|
| 64 |
+
question = qtext.strip()
|
| 65 |
+
else:
|
| 66 |
+
apath = _path(audio)
|
| 67 |
+
question = stt.transcribe(apath, language=lang) if apath else ""
|
| 68 |
+
cmd = detect_command(question)
|
| 69 |
+
if cmd:
|
| 70 |
+
print(f"[describe] command {cmd} <- {question!r}", flush=True)
|
| 71 |
+
return {"command": cmd, "question": question, "answer": "", "audio": None}
|
| 72 |
+
answer = vlm.describe(_path(image), question, lang=lang)
|
| 73 |
if not answer.strip():
|
| 74 |
+
answer = "I couldn't describe that." if lang == "en" else "Não consegui descrever isso."
|
| 75 |
+
wav = tts.synthesize(answer, lang=lang)
|
| 76 |
print(f"[describe] q={question!r} a={answer!r}", flush=True)
|
| 77 |
return {
|
| 78 |
"question": question,
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
|
| 84 |
+
# Live mode: describe ONLY new/relevant things, sparingly (avoid verbosity).
|
| 85 |
+
WATCH_PT = (
|
| 86 |
+
"Você observa o ambiente para uma pessoa cega, com PARCIMÔNIA. "
|
| 87 |
+
"Coisas JÁ DITAS recentemente (não repita nenhuma): \"{prev}\". "
|
| 88 |
+
"Olhe a cena AGORA. Se algo NOVO e relevante apareceu — que ainda NÃO foi dito "
|
| 89 |
+
"(uma pessoa, um objeto trazido, um texto, um obstáculo próximo) — diga em UMA "
|
| 90 |
+
"frase curta o que é. Se nada de novo relevante apareceu, responda APENAS: NADA."
|
| 91 |
+
)
|
| 92 |
+
WATCH_EN = (
|
| 93 |
+
"You watch the environment for a blind person, SPARINGLY. "
|
| 94 |
+
"Already said recently (do not repeat any): \"{prev}\". "
|
| 95 |
+
"Look at the scene NOW. If something NEW and relevant appeared — not already said "
|
| 96 |
+
"(a person, an object, text, a nearby obstacle) — say it in ONE short sentence. "
|
| 97 |
+
"If nothing new and relevant appeared, reply ONLY the word: NONE."
|
| 98 |
+
)
|
| 99 |
+
_QUIET = {"nada", "none", "nenhum", "nothing", "sem novidade", "no change"}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.api(name="watch")
|
| 103 |
+
def watch(image: FileData, prev: str = "", lang: str = "pt") -> dict:
|
| 104 |
+
"""Live mode. First call (no history) describes the scene as a baseline;
|
| 105 |
+
after that, speak ONLY when something new/relevant entered (else stay quiet)."""
|
| 106 |
+
prev = (prev or "").strip()
|
| 107 |
+
if not prev:
|
| 108 |
+
# baseline: describe the current scene so the user hears the surroundings
|
| 109 |
+
answer = vlm.describe(_path(image), lang=lang)
|
| 110 |
+
else:
|
| 111 |
+
tmpl = WATCH_EN if lang == "en" else WATCH_PT
|
| 112 |
+
sys = tmpl.format(prev=prev)
|
| 113 |
+
q = "What is new?" if lang == "en" else "O que há de novo?"
|
| 114 |
+
answer = vlm.describe(_path(image), question=q, lang=lang, system=sys)
|
| 115 |
+
low = answer.strip().lower().strip(".!").strip()
|
| 116 |
+
if not low or low in _QUIET:
|
| 117 |
+
print(f"[watch] quiet ({answer!r})", flush=True)
|
| 118 |
+
return {"speak": False, "answer": ""}
|
| 119 |
+
wav = tts.synthesize(answer, lang=lang)
|
| 120 |
+
print(f"[watch] SPEAK: {answer!r}", flush=True)
|
| 121 |
+
return {"speak": True, "answer": answer, "audio": FileData(path=wav) if wav else None}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.api(name="detect_lang")
|
| 125 |
+
def detect_lang(audio: FileData) -> dict:
|
| 126 |
+
"""Choose the language by voice: the person says 'português' or 'english'."""
|
| 127 |
+
text, detected = stt.transcribe_auto(_path(audio))
|
| 128 |
+
low = text.lower()
|
| 129 |
+
if "ingl" in low or "english" in low:
|
| 130 |
+
lang = "en"
|
| 131 |
+
elif "portug" in low or "brasil" in low:
|
| 132 |
+
lang = "pt"
|
| 133 |
+
else:
|
| 134 |
+
lang = "pt" if detected == "pt" else "en"
|
| 135 |
+
print(f"[detect_lang] {text!r} (det={detected}) -> {lang}", flush=True)
|
| 136 |
+
return {"lang": lang, "text": text}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
app.mount("/static", StaticFiles(directory=str(HERE / "frontend")), name="static")
|
| 140 |
|
| 141 |
|
|
|
|
| 148 |
if os.environ.get("IRIS_WARMUP") == "1":
|
| 149 |
print("Warmup...", flush=True)
|
| 150 |
try:
|
| 151 |
+
vlm.describe(str(HERE.parent / "viability" / "samples" / "indoor.jpg"), "test")
|
| 152 |
+
stt.transcribe(tts.synthesize("test"))
|
| 153 |
print("Warmup OK", flush=True)
|
| 154 |
except Exception as e:
|
| 155 |
+
print("Warmup failed:", e, flush=True)
|
| 156 |
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860)))
|
| 157 |
app.launch(server_name="0.0.0.0", server_port=port, show_error=True,
|
| 158 |
allowed_paths=[tempfile.gettempdir()])
|
core/gpu.py
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
"""ZeroGPU helpers.
|
| 2 |
|
| 3 |
-
`gpu`
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
import functools
|
| 8 |
|
| 9 |
try:
|
| 10 |
-
import spaces #
|
| 11 |
_HAS_SPACES = True
|
| 12 |
except Exception:
|
| 13 |
_HAS_SPACES = False
|
| 14 |
|
| 15 |
|
| 16 |
def gpu(duration: int = 60):
|
| 17 |
-
"""
|
| 18 |
def decorate(fn):
|
| 19 |
if _HAS_SPACES:
|
| 20 |
return spaces.GPU(duration=duration)(fn)
|
|
|
|
| 1 |
"""ZeroGPU helpers.
|
| 2 |
|
| 3 |
+
`gpu` is a decorator: on HF Spaces (ZeroGPU) it allocates a GPU per call;
|
| 4 |
+
locally (without the `spaces` package) it becomes a transparent no-op, so the
|
| 5 |
+
same code runs on the local GPUs and on the Space.
|
| 6 |
"""
|
| 7 |
import functools
|
| 8 |
|
| 9 |
try:
|
| 10 |
+
import spaces # available only on ZeroGPU Spaces
|
| 11 |
_HAS_SPACES = True
|
| 12 |
except Exception:
|
| 13 |
_HAS_SPACES = False
|
| 14 |
|
| 15 |
|
| 16 |
def gpu(duration: int = 60):
|
| 17 |
+
"""Allocate a GPU for `duration`s on the call (ZeroGPU). No-op locally."""
|
| 18 |
def decorate(fn):
|
| 19 |
if _HAS_SPACES:
|
| 20 |
return spaces.GPU(duration=duration)(fn)
|
core/stt.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""Speech-to-text (Whisper via faster-whisper).
|
| 2 |
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
|
|
@@ -12,7 +12,7 @@ def _load():
|
|
| 12 |
if _model is None:
|
| 13 |
from faster_whisper import WhisperModel
|
| 14 |
size = os.environ.get("IRIS_STT_MODEL", "small")
|
| 15 |
-
# CTranslate2
|
| 16 |
device = os.environ.get("IRIS_STT_DEVICE", "cpu")
|
| 17 |
if device == "cuda":
|
| 18 |
try:
|
|
@@ -26,9 +26,21 @@ def _load():
|
|
| 26 |
|
| 27 |
def transcribe(audio_path: str, language: str = "pt") -> str:
|
| 28 |
if not audio_path or not os.path.exists(audio_path):
|
| 29 |
-
print(f"[stt]
|
| 30 |
return ""
|
| 31 |
segments, info = _load().transcribe(audio_path, language=language)
|
| 32 |
text = " ".join(s.text for s in segments).strip()
|
| 33 |
print(f"[stt] {audio_path} ({getattr(info, 'duration', '?')}s) -> {text!r}", flush=True)
|
| 34 |
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speech-to-text (Whisper via faster-whisper). Portuguese by default.
|
| 2 |
|
| 3 |
+
No torch needed — faster-whisper runs on CTranslate2 (GPU if available, else CPU).
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
|
|
|
|
| 12 |
if _model is None:
|
| 13 |
from faster_whisper import WhisperModel
|
| 14 |
size = os.environ.get("IRIS_STT_MODEL", "small")
|
| 15 |
+
# CTranslate2 needs CUDA 12 libs (cublas/cudnn). Fall back to CPU if missing.
|
| 16 |
device = os.environ.get("IRIS_STT_DEVICE", "cpu")
|
| 17 |
if device == "cuda":
|
| 18 |
try:
|
|
|
|
| 26 |
|
| 27 |
def transcribe(audio_path: str, language: str = "pt") -> str:
|
| 28 |
if not audio_path or not os.path.exists(audio_path):
|
| 29 |
+
print(f"[stt] no audio: {audio_path!r}", flush=True)
|
| 30 |
return ""
|
| 31 |
segments, info = _load().transcribe(audio_path, language=language)
|
| 32 |
text = " ".join(s.text for s in segments).strip()
|
| 33 |
print(f"[stt] {audio_path} ({getattr(info, 'duration', '?')}s) -> {text!r}", flush=True)
|
| 34 |
return text
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def transcribe_auto(audio_path: str):
|
| 38 |
+
"""Transcribe WITHOUT forcing a language; returns (text, detected_language).
|
| 39 |
+
Used for choosing the language by voice."""
|
| 40 |
+
if not audio_path or not os.path.exists(audio_path):
|
| 41 |
+
return "", "en"
|
| 42 |
+
segments, info = _load().transcribe(audio_path, language=None)
|
| 43 |
+
text = " ".join(s.text for s in segments).strip()
|
| 44 |
+
lang = getattr(info, "language", "en")
|
| 45 |
+
print(f"[stt-auto] -> {text!r} (lang={lang})", flush=True)
|
| 46 |
+
return text, lang
|
core/tts.py
CHANGED
|
@@ -1,35 +1,37 @@
|
|
| 1 |
-
"""Text-to-speech (Piper
|
| 2 |
|
| 3 |
-
Piper
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
import tempfile
|
| 7 |
import wave
|
| 8 |
|
| 9 |
-
_voice = None
|
| 10 |
_REPO = "rhasspy/piper-voices"
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
-
def _load():
|
| 16 |
-
|
| 17 |
-
if _voice is None:
|
| 18 |
from huggingface_hub import hf_hub_download
|
| 19 |
from piper import PiperVoice
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
-
def synthesize(text: str) -> str | None:
|
| 27 |
if not text or not text.strip():
|
| 28 |
return None
|
| 29 |
-
voice = _load()
|
| 30 |
chunks = list(voice.synthesize(text))
|
| 31 |
if not chunks:
|
| 32 |
-
print(f"[tts]
|
| 33 |
return None
|
| 34 |
path = tempfile.mktemp(suffix=".wav")
|
| 35 |
with wave.open(path, "wb") as wf:
|
|
|
|
| 1 |
+
"""Text-to-speech (Piper). Returns the path to a .wav. Bilingual PT/EN.
|
| 2 |
|
| 3 |
+
Piper runs on onnxruntime (no torch). Voices come from rhasspy/piper-voices.
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
import tempfile
|
| 7 |
import wave
|
| 8 |
|
|
|
|
| 9 |
_REPO = "rhasspy/piper-voices"
|
| 10 |
+
_VOICES = {
|
| 11 |
+
"pt": os.environ.get("IRIS_TTS_VOICE_PT", "pt/pt_BR/faber/medium/pt_BR-faber-medium"),
|
| 12 |
+
"en": os.environ.get("IRIS_TTS_VOICE_EN", "en/en_US/amy/medium/en_US-amy-medium"),
|
| 13 |
+
}
|
| 14 |
+
_cache = {}
|
| 15 |
|
| 16 |
|
| 17 |
+
def _load(lang: str):
|
| 18 |
+
if lang not in _cache:
|
|
|
|
| 19 |
from huggingface_hub import hf_hub_download
|
| 20 |
from piper import PiperVoice
|
| 21 |
+
name = _VOICES.get(lang, _VOICES["pt"])
|
| 22 |
+
onnx = hf_hub_download(_REPO, f"{name}.onnx")
|
| 23 |
+
conf = hf_hub_download(_REPO, f"{name}.onnx.json")
|
| 24 |
+
_cache[lang] = PiperVoice.load(onnx, config_path=conf)
|
| 25 |
+
return _cache[lang]
|
| 26 |
|
| 27 |
|
| 28 |
+
def synthesize(text: str, lang: str = "pt") -> str | None:
|
| 29 |
if not text or not text.strip():
|
| 30 |
return None
|
| 31 |
+
voice = _load("en" if lang == "en" else "pt")
|
| 32 |
chunks = list(voice.synthesize(text))
|
| 33 |
if not chunks:
|
| 34 |
+
print(f"[tts] no audio for text: {text!r}", flush=True)
|
| 35 |
return None
|
| 36 |
path = tempfile.mktemp(suffix=".wav")
|
| 37 |
with wave.open(path, "wb") as wf:
|
core/vlm.py
CHANGED
|
@@ -1,24 +1,40 @@
|
|
| 1 |
-
"""Vision-language:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
- Qwen/
|
| 5 |
-
- openbmb/MiniCPM-V-4.6
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
import os
|
| 9 |
|
| 10 |
_model = None
|
| 11 |
-
_aux = None
|
| 12 |
-
MODEL_ID = os.environ.get("IRIS_VLM_MODEL", "Qwen/
|
|
|
|
| 13 |
|
| 14 |
SYSTEM_PT = (
|
| 15 |
"Você é os olhos de uma pessoa cega. RESPONDA OBRIGATORIAMENTE EM PORTUGUÊS "
|
| 16 |
"DO BRASIL, em no máximo duas frases curtas, dizendo só o que é relevante e "
|
| 17 |
-
"útil sobre a cena. Não comece com 'a imagem mostra'.
|
| 18 |
-
"importante (rótulo, placa, remédio), leia-o exatamente como está."
|
|
|
|
|
|
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _family() -> str:
|
|
@@ -29,20 +45,13 @@ def _load():
|
|
| 29 |
global _model, _aux
|
| 30 |
if _model is None:
|
| 31 |
import torch
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
else:
|
| 40 |
-
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
|
| 41 |
-
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
| 42 |
-
MODEL_ID, torch_dtype=torch.float16, device_map="cuda:0",
|
| 43 |
-
low_cpu_mem_usage=True,
|
| 44 |
-
).eval()
|
| 45 |
-
_aux = AutoProcessor.from_pretrained(MODEL_ID)
|
| 46 |
return _model, _aux
|
| 47 |
|
| 48 |
|
|
@@ -51,9 +60,9 @@ def _to_pil(image):
|
|
| 51 |
if isinstance(image, str):
|
| 52 |
image = Image.open(image)
|
| 53 |
elif not isinstance(image, Image.Image):
|
| 54 |
-
image = Image.fromarray(image) # frame
|
| 55 |
image = image.convert("RGB")
|
| 56 |
-
image.thumbnail((1024, 1024)) #
|
| 57 |
return image
|
| 58 |
|
| 59 |
|
|
@@ -61,43 +70,32 @@ from .gpu import gpu
|
|
| 61 |
|
| 62 |
|
| 63 |
@gpu(duration=60)
|
| 64 |
-
def describe(image, question: str = "") -> str:
|
|
|
|
| 65 |
image = _to_pil(image)
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
model, aux = _load()
|
| 68 |
|
| 69 |
-
if _family() == "minicpm":
|
| 70 |
-
import torch
|
| 71 |
-
messages = [{"role": "user", "content": [
|
| 72 |
-
{"type": "image", "image": image},
|
| 73 |
-
{"type": "text", "text": f"{SYSTEM_PT}\n\nPergunta: {user}"},
|
| 74 |
-
]}]
|
| 75 |
-
inputs = aux.apply_chat_template(
|
| 76 |
-
messages, tokenize=True, add_generation_prompt=True,
|
| 77 |
-
return_dict=True, return_tensors="pt",
|
| 78 |
-
downsample_mode=DOWNSAMPLE, max_slice_nums=36,
|
| 79 |
-
).to(model.device)
|
| 80 |
-
with torch.no_grad():
|
| 81 |
-
generated = model.generate(**inputs, downsample_mode=DOWNSAMPLE,
|
| 82 |
-
max_new_tokens=96, do_sample=False)
|
| 83 |
-
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, generated)]
|
| 84 |
-
return aux.batch_decode(trimmed, skip_special_tokens=True)[0].strip()
|
| 85 |
-
|
| 86 |
-
# Qwen2.5-VL
|
| 87 |
-
import torch
|
| 88 |
-
from qwen_vl_utils import process_vision_info
|
| 89 |
messages = [
|
| 90 |
-
{"role": "system", "content":
|
| 91 |
{"role": "user", "content": [
|
| 92 |
{"type": "image", "image": image},
|
| 93 |
{"type": "text", "text": user},
|
| 94 |
]},
|
| 95 |
]
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
with torch.no_grad():
|
| 101 |
-
generated = model.generate(**inputs, max_new_tokens=96, do_sample=False)
|
| 102 |
-
trimmed =
|
| 103 |
return aux.batch_decode(trimmed, skip_special_tokens=True)[0].strip()
|
|
|
|
| 1 |
+
"""Vision-language: describe / answer about an image, in PT/EN.
|
| 2 |
|
| 3 |
+
Generic loader (AutoModelForImageTextToText) — supports:
|
| 4 |
+
- Qwen/Qwen3-VL-2B-Instruct (default — light, fast, strong OCR)
|
| 5 |
+
- Qwen/Qwen2.5-VL-3B-Instruct, openbmb/MiniCPM-V-4.6, etc.
|
| 6 |
+
Swappable via IRIS_VLM_MODEL. The VLM IS the text generator for speech.
|
| 7 |
"""
|
| 8 |
import os
|
| 9 |
|
| 10 |
_model = None
|
| 11 |
+
_aux = None
|
| 12 |
+
MODEL_ID = os.environ.get("IRIS_VLM_MODEL", "Qwen/Qwen3-VL-2B-Instruct")
|
| 13 |
+
DOWNSAMPLE = os.environ.get("IRIS_DOWNSAMPLE", "4x") # MiniCPM only (detail for OCR)
|
| 14 |
|
| 15 |
SYSTEM_PT = (
|
| 16 |
"Você é os olhos de uma pessoa cega. RESPONDA OBRIGATORIAMENTE EM PORTUGUÊS "
|
| 17 |
"DO BRASIL, em no máximo duas frases curtas, dizendo só o que é relevante e "
|
| 18 |
+
"útil sobre a cena. Não comece com 'a imagem mostra'. "
|
| 19 |
+
"Se houver texto importante (rótulo, placa, remédio), leia-o exatamente como está. "
|
| 20 |
+
"Se houver DINHEIRO (cédulas ou moedas de real), identifique cada valor e diga o TOTAL. "
|
| 21 |
+
"Se for uma CONTA, boleto ou documento, leia o VALOR TOTAL e a DATA DE VENCIMENTO."
|
| 22 |
)
|
| 23 |
+
SYSTEM_EN = (
|
| 24 |
+
"You are the eyes of a blind person. ALWAYS REPLY IN ENGLISH, in at most two "
|
| 25 |
+
"short sentences, saying only what is relevant and useful about the scene. Do "
|
| 26 |
+
"not start with 'the image shows'. "
|
| 27 |
+
"If there is important text (label, sign, medicine), read it exactly as written. "
|
| 28 |
+
"If there is MONEY (banknotes or coins), identify each value and state the TOTAL. "
|
| 29 |
+
"If it is a BILL or document, read the TOTAL AMOUNT and the DUE DATE."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
|
| 33 |
+
def _prompt(lang):
|
| 34 |
+
"""Return (system_prompt, default_question) for the language."""
|
| 35 |
+
if lang == "en":
|
| 36 |
+
return SYSTEM_EN, "What is in front of me?"
|
| 37 |
+
return SYSTEM_PT, "O que há à minha frente?"
|
| 38 |
|
| 39 |
|
| 40 |
def _family() -> str:
|
|
|
|
| 45 |
global _model, _aux
|
| 46 |
if _model is None:
|
| 47 |
import torch
|
| 48 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 49 |
+
kw = {"trust_remote_code": True} if _family() == "minicpm" else {}
|
| 50 |
+
_model = AutoModelForImageTextToText.from_pretrained(
|
| 51 |
+
MODEL_ID, torch_dtype=torch.float16, device_map="cuda:0",
|
| 52 |
+
low_cpu_mem_usage=True, **kw,
|
| 53 |
+
).eval()
|
| 54 |
+
_aux = AutoProcessor.from_pretrained(MODEL_ID, **kw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return _model, _aux
|
| 56 |
|
| 57 |
|
|
|
|
| 60 |
if isinstance(image, str):
|
| 61 |
image = Image.open(image)
|
| 62 |
elif not isinstance(image, Image.Image):
|
| 63 |
+
image = Image.fromarray(image) # numpy frame from the webcam
|
| 64 |
image = image.convert("RGB")
|
| 65 |
+
image.thumbnail((1024, 1024)) # fewer vision tokens -> faster; still good OCR
|
| 66 |
return image
|
| 67 |
|
| 68 |
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
@gpu(duration=60)
|
| 73 |
+
def describe(image, question: str = "", lang: str = "pt", system: str = None) -> str:
|
| 74 |
+
import torch
|
| 75 |
image = _to_pil(image)
|
| 76 |
+
sys_prompt, default_q = _prompt(lang)
|
| 77 |
+
if system:
|
| 78 |
+
sys_prompt = system
|
| 79 |
+
user = (question or "").strip() or default_q
|
| 80 |
model, aux = _load()
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
messages = [
|
| 83 |
+
{"role": "system", "content": sys_prompt},
|
| 84 |
{"role": "user", "content": [
|
| 85 |
{"type": "image", "image": image},
|
| 86 |
{"type": "text", "text": user},
|
| 87 |
]},
|
| 88 |
]
|
| 89 |
+
tmpl_kw, gen_kw = {}, {}
|
| 90 |
+
if _family() == "minicpm":
|
| 91 |
+
tmpl_kw = {"downsample_mode": DOWNSAMPLE, "max_slice_nums": 36}
|
| 92 |
+
gen_kw = {"downsample_mode": DOWNSAMPLE}
|
| 93 |
+
|
| 94 |
+
inputs = aux.apply_chat_template(
|
| 95 |
+
messages, tokenize=True, add_generation_prompt=True,
|
| 96 |
+
return_dict=True, return_tensors="pt", **tmpl_kw,
|
| 97 |
+
).to(model.device)
|
| 98 |
with torch.no_grad():
|
| 99 |
+
generated = model.generate(**inputs, max_new_tokens=96, do_sample=False, **gen_kw)
|
| 100 |
+
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, generated)]
|
| 101 |
return aux.batch_decode(trimmed, skip_special_tokens=True)[0].strip()
|
frontend/app.js
CHANGED
|
@@ -1,49 +1,87 @@
|
|
| 1 |
-
import { Client, handle_file } from "https://
|
| 2 |
|
| 3 |
const $ = (id) => document.getElementById(id);
|
| 4 |
const cam = $("cam"), canvas = $("canvas"), statusEl = $("status"),
|
| 5 |
answerEl = $("answer"), hintEl = $("hint"), langBtn = $("lang"),
|
| 6 |
-
player = $("player"), stage = $("stage")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
// ---- i18n (EN default + PT) ----
|
| 9 |
-
let lang = "en";
|
| 10 |
const T = {
|
| 11 |
en: { idle: "Iris", listening: "Listening…", thinking: "Thinking…",
|
| 12 |
-
hint: "Tap
|
| 13 |
-
err: "Something went wrong"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
pt: { idle: "Iris", listening: "Ouvindo…", thinking: "Pensando…",
|
| 15 |
-
hint: "Toque
|
| 16 |
-
err: "Algo deu errado"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
};
|
| 18 |
const t = (k) => T[lang][k];
|
|
|
|
| 19 |
|
| 20 |
function setState(s, msg) {
|
| 21 |
document.body.dataset.state = s || "";
|
| 22 |
-
if (msg !== undefined) statusEl.textContent = msg;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
langBtn.onclick = (e) => {
|
| 26 |
e.stopPropagation();
|
| 27 |
lang = lang === "en" ? "pt" : "en";
|
| 28 |
-
langBtn.textContent = lang.toUpperCase();
|
| 29 |
-
hintEl.textContent = t("hint");
|
| 30 |
document.documentElement.lang = lang;
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
};
|
| 33 |
|
| 34 |
-
// ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
async function startCamera() {
|
| 36 |
-
const
|
| 37 |
-
{ video: { facingMode: { ideal: "environment" } }, audio: false },
|
| 38 |
-
{ video: true, audio: false },
|
| 39 |
-
];
|
| 40 |
-
for (const c of tries) {
|
| 41 |
try { cam.srcObject = await navigator.mediaDevices.getUserMedia(c); return; }
|
| 42 |
-
catch (e) {
|
| 43 |
}
|
| 44 |
setState("", t("camErr"));
|
| 45 |
}
|
| 46 |
-
|
| 47 |
function grabFrame() {
|
| 48 |
const w = cam.videoWidth, h = cam.videoHeight;
|
| 49 |
if (!w || !h) return Promise.resolve(null);
|
|
@@ -52,8 +90,8 @@ function grabFrame() {
|
|
| 52 |
return new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
|
| 53 |
}
|
| 54 |
|
| 55 |
-
// ---- microfone
|
| 56 |
-
let micStream = null,
|
| 57 |
async function startRec() {
|
| 58 |
try { if (!micStream) micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
| 59 |
catch (e) { return false; }
|
|
@@ -73,40 +111,177 @@ function stopRec() {
|
|
| 73 |
|
| 74 |
// ---- backend ----
|
| 75 |
let client = null;
|
| 76 |
-
let busy = false;
|
| 77 |
|
| 78 |
-
async function send(frame, audio) {
|
| 79 |
if (busy) return;
|
| 80 |
busy = true;
|
| 81 |
answerEl.textContent = "";
|
| 82 |
setState("thinking", t("thinking"));
|
| 83 |
try {
|
| 84 |
-
const payload = { image: handle_file(frame) };
|
| 85 |
if (audio) payload.audio = handle_file(audio);
|
|
|
|
| 86 |
const result = await client.predict("/describe", payload);
|
| 87 |
const out = Array.isArray(result.data) ? result.data[0] : result.data;
|
|
|
|
|
|
|
| 88 |
answerEl.textContent = (out && out.answer) || "";
|
| 89 |
setState("speaking", "");
|
| 90 |
-
const
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
} catch (e) {
|
| 94 |
-
console.error(e);
|
| 95 |
setState("", t("err"));
|
| 96 |
resetSoon();
|
| 97 |
} finally {
|
| 98 |
busy = false;
|
| 99 |
}
|
| 100 |
}
|
| 101 |
-
function resetSoon() { setTimeout(() => { if (!busy) setState("", t("idle")); }, 600); }
|
| 102 |
-
player.addEventListener("ended", () => { if (!busy) setState("", t("idle")); });
|
| 103 |
|
| 104 |
-
// ----
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if (busy) return;
|
|
|
|
| 110 |
holding = true;
|
| 111 |
stage.classList.add("armed");
|
| 112 |
holdTimer = setTimeout(async () => {
|
|
@@ -115,25 +290,112 @@ stage.addEventListener("pointerdown", () => {
|
|
| 115 |
}, HOLD_MS);
|
| 116 |
});
|
| 117 |
|
|
|
|
|
|
|
|
|
|
| 118 |
async function endPress() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
if (!holding) return;
|
| 120 |
holding = false;
|
| 121 |
stage.classList.remove("armed");
|
| 122 |
clearTimeout(holdTimer);
|
| 123 |
-
|
| 124 |
-
if (
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
stage.addEventListener("pointerup", endPress);
|
| 129 |
stage.addEventListener("pointercancel", endPress);
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
// ---- boot ----
|
| 132 |
(async () => {
|
|
|
|
| 133 |
langBtn.textContent = lang.toUpperCase();
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
await startCamera();
|
| 137 |
-
try { client = await Client.connect(window.location.origin); }
|
| 138 |
-
catch (e) { console.error(e); setState("", t("err")); }
|
| 139 |
})();
|
|
|
|
| 1 |
+
import { Client, handle_file } from "https://esm.sh/@gradio/client";
|
| 2 |
|
| 3 |
const $ = (id) => document.getElementById(id);
|
| 4 |
const cam = $("cam"), canvas = $("canvas"), statusEl = $("status"),
|
| 5 |
answerEl = $("answer"), hintEl = $("hint"), langBtn = $("lang"),
|
| 6 |
+
player = $("player"), stage = $("stage"),
|
| 7 |
+
btnAsk = $("btn-ask"), btnDescribe = $("btn-describe"),
|
| 8 |
+
btnLive = $("btn-live"), a11yBtn = $("a11y");
|
| 9 |
+
|
| 10 |
+
function vibrate(ms) { try { navigator.vibrate && navigator.vibrate(ms); } catch (e) {} }
|
| 11 |
+
|
| 12 |
+
// ---- idioma (detecta do navegador; trocável por voz no onboarding ou pelo botão) ----
|
| 13 |
+
let lang = (navigator.language || "en").toLowerCase().startsWith("pt") ? "pt" : "en";
|
| 14 |
|
|
|
|
|
|
|
| 15 |
const T = {
|
| 16 |
en: { idle: "Iris", listening: "Listening…", thinking: "Thinking…",
|
| 17 |
+
hint: "Tap: describe · Hold: ask · Double-tap: live",
|
| 18 |
+
camErr: "Camera blocked — allow access", err: "Something went wrong",
|
| 19 |
+
langListen: "Listening… say your language",
|
| 20 |
+
welcome: "Welcome to Iris. Tap the screen to describe what is in front of you. Hold to ask a question. Double-tap to turn live mode on or off, which announces new things around you.",
|
| 21 |
+
liveOn: "Live mode on.", liveOff: "Live mode off.", confLang: "English selected.",
|
| 22 |
+
bAsk: "Ask", bDescribe: "Describe", bLive: "Live" },
|
| 23 |
pt: { idle: "Iris", listening: "Ouvindo…", thinking: "Pensando…",
|
| 24 |
+
hint: "Toque: descrever · Segurar: perguntar · Toque duplo: ao vivo",
|
| 25 |
+
camErr: "Câmera bloqueada — permita o acesso", err: "Algo deu errado",
|
| 26 |
+
langListen: "Ouvindo… diga seu idioma",
|
| 27 |
+
welcome: "Bem-vindo ao Iris. Toque na tela para descrever o que está à sua frente. Segure para fazer uma pergunta. Toque duas vezes para ligar ou desligar o modo ao vivo, que avisa o que aparece de novo à sua volta.",
|
| 28 |
+
liveOn: "Modo ao vivo ligado.", liveOff: "Modo ao vivo desligado.", confLang: "Português selecionado.",
|
| 29 |
+
bAsk: "Perguntar", bDescribe: "Descrever", bLive: "Ao vivo" },
|
| 30 |
};
|
| 31 |
const t = (k) => T[lang][k];
|
| 32 |
+
const LANG_PROMPT = "Segure e diga seu idioma · Hold and say your language";
|
| 33 |
|
| 34 |
function setState(s, msg) {
|
| 35 |
document.body.dataset.state = s || "";
|
| 36 |
+
if (msg !== undefined) statusEl.textContent = (liveOn ? "● " : "") + msg;
|
| 37 |
+
}
|
| 38 |
+
// fala de UI via voz do navegador (instruções, confirmações)
|
| 39 |
+
function speak(text) {
|
| 40 |
+
try {
|
| 41 |
+
speechSynthesis.cancel();
|
| 42 |
+
const u = new SpeechSynthesisUtterance(text);
|
| 43 |
+
u.lang = lang === "pt" ? "pt-BR" : "en-US";
|
| 44 |
+
speechSynthesis.speak(u);
|
| 45 |
+
} catch (e) { console.error("speak:", e); }
|
| 46 |
}
|
| 47 |
|
| 48 |
+
// ---- estado ----
|
| 49 |
+
let onboarded = false;
|
| 50 |
+
try { onboarded = localStorage.getItem("iris_onboarded") === "1"; } catch (e) {}
|
| 51 |
+
let mode = onboarded ? "normal" : "onboarding";
|
| 52 |
+
let busy = false;
|
| 53 |
+
let liveOn = false, liveTimer = null;
|
| 54 |
+
let holding = false, recording = false, holdTimer = null;
|
| 55 |
+
const HOLD_MS = 350;
|
| 56 |
+
|
| 57 |
langBtn.onclick = (e) => {
|
| 58 |
e.stopPropagation();
|
| 59 |
lang = lang === "en" ? "pt" : "en";
|
|
|
|
|
|
|
| 60 |
document.documentElement.lang = lang;
|
| 61 |
+
langBtn.textContent = lang.toUpperCase();
|
| 62 |
+
hintEl.textContent = onboarded ? t("hint") : "";
|
| 63 |
+
updateLabels();
|
| 64 |
+
if (!busy) setState("", onboarded ? t("idle") : LANG_PROMPT);
|
| 65 |
};
|
| 66 |
|
| 67 |
+
// ---- destrava o áudio (autoplay) no 1º toque ----
|
| 68 |
+
const SILENT = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
| 69 |
+
let audioUnlocked = false;
|
| 70 |
+
function unlockAudio() {
|
| 71 |
+
if (audioUnlocked) return;
|
| 72 |
+
audioUnlocked = true;
|
| 73 |
+
player.src = SILENT;
|
| 74 |
+
player.play().catch(() => {});
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// ---- câmera ao vivo ----
|
| 78 |
async function startCamera() {
|
| 79 |
+
for (const c of [{ video: { facingMode: { ideal: "environment" } }, audio: false }, { video: true, audio: false }]) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
try { cam.srcObject = await navigator.mediaDevices.getUserMedia(c); return; }
|
| 81 |
+
catch (e) {}
|
| 82 |
}
|
| 83 |
setState("", t("camErr"));
|
| 84 |
}
|
|
|
|
| 85 |
function grabFrame() {
|
| 86 |
const w = cam.videoWidth, h = cam.videoHeight;
|
| 87 |
if (!w || !h) return Promise.resolve(null);
|
|
|
|
| 90 |
return new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
|
| 91 |
}
|
| 92 |
|
| 93 |
+
// ---- microfone ----
|
| 94 |
+
let micStream = null, chunks = [], recorder = null;
|
| 95 |
async function startRec() {
|
| 96 |
try { if (!micStream) micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
| 97 |
catch (e) { return false; }
|
|
|
|
| 111 |
|
| 112 |
// ---- backend ----
|
| 113 |
let client = null;
|
|
|
|
| 114 |
|
| 115 |
+
async function send(frame, audio, qtext = "") {
|
| 116 |
if (busy) return;
|
| 117 |
busy = true;
|
| 118 |
answerEl.textContent = "";
|
| 119 |
setState("thinking", t("thinking"));
|
| 120 |
try {
|
| 121 |
+
const payload = { image: handle_file(frame), lang };
|
| 122 |
if (audio) payload.audio = handle_file(audio);
|
| 123 |
+
if (qtext) payload.qtext = qtext;
|
| 124 |
const result = await client.predict("/describe", payload);
|
| 125 |
const out = Array.isArray(result.data) ? result.data[0] : result.data;
|
| 126 |
+
console.log("Iris result:", out);
|
| 127 |
+
if (out && out.command) { busy = false; handleCommand(out.command); return; }
|
| 128 |
answerEl.textContent = (out && out.answer) || "";
|
| 129 |
setState("speaking", "");
|
| 130 |
+
const a = out && out.audio;
|
| 131 |
+
let url = a && a.url;
|
| 132 |
+
if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
|
| 133 |
+
if (url) {
|
| 134 |
+
srMuted = true;
|
| 135 |
+
player.src = url;
|
| 136 |
+
try { await player.play(); } catch (err) { console.error("play:", err); }
|
| 137 |
+
} else { resetSoon(); }
|
| 138 |
} catch (e) {
|
| 139 |
+
console.error("describe:", e);
|
| 140 |
setState("", t("err"));
|
| 141 |
resetSoon();
|
| 142 |
} finally {
|
| 143 |
busy = false;
|
| 144 |
}
|
| 145 |
}
|
| 146 |
+
function resetSoon() { setTimeout(() => { srMuted = false; if (!busy) setState("", t("idle")); }, 600); }
|
| 147 |
+
player.addEventListener("ended", () => { srMuted = false; if (!busy) setState("", t("idle")); });
|
| 148 |
|
| 149 |
+
// ---- escolha de idioma por voz (onboarding) ----
|
| 150 |
+
async function chooseLang(audio) {
|
| 151 |
+
if (!audio || !client) { setState("", LANG_PROMPT); return; }
|
| 152 |
+
busy = true; setState("thinking", t("thinking"));
|
| 153 |
+
try {
|
| 154 |
+
const result = await client.predict("/detect_lang", { audio: handle_file(audio) });
|
| 155 |
+
const out = Array.isArray(result.data) ? result.data[0] : result.data;
|
| 156 |
+
lang = (out && out.lang === "en") ? "en" : "pt";
|
| 157 |
+
document.documentElement.lang = lang;
|
| 158 |
+
langBtn.textContent = lang.toUpperCase();
|
| 159 |
+
onboarded = true; mode = "normal";
|
| 160 |
+
try { localStorage.setItem("iris_onboarded", "1"); } catch (e) {}
|
| 161 |
+
hintEl.textContent = t("hint");
|
| 162 |
+
setState("", t("idle"));
|
| 163 |
+
speak(t("confLang") + " " + t("welcome"));
|
| 164 |
+
} catch (e) {
|
| 165 |
+
console.error("detect_lang:", e);
|
| 166 |
+
setState("", LANG_PROMPT);
|
| 167 |
+
} finally { busy = false; }
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// ---- modo ao vivo (ligado/desligado por comando de voz) ----
|
| 171 |
+
function handleCommand(cmd) {
|
| 172 |
+
if (cmd === "live_on") setLive(true);
|
| 173 |
+
else if (cmd === "live_off") setLive(false);
|
| 174 |
+
}
|
| 175 |
+
const LIVE_INTERVAL = 2500; // checa a cena a cada 2.5s
|
| 176 |
+
const LIVE_THRESHOLD = 14; // mudança média por pixel (0-255) p/ disparar o VLM
|
| 177 |
+
let lastSig = null, history = []; // histórico das últimas descrições (anti-repetição)
|
| 178 |
+
|
| 179 |
+
function setLive(on) {
|
| 180 |
+
liveOn = on;
|
| 181 |
+
btnLive.setAttribute("aria-pressed", on ? "true" : "false");
|
| 182 |
+
btnLive.classList.toggle("on", on);
|
| 183 |
+
vibrate(on ? [20, 40, 20] : 20);
|
| 184 |
+
speak(on ? t("liveOn") : t("liveOff"));
|
| 185 |
+
setState("", t("idle"));
|
| 186 |
+
clearInterval(liveTimer); liveTimer = null;
|
| 187 |
+
lastSig = null; history = [];
|
| 188 |
+
if (on) { liveTimer = setInterval(liveTick, LIVE_INTERVAL); startListening(); }
|
| 189 |
+
else stopListening();
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// ---- hands-free continuous listening (live mode) via Web Speech API ----
|
| 193 |
+
let recog = null, listening = false, srMuted = false;
|
| 194 |
+
function setupRecognition() {
|
| 195 |
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 196 |
+
if (!SR) { console.warn("SpeechRecognition não suportado neste navegador"); return null; }
|
| 197 |
+
const r = new SR();
|
| 198 |
+
r.continuous = true;
|
| 199 |
+
r.interimResults = false;
|
| 200 |
+
r.onresult = (e) => {
|
| 201 |
+
if (srMuted || busy) return;
|
| 202 |
+
const txt = e.results[e.results.length - 1][0].transcript.trim();
|
| 203 |
+
if (txt) onVoice(txt);
|
| 204 |
+
};
|
| 205 |
+
r.onend = () => { if (listening) { try { r.start(); } catch (e) {} } }; // keep alive
|
| 206 |
+
r.onerror = (e) => { console.log("SR:", e.error); };
|
| 207 |
+
return r;
|
| 208 |
+
}
|
| 209 |
+
function startListening() {
|
| 210 |
+
if (!recog) recog = setupRecognition();
|
| 211 |
+
if (!recog || listening) return;
|
| 212 |
+
listening = true;
|
| 213 |
+
recog.lang = lang === "pt" ? "pt-BR" : "en-US";
|
| 214 |
+
try { recog.start(); } catch (e) {}
|
| 215 |
+
}
|
| 216 |
+
function stopListening() {
|
| 217 |
+
listening = false;
|
| 218 |
+
if (recog) { try { recog.stop(); } catch (e) {} }
|
| 219 |
+
}
|
| 220 |
+
async function onVoice(txt) {
|
| 221 |
+
if (busy) return;
|
| 222 |
+
const frame = await grabFrame();
|
| 223 |
+
if (frame) send(frame, null, txt); // pergunta por texto (do reconhecimento do navegador)
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// assinatura visual barata (32x32 cinza) p/ detectar mudança sem chamar o modelo
|
| 227 |
+
function frameSignature() {
|
| 228 |
+
if (!cam.videoWidth) return null;
|
| 229 |
+
const c = document.createElement("canvas"); c.width = 32; c.height = 32;
|
| 230 |
+
const ctx = c.getContext("2d");
|
| 231 |
+
ctx.drawImage(cam, 0, 0, 32, 32);
|
| 232 |
+
const d = ctx.getImageData(0, 0, 32, 32).data;
|
| 233 |
+
const g = new Uint8Array(1024);
|
| 234 |
+
for (let i = 0; i < 1024; i++) g[i] = (d[i * 4] + d[i * 4 + 1] + d[i * 4 + 2]) / 3;
|
| 235 |
+
return g;
|
| 236 |
+
}
|
| 237 |
+
function changeAmount(a, b) {
|
| 238 |
+
if (!a || !b) return 999;
|
| 239 |
+
let s = 0;
|
| 240 |
+
for (let i = 0; i < a.length; i++) s += Math.abs(a[i] - b[i]);
|
| 241 |
+
return s / a.length;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
async function liveTick() {
|
| 245 |
+
if (busy || mode !== "normal" || !liveOn || !player.paused) return;
|
| 246 |
+
const sig = frameSignature();
|
| 247 |
+
if (!sig) return;
|
| 248 |
+
if (lastSig && changeAmount(lastSig, sig) < LIVE_THRESHOLD) return; // cena parada -> silêncio
|
| 249 |
+
lastSig = sig;
|
| 250 |
+
const frame = await grabFrame();
|
| 251 |
+
if (frame) sendWatch(frame);
|
| 252 |
+
}
|
| 253 |
|
| 254 |
+
async function sendWatch(frame) {
|
| 255 |
+
if (busy) return;
|
| 256 |
+
busy = true;
|
| 257 |
+
try {
|
| 258 |
+
const result = await client.predict("/watch", { image: handle_file(frame), prev: history.join(" · "), lang });
|
| 259 |
+
const out = Array.isArray(result.data) ? result.data[0] : result.data;
|
| 260 |
+
if (out && out.speak) {
|
| 261 |
+
if (out.answer) { history.push(out.answer); if (history.length > 5) history.shift(); }
|
| 262 |
+
answerEl.textContent = out.answer || "";
|
| 263 |
+
setState("speaking", "");
|
| 264 |
+
const a = out.audio;
|
| 265 |
+
let url = a && a.url;
|
| 266 |
+
if (!url && a && a.path) url = window.location.origin + "/gradio_api/file=" + a.path;
|
| 267 |
+
if (url) { srMuted = true; player.src = url; try { await player.play(); } catch (e) { console.error("play:", e); } }
|
| 268 |
+
}
|
| 269 |
+
} catch (e) { console.error("watch:", e); }
|
| 270 |
+
finally { busy = false; }
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// ---- interação ----
|
| 274 |
+
stage.addEventListener("pointerdown", async () => {
|
| 275 |
+
if (mode === "onboarding") {
|
| 276 |
+
if (busy) return;
|
| 277 |
+
unlockAudio();
|
| 278 |
+
recording = await startRec();
|
| 279 |
+
if (recording) setState("listening", t("langListen"));
|
| 280 |
+
return;
|
| 281 |
+
}
|
| 282 |
+
if (liveOn) { speechSynthesis.cancel(); player.pause(); }
|
| 283 |
if (busy) return;
|
| 284 |
+
unlockAudio();
|
| 285 |
holding = true;
|
| 286 |
stage.classList.add("armed");
|
| 287 |
holdTimer = setTimeout(async () => {
|
|
|
|
| 290 |
}, HOLD_MS);
|
| 291 |
});
|
| 292 |
|
| 293 |
+
let lastTapTime = 0, tapTimer = null;
|
| 294 |
+
const DOUBLE_MS = 320; // janela do duplo-toque
|
| 295 |
+
|
| 296 |
async function endPress() {
|
| 297 |
+
if (mode === "onboarding") {
|
| 298 |
+
if (!recording) return;
|
| 299 |
+
recording = false;
|
| 300 |
+
const audio = await stopRec();
|
| 301 |
+
return chooseLang(audio);
|
| 302 |
+
}
|
| 303 |
if (!holding) return;
|
| 304 |
holding = false;
|
| 305 |
stage.classList.remove("armed");
|
| 306 |
clearTimeout(holdTimer);
|
| 307 |
+
|
| 308 |
+
if (recording) { // segurou e falou = pergunta
|
| 309 |
+
recording = false;
|
| 310 |
+
const frame = await grabFrame();
|
| 311 |
+
const audio = await stopRec();
|
| 312 |
+
if (frame) send(frame, audio); else setState("", t("camErr"));
|
| 313 |
+
return;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// toque rápido: duplo-toque liga/desliga ao vivo; toque simples descreve
|
| 317 |
+
const now = performance.now();
|
| 318 |
+
if (now - lastTapTime < DOUBLE_MS) {
|
| 319 |
+
lastTapTime = 0;
|
| 320 |
+
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; }
|
| 321 |
+
setLive(!liveOn);
|
| 322 |
+
} else {
|
| 323 |
+
lastTapTime = now;
|
| 324 |
+
if (tapTimer) clearTimeout(tapTimer);
|
| 325 |
+
tapTimer = setTimeout(async () => {
|
| 326 |
+
tapTimer = null;
|
| 327 |
+
const frame = await grabFrame();
|
| 328 |
+
if (frame) send(frame, null); else setState("", t("camErr"));
|
| 329 |
+
}, DOUBLE_MS);
|
| 330 |
+
}
|
| 331 |
}
|
| 332 |
stage.addEventListener("pointerup", endPress);
|
| 333 |
stage.addEventListener("pointercancel", endPress);
|
| 334 |
|
| 335 |
+
// ---- botões explícitos (baixa visão / teclado / leitor de tela) ----
|
| 336 |
+
btnDescribe.addEventListener("click", async (e) => {
|
| 337 |
+
e.stopPropagation();
|
| 338 |
+
if (mode !== "normal" || busy) return;
|
| 339 |
+
unlockAudio(); vibrate(10);
|
| 340 |
+
const frame = await grabFrame();
|
| 341 |
+
if (frame) send(frame, null); else setState("", t("camErr"));
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
let askRecBtn = false;
|
| 345 |
+
btnAsk.addEventListener("pointerdown", async (e) => {
|
| 346 |
+
e.stopPropagation();
|
| 347 |
+
if (mode !== "normal" || busy) return;
|
| 348 |
+
unlockAudio(); vibrate(10);
|
| 349 |
+
askRecBtn = await startRec();
|
| 350 |
+
if (askRecBtn) setState("listening", t("listening"));
|
| 351 |
+
});
|
| 352 |
+
async function btnAskEnd(e) {
|
| 353 |
+
if (e) e.stopPropagation();
|
| 354 |
+
if (!askRecBtn) return; askRecBtn = false;
|
| 355 |
+
const frame = await grabFrame();
|
| 356 |
+
const audio = await stopRec();
|
| 357 |
+
if (frame) send(frame, audio); else setState("", t("camErr"));
|
| 358 |
+
}
|
| 359 |
+
btnAsk.addEventListener("pointerup", btnAskEnd);
|
| 360 |
+
btnAsk.addEventListener("pointerleave", btnAskEnd);
|
| 361 |
+
btnAsk.addEventListener("pointercancel", btnAskEnd);
|
| 362 |
+
|
| 363 |
+
btnLive.addEventListener("click", (e) => {
|
| 364 |
+
e.stopPropagation();
|
| 365 |
+
if (mode !== "normal") return;
|
| 366 |
+
unlockAudio();
|
| 367 |
+
setLive(!liveOn);
|
| 368 |
+
});
|
| 369 |
+
|
| 370 |
+
// ---- acessibilidade: contraste máximo + texto maior (persistente) ----
|
| 371 |
+
let boost = false;
|
| 372 |
+
try { boost = localStorage.getItem("iris_a11y") === "1"; } catch (e) {}
|
| 373 |
+
document.body.classList.toggle("a11y-boost", boost);
|
| 374 |
+
a11yBtn.setAttribute("aria-pressed", boost ? "true" : "false");
|
| 375 |
+
a11yBtn.addEventListener("click", (e) => {
|
| 376 |
+
e.stopPropagation();
|
| 377 |
+
boost = !boost;
|
| 378 |
+
document.body.classList.toggle("a11y-boost", boost);
|
| 379 |
+
a11yBtn.setAttribute("aria-pressed", boost ? "true" : "false");
|
| 380 |
+
try { localStorage.setItem("iris_a11y", boost ? "1" : "0"); } catch (e) {}
|
| 381 |
+
vibrate(10);
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
// rótulos dos botões conforme idioma
|
| 385 |
+
function updateLabels() {
|
| 386 |
+
btnAsk.querySelector(".ctl-lbl").textContent = t("bAsk");
|
| 387 |
+
btnDescribe.querySelector(".ctl-lbl").textContent = t("bDescribe");
|
| 388 |
+
btnLive.querySelector(".ctl-lbl").textContent = t("bLive");
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
// ---- boot ----
|
| 392 |
(async () => {
|
| 393 |
+
document.documentElement.lang = lang;
|
| 394 |
langBtn.textContent = lang.toUpperCase();
|
| 395 |
+
updateLabels();
|
| 396 |
+
hintEl.textContent = onboarded ? t("hint") : "";
|
| 397 |
+
setState("", onboarded ? t("idle") : LANG_PROMPT);
|
| 398 |
await startCamera();
|
| 399 |
+
try { client = await Client.connect(window.location.origin); console.log("Iris conectado"); }
|
| 400 |
+
catch (e) { console.error("connect:", e); setState("", t("err")); }
|
| 401 |
})();
|
frontend/index.html
CHANGED
|
@@ -3,27 +3,57 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
|
|
|
| 6 |
<title>Iris</title>
|
| 7 |
<link rel="stylesheet" href="/static/style.css" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<!-- câmera ao vivo (fundo) -->
|
| 11 |
-
<video id="cam" autoplay playsinline muted></video>
|
| 12 |
<canvas id="canvas" hidden></canvas>
|
| 13 |
|
| 14 |
-
<!--
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
<p id="answer" aria-live="polite"></p>
|
| 19 |
-
<p id="hint"
|
| 20 |
</main>
|
| 21 |
|
| 22 |
-
<!--
|
| 23 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
<audio id="player" playsinline></audio>
|
| 26 |
-
|
| 27 |
<script type="module" src="/static/app.js"></script>
|
| 28 |
</body>
|
| 29 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
| 6 |
+
<meta name="theme-color" content="#05060a" />
|
| 7 |
<title>Iris</title>
|
| 8 |
<link rel="stylesheet" href="/static/style.css" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<!-- câmera ao vivo (fundo) -->
|
| 12 |
+
<video id="cam" autoplay playsinline muted aria-hidden="true"></video>
|
| 13 |
<canvas id="canvas" hidden></canvas>
|
| 14 |
|
| 15 |
+
<!-- barra superior: acessibilidade + idioma -->
|
| 16 |
+
<header id="topbar">
|
| 17 |
+
<button id="a11y" class="chip" aria-pressed="false" aria-label="High contrast and larger text">
|
| 18 |
+
<span aria-hidden="true">Aa</span>
|
| 19 |
+
</button>
|
| 20 |
+
<button id="lang" class="chip" aria-label="Language">PT</button>
|
| 21 |
+
</header>
|
| 22 |
+
|
| 23 |
+
<!-- palco: a tela inteira é tocável (toque=descrever, segurar=perguntar, toque duplo=ao vivo) -->
|
| 24 |
+
<main id="stage" aria-label="Iris. Tap to describe, hold to ask, double tap for live mode.">
|
| 25 |
+
<div id="halo" aria-hidden="true"></div>
|
| 26 |
+
<p id="status" role="status" aria-live="assertive">Iris</p>
|
| 27 |
<p id="answer" aria-live="polite"></p>
|
| 28 |
+
<p id="hint" aria-hidden="true"></p>
|
| 29 |
</main>
|
| 30 |
|
| 31 |
+
<!-- controles explícitos (baixa visão / teclado / leitor de tela) -->
|
| 32 |
+
<nav id="controls" aria-label="Actions">
|
| 33 |
+
<button id="btn-ask" class="ctl" aria-label="Ask a question. Press and hold, then speak.">
|
| 34 |
+
<span class="ic" aria-hidden="true">
|
| 35 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 36 |
+
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/>
|
| 37 |
+
</svg>
|
| 38 |
+
</span><span class="ctl-lbl">Ask</span>
|
| 39 |
+
</button>
|
| 40 |
+
<button id="btn-describe" class="ctl primary" aria-label="Describe what is in front of me">
|
| 41 |
+
<span class="ic" aria-hidden="true">
|
| 42 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 43 |
+
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>
|
| 44 |
+
</svg>
|
| 45 |
+
</span><span class="ctl-lbl">Describe</span>
|
| 46 |
+
</button>
|
| 47 |
+
<button id="btn-live" class="ctl" aria-pressed="false" aria-label="Live mode: announce new things around you">
|
| 48 |
+
<span class="ic" aria-hidden="true">
|
| 49 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 50 |
+
<circle cx="12" cy="12" r="2"/><path d="M16.2 7.8a6 6 0 0 1 0 8.5M7.8 16.2a6 6 0 0 1 0-8.5M19 5a10 10 0 0 1 0 14M5 19A10 10 0 0 1 5 5"/>
|
| 51 |
+
</svg>
|
| 52 |
+
</span><span class="ctl-lbl">Live</span>
|
| 53 |
+
</button>
|
| 54 |
+
</nav>
|
| 55 |
|
| 56 |
<audio id="player" playsinline></audio>
|
|
|
|
| 57 |
<script type="module" src="/static/app.js"></script>
|
| 58 |
</body>
|
| 59 |
</html>
|
frontend/style.css
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
:root {
|
| 2 |
--bg: #05060a;
|
| 3 |
--fg: #ffffff;
|
|
|
|
| 4 |
--accent: #ff7a18;
|
| 5 |
-
--
|
| 6 |
--ok: #36d399;
|
|
|
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -15,94 +19,108 @@ html, body {
|
|
| 15 |
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
| 16 |
overflow: hidden;
|
| 17 |
-webkit-tap-highlight-color: transparent;
|
| 18 |
-
user-select: none;
|
| 19 |
}
|
| 20 |
|
| 21 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
#cam {
|
| 23 |
-
position: fixed;
|
| 24 |
-
|
| 25 |
-
width: 100%;
|
| 26 |
-
height: 100%;
|
| 27 |
object-fit: cover;
|
| 28 |
-
filter: brightness(
|
| 29 |
z-index: 0;
|
| 30 |
}
|
| 31 |
|
| 32 |
-
/*
|
| 33 |
#stage {
|
| 34 |
-
position: fixed;
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
align-items: center;
|
| 40 |
-
justify-content: center;
|
| 41 |
-
text-align: center;
|
| 42 |
-
padding: 6vmin;
|
| 43 |
-
gap: 3vmin;
|
| 44 |
-
cursor: pointer;
|
| 45 |
}
|
| 46 |
|
| 47 |
-
/* halo central que reage ao estado */
|
| 48 |
#halo {
|
| 49 |
-
position: absolute;
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
border-radius: 50%;
|
| 53 |
-
background: radial-gradient(circle, rgba(255,122,24,0.18), transparent 62%);
|
| 54 |
-
transition: transform .25s ease, background .25s ease;
|
| 55 |
-
pointer-events: none;
|
| 56 |
}
|
| 57 |
-
body[data-state="listening"] #halo { background: radial-gradient(circle, rgba(0,212,255,
|
| 58 |
-
body[data-state="thinking"]
|
| 59 |
-
body[data-state="speaking"]
|
| 60 |
-
|
| 61 |
-
@keyframes pulse { 0%,100%{transform:scale(1.10)} 50%{transform:scale(1.22)} }
|
| 62 |
@keyframes spin { to { transform: rotate(360deg) } }
|
| 63 |
|
| 64 |
#status {
|
| 65 |
-
position: relative;
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
font-weight: 800;
|
| 69 |
-
letter-spacing: .01em;
|
| 70 |
-
text-shadow: 0 2px 18px rgba(0,0,0,.8);
|
| 71 |
}
|
| 72 |
-
|
| 73 |
#answer {
|
| 74 |
-
position: relative;
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
font-weight: 600;
|
| 78 |
-
line-height: 1.3;
|
| 79 |
-
max-width: 92vw;
|
| 80 |
-
text-shadow: 0 2px 18px rgba(0,0,0,.9);
|
| 81 |
-
min-height: 1.2em;
|
| 82 |
}
|
| 83 |
-
|
| 84 |
#hint {
|
| 85 |
-
position:
|
| 86 |
-
|
| 87 |
-
z-index: 2;
|
| 88 |
-
font-size: clamp(14px, 3vmin, 20px);
|
| 89 |
-
opacity: .8;
|
| 90 |
text-shadow: 0 2px 12px rgba(0,0,0,.9);
|
| 91 |
}
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
top: max(
|
| 96 |
-
|
| 97 |
-
z-index: 3;
|
| 98 |
-
background: rgba(255,255,255,.12);
|
| 99 |
-
color: var(--fg);
|
| 100 |
-
border: 2px solid rgba(255,255,255,.35);
|
| 101 |
-
border-radius: 999px;
|
| 102 |
-
padding: 10px 18px;
|
| 103 |
-
font-size: 18px;
|
| 104 |
-
font-weight: 700;
|
| 105 |
-
cursor: pointer;
|
| 106 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
| 1 |
:root {
|
| 2 |
--bg: #05060a;
|
| 3 |
--fg: #ffffff;
|
| 4 |
+
--muted: rgba(255, 255, 255, .82);
|
| 5 |
--accent: #ff7a18;
|
| 6 |
+
--listen: #00d4ff;
|
| 7 |
--ok: #36d399;
|
| 8 |
+
--glass: rgba(16, 18, 28, .55);
|
| 9 |
+
--stroke: rgba(255, 255, 255, .26);
|
| 10 |
+
--focus: #ffd166;
|
| 11 |
}
|
| 12 |
|
| 13 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
| 19 |
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
| 20 |
overflow: hidden;
|
| 21 |
-webkit-tap-highlight-color: transparent;
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
+
/* foco SEMPRE visível (acessibilidade de teclado) */
|
| 25 |
+
:focus-visible {
|
| 26 |
+
outline: 4px solid var(--focus);
|
| 27 |
+
outline-offset: 3px;
|
| 28 |
+
border-radius: 14px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
#cam {
|
| 32 |
+
position: fixed; inset: 0;
|
| 33 |
+
width: 100%; height: 100%;
|
|
|
|
|
|
|
| 34 |
object-fit: cover;
|
| 35 |
+
filter: brightness(.42) saturate(1.05);
|
| 36 |
z-index: 0;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
/* palco tocável */
|
| 40 |
#stage {
|
| 41 |
+
position: fixed; inset: 0; z-index: 1;
|
| 42 |
+
display: flex; flex-direction: column;
|
| 43 |
+
align-items: center; justify-content: center;
|
| 44 |
+
text-align: center; padding: 8vmin 6vmin 24vmin;
|
| 45 |
+
gap: 2.4vmin; cursor: pointer; user-select: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
|
|
|
| 48 |
#halo {
|
| 49 |
+
position: absolute; width: 60vmin; height: 60vmin; border-radius: 50%;
|
| 50 |
+
background: radial-gradient(circle, rgba(255,122,24,.16), transparent 62%);
|
| 51 |
+
transition: transform .25s ease, background .25s ease; pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
+
body[data-state="listening"] #halo { background: radial-gradient(circle, rgba(0,212,255,.34), transparent 60%); animation: pulse 1.1s ease-in-out infinite; }
|
| 54 |
+
body[data-state="thinking"] #halo { background: radial-gradient(circle, rgba(255,122,24,.30), transparent 60%); animation: spin 1.4s linear infinite; }
|
| 55 |
+
body[data-state="speaking"] #halo { background: radial-gradient(circle, rgba(54,211,153,.30), transparent 60%); }
|
| 56 |
+
@keyframes pulse { 0%,100%{transform:scale(1.06)} 50%{transform:scale(1.20)} }
|
|
|
|
| 57 |
@keyframes spin { to { transform: rotate(360deg) } }
|
| 58 |
|
| 59 |
#status {
|
| 60 |
+
position: relative; z-index: 2;
|
| 61 |
+
font-size: clamp(24px, 6vmin, 48px); font-weight: 800;
|
| 62 |
+
text-shadow: 0 2px 18px rgba(0,0,0,.85);
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
|
|
|
| 64 |
#answer {
|
| 65 |
+
position: relative; z-index: 2;
|
| 66 |
+
font-size: clamp(20px, 4.6vmin, 34px); font-weight: 600; line-height: 1.32;
|
| 67 |
+
max-width: 92vw; min-height: 1.2em; text-shadow: 0 2px 18px rgba(0,0,0,.9);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
|
|
|
| 69 |
#hint {
|
| 70 |
+
position: relative; z-index: 2;
|
| 71 |
+
font-size: clamp(13px, 2.8vmin, 18px); color: var(--muted);
|
|
|
|
|
|
|
|
|
|
| 72 |
text-shadow: 0 2px 12px rgba(0,0,0,.9);
|
| 73 |
}
|
| 74 |
|
| 75 |
+
/* barra superior */
|
| 76 |
+
#topbar {
|
| 77 |
+
position: fixed; top: max(3vmin, env(safe-area-inset-top, 0)); left: 0; right: 0;
|
| 78 |
+
z-index: 4; display: flex; justify-content: space-between; padding: 0 5vmin;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
+
.chip {
|
| 81 |
+
min-width: 56px; min-height: 56px; padding: 0 18px;
|
| 82 |
+
background: var(--glass); color: var(--fg);
|
| 83 |
+
border: 2px solid var(--stroke); border-radius: 999px;
|
| 84 |
+
font-size: 20px; font-weight: 800; cursor: pointer;
|
| 85 |
+
backdrop-filter: blur(10px);
|
| 86 |
+
}
|
| 87 |
+
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #1a1206; }
|
| 88 |
+
|
| 89 |
+
/* barra de controles (baixa visão / teclado / leitor de tela) */
|
| 90 |
+
#controls {
|
| 91 |
+
position: fixed; left: 0; right: 0;
|
| 92 |
+
bottom: max(4vmin, env(safe-area-inset-bottom, 0)); z-index: 4;
|
| 93 |
+
display: flex; align-items: flex-end; justify-content: center; gap: 4vmin;
|
| 94 |
+
}
|
| 95 |
+
.ctl {
|
| 96 |
+
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
| 97 |
+
min-width: 88px; min-height: 88px; padding: 12px 10px;
|
| 98 |
+
background: var(--glass); color: var(--fg);
|
| 99 |
+
border: 2px solid var(--stroke); border-radius: 24px;
|
| 100 |
+
font-size: 16px; font-weight: 700; cursor: pointer;
|
| 101 |
+
backdrop-filter: blur(12px); transition: transform .1s ease, background .15s ease;
|
| 102 |
+
}
|
| 103 |
+
.ctl:active { transform: scale(.95); }
|
| 104 |
+
.ctl .ic { display: flex; align-items: center; justify-content: center; }
|
| 105 |
+
.ctl .ic svg { width: 28px; height: 28px; display: block; }
|
| 106 |
+
.ctl-lbl { font-size: 15px; letter-spacing: .01em; }
|
| 107 |
+
.ctl.primary {
|
| 108 |
+
min-width: 116px; min-height: 116px; border-radius: 50%;
|
| 109 |
+
background: var(--accent); color: #1a1206; border-color: var(--accent);
|
| 110 |
+
font-size: 18px; box-shadow: 0 8px 30px rgba(255,122,24,.4);
|
| 111 |
+
}
|
| 112 |
+
.ctl.primary .ic svg { width: 42px; height: 42px; }
|
| 113 |
+
#btn-live.on { background: var(--ok); border-color: var(--ok); color: #062018; }
|
| 114 |
+
#btn-live.on::after { content: ""; }
|
| 115 |
+
|
| 116 |
+
/* ===== modo acessível (contraste máximo + texto maior) ===== */
|
| 117 |
+
body.a11y-boost { --glass: rgba(0,0,0,.92); --stroke: #ffffff; }
|
| 118 |
+
body.a11y-boost #cam { filter: brightness(.25); }
|
| 119 |
+
body.a11y-boost #status { font-size: clamp(30px, 8vmin, 60px); }
|
| 120 |
+
body.a11y-boost #answer { font-size: clamp(26px, 6vmin, 44px); font-weight: 800; }
|
| 121 |
+
body.a11y-boost .ctl, body.a11y-boost .chip { font-size: 20px; border-width: 3px; color: #fff; }
|
| 122 |
+
body.a11y-boost .ctl-lbl { font-weight: 900; }
|
| 123 |
|
| 124 |
+
/* respeita preferências do sistema */
|
| 125 |
+
@media (prefers-contrast: more) { :root { --glass: rgba(0,0,0,.9); --stroke: #fff; } }
|
| 126 |
+
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|