primocode 10.14.1 → 10.15.1

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.
@@ -0,0 +1,319 @@
1
+ """tela.py — os dados REAIS da tela do Primo (calendário, clima, música, máquina) e as regras pequenas dela.
2
+
3
+ Nada aqui desenha. hud.py chama estas funções numa thread e empurra o resultado para hud.html;
4
+ a lógica que não depende de tela (texto de "em X min", troca de robô, quem fala nas barras) se testa sem microfone.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from typing import Dict, List, Optional
17
+
18
+ from primo_voz import identidade
19
+ from primo_voz import painel as pn
20
+
21
+
22
+ # ── regras puras ─────────────────────────────────────────────────────────
23
+
24
+ def em_x_min(inicio: float, agora: float) -> str:
25
+ """'em 25 min', 'em 1 h 10 min', 'agora' (começou há pouco), 'amanhã' passa da meia-noite."""
26
+ d = int((inicio - agora) // 60)
27
+ if d < -5:
28
+ return "já começou"
29
+ if d <= 0:
30
+ return "agora"
31
+ if d < 60:
32
+ return f"em {d} min"
33
+ h, m = divmod(d, 60)
34
+ if h >= 24:
35
+ return "amanhã"
36
+ return f"em {h} h" + (f" {m} min" if m else "")
37
+
38
+
39
+ def barras(estado: str, nivel: float) -> Dict[str, object]:
40
+ """Quem fala e quão alto: a PESSOA (ciano) em 'ouvindo' e em espera (o mic escuta); o PRIMO (verde) só em 'falando'. O resto é silêncio."""
41
+ n = max(0.0, min(1.0, float(nivel or 0)))
42
+ if estado in ("ouvindo", "dormindo"):
43
+ return {"quem": "pessoa", "nivel": n}
44
+ if estado == "falando":
45
+ return {"quem": "primo", "nivel": n}
46
+ return {"quem": None, "nivel": 0.0}
47
+
48
+
49
+ def nivel_do_mic(rms: float) -> float:
50
+ return max(0.0, min(1.0, rms / 4000.0))
51
+
52
+
53
+ def envelope(arquivo, janela: float = 0.05) -> List[float]:
54
+ """Volume real da fala do Primo (0..1 a cada 50 ms): converte o mp3 em wav com afconvert e mede. Sem Mac = []."""
55
+ if sys.platform != "darwin" or not shutil.which("afconvert"):
56
+ return []
57
+ import array
58
+ import wave
59
+ tmp = tempfile.mktemp(suffix=".wav")
60
+ try:
61
+ subprocess.run(["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(arquivo), tmp],
62
+ capture_output=True, timeout=10, check=True)
63
+ with wave.open(tmp) as w:
64
+ dados = array.array("h", w.readframes(w.getnframes()))
65
+ taxa = w.getframerate()
66
+ passo = max(1, int(taxa * janela))
67
+ env = []
68
+ for i in range(0, len(dados), passo):
69
+ b = dados[i:i + passo]
70
+ env.append((sum(x * x for x in b) / len(b)) ** 0.5 if b else 0.0)
71
+ pico = max(env) if env else 0
72
+ return [round(min(1.0, e / pico * 1.1), 3) for e in env] if pico else []
73
+ except Exception:
74
+ return []
75
+ finally:
76
+ try:
77
+ os.unlink(tmp)
78
+ except OSError:
79
+ pass
80
+
81
+
82
+ def hoje_lista(eventos: List[dict], agora: float) -> List[dict]:
83
+ """Os eventos de HOJE que ainda importam (não acabaram), o primeiro marcado como próximo, com 'em X min'."""
84
+ t = time.localtime(agora)
85
+ out = []
86
+ for e in eventos:
87
+ if time.localtime(e["inicio"]).tm_mday != t.tm_mday or e["fim"] <= agora and not e.get("dia_todo"):
88
+ continue
89
+ item = {"titulo": e["titulo"], "hora": "dia todo" if e.get("dia_todo") else time.strftime("%H:%M", time.localtime(e["inicio"])),
90
+ "em": "" if e.get("dia_todo") else em_x_min(e["inicio"], agora), "prox": False}
91
+ out.append(item)
92
+ for item in out:
93
+ if item["hora"] != "dia todo":
94
+ item["prox"] = True
95
+ break
96
+ return out
97
+
98
+
99
+ # ── calendário (EventKit; pede permissão uma vez) ────────────────────────
100
+
101
+ _ek: dict = {}
102
+
103
+
104
+ def _store():
105
+ if "s" not in _ek:
106
+ from EventKit import EKEventStore
107
+ _ek["s"] = EKEventStore.alloc().init()
108
+ return _ek["s"]
109
+
110
+
111
+ def calendario_permissao() -> str:
112
+ """ok | pendente | negada | sem (sem EventKit/sem Mac)."""
113
+ if sys.platform != "darwin":
114
+ return "sem"
115
+ try:
116
+ from EventKit import EKEventStore
117
+ except ImportError:
118
+ return "sem"
119
+ st = int(EKEventStore.authorizationStatusForEntityType_(0))
120
+ # 4 = só escrita (um pedido antigo concedeu menos do que o Primo precisa): o macOS não deixa "subir" por código,
121
+ # então tratamos como pendente e o pedido zera a decisão do próprio app antes de pedir o acesso completo.
122
+ return {3: "ok", 0: "pendente", 4: "pendente", 1: "negada", 2: "negada"}.get(st, "negada")
123
+
124
+
125
+ def pedir_calendario() -> None:
126
+ """Dispara o pedido do macOS (uma vez). Não bloqueia. O resultado vai para o log (stderr)."""
127
+ if time.time() - _ek.get("pedindo", 0) < 120: # o aviso do macOS já está na tela esperando resposta
128
+ return
129
+ _ek["pedindo"] = time.time()
130
+
131
+ def fim(ok, err):
132
+ _ek["pedindo"] = 0
133
+ sys.stderr.write(f"calendario: pedido respondido ok={bool(ok)} erro={err} status={calendario_permissao()}\n")
134
+ sys.stderr.flush()
135
+ try:
136
+ from EventKit import EKEventStore
137
+ if int(EKEventStore.authorizationStatusForEntityType_(0)) == 4:
138
+ r = subprocess.run(["tccutil", "reset", "Calendar", "ai.primocode.primo"], capture_output=True, text=True)
139
+ sys.stderr.write(f"calendario: acesso só de escrita; zerando a decisão do Primo ({r.returncode})\n")
140
+ _ek.pop("s", None)
141
+ s = _store()
142
+ sys.stderr.write(f"calendario: pedindo acesso (status antes={calendario_permissao()}, bundle={_bundle()})\n")
143
+ sys.stderr.flush()
144
+ if hasattr(s, "requestFullAccessToEventsWithCompletion_"):
145
+ s.requestFullAccessToEventsWithCompletion_(fim)
146
+ else:
147
+ s.requestAccessToEntityType_completion_(0, fim)
148
+ except Exception as e:
149
+ sys.stderr.write(f"calendario: falhou ao pedir: {e}\n")
150
+
151
+
152
+ def _bundle() -> str:
153
+ try:
154
+ from Foundation import NSBundle
155
+ return str(NSBundle.mainBundle().bundleIdentifier())
156
+ except Exception:
157
+ return "?"
158
+
159
+
160
+ def eventos_do_mes(agora: Optional[float] = None) -> dict:
161
+ """{'permissao','eventos':[{titulo,inicio,fim,dia_todo,dia}]} do mês inteiro (hoje e dias com ponto)."""
162
+ perm = calendario_permissao()
163
+ if perm == "pendente":
164
+ pedir_calendario()
165
+ if perm != "ok":
166
+ return {"permissao": perm, "eventos": []}
167
+ from Foundation import NSDate
168
+ agora = agora or time.time()
169
+ t = time.localtime(agora)
170
+ ini = time.mktime((t.tm_year, t.tm_mon, 1, 0, 0, 0, 0, 0, -1))
171
+ fim = time.mktime((t.tm_year + (t.tm_mon == 12), t.tm_mon % 12 + 1, 1, 0, 0, 0, 0, 0, -1))
172
+ s = _store()
173
+ pred = s.predicateForEventsWithStartDate_endDate_calendars_(NSDate.dateWithTimeIntervalSince1970_(ini),
174
+ NSDate.dateWithTimeIntervalSince1970_(fim), None)
175
+ out = []
176
+ for e in sorted(s.eventsMatchingPredicate_(pred) or [], key=lambda e: e.startDate().timeIntervalSince1970()):
177
+ a, b = e.startDate().timeIntervalSince1970(), e.endDate().timeIntervalSince1970()
178
+ out.append({"titulo": str(e.title() or "(sem título)"), "inicio": a, "fim": b, "dia_todo": bool(e.isAllDay()),
179
+ "dia": time.localtime(a).tm_mday})
180
+ return {"permissao": perm, "eventos": out}
181
+
182
+
183
+ # ── clima (open-meteo, cidade por IP, sem chave) ─────────────────────────
184
+
185
+ _COD = [((0,), "Céu limpo", "☀︎"), ((1, 2), "Poucas nuvens", "⛅︎"), ((3,), "Nublado", "☁︎"), ((45, 48), "Neblina", "≋"),
186
+ ((51, 53, 55, 56, 57), "Garoa", "☂︎"), ((61, 63, 65, 66, 67, 80, 81, 82), "Chuva", "☂︎"),
187
+ ((71, 73, 75, 77, 85, 86), "Neve", "❄︎"), ((95, 96, 99), "Tempestade", "⚡︎")]
188
+
189
+
190
+ def condicao(cod: int):
191
+ for cods, txt, ico in _COD:
192
+ if cod in cods:
193
+ return txt, ico
194
+ return "—", "·"
195
+
196
+
197
+ def clima_widget(cache: dict) -> dict:
198
+ """cidade, temperatura agora, condição, máx/mín e os próximos 4 dias. Cache de 20 min; offline = o último."""
199
+ if time.time() - cache.get("tw", 0) < 1200 and "w" in cache:
200
+ return cache["w"]
201
+ cache["tw"] = time.time()
202
+ v = cache.get("w", {})
203
+ try:
204
+ g = pn._json_url("https://ipapi.co/json/")
205
+ lat, lon = float(g["latitude"]), float(g["longitude"])
206
+ f = pn._json_url(f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
207
+ "&current=temperature_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min"
208
+ "&timezone=auto&forecast_days=5")
209
+ d, c = f["daily"], f["current"]
210
+ txt, ico = condicao(int(c["weather_code"]))
211
+ v = {"cidade": g.get("city", ""), "temp": round(c["temperature_2m"]), "cond": txt, "icone": ico,
212
+ "max": round(d["temperature_2m_max"][0]), "min": round(d["temperature_2m_min"][0]),
213
+ "dias": [{"data": d["time"][i], "max": round(d["temperature_2m_max"][i]),
214
+ "icone": condicao(int(d["weather_code"][i]))[1]} for i in range(1, len(d["time"]))]}
215
+ except Exception:
216
+ pass
217
+ cache["w"] = v
218
+ return v
219
+
220
+
221
+ # ── música (Spotify ou Music) ────────────────────────────────────────────
222
+
223
+ _capa: dict = {}
224
+
225
+
226
+ def _capa_music() -> str:
227
+ """O Music não dá URL: grava a arte da faixa num arquivo temporário e devolve como data URI."""
228
+ tmp = os.path.join(tempfile.gettempdir(), "primo-capa.dat")
229
+ r = pn._osa('tell application "Music" to tell artwork 1 of current track to set d to raw data\n'
230
+ f'set f to open for access POSIX file "{tmp}" with write permission\nset eof f to 0\nwrite d to f\nclose access f', 4)
231
+ try:
232
+ b = open(tmp, "rb").read()
233
+ os.unlink(tmp)
234
+ except OSError:
235
+ return ""
236
+ if not b:
237
+ return ""
238
+ tipo = "image/png" if b[:4] == b"\x89PNG" else "image/jpeg"
239
+ return f"data:{tipo};base64," + base64.b64encode(b).decode()
240
+
241
+
242
+ def musica() -> dict:
243
+ """{'app','estado','faixa','artista','pos','dur','capa'}; {} se nada está aberto. A capa só é buscada quando a faixa muda."""
244
+ if sys.platform != "darwin":
245
+ return {}
246
+ for app in ("Spotify", "Music"):
247
+ div = 1000.0 if app == "Spotify" else 1.0
248
+ art = ' & "|" & (artwork url of current track)' if app == "Spotify" else ""
249
+ r = pn._osa(f'if application "{app}" is running then tell application "{app}" to return (player state as string) & "|" & '
250
+ f'(player position as string) & "|" & (duration of current track as string) & "|" & name of current track & "|" '
251
+ f'& artist of current track{art}', 2.5)
252
+ p = r.split("|")
253
+ if len(p) < 5:
254
+ continue
255
+ try:
256
+ pos, dur = float(p[1].replace(",", ".")), float(p[2].replace(",", ".")) / div
257
+ except ValueError:
258
+ continue
259
+ chave = (app, p[3], p[4])
260
+ if _capa.get("k") != chave:
261
+ _capa["k"] = chave
262
+ _capa["v"] = p[5] if app == "Spotify" and len(p) > 5 else (_capa_music() if app == "Music" else "")
263
+ return {"app": app, "estado": p[0], "faixa": p[3], "artista": p[4], "pos": pos, "dur": dur, "capa": _capa.get("v", "")}
264
+ return {}
265
+
266
+
267
+ def controle(acao: str) -> None:
268
+ pn.controle_midia(acao)
269
+
270
+
271
+ # ── a máquina ────────────────────────────────────────────────────────────
272
+
273
+ def memoria() -> dict:
274
+ """{'usada_gb','total_gb','pct'} (usada = ativa + fixa + comprimida, como o painel antigo)."""
275
+ try:
276
+ total = int(pn._rodar(["sysctl", "-n", "hw.memsize"], 2.0).strip())
277
+ s = pn._rodar(["vm_stat"], 2.0)
278
+ tam = int(re.search(r"page size of (\d+)", s).group(1))
279
+ pag = {k: int(v) for k, v in re.findall(r"Pages ([a-z ]+):\s+(\d+)", s)}
280
+ usada = (pag.get("active", 0) + pag.get("wired down", 0) + pag.get("occupied by compressor", 0)) * tam
281
+ return {"usada_gb": round(usada / 1e9, 1), "total_gb": round(total / 1e9), "pct": round(100 * usada / total)}
282
+ except Exception:
283
+ return {}
284
+
285
+
286
+ def disco() -> dict:
287
+ try:
288
+ u = shutil.disk_usage("/")
289
+ return {"pct": round(100 * u.used / u.total), "livre_gb": round(u.free / 1e9)}
290
+ except Exception:
291
+ return {}
292
+
293
+
294
+ def bateria() -> dict:
295
+ s = pn._rodar(["pmset", "-g", "batt"], 2.0)
296
+ m = re.search(r"(\d+)%", s)
297
+ if not m:
298
+ return {}
299
+ return {"pct": int(m.group(1)), "tomada": "AC Power" in s}
300
+
301
+
302
+ def maquina(am: "pn.Amostrador") -> dict:
303
+ cores = [round(x, 3) for x in am.cores()]
304
+ r = {"cpu": round(100 * sum(cores) / len(cores)) if cores else 0, "nucleos": len(cores) or (os.cpu_count() or 0),
305
+ "mem": memoria(), "disco": disco(), "bat": bateria()}
306
+ r.update({k: round(v, 1) for k, v in am.rede().items()})
307
+ return r
308
+
309
+
310
+ def acao(m: dict) -> bool:
311
+ """Ações da tela que só ela entende. True = tratada."""
312
+ a, arg = m.get("acao"), str(m.get("arg") or "")
313
+ if a == "calendario":
314
+ if calendario_permissao() == "pendente":
315
+ pedir_calendario() # o pedido do macOS sai com o nome do app (Primo)
316
+ else:
317
+ subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"])
318
+ return True
319
+ return False
@@ -27,7 +27,7 @@ import threading
27
27
  from pathlib import Path
28
28
  from typing import Optional
29
29
 
30
- MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "small").strip() or "small"
30
+ MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "base").strip() or "base"
31
31
  PASTA = Path.home() / ".primocode" / "modelos" / "whisper"
32
32
 
33
33
  _modelo = None
@@ -69,6 +69,8 @@ def aquecer() -> None:
69
69
  def _ir():
70
70
  try:
71
71
  carregar()
72
+ import numpy as np # uma passada em silêncio: a primeira frase de verdade já não paga o aquecimento
73
+ list(_modelo.transcribe(np.zeros(16000, dtype=np.float32), language="pt", beam_size=1)[0])
72
74
  except Exception:
73
75
  pass
74
76
  threading.Thread(target=_ir, daemon=True).start()
@@ -112,9 +114,18 @@ def aproveitavel(segmento) -> bool:
112
114
 
113
115
  def transcrever(wav: Path, idioma: str = "pt") -> str:
114
116
  modelo = carregar()
117
+ entrada: object = str(wav)
118
+ try: # WAV 16 kHz mono: entrega o áudio já decodificado (não depende do `av`, que às vezes destoa do faster-whisper)
119
+ import wave
120
+ import numpy as np
121
+ with wave.open(str(wav), "rb") as w:
122
+ if w.getframerate() == 16000 and w.getnchannels() == 1 and w.getsampwidth() == 2:
123
+ entrada = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
124
+ except Exception:
125
+ entrada = str(wav)
115
126
  # vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
116
127
  # "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
117
- segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
128
+ segmentos, _ = modelo.transcribe(entrada, language=idioma, beam_size=1,
118
129
  vad_filter=True, condition_on_previous_text=False)
119
130
  return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
120
131