primocode 10.15.0 → 10.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
 
31
31
  import threading
32
32
  import time
33
+ import os
33
34
  import wave
34
35
  from pathlib import Path
35
36
  from typing import List, Optional
@@ -46,10 +47,10 @@ TETO_FALA = 700.0 # nunca exige mais que isso (não ficar surdo)
46
47
  FATOR_CONTINUAR = 0.5 # a histerese: depois de começar, o limiar cai
47
48
  PRE_ROLL = 4 # blocos guardados ANTES do início da fala
48
49
 
49
- RABO_SILENCIO = 2.0 # silêncio que encerra a frase
50
- RABO_SILENCIO_LONGA = 2.8 # …e quando a fala já vem longa
50
+ RABO_SILENCIO = 1.4 # silêncio que encerra a frase
51
+ RABO_SILENCIO_LONGA = 1.8 # …e quando a fala já vem longa
51
52
  FALA_LONGA = 6.0
52
- RABO_VAD = 0.9 # silêncio que encerra a frase quando o VAD confirma
53
+ RABO_VAD = 1.1 # silêncio que encerra a frase quando o VAD confirma
53
54
  ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
54
55
  MAX_FRASE = 45.0
55
56
  MAX_FRASE_ESTENDIDA = 100.0
@@ -97,6 +98,7 @@ class Gravador:
97
98
  # VAD opcional (Silero, via pysilero-vad): bloco → True se for voz.
98
99
  # Com ele, a frase fecha em 0,9 s de silêncio em vez de 2 s.
99
100
  self.vad = None
101
+ self.espera = ESPERA_COMECAR # quanto espera a fala começar (encurta quando há frase pendente)
100
102
 
101
103
  # O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
102
104
  # abrir o microfone daria o silêncio do processo, não o do cômodo.
@@ -142,7 +144,7 @@ class Gravador:
142
144
  self._falando = True
143
145
  self._blocos = list(self._pre)
144
146
  self._ultimo_som = agora
145
- elif agora > ESPERA_COMECAR:
147
+ elif agora > self.espera:
146
148
  self.encerrou_por = "ninguem-falou"
147
149
  return True
148
150
  return False
@@ -228,7 +230,31 @@ class Microfone:
228
230
  self._ao_receber = ao_receber
229
231
  self._fluxo = None
230
232
 
233
+ def _abrir_de_arquivo(self, caminho: str) -> None:
234
+ """Modo de teste (PRIMOCODE_TESTE_WAV): em vez do microfone, toca um WAV 16 kHz mono na mesma entrada,
235
+ depois de 12 s de silêncio (a saudação termina e o motor assenta) e com silêncio no fim."""
236
+ import threading
237
+ import time as _t
238
+ import numpy as _np
239
+
240
+ def alimentar():
241
+ # "a.wav@12;b.wav@90": cada arquivo entra N s depois do anterior (ou da abertura)
242
+ silencio = _np.zeros(BLOCO, dtype=_np.int16)
243
+ for item in caminho.split(";"):
244
+ arq, _, espera = item.partition("@")
245
+ with wave.open(arq, "rb") as w:
246
+ dados = _np.frombuffer(w.readframes(w.getnframes()), dtype=_np.int16)
247
+ _t.sleep(float(espera or os.environ.get("PRIMOCODE_TESTE_ATRASO", "12")))
248
+ for i in range(0, len(dados) + BLOCO * 20, BLOCO):
249
+ bloco = dados[i:i + BLOCO]
250
+ self._ao_receber(bloco if len(bloco) == BLOCO else (_np.concatenate([bloco, silencio])[:BLOCO]))
251
+ _t.sleep(BLOCO_MS / 1000)
252
+ threading.Thread(target=alimentar, daemon=True).start()
253
+
231
254
  def abrir(self) -> None:
255
+ if os.environ.get("PRIMOCODE_TESTE_WAV"):
256
+ return self._abrir_de_arquivo(os.environ["PRIMOCODE_TESTE_WAV"])
257
+
232
258
  def retorno(dados, quadros, tempo, estado): # noqa: ARG001
233
259
  self._ao_receber(dados[:, 0].copy())
234
260
 
