primocode 10.14.1 → 10.15.0

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,459 @@
1
+ """painel.py — o que o HUD mostra, sem desenhar nada.
2
+
3
+ `Painel.aplicar(msg)` recebe uma linha do protocolo (JSON já lido) e atualiza o
4
+ quadro; hud.py (Mac, PyObjC) e `renderizar_tui` (qualquer terminal) só
5
+ DESENHAM este quadro. Assim a regra mora num lugar e se testa sem tela.
6
+
7
+ Mensagens aceitas (todas opcionais, uma por linha):
8
+ {"estado": "ouvindo|pensando|falando|executando|dormindo|interrompido|esperando", "nivel": 0..1}
9
+ {"transcricao": "texto", "parcial": true}
10
+ {"resposta": "texto do painel"}
11
+ {"info": {"projeto": "...", "branch": "...", "tokens": "12k", "plano": "..."}}
12
+ {"painel": "esconder" | "mostrar" | "alternar"}
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from typing import Dict, List, Optional
23
+
24
+ ROTULOS = {
25
+ "esperando": "PRONTO", "dormindo": "EM ESPERA", "ouvindo": "OUVINDO", "pensando": "PENSANDO",
26
+ "falando": "FALANDO", "executando": "EXECUTANDO", "interrompido": "INTERROMPIDO",
27
+ }
28
+
29
+
30
+ class Painel:
31
+ def __init__(self) -> None:
32
+ self.estado = "esperando"
33
+ self.nivel = 0.0
34
+ self.transcricao = ""
35
+ self.parcial = False
36
+ self.resposta = ""
37
+ self.nome = "Primo"
38
+ self.visivel = True
39
+ # só o que dá para pegar do computador da pessoa (ver coletar_tudo)
40
+ self.info: Dict[str, str] = {"tocando": "", "app": "", "wifi": "", "clima": "", "evento": ""}
41
+ self.sistema: Dict[str, Optional[float]] = {"cpu": None, "mem": None, "disco": None, "bat": None}
42
+
43
+ def aplicar(self, msg: dict) -> None:
44
+ if "estado" in msg and msg["estado"]:
45
+ self.estado = str(msg["estado"])
46
+ if "nivel" in msg and msg["nivel"] is not None:
47
+ self.nivel = max(0.0, min(1.0, float(msg["nivel"])))
48
+ if "transcricao" in msg:
49
+ self.transcricao = str(msg["transcricao"] or "")
50
+ self.parcial = bool(msg.get("parcial"))
51
+ if not self.parcial and self.transcricao:
52
+ self.resposta = "" # pedido novo: a resposta velha sai
53
+ if "resposta" in msg:
54
+ self.resposta = str(msg["resposta"] or "")
55
+ if msg.get("nome"):
56
+ self.nome = str(msg["nome"])
57
+ if isinstance(msg.get("info"), dict):
58
+ for k, v in msg["info"].items():
59
+ if k in self.info:
60
+ self.info[k] = str(v)
61
+ p = msg.get("painel")
62
+ if p == "esconder":
63
+ self.visivel = False
64
+ elif p == "mostrar":
65
+ self.visivel = True
66
+ elif p == "alternar":
67
+ self.visivel = not self.visivel
68
+ if msg.get("esconder"):
69
+ self.visivel = False
70
+ if msg.get("mostrar"):
71
+ self.visivel = True
72
+
73
+ @property
74
+ def rotulo(self) -> str:
75
+ return ROTULOS.get(self.estado, self.estado.upper())
76
+
77
+ @property
78
+ def cor(self) -> str:
79
+ """ciano = a conversa (ouvir, falar); âmbar = o trabalho (pensar, executar)."""
80
+ return "ambar" if self.estado in ("pensando", "executando", "interrompido") else "ciano"
81
+
82
+
83
+ # ── o que o computador está fazendo (sem dependência) ────────────────────
84
+
85
+ def _rodar(cmd: List[str], t: float = 3.0) -> str:
86
+ try:
87
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=t).stdout
88
+ except Exception:
89
+ return ""
90
+
91
+
92
+ def coletar_sistema() -> Dict[str, Optional[float]]:
93
+ """cpu, mem e bateria em 0..1 (None = não deu para medir)."""
94
+ cpu = mem = bat = None
95
+ try:
96
+ n = os.cpu_count() or 1
97
+ cpu = min(1.0, os.getloadavg()[0] / n)
98
+ except Exception:
99
+ pass
100
+ try:
101
+ import psutil # opcional
102
+ cpu = psutil.cpu_percent(interval=None) / 100.0
103
+ mem = psutil.virtual_memory().percent / 100.0
104
+ b = psutil.sensors_battery()
105
+ bat = b.percent / 100.0 if b else None
106
+ return {"cpu": cpu, "mem": mem, "bat": bat}
107
+ except Exception:
108
+ pass
109
+ if shutil.which("vm_stat"):
110
+ s = _rodar(["vm_stat"])
111
+ pag = {k: int(v) for k, v in re.findall(r"Pages ([a-z ]+):\s+(\d+)", s)}
112
+ usadas = pag.get("active", 0) + pag.get("wired down", 0) + pag.get("occupied by compressor", 0)
113
+ total = usadas + pag.get("free", 0) + pag.get("inactive", 0) + pag.get("speculative", 0)
114
+ mem = usadas / total if total else None
115
+ if shutil.which("pmset"):
116
+ m = re.search(r"(\d+)%", _rodar(["pmset", "-g", "batt"]))
117
+ bat = int(m.group(1)) / 100.0 if m else None
118
+ return {"cpu": cpu, "mem": mem, "bat": bat}
119
+
120
+
121
+ def _osa(script: str, t: float = 2.5) -> str:
122
+ return _rodar(["osascript", "-e", script], t).strip()
123
+
124
+
125
+ def tocando() -> str:
126
+ """Faixa — artista do Spotify ou do Music (só se o app já estiver aberto).
127
+ "♪ " tocando, "⏸ " pausado. Quem toca ganha de quem só está pausado."""
128
+ if sys.platform != "darwin":
129
+ return ""
130
+ pausado = ""
131
+ for app in ("Spotify", "Music"):
132
+ r = _osa(f'if application "{app}" is running then tell application "{app}" to return '
133
+ '(player state as string) & "|" & name of current track & " — " & artist of current track', 2.0)
134
+ estado, _, faixa = r.partition("|")
135
+ if not faixa:
136
+ continue
137
+ if estado == "playing":
138
+ return faixa
139
+ pausado = pausado or "⏸ " + faixa
140
+ return pausado
141
+
142
+
143
+ def app_em_foco() -> str:
144
+ if sys.platform != "darwin":
145
+ return ""
146
+ return _osa('tell application "System Events" to name of first application process whose frontmost is true')
147
+
148
+
149
+ def wifi() -> str:
150
+ if sys.platform != "darwin":
151
+ return ""
152
+ for dev in ("en0", "en1"):
153
+ r = _rodar(["networksetup", "-getairportnetwork", dev], 2.0)
154
+ m = re.search(r"Network:\s*(.+)", r)
155
+ if m:
156
+ return m.group(1).strip()
157
+ return ""
158
+
159
+
160
+ def clima(cache: dict) -> str:
161
+ """wttr.in (grátis, sem chave), no máximo a cada 20 min. Offline = vazio."""
162
+ if time.time() - cache.get("t", 0) < 1200:
163
+ return cache.get("v", "")
164
+ cache["t"] = time.time()
165
+ try:
166
+ import urllib.request
167
+ with urllib.request.urlopen(urllib.request.Request(
168
+ "https://wttr.in/?format=%t+%C&lang=pt", headers={"User-Agent": "curl/8"}), timeout=4) as r:
169
+ v = r.read().decode("utf-8", "replace").strip()
170
+ cache["v"] = v if v and len(v) < 40 and "Unknown" not in v else ""
171
+ except Exception:
172
+ cache["v"] = cache.get("v", "")
173
+ return cache["v"]
174
+
175
+
176
+ def proximo_evento() -> str:
177
+ """Próximo evento do Calendário. Só com PRIMOCODE_VOZ_CALENDARIO=1: o macOS pede permissão."""
178
+ if sys.platform != "darwin" or os.environ.get("PRIMOCODE_VOZ_CALENDARIO") != "1":
179
+ return ""
180
+ return _osa('tell application "Calendar" to summary of (first event of calendar 1 '
181
+ 'whose start date > (current date))', 6)
182
+
183
+
184
+ def disco() -> Optional[float]:
185
+ try:
186
+ u = shutil.disk_usage("/")
187
+ return u.used / u.total
188
+ except Exception:
189
+ return None
190
+
191
+
192
+ def coletar_tudo(cache: dict) -> dict:
193
+ """Tudo o que o painel mostra do computador. Lento (osascript): rode em thread."""
194
+ sis = coletar_sistema()
195
+ sis["disco"] = disco()
196
+ return {"sistema": sis, "info": {"tocando": tocando(), "app": app_em_foco(), "wifi": wifi(),
197
+ "clima": clima(cache), "evento": proximo_evento()}}
198
+
199
+
200
+ # ── o painel no terminal (fora do Mac, ou sem PyObjC) ────────────────────
201
+
202
+ def _barra(v: Optional[float], n: int = 10) -> str:
203
+ if v is None:
204
+ return "·" * n
205
+ cheio = int(round(max(0.0, min(1.0, v)) * n))
206
+ return "█" * cheio + "░" * (n - cheio)
207
+
208
+
209
+ def renderizar_tui(p: Painel, largura: int = 56) -> str:
210
+ """Um quadro de texto puro: cabe em qualquer terminal."""
211
+ larg = max(40, largura)
212
+ u = larg - 4
213
+ anel = {"ouvindo": "(( ◉ ))", "pensando": "(( ◌ ))", "falando": "((( ◉ )))", "executando": "(( ▸ ))",
214
+ "dormindo": "( · )", "interrompido": "(( ✕ ))"}.get(p.estado, "( ◉ )")
215
+
216
+ def corta(t: str, n: int) -> str:
217
+ t = " ".join(str(t).split())
218
+ return t if len(t) <= n else t[: n - 1] + "…"
219
+
220
+ def linha(t: str) -> str:
221
+ return "│ " + corta(t, u).ljust(u) + " │"
222
+
223
+ agora = time.strftime("%H:%M %d/%m")
224
+ s = p.sistema
225
+ linhas = [
226
+ "┌" + "─" * (larg - 2) + "┐",
227
+ linha(f"{anel:<9} {p.nome.upper()} · {p.rotulo}".ljust(u - len(agora)) + agora),
228
+ linha("você › " + p.transcricao),
229
+ linha(f"{p.nome.lower()[:5]:<5} › " + p.resposta),
230
+ linha(f"cpu {_barra(s.get('cpu'), 8)} mem {_barra(s.get('mem'), 8)} disco {_barra(s.get('disco'), 8)}"),
231
+ linha(f"bat {_barra(s.get('bat'), 8)} wifi {p.info.get('wifi') or '-'} {p.info.get('clima') or ''}"),
232
+ linha("♪ " + (p.info.get("tocando") or "—") + (" ▢ " + p.info["app"] if p.info.get("app") else "")),
233
+ "└" + "─" * (larg - 2) + "┘",
234
+ ]
235
+ return "\n".join(linhas)
236
+
237
+
238
+ def tui_laco(entrada, saida=None, largura: int = 56) -> None:
239
+ """Lê o protocolo e redesenha o quadro no lugar. Para o `/voice painel` fora do Mac."""
240
+ import sys
241
+ saida = saida or sys.stdout
242
+ p = Painel()
243
+ ultimo = 0.0
244
+ cache: dict = {}
245
+ cache_t = [0.0]
246
+ for linha in entrada:
247
+ try:
248
+ import json
249
+ p.aplicar(json.loads(linha))
250
+ except ValueError:
251
+ continue
252
+ if time.time() - ultimo > 0.2:
253
+ if time.time() - cache_t[0] > 5:
254
+ r = coletar_tudo(cache)
255
+ p.sistema, p.info = r["sistema"], {**p.info, **r["info"]}
256
+ cache_t[0] = time.time()
257
+ saida.write("\x1b[H\x1b[J" + renderizar_tui(p, largura) + "\n")
258
+ saida.flush()
259
+ ultimo = time.time()
260
+
261
+
262
+ # ── amostras ao vivo para o HUD de tela cheia ────────────────────────────
263
+
264
+ class Amostrador:
265
+ """CPU por núcleo e tráfego de rede, por diferença entre duas leituras."""
266
+
267
+ def __init__(self) -> None:
268
+ self._cpu = None
269
+ self._rede = None
270
+ self._lib = None
271
+
272
+ def _ticks(self):
273
+ import ctypes
274
+ from ctypes import POINTER, byref, c_int, c_uint
275
+ if self._lib is None:
276
+ self._lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
277
+ self._lib.mach_host_self.restype = c_uint
278
+ n, info, cnt = c_uint(), POINTER(c_int)(), c_uint()
279
+ if self._lib.host_processor_info(self._lib.mach_host_self(), 2, byref(n), byref(info), byref(cnt)) != 0:
280
+ return None
281
+ ticks = [tuple(info[i * 4 + k] & 0xFFFFFFFF for k in range(4)) for i in range(n.value)]
282
+ try:
283
+ self._lib.vm_deallocate.argtypes = [c_uint, ctypes.c_void_p, ctypes.c_size_t]
284
+ self._lib.vm_deallocate(c_uint.in_dll(self._lib, "mach_task_self_").value,
285
+ ctypes.cast(info, ctypes.c_void_p), cnt.value * 4)
286
+ except Exception:
287
+ pass
288
+ return ticks
289
+
290
+ def cores(self) -> List[float]:
291
+ if sys.platform != "darwin":
292
+ return []
293
+ try:
294
+ atual = self._ticks()
295
+ except Exception:
296
+ return []
297
+ antes, self._cpu = self._cpu, atual
298
+ if not atual or not antes or len(antes) != len(atual):
299
+ return [0.0] * len(atual or [])
300
+ saida = []
301
+ for a, b in zip(antes, atual):
302
+ d = [(y - x) & 0xFFFFFFFF for x, y in zip(a, b)]
303
+ total = sum(d)
304
+ saida.append(0.0 if not total else 1.0 - d[2] / total)
305
+ return saida
306
+
307
+ def rede(self) -> Dict[str, float]:
308
+ """bytes/s de entrada e saída (soma das interfaces en*)."""
309
+ s = _rodar(["netstat", "-ib"], 2.0)
310
+ linhas = s.splitlines()
311
+ if not linhas:
312
+ return {"up": 0.0, "down": 0.0}
313
+ cab = linhas[0].split()
314
+ try:
315
+ ii, oi = cab.index("Ibytes"), cab.index("Obytes")
316
+ except ValueError:
317
+ return {"up": 0.0, "down": 0.0}
318
+ vistos, ent, sai = set(), 0, 0
319
+ for l in linhas[1:]:
320
+ c = l.split()
321
+ if len(c) > max(ii, oi) and c[0].startswith("en") and c[0] not in vistos and "<Link" in l:
322
+ vistos.add(c[0])
323
+ try:
324
+ ent += int(c[ii])
325
+ sai += int(c[oi])
326
+ except ValueError:
327
+ pass
328
+ agora = time.time()
329
+ antes, self._rede = self._rede, (agora, ent, sai)
330
+ if not antes or agora <= antes[0]:
331
+ return {"up": 0.0, "down": 0.0}
332
+ dt = agora - antes[0]
333
+ return {"down": max(0, ent - antes[1]) / dt, "up": max(0, sai - antes[2]) / dt}
334
+
335
+
336
+ def carregando() -> bool:
337
+ return "AC Power" in _rodar(["pmset", "-g", "batt"], 2.0) if sys.platform == "darwin" else False
338
+
339
+
340
+ def capa_da_faixa() -> str:
341
+ """URL da capa no Spotify (o Music não expõe URL)."""
342
+ if sys.platform != "darwin":
343
+ return ""
344
+ return _osa('if application "Spotify" is running then tell application "Spotify" to return artwork url of current track', 2.0)
345
+
346
+
347
+ # ── extras do HUD de tela cheia: apps do Dock, rede, tempo, mídia ────────
348
+
349
+ def apps_do_dock() -> List[str]:
350
+ """Os apps fixos no Dock da pessoa (nomes), via defaults + plutil."""
351
+ if sys.platform != "darwin":
352
+ return []
353
+ import plistlib
354
+ r = subprocess.run(["defaults", "export", "com.apple.dock", "-"], capture_output=True, timeout=3)
355
+ try:
356
+ j = plistlib.loads(r.stdout)
357
+ except Exception:
358
+ return []
359
+ nomes = []
360
+ for it in j.get("persistent-apps", []):
361
+ d = it.get("tile-data", {})
362
+ n = d.get("file-label")
363
+ if n and n not in ("Launchpad", "Mission Control", "Apps"):
364
+ nomes.append(n)
365
+ return nomes
366
+
367
+
368
+ def ip_local() -> str:
369
+ try:
370
+ import socket
371
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
372
+ s.connect(("10.255.255.255", 1))
373
+ ip = s.getsockname()[0]
374
+ s.close()
375
+ return ip
376
+ except Exception:
377
+ return ""
378
+
379
+
380
+ def uptime_s() -> int:
381
+ m = re.search(r"sec = (\d+)", _rodar(["sysctl", "-n", "kern.boottime"], 2.0))
382
+ return int(time.time() - int(m.group(1))) if m else 0
383
+
384
+
385
+ def _json_url(url: str, t: float = 5.0):
386
+ import json as _j
387
+ import urllib.request
388
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "primocode"}), timeout=t) as r:
389
+ return _j.loads(r.read().decode("utf-8", "replace"))
390
+
391
+
392
+ def local_e_clima3(cache: dict) -> dict:
393
+ """IP público, cidade e previsão de 3 dias. ipapi.co + open-meteo: grátis, sem chave. Cache de 30 min."""
394
+ if time.time() - cache.get("t3", 0) < 1800 and "v3" in cache:
395
+ return cache["v3"]
396
+ cache["t3"] = time.time()
397
+ v = cache.get("v3", {})
398
+ try:
399
+ g = _json_url("https://ipapi.co/json/")
400
+ lat, lon = float(g["latitude"]), float(g["longitude"])
401
+ v = {"ip_publico": g.get("ip", ""), "cidade": g.get("city", ""), "lat": lat, "lon": lon}
402
+ f = _json_url(f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
403
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=3")
404
+ d = f["daily"]
405
+ v["previsao"] = [{"dia": d["time"][i], "max": round(d["temperature_2m_max"][i]), "min": round(d["temperature_2m_min"][i]),
406
+ "cod": d["weather_code"][i]} for i in range(len(d["time"]))]
407
+ except Exception:
408
+ pass
409
+ cache["v3"] = v
410
+ return v
411
+
412
+
413
+ def midia() -> dict:
414
+ """Spotify (ou Music): estado, posição e duração em segundos."""
415
+ if sys.platform != "darwin":
416
+ return {}
417
+ for app in ("Spotify", "Music"):
418
+ div = "1000" if app == "Spotify" else "1"
419
+ r = _osa(f'if application "{app}" is running then tell application "{app}" to return (player state as string) & "|" '
420
+ f'& (player position as string) & "|" & ((duration of current track) as string)', 2.0)
421
+ p = r.split("|")
422
+ if len(p) == 3:
423
+ try:
424
+ return {"app": app, "estado": p[0], "pos": float(p[1].replace(",", ".")),
425
+ "dur": float(p[2].replace(",", ".")) / float(div)}
426
+ except ValueError:
427
+ continue
428
+ return {}
429
+
430
+
431
+ def controle_midia(acao: str) -> None:
432
+ cmd = {"anterior": "previous track", "tocar": "playpause", "proxima": "next track"}.get(acao)
433
+ if not cmd or sys.platform != "darwin":
434
+ return
435
+ for app in ("Spotify", "Music"):
436
+ if _osa(f'application "{app}" is running') == "true":
437
+ _osa(f'tell application "{app}" to {cmd}')
438
+ return
439
+
440
+
441
+ def executar_acao(m: dict) -> None:
442
+ """Ações dos botões do HUD. Tudo real; sem Mac, não faz nada."""
443
+ a, arg = m.get("acao"), str(m.get("arg") or "")
444
+ if sys.platform != "darwin":
445
+ return
446
+ if a == "app" and arg:
447
+ subprocess.Popen(["open", "-a", arg])
448
+ elif a == "midia":
449
+ controle_midia(arg)
450
+ elif a == "volume":
451
+ d = 10 if arg == "mais" else -10
452
+ _osa(f"set volume output volume ((output volume of (get volume settings)) + ({d}))")
453
+ elif a == "travar":
454
+ subprocess.Popen(["pmset", "displaysleepnow"])
455
+ elif a == "foco":
456
+ subprocess.Popen(["open", "x-apple.systempreferences:com.apple.Focus-Settings.extension"])
457
+ elif a == "print":
458
+ pasta = os.path.expanduser("~/Desktop")
459
+ subprocess.Popen(["screencapture", "-x", os.path.join(pasta, "primo-" + time.strftime("%Y%m%d-%H%M%S") + ".png")])
@@ -0,0 +1,26 @@
1
+ """painel_tui.py — o painel do Primo no terminal, para quem não está no Mac (ou sem PyObjC)."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import time
6
+
7
+ from primo_voz import painel as pn
8
+
9
+
10
+ def main() -> int:
11
+ p = pn.Painel()
12
+ cache: dict = {}
13
+ am = pn.Amostrador()
14
+ while True:
15
+ r = pn.coletar_tudo(cache)
16
+ p.sistema, p.info = r["sistema"], {**p.info, **r["info"]}
17
+ sys.stdout.write("\x1b[H\x1b[J" + pn.renderizar_tui(p) + "\n")
18
+ sys.stdout.flush()
19
+ time.sleep(2)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ try:
24
+ sys.exit(main())
25
+ except KeyboardInterrupt:
26
+ sys.exit(0)
@@ -0,0 +1,96 @@
1
+ """sons.py — os efeitos sonoros de interface do Primo, sintetizados (sem arquivo de terceiros).
2
+
3
+ Curtos (60-300 ms), de volume baixo, notas de uma escala pentatônica com
4
+ decaimento suave — nada de bipe de alarme. São gerados uma vez em
5
+ ~/.primocode/voz-sons/*.wav e tocados com o tocador do sistema. Regra: nunca em
6
+ cima da fala (quem chama passa `falando=True`) e desligável (voz.json "sons":
7
+ false, ou PRIMOCODE_SEM_SONS=1).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import os
13
+ import shutil
14
+ import struct
15
+ import subprocess
16
+ import sys
17
+ import wave
18
+ from pathlib import Path
19
+ from typing import Dict, List, Tuple
20
+
21
+ TAXA = 22050
22
+ VOLUME = 0.22 # 0..1 do arquivo; o tocador ainda baixa mais
23
+
24
+ # nome → lista de (frequência Hz, início s, duração s, ganho)
25
+ RECEITAS: Dict[str, List[Tuple[float, float, float, float]]] = {
26
+ "boot": [(261.6, 0.00, 0.30, 0.5), (392.0, 0.18, 0.30, 0.55), (523.3, 0.36, 0.30, 0.6),
27
+ (784.0, 0.54, 0.30, 0.7), (1046.5, 0.72, 0.60, 0.9)],
28
+ "acordou": [(523.3, 0.00, 0.12, 0.8), (784.0, 0.09, 0.22, 1.0)],
29
+ "ouvindo": [(880.0, 0.00, 0.07, 0.6)],
30
+ "entendeu": [(659.3, 0.00, 0.07, 0.7), (880.0, 0.06, 0.12, 0.8)],
31
+ "pensando": [(392.0, 0.00, 0.18, 0.35)],
32
+ "pronto": [(784.0, 0.00, 0.10, 0.7), (1046.5, 0.08, 0.26, 0.9)],
33
+ "erro": [(311.1, 0.00, 0.14, 0.9), (233.1, 0.12, 0.26, 0.9)],
34
+ "painel_abre": [(587.3, 0.00, 0.06, 0.6), (880.0, 0.05, 0.10, 0.7)],
35
+ "painel_fecha": [(880.0, 0.00, 0.06, 0.6), (587.3, 0.05, 0.10, 0.7)],
36
+ }
37
+
38
+
39
+ def sintetizar(nome: str) -> List[int]:
40
+ notas = RECEITAS[nome]
41
+ total = max(i + d for _, i, d, _ in notas) + 0.05
42
+ n = int(total * TAXA)
43
+ amostras = [0.0] * n
44
+ for f, ini, dur, g in notas:
45
+ a = int(ini * TAXA)
46
+ for k in range(int(dur * TAXA)):
47
+ if a + k >= n:
48
+ break
49
+ t = k / TAXA
50
+ ataque = min(1.0, t / 0.006)
51
+ queda = math.exp(-t * (5.0 / dur))
52
+ v = math.sin(math.tau * f * t) + 0.25 * math.sin(math.tau * 2 * f * t)
53
+ amostras[a + k] += v * ataque * queda * g
54
+ pico = max(1e-9, max(abs(x) for x in amostras))
55
+ return [int(32767 * VOLUME * x / max(pico, 1.0)) for x in amostras]
56
+
57
+
58
+ def pasta() -> Path:
59
+ return Path(os.environ.get("PRIMOCODE_SONS_DIR") or (Path.home() / ".primocode" / "voz-sons"))
60
+
61
+
62
+ def arquivo(nome: str) -> Path:
63
+ p = pasta() / f"{nome}.wav"
64
+ if not p.exists():
65
+ p.parent.mkdir(parents=True, exist_ok=True)
66
+ with wave.open(str(p), "wb") as w:
67
+ w.setnchannels(1)
68
+ w.setsampwidth(2)
69
+ w.setframerate(TAXA)
70
+ w.writeframes(b"".join(struct.pack("<h", x) for x in sintetizar(nome)))
71
+ return p
72
+
73
+
74
+ class Sons:
75
+ def __init__(self, ligado: bool = True) -> None:
76
+ self.ligado = ligado and os.environ.get("PRIMOCODE_SEM_SONS") != "1"
77
+ self.cmd = None
78
+ if self.ligado:
79
+ if sys.platform == "darwin":
80
+ self.cmd = ["afplay", "-v", "0.5"]
81
+ else:
82
+ for c in (["paplay"], ["aplay", "-q"], ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"]):
83
+ if shutil.which(c[0]):
84
+ self.cmd = c
85
+ break
86
+ self.ligado = bool(self.cmd)
87
+
88
+ def tocar(self, nome: str, falando: bool = False) -> bool:
89
+ """Toca e volta na hora. Em cima da fala (falando=True) não toca."""
90
+ if not self.ligado or falando or nome not in RECEITAS:
91
+ return False
92
+ try:
93
+ subprocess.Popen(self.cmd + [str(arquivo(nome))], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
94
+ return True
95
+ except Exception:
96
+ return False