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>

Files changed (9) hide show
  1. README.md +50 -20
  2. app.py +106 -16
  3. core/gpu.py +5 -5
  4. core/stt.py +16 -4
  5. core/tts.py +17 -15
  6. core/vlm.py +53 -55
  7. frontend/app.js +305 -43
  8. frontend/index.html +39 -9
  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: false
10
  license: apache-2.0
11
- short_description: Your father's eyes, by voice. Describe & ask about the world.
 
 
 
 
 
 
12
  ---
13
 
14
- # Iris — your father's eyes, by voice
15
 
16
- Iris is a voice-first assistant for blind and low-vision users. Open it on a
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
- Built for the **Build Small Hackathon** (Backyard AI track) for my father.
22
 
23
- ## How it works (all small models, 32B total)
24
- - **Speech-to-text:** Whisper small (faster-whisper)understands the question.
25
- - **Vision-language:** Qwen2.5-VL-3B-Instruct describes the scene / reads text, in PT.
26
- - **Text-to-speech:** Piper (pt_BR) speaks the answer.
27
 
28
- Custom voice-first frontend via `gr.Server` (tap-anywhere, high-contrast,
29
- camera auto-on). Inference runs in-Space on ZeroGPU **no third-party model APIs**.
 
 
 
 
30
 
31
- ### Parameter budget
32
- Whisper-small (~0.24B) + Qwen2.5-VL-3B (~3B) ≈ **~3.3B total** well under 32B.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  ## Not a mobility aid
35
- Iris describes the environment and reads text. It is **not** an obstacle-avoidance
36
- or navigation device and should not be relied on for physical safety.
37
 
38
  ## Run locally
39
  ```bash
40
  pip install -r requirements.txt
41
- python app.py # http://localhost:7860
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 — os olhos do seu pai por voz (HF Space, ZeroGPU).
2
 
3
- gr.Server: serve um frontend custom voz-first (frontend/index.html) e expõe a
4
- API `describe` (imagem + pergunta de voz opcional -> resposta em PT + áudio).
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 entrega FileData como dict; aceita dict ou objeto."""
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) -> dict:
33
- """Recebe um frame da câmera (+ pergunta de voz opcional) e devolve a
34
- descrição em PT + o áudio falado."""
35
- apath = _path(audio)
36
- question = stt.transcribe(apath) if apath else ""
37
- answer = vlm.describe(_path(image), question)
 
 
 
 
 
 
 
 
38
  if not answer.strip():
39
- answer = "Não consegui descrever isso. Tente de novo."
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"), "teste")
62
- stt.transcribe(tts.synthesize("teste"))
63
  print("Warmup OK", flush=True)
64
  except Exception as e:
65
- print("Warmup falhou:", e, flush=True)
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` é um decorator: no HF Spaces (ZeroGPU) ele aloca uma GPU por chamada;
4
- localmente (sem o pacote `spaces`) vira um no-op transparente. Assim o mesmo
5
- código roda nas 3060 locais e no Space.
6
  """
7
  import functools
8
 
9
  try:
10
- import spaces # disponível nos Spaces ZeroGPU
11
  _HAS_SPACES = True
12
  except Exception:
13
  _HAS_SPACES = False
14
 
15
 
16
  def gpu(duration: int = 60):
17
- """Aloca GPU por `duration`s na chamada (ZeroGPU). No-op local."""
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). Português por padrão.
2
 
3
- Não usa torch — faster-whisper roda em CTranslate2 (GPU se disponível, senão CPU).
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 precisa das libs CUDA 12 (cublas/cudnn). Se faltarem, CPU.
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] sem 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
 
 
 
 
 
 
 
 
 
 
 
 
 
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, pt_BR). Retorna caminho de um .wav.
2
 
3
- Piper roda em onnxruntime (sem torch). A voz é baixada do repo rhasspy/piper-voices.
4
  """
5
  import os
6
  import tempfile
7
  import wave
8
 
9
- _voice = None
10
  _REPO = "rhasspy/piper-voices"
11
- # voz pt_BR padrão; trocar via IRIS_TTS_VOICE
12
- _VOICE = os.environ.get("IRIS_TTS_VOICE", "pt/pt_BR/faber/medium/pt_BR-faber-medium")
 
 
 
13
 
14
 
15
- def _load():
16
- global _voice
17
- if _voice is None:
18
  from huggingface_hub import hf_hub_download
19
  from piper import PiperVoice
20
- onnx = hf_hub_download(_REPO, f"{_VOICE}.onnx")
21
- conf = hf_hub_download(_REPO, f"{_VOICE}.onnx.json")
22
- _voice = PiperVoice.load(onnx, config_path=conf)
23
- return _voice
 
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] sem áudio para texto: {text!r}", flush=True)
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: descreve / responde sobre uma imagem, em PT.
2
 