@@ -0,0 +1,87 @@
1
+ """fala.py — a voz do Primo: Edge TTS (neural pt-BR) dentro do processo, sem subprocesso por frase.
2
+
3
+ Ordem: Edge TTS (precisa de internet) → ElevenLabs só se a pessoa pediu (`"tts": "elevenlabs"` em
4
+ ~/.primocode/voz.json) → voz do sistema (`say`) offline. As vozes: francisca (padrão), antonio, thalita.
5
+ O event loop e o import do edge_tts ficam vivos na thread `Edge`; cada frase é uma tarefa nele.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import threading
11
+ import unicodedata
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ VOZES = {"francisca": "pt-BR-FranciscaNeural", "antonio": "pt-BR-AntonioNeural", "thalita": "pt-BR-ThalitaMultilingualNeural"}
16
+ ORDEM = ["francisca", "antonio", "thalita"]
17
+
18
+
19
+ def _s(t: str) -> str:
20
+ return "".join(c for c in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(c) != "Mn")
21
+
22
+
23
+ def voz_pedida(texto: str, atual: str) -> Optional[str]:
24
+ """"muda a voz" / "troca a voz para antonio" → o id da nova voz (a próxima da lista, se não disse qual); None se não era isso."""
25
+ import re
26
+ m = re.search(r"\b(?:muda|mudar|troca|trocar|mude|troque)\s+(?:a\s+)?voz(?:\s+(?:para|pra|pro|do|da|de)\s+(?:a\s+|o\s+)?(\w+))?", _s(texto))
27
+ if not m:
28
+ return None
29
+ pedida = m.group(1)
30
+ if pedida and pedida in VOZES:
31
+ return pedida
32
+ i = ORDEM.index(atual) if atual in ORDEM else -1
33
+ return ORDEM[(i + 1) % len(ORDEM)]
34
+
35
+
36
+ class Edge:
37
+ def __init__(self) -> None:
38
+ self.ok = False
39
+ self._loop: "asyncio.AbstractEventLoop | None" = None
40
+ self._pronto = threading.Event()
41
+ threading.Thread(target=self._rodar, daemon=True).start()
42
+
43
+ def _rodar(self) -> None:
44
+ try:
45
+ import edge_tts # noqa: F401
46
+ self.ok = True
47
+ except ImportError:
48
+ self._pronto.set()
49
+ return
50
+ self._loop = asyncio.new_event_loop()
51
+ self._pronto.set()
52
+ self._loop.run_forever()
53
+
54
+ async def _tarefa(self, texto: str, voz: str, saida: Path) -> bool:
55
+ import edge_tts
56
+ n = 0
57
+ with open(saida, "wb") as f:
58
+ async for c in edge_tts.Communicate(texto, voz, rate="+8%").stream():
59
+ if c["type"] == "audio":
60
+ f.write(c["data"])
61
+ n += len(c["data"])
62
+ return n > 0
63
+
64
+ def sintetizar(self, texto: str, voz_id: str, saida: Path, limite: float = 15.0) -> bool:
65
+ self._pronto.wait(5)
66
+ if not self.ok or not self._loop:
67
+ return False
68
+ try:
69
+ fut = asyncio.run_coroutine_threadsafe(self._tarefa(texto, VOZES.get(voz_id, VOZES["francisca"]), saida), self._loop)
70
+ return bool(fut.result(limite))
71
+ except Exception:
72
+ return False
73
+
74
+ def aquecer(self) -> None:
75
+ """Uma frase curta descartada: abre DNS, TLS e o módulo antes da primeira fala de verdade."""
76
+ import tempfile
77
+ p = Path(tempfile.gettempdir()) / "primo-voz-aquece.mp3"
78
+ self.sintetizar("oi", "francisca", p)
79
+ try:
80
+ p.unlink()
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ def hora_falada(t) -> str:
86
+ """"São 19 e 05." — o mesmo texto no app.py (resposta) e no motor (pré-voz do próximo minuto)."""
87
+ return f"São {t.tm_hour} e {t.tm_min:02d}." if t.tm_min else f"São {t.tm_hour} horas em ponto."
@@ -0,0 +1,151 @@
1
+ """fala_pt.py — o texto vira o que se FALA, em português do Brasil, antes de ir para a voz.
2
+
3
+ Rede de segurança: o cérebro já devolve um `falar` escrito para ser ouvido (app.py), e isto limpa o resto —
4
+ markdown, links, caminhos, código, emojis — e diz por extenso números, unidades, dinheiro, horas, datas,
5
+ versões e siglas ("32 s" → "trinta e dois segundos", "R$ 99" → "noventa e nove reais", "PR" → "pê érre").
6
+ Puro: sem rede, sem Mac. Python 3.9+.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List
12
+
13
+ _UN = ["zero", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove", "dez", "onze", "doze", "treze",
14
+ "catorze", "quinze", "dezesseis", "dezessete", "dezoito", "dezenove"]
15
+ _DZ = ["", "", "vinte", "trinta", "quarenta", "cinquenta", "sessenta", "setenta", "oitenta", "noventa"]
16
+ _CT = ["", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos", "seiscentos", "setecentos", "oitocentos", "novecentos"]
17
+
18
+
19
+ def _ate_999(n: int) -> str:
20
+ if n < 20:
21
+ return _UN[n]
22
+ if n < 100:
23
+ d, u = divmod(n, 10)
24
+ return _DZ[d] + (" e " + _UN[u] if u else "")
25
+ if n == 100:
26
+ return "cem"
27
+ c, r = divmod(n, 100)
28
+ return _CT[c] + (" e " + _ate_999(r) if r else "")
29
+
30
+
31
+ def extenso(n: int, feminino: bool = False) -> str:
32
+ """0..999.999.999.999 por extenso ("mil duzentos e trinta e quatro")."""
33
+ if n < 0:
34
+ return "menos " + extenso(-n, feminino)
35
+ if n < 1000:
36
+ t = _ate_999(n)
37
+ if feminino:
38
+ t = re.sub(r"\bum\b", "uma", t)
39
+ t = re.sub(r"\bdois\b", "duas", t)
40
+ t = re.sub(r"(\w+)entos\b", r"\1entas", t)
41
+ return t
42
+ partes: List[str] = []
43
+ for valor, sing, plur in ((10 ** 9, "bilhão", "bilhões"), (10 ** 6, "milhão", "milhões"), (1000, "mil", "mil")):
44
+ q, n = divmod(n, valor)
45
+ if not q:
46
+ continue
47
+ if valor == 1000:
48
+ partes.append("mil" if q == 1 else extenso(q, feminino) + " mil")
49
+ else:
50
+ partes.append(extenso(q) + " " + (sing if q == 1 else plur))
51
+ if n:
52
+ partes.append(("e " if n < 100 or n % 100 == 0 else "") + extenso(n, feminino))
53
+ return " ".join(partes).replace(" ", " ")
54
+
55
+
56
+ def _inteiro(txt: str) -> int:
57
+ return int(txt.replace(".", "")) if re.fullmatch(r"\d{1,3}(?:\.\d{3})+", txt) else int(txt)
58
+
59
+
60
+ def _decimal(parte_int: str, parte_dec: str) -> str:
61
+ return f"{extenso(_inteiro(parte_int))} vírgula " + " ".join(extenso(int(d)) for d in parte_dec) if parte_dec.startswith("0") \
62
+ else f"{extenso(_inteiro(parte_int))} vírgula {extenso(int(parte_dec))}"
63
+
64
+
65
+ def _num(txt: str) -> str:
66
+ if "," in txt:
67
+ i, d = txt.split(",", 1)
68
+ return _decimal(i, d)
69
+ return extenso(_inteiro(txt))
70
+
71
+
72
+ _UNIDADES = {
73
+ "ms": ("milissegundo", "milissegundos"), "s": ("segundo", "segundos"), "seg": ("segundo", "segundos"),
74
+ "min": ("minuto", "minutos"), "h": ("hora", "horas"), "px": ("pixel", "pixels"),
75
+ "kb": ("kilobyte", "kilobytes"), "mb": ("megabyte", "megabytes"), "gb": ("gigabyte", "gigabytes"), "tb": ("terabyte", "terabytes"),
76
+ "km": ("quilômetro", "quilômetros"), "kg": ("quilo", "quilos"), "g": ("grama", "gramas"), "cm": ("centímetro", "centímetros"),
77
+ "mm": ("milímetro", "milímetros"), "m": ("metro", "metros"), "fps": ("quadro por segundo", "quadros por segundo"),
78
+ "mhz": ("megahertz", "megahertz"), "ghz": ("gigahertz", "gigahertz"), "hz": ("hertz", "hertz"),
79
+ "kbps": ("kilobit por segundo", "kilobits por segundo"), "mbps": ("megabit por segundo", "megabits por segundo"),
80
+ }
81
+ _SIGLAS = {
82
+ "PR": "pê érre", "PRs": "pê érres", "npm": "ene pê eme", "CTA": "chamada", "API": "á pê í", "APIs": "á pê ís", "CSS": "cê ésse ésse",
83
+ "HTML": "agá tê eme éle", "JSON": "jêi són", "URL": "u érre éle", "CLI": "cê éle í", "CPU": "cê pê u", "RAM": "ram", "UI": "u í",
84
+ "UX": "u ékis", "SQL": "ésse quê éle", "SVG": "ésse vê gê", "PDF": "pê dê éfe", "MP3": "ême pê três", "MP4": "ême pê quatro",
85
+ "IA": "í á", "GPU": "gê pê u", "SSD": "ésse ésse dê", "TTS": "tê tê ésse", "STT": "ésse tê tê", "VAD": "vê á dê", "OK": "ôquei",
86
+ "TCC": "tê cê cê", "SDK": "ésse dê cá", "CI": "cê í", "CD": "cê dê", "ID": "í dê", "TI": "tê í", "QA": "quê á", "OKR": "ô cá érre",
87
+ "git": "guit", "GitHub": "guit rrâb", "README": "rídmi", "Spotify": "Spotifái", "WhatsApp": "uótsápi", "macOS": "mák ó ésse",
88
+ }
89
+ _LETRAS = dict(zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "á bê cê dê é éfe gê agá í jota cá éle ême ene ó pê quê érre ésse tê u vê dáblio ékis ípsilon zê".split()))
90
+ _MESES = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
91
+
92
+
93
+ def _dinheiro(m: "re.Match") -> str:
94
+ ints, cents = m.group(1), m.group(2)
95
+ n = _inteiro(ints)
96
+ t = extenso(n) + (" real" if n == 1 else " reais")
97
+ if cents and int(cents):
98
+ c = int(cents)
99
+ t += " e " + extenso(c) + (" centavo" if c == 1 else " centavos")
100
+ return t
101
+
102
+
103
+ def _unidade(m: "re.Match") -> str:
104
+ num, un = m.group(1), m.group(2)
105
+ forma = _UNIDADES.get(un.lower())
106
+ if not forma or (len(un) == 1 and un not in "smhg" and un != "m"):
107
+ return m.group(0)
108
+ singular = num in ("1", "01") or num == "1,0"
109
+ return f"{_num(num)} {forma[0] if singular else forma[1]}"
110
+
111
+
112
+ def normalizar(texto: str) -> str:
113
+ t = str(texto or "")
114
+ t = re.sub(r"```[\s\S]*?```", " ", t) # bloco de código
115
+ t = re.sub(r"`([^`\n]*)`", lambda m: "" if re.search(r"[/\\.]|\(|\)", m.group(1)) else m.group(1), t)
116
+ t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", t) # [texto](link) → texto
117
+ t = re.sub(r"https?://\S+|www\.\S+", "", t)
118
+ t = re.sub(r"(?<![\w])(?:~|\.{1,2})?/[\w.\-@%+/]+", " ", t) # caminhos /a/b.js ~/Primo/x
119
+ t = re.sub(r"\b[\w\-]+(?:/[\w.\-]+)+\.\w+\b", " ", t) # a/b/c.js
120
+ t = re.sub(r"\b[\w\-]+\.(?:js|ts|tsx|jsx|py|html|css|json|md|sh|yml|yaml|png|jpg|svg|mp4|wav|mp3|pdf)\b", "um arquivo", t)
121
+ t = re.sub(r"^\s*(?:#{1,6}|[-*•]|\d+[.)])\s+", "", t, flags=re.M) # títulos e marcadores
122
+ t = re.sub(r"[*_#>|~]+", " ", t)
123
+ t = re.sub(r"[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍←-⇿⌀-⏿●▸▪▣⎿]", " ", t) # emojis e símbolos
124
+ # dinheiro
125
+ t = re.sub(r"R\$\s*(\d{1,3}(?:\.\d{3})*|\d+)(?:,(\d{1,2}))?", _dinheiro, t)
126
+ # versões: v10.15.2 / 10.15.2 / v10.15
127
+ t = re.sub(r"\bv(\d+(?:\.\d+){1,3})\b", lambda m: "versão " + " ponto ".join(extenso(int(x)) for x in m.group(1).split(".")), t)
128
+ t = re.sub(r"\b(\d+(?:\.\d+){2,3})\b", lambda m: " ponto ".join(extenso(int(x)) for x in m.group(1).split(".")), t)
129
+ # hora e data
130
+ t = re.sub(r"\b([01]?\d|2[0-3]):([0-5]\d)\b", lambda m: extenso(int(m.group(1))) + (" horas" if int(m.group(1)) != 1 else " hora") + (" e " + extenso(int(m.group(2))) if int(m.group(2)) else ""), t)
131
+ t = re.sub(r"\b(0?[1-9]|[12]\d|3[01])/(0?[1-9]|1[0-2])(?:/(\d{4}))?\b",
132
+ lambda m: extenso(int(m.group(1))) + " de " + _MESES[int(m.group(2)) - 1] + (" de " + extenso(int(m.group(3))) if m.group(3) else ""), t)
133
+ # porcentagem
134
+ t = re.sub(r"(\d+(?:,\d+)?)\s?%", lambda m: _num(m.group(1)) + " por cento", t)
135
+ # número + unidade ("32 s", "10px", "1,5 GB")
136
+ t = re.sub(r"(?<![\w.,])(\d{1,3}(?:\.\d{3})+|\d+(?:,\d+)?)\s?([A-Za-z]{1,4})\b(?!\w)", _unidade, t)
137
+ # siglas e nomes técnicos (palavra inteira)
138
+ for sig in sorted(_SIGLAS, key=len, reverse=True):
139
+ t = re.sub(r"(?<![\w])" + re.escape(sig) + r"(?![\w])", _SIGLAS[sig], t)
140
+ t = re.sub(r"(?<![\w])([A-Z]{2,4})(?![\w])", lambda m: " ".join(_LETRAS[c] for c in m.group(1)), t) # sigla desconhecida: soletra
141
+ # números que sobraram
142
+ t = re.sub(r"(?<![\w.])(\d{1,3}(?:\.\d{3})+|\d+),(\d+)(?![\w])", lambda m: _decimal(m.group(1), m.group(2)), t)
143
+ t = re.sub(r"(?<![\w.,])(\d{1,3}(?:\.\d{3})+|\d+)(?![\w.,]\d)", lambda m: extenso(_inteiro(m.group(1))), t)
144
+ t = re.sub(r"\s*[()\[\]{}]\s*", ", ", t)
145
+ t = re.sub(r"\s*[—–]\s*", ", ", t)
146
+ t = re.sub(r"\s*[&]\s*", " e ", t)
147
+ t = re.sub(r"\s*\+\s*", " mais ", t)
148
+ t = re.sub(r"\s*=>?\s*", " ", t)
149
+ t = re.sub(r"\s+([,.!?;:])", r"\1", t)
150
+ t = re.sub(r"(,\s*){2,}", ", ", t)
151
+ return re.sub(r"\s+", " ", t).strip(" ,")
@@ -3,6 +3,9 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>Primo</title>
6
+ <link rel="preload" as="script" href="vendor/spline-runtime.min.js">
7
+ <link rel="preload" as="fetch" crossorigin href="https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode">
8
+ <script src="vendor/spline-runtime.min.js"></script>
6
9
  <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
7
10
  <style>
8
11
  :root {
@@ -15,6 +18,10 @@
15
18
  html,body { height:100%; background:var(--fundo); color:var(--texto); font-family:var(--sans); -webkit-font-smoothing:antialiased; user-select:none; -webkit-user-select:none; }
16
19
  body { background:radial-gradient(ellipse 60% 55% at 50% 42%, #1a2a2c 0%, var(--fundo) 62%); overflow:hidden; }
17
20
  .tela { height:100vh; padding:38px 28px 24px; display:grid; gap:18px; grid-template-columns:minmax(250px,22%) 1fr minmax(280px,25%); grid-template-rows:minmax(0,1fr) auto auto; }
21
+ @property --v { syntax:'<number>'; inherits:true; initial-value:0; }
22
+ .cartao { transition:transform .28s cubic-bezier(.2,.8,.2,1), box-shadow .28s, border-color .28s; animation:sobe .75s cubic-bezier(.2,.8,.2,1) backwards; animation-delay:calc(var(--i,0)*70ms); }
23
+ .cartao:hover { transform:translateY(-3px); border-color:#3a3a36; box-shadow:0 1px 0 rgba(255,255,255,.05) inset, 0 28px 44px -22px rgba(0,0,0,.9); }
24
+ @keyframes sobe { from { opacity:0; transform:translateY(18px); } }
18
25
  .cartao { background:linear-gradient(180deg,var(--cartao2),var(--cartao)); border:1px solid var(--borda); border-radius:var(--raio); padding:18px 22px; box-shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 20px 40px -24px rgba(0,0,0,.8); }
19
26
  .rotulo { font-size:11px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--fraco); }
20
27
 
@@ -26,7 +33,12 @@
26
33
  .cal .sem { color:var(--fraco); font-size:11px; font-weight:600; padding-bottom:3px; height:auto; }
27
34
  .cal span { height:30px; display:grid; place-items:center; color:var(--apagado); border-radius:50%; width:30px; margin:auto; position:relative; }
28
35
  .cal .fds { color:var(--fraco); }
29
- .cal .hoje { background:var(--marca); color:#06191c; font-weight:700; }
36
+ .cal .hoje { background:var(--marca); color:#06191c; font-weight:700; animation:pulso-dia 2.6s ease-in-out infinite; }
37
+ @keyframes pulso-dia { 0%,100% { box-shadow:0 0 0 0 rgba(34,211,238,.45); } 50% { box-shadow:0 0 0 7px rgba(34,211,238,0); } }
38
+ .capa.troca { animation:capa-troca .8s cubic-bezier(.2,.8,.2,1); } @keyframes capa-troca { from { opacity:0; transform:rotate(-140deg) scale(.6); } }
39
+ @keyframes fade { from { opacity:0; } }
40
+ .faixa.troca { animation:fade .6s ease; }
41
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } }
30
42
  .cal .tem::after { content:""; width:4px; height:4px; border-radius:50%; background:var(--marca); position:absolute; bottom:1px; }
31
43
  .cal .hoje.tem::after { background:#06191c; }
32
44
  .hoje-card { flex:1; min-height:0; overflow:hidden; }
@@ -49,13 +61,22 @@
49
61
  .nome p::before { content:""; display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--ok); margin-right:8px; box-shadow:0 0 10px var(--ok); }
50
62
  .nome[data-e="pensando"] p::before, .nome[data-e="executando"] p::before { background:#f5b94a; box-shadow:0 0 10px #f5b94a; }
51
63
  .nome[data-e="dormindo"] p::before { background:var(--fraco); box-shadow:none; }
64
+ .nome .pasta { font:12px var(--mono); color:var(--fraco); margin-top:2px; }
65
+ .nome .pasta::before { display:none; }
66
+ .b-entrar { margin-top:8px; background:var(--marca); color:#06191c; border:0; border-radius:999px; padding:8px 18px; font:600 13px var(--sans); cursor:pointer; }
52
67
  .fala { position:absolute; top:0; left:50%; transform:translateX(-50%); max-width:70%; text-align:center; color:var(--apagado); font-size:13px; line-height:1.4; max-height:3em; overflow:hidden; }
53
68
  /* robô 3D: canvas transparente sobre o palco; o SVG só aparece se o WebGL falhar */
54
69
  #robo3d { width:100%; height:100%; display:none; -webkit-mask-image:linear-gradient(#000 82%,transparent); mask-image:linear-gradient(#000 82%,transparent); }
55
- .tres-d #robo3d { display:block; } .tres-d #svg-robo { display:none; }
70
+ #svg-robo { display:none; }
71
+ .tres-d #robo3d { display:block; animation:entra .6s ease both; } .svg #svg-robo { display:block; animation:entra .6s ease both; }
72
+ @keyframes entra { from { opacity:0; } }
73
+ /* carregando: nenhum robô, só o lugar dele com um brilho ciano suave pulsando */
74
+ #carrega { display:none; width:min(30vh,260px); height:min(48vh,420px); border-radius:50% 50% 42% 42% / 38% 38% 62% 62%; background:radial-gradient(closest-side,rgba(34,211,238,.22),rgba(34,211,238,.07) 62%,transparent); filter:blur(14px); animation:carrega 2.2s ease-in-out infinite; pointer-events:none; }
75
+ .carregando #carrega { display:block; }
76
+ @keyframes carrega { 0%,100% { opacity:.35; transform:scale(.94); } 50% { opacity:.95; transform:scale(1.04); } }
56
77
  /* robô do Spline (cena da comunidade): canvas transparente, brilho ciano por CSS, busto pelo zoom da câmera */
57
78
  #robo-spline { width:100% !important; height:100% !important; visibility:hidden; background:transparent; --nv:0; -webkit-mask-image:linear-gradient(#000 44%,rgba(0,0,0,.55) 58%,transparent 76%); mask-image:linear-gradient(#000 44%,rgba(0,0,0,.55) 58%,transparent 76%); filter:drop-shadow(0 0 18px rgba(34,211,238,.22)); transition:filter .4s; }
58
- .spline #robo-spline { visibility:visible; } .spline #robo3d, .spline #svg-robo { display:none; }
79
+ .spline #robo-spline { visibility:visible; animation:entra .6s ease both; } .spline #robo3d, .spline #svg-robo { display:none; }
59
80
  .palco > #robo-spline { grid-area:1/1; }
60
81
  .spline.e-ouvindo #robo-spline { filter:drop-shadow(0 0 26px rgba(34,211,238,.55)); }
61
82
  .spline.e-pensando #robo-spline, .spline.e-executando #robo-spline { filter:drop-shadow(0 0 22px rgba(34,211,238,.38)); animation:sp-pensa 1.8s ease-in-out infinite; }
@@ -97,16 +118,16 @@
97
118
  .capa::after { content:""; position:absolute; inset:44%; border-radius:50%; background:var(--fundo); box-shadow:0 0 0 2px #0b0b0a; }
98
119
  .faixa { text-align:center; max-width:100%; } .faixa b { display:block; font-size:16px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .faixa small { color:var(--apagado); font-size:13px; }
99
120
  .prog { width:100%; display:flex; align-items:center; gap:10px; font:12px var(--mono); color:var(--fraco); }
100
- .prog .t { flex:1; height:4px; border-radius:2px; background:var(--borda); overflow:hidden; } .prog .t i { display:block; height:100%; width:0; background:var(--texto); border-radius:2px; }
121
+ .prog .t { flex:1; height:4px; border-radius:2px; background:var(--borda); overflow:hidden; } .prog .t i { display:block; height:100%; width:0; transition:width .3s linear; background:var(--texto); border-radius:2px; }
101
122
  .ctrl { display:flex; gap:30px; align-items:center; }
102
123
  .ctrl button { background:none; border:0; color:var(--texto); cursor:pointer; display:grid; place-items:center; }
103
124
  .ctrl button:active { transform:scale(.9); }
104
125
  .ctrl .play { width:50px; height:50px; border-radius:50%; background:var(--texto); color:var(--fundo); }
105
126
 
106
127
  /* medidores */
107
- .medidores { grid-column:1 / -1; display:grid; grid-template-columns:repeat(4,1fr) 1.6fr; gap:18px; }
128
+ .medidores { grid-column:1 / -1; display:grid; grid-template-columns:repeat(4,1fr); gap:18px; }
108
129
  .med { display:flex; align-items:center; gap:16px; padding:14px 20px; }
109
- .anel { --v:0; width:62px; height:62px; border-radius:50%; flex:none; display:grid; place-items:center; background:conic-gradient(var(--c,var(--marca)) calc(var(--v)*1%), var(--borda) 0); position:relative; }
130
+ .anel { transition:--v .9s cubic-bezier(.2,.8,.2,1); --v:0; width:62px; height:62px; border-radius:50%; flex:none; display:grid; place-items:center; background:conic-gradient(var(--c,var(--marca)) calc(var(--v)*1%), var(--borda) 0); position:relative; }
110
131
  .anel::before { content:""; position:absolute; inset:6px; border-radius:50%; background:var(--cartao); }
111
132
  .anel span { position:relative; font:600 15px var(--sans); font-variant-numeric:tabular-nums; }
112
133
  .med b { display:block; font-size:14px; } .med small { color:var(--apagado); font-size:12px; }
@@ -117,7 +138,7 @@
117
138
  /* voz */
118
139
  .voz { grid-column:1 / -1; display:flex; align-items:center; gap:18px; padding:12px 22px; }
119
140
  #barras { flex:1; height:46px; display:flex; align-items:center; gap:3px; }
120
- #barras i { flex:1; border-radius:3px; height:8%; background:var(--marca); transition:height .08s linear; }
141
+ #barras i { flex:1; border-radius:3px; height:8%; background:var(--marca); transition:height .12s ease-out; }
121
142
  #barras i.p { background:var(--ok); } #barras i.z { background:var(--borda); }
122
143
  .leg { display:flex; gap:16px; font-size:12px; color:var(--apagado); white-space:nowrap; }
123
144
  .leg span::before { content:""; display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--marca); }
@@ -143,6 +164,7 @@
143
164
  <div class="fala" id="fala"></div>
144
165
  <div class="palco">
145
166
  <div class="chao"></div>
167
+ <div id="carrega"></div>
146
168
  <canvas id="robo3d" aria-label="Primo"></canvas>
147
169
  <canvas id="robo-spline" aria-label="Primo"></canvas>
148
170
  <svg id="svg-robo" viewBox="0 0 400 520" aria-label="Primo" class="desmontado">
@@ -216,7 +238,7 @@
216
238
  </g>
217
239
  </svg>
218
240
  </div>
219
- <div class="nome" id="nome" data-e="dormindo"><h1 id="nome-h">Primo</h1><p id="estado-t">em espera</p></div>
241
+ <div class="nome" id="nome" data-e="dormindo"><h1 id="nome-h">Primo</h1><p id="estado-t">em espera</p><p id="pasta" class="pasta"></p><button id="b-entrar" class="b-entrar" hidden>Entrar</button></div>
220
242
  </section>
221
243
 
222
244
  <section class="dir">
@@ -242,10 +264,6 @@
242
264
  <div class="cartao med"><div class="anel" id="a-mem" style="--c:var(--marca-clara)"><span>–</span></div><div><b>Memória</b><small id="s-mem">&nbsp;</small></div></div>
243
265
  <div class="cartao med"><div class="anel" id="a-disco" style="--c:var(--apagado)"><span>–</span></div><div><b>Disco</b><small id="s-disco">&nbsp;</small></div></div>
244
266
  <div class="cartao med"><div class="anel" id="a-bat" style="--c:var(--ok)"><span>–</span></div><div><b>Bateria</b><small id="s-bat">&nbsp;</small></div></div>
245
- <div class="cartao rede">
246
- <div class="lin"><span>REDE</span><span id="r-txt">↓ 0 KB/s · ↑ 0 KB/s</span></div>
247
- <svg viewBox="0 0 300 44" preserveAspectRatio="none"><path id="r-down" d="" fill="none" stroke="#22d3ee" stroke-width="2"/><path id="r-up" d="" fill="none" stroke="#7ad7a3" stroke-width="1.5" opacity=".85"/></svg>
248
- </div>
249
267
  </section>
250
268
 
251
269
  <section class="cartao voz">
@@ -256,7 +274,6 @@
256
274
  </main>
257
275
 
258
276
  <script src="vendor/three-robo.min.js"></script>
259
- <script src="vendor/spline-runtime.min.js"></script>
260
277
  <script>
261
278
  const $ = (id) => document.getElementById(id);
262
279
  const post = (o) => { try { window.webkit.messageHandlers.primo.postMessage(JSON.stringify(o)); } catch (e) {} };
@@ -353,12 +370,13 @@ const R3 = (function () {
353
370
  /* ── robô do Spline (runtime local, cena na rede). Falhou ou passou de 6 s: fica o RobotExpressive ── */
354
371
  const SP = (function () {
355
372
  const CENA = 'https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode';
356
- const X = { ok: false, tentou: false, app: null, bot: null };
373
+ const X = { ok: false, tentou: false, app: null, bot: null, falhou: false };
357
374
  X.iniciar = () => {
358
375
  if (X.tentou || !window.SplineApp) return; X.tentou = true;
359
376
  let acabou = false;
360
- const cai = (why) => { if (acabou) return; acabou = true; try { X.app && X.app.stop(); } catch (e) {} X.ok = false; post({ acao: 'erro', arg: 'spline: ' + why + ' (fallback RobotExpressive)' }); classeTela(); };
377
+ const cai = (why) => { if (acabou) return; acabou = true; try { X.app && X.app.stop(); } catch (e) {} X.ok = false; X.falhou = true; post({ acao: 'erro', arg: 'spline: ' + why + ' (fallback RobotExpressive)' }); classeTela(); };
361
378
  const tempo = setTimeout(() => cai('mais de 6 s'), 6000);
379
+ X.cancela = () => { acabou = true; clearTimeout(tempo); X.falhou = true; try { X.app && X.app.stop(); } catch (e) {} };
362
380
  try {
363
381
  const app = new window.SplineApp($('robo-spline')); X.app = app;
364
382
  app.load(CENA).then(() => {
@@ -372,6 +390,7 @@ const SP = (function () {
372
390
  } catch (e) { clearTimeout(tempo); cai(String(e)); }
373
391
  };
374
392
  // leve: janela oculta/minimizada = para o render
393
+ setTimeout(() => X.iniciar(), 0); // já na carga da página, antes de a ponte do Python falar
375
394
  document.addEventListener('visibilitychange', () => { if (!X.app || !X.ok) return; if (document.hidden) X.app.stop(); else X.app.play(); });
376
395
  // falando: o robô respira no ritmo da voz (escala do objeto "Bot" da cena) + brilho ciano pelo CSS (--nv)
377
396
  X.quadro = (agora) => {
@@ -427,7 +446,7 @@ function desenharHoje() {
427
446
  function desenharClima(c) {
428
447
  if (!c || c.temp === undefined) return;
429
448
  $('c-cidade').textContent = c.cidade || '';
430
- $('c-temp').textContent = c.temp + '°';
449
+ rolar($('c-temp'), c.temp, '°');
431
450
  $('c-cond').textContent = c.icone + ' ' + c.cond;
432
451
  $('c-mm').textContent = 'Máx. ' + c.max + '° · Mín. ' + c.min + '°';
433
452
  $('c-dias').innerHTML = (c.dias || []).slice(0, 4).map((d) => {
@@ -444,6 +463,7 @@ $('m-play').innerHTML = ICO_PLAY;
444
463
  function desenharMusica(m) {
445
464
  S.musica = m && m.faixa ? m : null; S.musicaT = Date.now();
446
465
  if (!S.musica) { $('m-faixa').textContent = 'Nada tocando'; $('m-artista').textContent = 'abra o Spotify ou a Música'; $('disco').classList.remove('toca'); $('capa').style.backgroundImage = ''; $('m-dur').textContent = '0:00'; $('m-play').innerHTML = ICO_PLAY; return; }
466
+ if (m.faixa !== S.faixaAnt) { S.faixaAnt = m.faixa; for (const e of [$('capa'), document.querySelector('.faixa')]) { e.classList.remove('troca'); void e.offsetWidth; e.classList.add('troca'); } }
447
467
  $('m-faixa').textContent = m.faixa; $('m-artista').textContent = m.artista; $('m-dur').textContent = mmss(m.dur);
448
468
  const tocando = m.estado === 'playing';
449
469
  $('disco').classList.toggle('toca', tocando);
@@ -469,17 +489,23 @@ document.querySelectorAll('.ctrl button').forEach((b) => b.onclick = (e) => {
469
489
  const fmtBytes = (b) => b >= 1e6 ? (b / 1e6).toFixed(1) + ' MB/s' : Math.round(b / 1e3) + ' KB/s';
470
490
  const hist = { down: Array(60).fill(0), up: Array(60).fill(0) };
471
491
  function caminho(a, max) { return a.map((v, i) => (i ? 'L' : 'M') + (i * 300 / (a.length - 1)).toFixed(1) + ' ' + (40 - 36 * Math.min(1, v / max)).toFixed(1)).join(' '); }
472
- function anel(id, pct, texto, cor) { const e = $(id); e.style.setProperty('--v', Math.max(0, Math.min(100, pct))); e.firstChild.textContent = texto; if (cor) e.style.setProperty('--c', cor); }
492
+ const RM = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
493
+ /* número que rola até o valor (60 fps, ease-out); sem animação se a pessoa pediu menos movimento */
494
+ function rolar(el, alvo, suf) {
495
+ suf = suf || ''; alvo = Number(alvo); const de = el._n === undefined ? 0 : el._n; el._n = alvo;
496
+ cancelAnimationFrame(el._r);
497
+ if (RM || de === alvo || isNaN(alvo)) { el.textContent = alvo + suf; return; }
498
+ const t0 = performance.now();
499
+ const f = (t) => { const k = Math.min(1, (t - t0) / 800), e = 1 - Math.pow(1 - k, 3); el.textContent = Math.round(de + (alvo - de) * e) + suf; if (k < 1) el._r = requestAnimationFrame(f); };
500
+ el._r = requestAnimationFrame(f);
501
+ }
502
+ function anel(id, pct, texto, cor) { const e = $(id); e.style.setProperty('--v', Math.max(0, Math.min(100, pct))); const m = String(texto).match(/^(-?\d+)(.*)$/); if (m) rolar(e.firstChild, m[1], m[2]); else e.firstChild.textContent = texto; if (cor) e.style.setProperty('--c', cor); }
473
503
  function desenharMaquina(m) {
474
504
  anel('a-cpu', m.cpu, m.cpu + '%'); $('s-cpu').textContent = m.nucleos + ' núcleos';
475
505
  if (m.mem && m.mem.total_gb) { anel('a-mem', m.mem.pct, m.mem.pct + '%'); $('s-mem').textContent = String(m.mem.usada_gb).replace('.', ',') + ' de ' + m.mem.total_gb + ' GB'; }
476
506
  if (m.disco && m.disco.livre_gb !== undefined) { anel('a-disco', m.disco.pct, m.disco.pct + '%'); $('s-disco').textContent = m.disco.livre_gb + ' GB livres'; }
477
507
  if (m.bat && m.bat.pct !== undefined) { anel('a-bat', m.bat.pct, String(m.bat.pct), m.bat.pct <= 20 && !m.bat.tomada ? 'var(--erro)' : 'var(--ok)'); $('s-bat').textContent = m.bat.tomada ? 'na tomada' : 'na bateria'; }
478
508
  else { $('s-bat').textContent = 'sem bateria'; }
479
- hist.down.push(m.down || 0); hist.down.shift(); hist.up.push(m.up || 0); hist.up.shift();
480
- const mx = Math.max(2e4, ...hist.down, ...hist.up);
481
- $('r-down').setAttribute('d', caminho(hist.down, mx)); $('r-up').setAttribute('d', caminho(hist.up, mx));
482
- $('r-txt').textContent = '↓ ' + fmtBytes(m.down || 0) + ' · ↑ ' + fmtBytes(m.up || 0);
483
509
  }
484
510
 
485
511
  /* ── barras da voz: o histórico rola; ciano = a pessoa falou, verde = o Primo falou ── */
@@ -495,7 +521,11 @@ function empurraBarra() {
495
521
  }
496
522
 
497
523
  /* ── o robô ── */
498
- function classeTela() { $('tela').className = 'tela e-' + S.estado + (SP.ok ? ' spline' : R3.ok ? ' tres-d' : ''); }
524
+ function classeTela() {
525
+ // enquanto o Spline carrega: só o brilho (nada de robô). O fallback só entra se o Spline falhou de verdade.
526
+ const f = SP.ok ? ' spline' : !SP.falhou ? ' carregando' : R3.ok ? ' tres-d' : ' svg';
527
+ $('tela').className = 'tela e-' + S.estado + f;
528
+ }
499
529
  function setEstado(e) {
500
530
  if (!e || e === S.estado) return;
501
531
  S.estado = e; classeTela(); $('nome').dataset.e = e;
@@ -506,6 +536,7 @@ function setEstado(e) {
506
536
  $('foco').style.opacity = e === 'falando' ? 0 : .9;
507
537
  }
508
538
  $('centro').onclick = () => post({ acao: 'ptt' });
539
+ $('b-entrar').onclick = (e) => { e.stopPropagation(); post({ acao: 'entrar' }); };
509
540
 
510
541
  /* simples: o visor vira equalizador de 13 barras quando fala; olha de lado quando pensa */
511
542
  const EQ = []; (function () { const g = $('eq'); for (let i = 0; i < 13; i++) { const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); r.setAttribute('x', 136 + i * 9.8); r.setAttribute('width', 6); r.setAttribute('rx', 3); r.setAttribute('fill', '#a5f3fc'); g.appendChild(r); EQ.push(r); } })();
@@ -525,21 +556,24 @@ function laco(t) { SP.quadro(t); if (!R3.ok && !SP.ok) animaEq(t); requestAnimat
525
556
 
526
557
  /* ── a ponte com o Python ── */
527
558
  window.primo = (m) => {
559
+ if (m.pasta !== undefined) $('pasta').textContent = m.pasta;
560
+ if (m.login !== undefined) $('b-entrar').hidden = !!m.login;
528
561
  if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; if (S.estado === 'dormindo') $('estado-t').textContent = 'diga "' + m.nome + '"'; }
529
- if (m.robo === 'spline') SP.iniciar();
562
+ if (m.robo === 'expressive' && !SP.ok) { if (SP.cancela) SP.cancela(); SP.falhou = true; classeTela(); }
530
563
  if (m.evento) R3.evento(m.evento);
531
564
  if (m.boot) montar();
532
565
  if (m.estado) setEstado(m.estado);
533
566
  if (m.barras) { S.quem = m.barras.quem; S.nivel = m.barras.nivel; R3.nivelFala(m.barras.quem === 'primo' ? m.barras.nivel : 0); }
534
567
  if (m.transcricao !== undefined || m.resposta !== undefined) { const t = m.resposta || m.transcricao || ''; $('fala').textContent = t.length > 160 ? t.slice(0, 157) + '…' : t; }
535
- if (m.calendario) { S.perm = m.calendario.permissao; diasComEvento = m.calendario.dias || []; desenharCalendario(); desenharHoje(); }
536
- if (m.hoje) { S.hoje = m.hoje; desenharHoje(); }
568
+ if (m.calendario) { const k = JSON.stringify(m.calendario); if (k !== S.calKey) { S.calKey = k; S.perm = m.calendario.permissao; diasComEvento = m.calendario.dias || []; desenharCalendario(); desenharHoje(); } }
569
+ if (m.hoje) { const k = JSON.stringify(m.hoje); S.perm = 'ok'; if (k !== S.hojeKey) { S.hojeKey = k; S.hoje = m.hoje; desenharHoje(); } }
537
570
  if (m.clima) desenharClima(m.clima);
538
571
  if (m.musica !== undefined) desenharMusica(m.musica);
539
572
  if (m.maquina) desenharMaquina(m.maquina);
540
573
  };
541
574
 
542
- viraDia(); desenharHoje(); setEstado('dormindo');
575
+ document.querySelectorAll('.cartao').forEach((c, i) => c.style.setProperty('--i', i));
576
+ viraDia(); desenharHoje(); setEstado('dormindo'); classeTela();
543
577
  setInterval(() => { viraDia(); andarMusica(); }, 250);
544
578
  setInterval(empurraBarra, 50);
545
579
  setTimeout(pisca, 3000);