3
- Backend plugável via IRIS_VLM_MODEL:
4
- - Qwen/Qwen2.5-VL-3B-Instruct (default)
5
- - openbmb/MiniCPM-V-4.6 -> track OpenBMB ($2.5k); API model.chat()
6
- O VLM É o gerador do texto que vai pra voz — sem LLM narrador separado.
7
  """
8
  import os
9
 
10
  _model = None
11
- _aux = None # processor (qwen) ou tokenizer (minicpm)
12
- MODEL_ID = os.environ.get("IRIS_VLM_MODEL", "Qwen/Qwen2.5-VL-3B-Instruct")
 
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'. Se houver texto "
18
- "importante (rótulo, placa, remédio), leia-o exatamente como está."
 
 
19
  )
 
 
 
 
 
 
 
 
 
20
 
21
- DOWNSAMPLE = os.environ.get("IRIS_DOWNSAMPLE", "4x") # 4x = detalhe fino (OCR); 16x = rápido
 
 
 
 
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
- if _family() == "minicpm":
33
- from transformers import AutoModelForImageTextToText, AutoProcessor
34
- _model = AutoModelForImageTextToText.from_pretrained(
35
- MODEL_ID, trust_remote_code=True, torch_dtype=torch.float16,
36
- low_cpu_mem_usage=True, device_map="cuda:0",
37
- ).eval()
38
- _aux = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
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 numpy da webcam
55
  image = image.convert("RGB")
56
- image.thumbnail((1024, 1024)) # menos tokens de visão -> mais rápido; mantém OCR
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
- user = (question or "").strip() or "O que há à minha frente?"
 
 
 
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": SYSTEM_PT},
91
  {"role": "user", "content": [
92
  {"type": "image", "image": image},
93
  {"type": "text", "text": user},
94
  ]},
95
  ]
96
- text = aux.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
97
- image_inputs, video_inputs = process_vision_info(messages)
98
- inputs = aux(text=[text], images=image_inputs, videos=video_inputs,
99
- padding=True, return_tensors="pt").to(model.device)
 
 
 
 
 
100
  with torch.no_grad():
101
- generated = model.generate(**inputs, max_new_tokens=96, do_sample=False)
102
- trimmed = generated[:, inputs.input_ids.shape[1]:]
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://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
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 to describe · hold to ask", camErr: "Camera blocked — allow access",
13
- err: "Something went wrong" },
 
 
 
 
14
  pt: { idle: "Iris", listening: "Ouvindo…", thinking: "Pensando…",
15
- hint: "Toque para descrever · segure para perguntar", camErr: "Câmera bloqueada permita o acesso",
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
- if (!busy) statusEl.textContent = t("idle");
 
 
 
32
  };
33
 
34
- // ---- câmera ao vivo (auto) ----
 
 
 
 
 
 
 
 
 
 
35
  async function startCamera() {
36
- const tries = [
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) { /* tenta o próximo */ }
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 (grava on-hold) ----
56
- let micStream = null, recorder = null, chunks = [];
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 url = out && out.audio && (out.audio.url || out.audio.path);
91
- if (url) { player.src = url; await player.play().catch(() => {}); }
92
- else { resetSoon(); }
 
 
 
 
 
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
- // ---- interação: tap = descrever · segurar = perguntar ----
105
- let holding = false, recording = false, holdTimer = null;
106
- const HOLD_MS = 350;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- stage.addEventListener("pointerdown", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const frame = await grabFrame();
124
- if (!frame) { setState("", t("camErr")); return; }
125
- if (recording) { const audio = await stopRec(); recording = false; send(frame, audio); }
126
- else { send(frame, null); } // tap rápido = descrever
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  }
128
  stage.addEventListener("pointerup", endPress);
129
  stage.addEventListener("pointercancel", endPress);
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  // ---- boot ----
132
  (async () => {
 
133
  langBtn.textContent = lang.toUpperCase();
134
- hintEl.textContent = t("hint");
135
- statusEl.textContent = t("idle");
 
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
- <!-- alvo único: toda a tela é o botão -->
15
- <main id="stage" role="application" aria-label="Iris">
16
- <div id="halo"></div>
17
- <p id="status" aria-live="assertive">Iris</p>
 
 
 
 
 
 
 
 
18
  <p id="answer" aria-live="polite"></p>
19
- <p id="hint">Toque para descrever · segure para perguntar</p>
20
  </main>
21
 
22
- <!-- idioma -->
23
- <button id="lang" aria-label="Idioma">PT</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- --accent-2: #00d4ff;
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
- /* câmera ao vivo como fundo, escurecida pra contraste do texto */
 
 
 
 
 
 
22
  #cam {
23
- position: fixed;
24
- inset: 0;
25
- width: 100%;
26
- height: 100%;
27
  object-fit: cover;
28
- filter: brightness(0.45) saturate(1.05);
29
  z-index: 0;
30
  }
31
 
32
- /* a tela inteira é o botão */
33
  #stage {
34
- position: fixed;
35
- inset: 0;
36
- z-index: 1;
37
- display: flex;
38
- flex-direction: column;
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
- width: 64vmin;
51
- height: 64vmin;
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,0.30), transparent 60%); transform: scale(1.12); animation: pulse 1.1s ease-in-out infinite; }
58
- body[data-state="thinking"] #halo { background: radial-gradient(circle, rgba(255,122,24,0.28), transparent 60%); animation: spin 1.4s linear infinite; }
59
- body[data-state="speaking"] #halo { background: radial-gradient(circle, rgba(54,211,153,0.28), transparent 60%); transform: scale(1.06); }
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
- z-index: 2;
67
- font-size: clamp(22px, 6vmin, 46px);
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
- z-index: 2;
76
- font-size: clamp(20px, 4.6vmin, 34px);
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: fixed;
86
- bottom: max(5vmin, env(safe-area-inset-bottom, 0));
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
- #lang {
94
- position: fixed;
95
- top: max(4vmin, env(safe-area-inset-top, 0));
96
- right: 5vmin;
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
- #stage.armed { background: rgba(0,212,255,.05); }
 
 
 
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; } }