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.
- package/LICENSES/robo.txt +19 -0
- package/README.md +6 -2
- package/bin/primocode.js +54 -3
- package/lib/act.js +1 -0
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/preparar.js +2 -0
- package/lib/tools.js +1 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +99 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +429 -57
- package/voz/primo_voz/app.py +350 -0
- package/voz/primo_voz/appmac.py +124 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +56 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/hud.html +584 -0
- package/voz/primo_voz/hud.py +275 -0
- package/voz/primo_voz/identidade.py +88 -0
- package/voz/primo_voz/luz.py +137 -0
- package/voz/primo_voz/painel.py +459 -0
- package/voz/primo_voz/painel_tui.py +26 -0
- package/voz/primo_voz/sons.py +96 -0
- package/voz/primo_voz/tela.py +319 -0
- package/voz/primo_voz/transcrever.py +13 -2
- package/voz/primo_voz/vendor/RobotExpressive.glb +0 -0
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
- package/voz/primo_voz/vendor/spline-runtime.min.js +8219 -0
- package/voz/primo_voz/vendor/three-robo.min.js +3824 -0
- package/voz/primo_voz/vendor/three.LICENSE.txt +21 -0
- package/voz/primo_voz/vivo.py +34 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""hud.py — a tela do Primo (macOS, PyObjC + WKWebView).
|
|
2
|
+
|
|
3
|
+
Uma janela GRANDE e normal (ocupa a área visível da tela, com os botões de
|
|
4
|
+
fechar/minimizar/redimensionar no canto, título transparente) que carrega hud.html:
|
|
5
|
+
calendário e compromissos de hoje, o robô 3D (é o próprio Primo; cai no SVG se o WebGL falhar),
|
|
6
|
+
clima, música com disco girando, anéis da máquina, rede e o visualizador de voz.
|
|
7
|
+
Abre no monitor onde está o mouse. Este arquivo só hospeda: os dados vêm de tela.py.
|
|
8
|
+
|
|
9
|
+
entra (stdin): mensagens do painel.py ({"estado":..,"nivel":..,"transcricao":..})
|
|
10
|
+
sai (stdout): {"acao":"ptt"} quando a pessoa clica
|
|
11
|
+
Esconder: ESC, "esconde o painel" ou ctrl+opt+P (este precisa de Acessibilidade).
|
|
12
|
+
Teste sem voz: python -m primo_voz.hud --demo
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from primo_voz import identidade, tela as td
|
|
26
|
+
from primo_voz import painel as pn
|
|
27
|
+
from primo_voz.sons import Sons
|
|
28
|
+
|
|
29
|
+
AQUI = Path(__file__).resolve().parent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None) -> int:
|
|
33
|
+
"""entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout."""
|
|
34
|
+
try:
|
|
35
|
+
from AppKit import (NSApplication, NSApplicationActivationPolicyRegular, NSImage, NSMenu, NSMenuItem, NSBackingStoreBuffered, NSColor,
|
|
36
|
+
NSEvent, NSScreen, NSWindow)
|
|
37
|
+
from Foundation import NSMakeRect, NSObject, NSTimer, NSURL
|
|
38
|
+
import objc
|
|
39
|
+
from WebKit import WKWebView, WKWebViewConfiguration, WKUserContentController
|
|
40
|
+
except ImportError as e:
|
|
41
|
+
sys.stderr.write(f"o painel precisa do PyObjC ({e.name})\n")
|
|
42
|
+
return 3
|
|
43
|
+
|
|
44
|
+
p = pn.Painel()
|
|
45
|
+
fila: "queue.Queue[str]" = queue.Queue()
|
|
46
|
+
sons = Sons(os.environ.get("PRIMOCODE_SEM_SONS") != "1")
|
|
47
|
+
|
|
48
|
+
def emitir(obj: dict) -> None:
|
|
49
|
+
if saida:
|
|
50
|
+
saida(obj)
|
|
51
|
+
else:
|
|
52
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
53
|
+
sys.stdout.flush()
|
|
54
|
+
|
|
55
|
+
def js(msg: dict) -> None:
|
|
56
|
+
fila.put("window.primo(" + json.dumps(msg, ensure_ascii=False) + ")")
|
|
57
|
+
|
|
58
|
+
class Janela(NSWindow):
|
|
59
|
+
def canBecomeKeyWindow(self):
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def canBecomeMainWindow(self):
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
class Alvo(NSObject):
|
|
66
|
+
def tick_(self, _):
|
|
67
|
+
n = 0
|
|
68
|
+
while n < 40:
|
|
69
|
+
try:
|
|
70
|
+
cod = fila.get_nowait()
|
|
71
|
+
except queue.Empty:
|
|
72
|
+
break
|
|
73
|
+
self.web.evaluateJavaScript_completionHandler_(cod, None)
|
|
74
|
+
n += 1
|
|
75
|
+
if self.jan.isMiniaturized():
|
|
76
|
+
return
|
|
77
|
+
if p.visivel != bool(self.jan.isVisible()):
|
|
78
|
+
if p.visivel:
|
|
79
|
+
self.jan.deminiaturize_(None)
|
|
80
|
+
self.jan.makeKeyAndOrderFront_(None)
|
|
81
|
+
js({"boot": True})
|
|
82
|
+
sons.tocar("boot")
|
|
83
|
+
else:
|
|
84
|
+
self.jan.orderOut_(None)
|
|
85
|
+
|
|
86
|
+
class Dono(NSObject):
|
|
87
|
+
def windowShouldClose_(self, _): # fechar o painel só esconde; o PrimoCode segue
|
|
88
|
+
p.visivel = False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def applicationWillTerminate_(self, _n):
|
|
92
|
+
if ao_sair:
|
|
93
|
+
try:
|
|
94
|
+
ao_sair()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def applicationShouldHandleReopen_hasVisibleWindows_(self, _a, _v): # clique no Dock traz de volta
|
|
99
|
+
p.visivel = True
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
class Msg(NSObject, protocols=[objc.protocolNamed("WKScriptMessageHandler")]):
|
|
103
|
+
def userContentController_didReceiveScriptMessage_(self, ucc, msg):
|
|
104
|
+
try:
|
|
105
|
+
m = json.loads(str(msg.body()))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return
|
|
108
|
+
a = m.get("acao")
|
|
109
|
+
if a == "erro": # erro de JavaScript da página: vai para o terminal
|
|
110
|
+
sys.stderr.write("hud.html: " + str(m.get("arg")) + "\n")
|
|
111
|
+
sys.stderr.flush()
|
|
112
|
+
elif a == "sair":
|
|
113
|
+
p.visivel = False
|
|
114
|
+
elif a in ("ptt", "entrar"):
|
|
115
|
+
emitir({"acao": a})
|
|
116
|
+
elif a == "print": # esconde o HUD para o print pegar a tela de verdade
|
|
117
|
+
p.visivel = False
|
|
118
|
+
threading.Timer(0.6, lambda: pn.executar_acao(m)).start()
|
|
119
|
+
threading.Timer(2.2, lambda: setattr(p, "visivel", True)).start()
|
|
120
|
+
elif td.acao(m):
|
|
121
|
+
pass
|
|
122
|
+
else:
|
|
123
|
+
def _fazer():
|
|
124
|
+
pn.executar_acao(m)
|
|
125
|
+
if a == "midia":
|
|
126
|
+
time.sleep(0.4)
|
|
127
|
+
js({"musica": td.musica()})
|
|
128
|
+
threading.Thread(target=_fazer, daemon=True).start()
|
|
129
|
+
|
|
130
|
+
app = NSApplication.sharedApplication()
|
|
131
|
+
app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # app de verdade: Dock e Cmd+Tab
|
|
132
|
+
try: # nome e ícone do Primo, mesmo fora do bundle
|
|
133
|
+
from AppKit import NSBundle
|
|
134
|
+
info = NSBundle.mainBundle().infoDictionary()
|
|
135
|
+
info["CFBundleName"] = info["CFBundleDisplayName"] = "Primo"
|
|
136
|
+
icone = AQUI / "vendor" / "primo-icone.png"
|
|
137
|
+
if icone.exists():
|
|
138
|
+
app.setApplicationIconImage_(NSImage.alloc().initWithContentsOfFile_(str(icone)))
|
|
139
|
+
except Exception as e:
|
|
140
|
+
sys.stderr.write(f"hud: ícone: {e}\n")
|
|
141
|
+
barra = NSMenu.alloc().init()
|
|
142
|
+
raiz = NSMenuItem.alloc().init()
|
|
143
|
+
barra.addItem_(raiz)
|
|
144
|
+
menu = NSMenu.alloc().init()
|
|
145
|
+
for rot, sel, tec in (("Sobre o Primo", "orderFrontStandardAboutPanel:", ""), (None, None, None),
|
|
146
|
+
("Esconder o Primo", "hide:", "h"), (None, None, None), ("Sair do Primo", "terminate:", "q")):
|
|
147
|
+
menu.addItem_(NSMenuItem.separatorItem() if rot is None else
|
|
148
|
+
NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(rot, sel, tec))
|
|
149
|
+
raiz.setSubmenu_(menu)
|
|
150
|
+
app.setMainMenu_(barra)
|
|
151
|
+
mouse = NSEvent.mouseLocation()
|
|
152
|
+
alvo_tela = next((s for s in NSScreen.screens() if s.frame().origin.x <= mouse.x < s.frame().origin.x + s.frame().size.width
|
|
153
|
+
and s.frame().origin.y <= mouse.y < s.frame().origin.y + s.frame().size.height), NSScreen.mainScreen())
|
|
154
|
+
tela = alvo_tela.visibleFrame() # maximizada, sem cobrir Dock nem barra de menu
|
|
155
|
+
# título | fechar | minimizar | redimensionar | conteúdo sob a barra de título
|
|
156
|
+
jan = Janela.alloc().initWithContentRect_styleMask_backing_defer_(tela, 1 | 2 | 4 | 8 | (1 << 15), NSBackingStoreBuffered, False)
|
|
157
|
+
jan.setTitlebarAppearsTransparent_(True)
|
|
158
|
+
jan.setTitleVisibility_(1) # sem texto de título
|
|
159
|
+
jan.setTitle_("Primo")
|
|
160
|
+
jan.setReleasedWhenClosed_(False)
|
|
161
|
+
dono = Dono.alloc().init()
|
|
162
|
+
jan.setDelegate_(dono)
|
|
163
|
+
app.setDelegate_(dono)
|
|
164
|
+
jan.setMinSize_((900, 600))
|
|
165
|
+
jan.setOpaque_(True)
|
|
166
|
+
jan.setBackgroundColor_(NSColor.colorWithSRGBRed_green_blue_alpha_(0.043, 0.043, 0.039, 1.0))
|
|
167
|
+
jan.setHasShadow_(False)
|
|
168
|
+
cfg = WKWebViewConfiguration.alloc().init()
|
|
169
|
+
cfg.preferences().setValue_forKey_(True, "allowFileAccessFromFileURLs") # o hud.html lê o .glb local (vendor/)
|
|
170
|
+
msg_h = Msg.alloc().init()
|
|
171
|
+
cfg.userContentController().addScriptMessageHandler_name_(msg_h, "primo")
|
|
172
|
+
web = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, tela.size.width, tela.size.height), cfg)
|
|
173
|
+
web.setAutoresizingMask_(2 | 16)
|
|
174
|
+
web.setValue_forKey_(False, "drawsBackground")
|
|
175
|
+
jan.setContentView_(web)
|
|
176
|
+
url = NSURL.fileURLWithPath_(str(AQUI / "hud.html"))
|
|
177
|
+
web.loadFileURL_allowingReadAccessToURL_(url, NSURL.fileURLWithPath_(str(AQUI)))
|
|
178
|
+
jan.makeKeyAndOrderFront_(None)
|
|
179
|
+
app.activateIgnoringOtherApps_(True)
|
|
180
|
+
alvo = Alvo.alloc().init()
|
|
181
|
+
alvo.jan, alvo.web = jan, web
|
|
182
|
+
NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(1 / 30, alvo, b"tick:", None, True)
|
|
183
|
+
threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}), js({"boot": True}), sons.tocar("boot"))).start()
|
|
184
|
+
|
|
185
|
+
def esconder():
|
|
186
|
+
p.visivel = False
|
|
187
|
+
|
|
188
|
+
def tecla(ev):
|
|
189
|
+
if ev.keyCode() == 53: # ESC
|
|
190
|
+
esconder()
|
|
191
|
+
return None
|
|
192
|
+
return ev
|
|
193
|
+
NSEvent.addLocalMonitorForEventsMatchingMask_handler_(1 << 10, tecla)
|
|
194
|
+
|
|
195
|
+
def atalho(ev):
|
|
196
|
+
f = ev.modifierFlags()
|
|
197
|
+
if (f & 0x40000) and (f & 0x80000) and ev.keyCode() == 35:
|
|
198
|
+
p.visivel = not p.visivel
|
|
199
|
+
try:
|
|
200
|
+
NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(1 << 10, atalho)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
def medir():
|
|
205
|
+
am = pn.Amostrador()
|
|
206
|
+
cache: dict = {}
|
|
207
|
+
k = 0
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
js({"maquina": td.maquina(am)})
|
|
211
|
+
js({"musica": td.musica()})
|
|
212
|
+
if k % 20 == 0:
|
|
213
|
+
js({"clima": td.clima_widget(cache)})
|
|
214
|
+
except Exception as e:
|
|
215
|
+
sys.stderr.write(f"hud: {e}\n")
|
|
216
|
+
k += 1
|
|
217
|
+
time.sleep(1)
|
|
218
|
+
threading.Thread(target=medir, daemon=True).start()
|
|
219
|
+
|
|
220
|
+
def agenda(): # em outra thread: o pedido de permissão do Calendário pode demorar
|
|
221
|
+
evs: dict = {"permissao": "pendente", "eventos": []}
|
|
222
|
+
k = 0
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
if k % 12 == 0 or evs["permissao"] != "ok": # sem acesso, olha a cada 5 s (a pessoa pode ter liberado)
|
|
226
|
+
evs = td.eventos_do_mes()
|
|
227
|
+
js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
|
|
228
|
+
if evs["permissao"] == "ok":
|
|
229
|
+
js({"hoje": td.hoje_lista(evs["eventos"], time.time())})
|
|
230
|
+
except Exception as e:
|
|
231
|
+
sys.stderr.write(f"hud agenda: {e}\n")
|
|
232
|
+
k += 1
|
|
233
|
+
time.sleep(5)
|
|
234
|
+
threading.Thread(target=agenda, daemon=True).start()
|
|
235
|
+
|
|
236
|
+
def ler():
|
|
237
|
+
for l in (entrada if entrada is not None else sys.stdin):
|
|
238
|
+
try:
|
|
239
|
+
m = json.loads(l)
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
p.aplicar(m)
|
|
243
|
+
js(m)
|
|
244
|
+
if "estado" in m or "nivel" in m:
|
|
245
|
+
js({"barras": td.barras(p.estado, p.nivel)})
|
|
246
|
+
if entrada is None:
|
|
247
|
+
os._exit(0)
|
|
248
|
+
|
|
249
|
+
def encenar():
|
|
250
|
+
roteiro = [("dormindo", "", "", 3), ("ouvindo", "roda os testes do projeto", "", 4),
|
|
251
|
+
("pensando", "roda os testes do projeto", "", 3),
|
|
252
|
+
("falando", "roda os testes do projeto", "Rodei. Passou tudo, 412 testes. Um aviso no painel.", 5)]
|
|
253
|
+
while True:
|
|
254
|
+
for est, tr, rs, dur in roteiro:
|
|
255
|
+
p.aplicar({"estado": est})
|
|
256
|
+
js({"estado": est, "transcricao": tr, "parcial": est == "ouvindo", "resposta": rs})
|
|
257
|
+
fim = time.time() + dur
|
|
258
|
+
while time.time() < fim:
|
|
259
|
+
v = abs(math.sin(time.time() * 9) * math.sin(time.time() * 2.3)) if est in ("ouvindo", "falando") else 0
|
|
260
|
+
js({"barras": td.barras(est, v)})
|
|
261
|
+
time.sleep(0.06)
|
|
262
|
+
if est == "falando":
|
|
263
|
+
js({"evento": "sucesso"})
|
|
264
|
+
if entrada is not None:
|
|
265
|
+
threading.Thread(target=ler, daemon=True).start()
|
|
266
|
+
elif "--livre" not in sys.argv:
|
|
267
|
+
threading.Thread(target=encenar if demo else ler, daemon=True).start()
|
|
268
|
+
if ao_iniciar:
|
|
269
|
+
ao_iniciar(js, p)
|
|
270
|
+
app.run()
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
sys.exit(main(demo="--demo" in sys.argv))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""identidade.py — quem é o assistente e quem é a pessoa.
|
|
2
|
+
|
|
3
|
+
O assistente se chama "Primo" até a pessoa dar outro nome ("seu nome agora é
|
|
4
|
+
Luna", ou `primocode voz nome Luna`). A palavra de ativação é esse nome. Fica em
|
|
5
|
+
~/.primocode/voz.json, junto com o nome da pessoa e as chaves de luz e som.
|
|
6
|
+
|
|
7
|
+
Personalidade: curta, natural, humor leve. Cumprimenta pela hora, lembra o nome
|
|
8
|
+
da pessoa, e varia a frase (nunca a mesma resposta robótica duas vezes seguidas).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
PADRAO = {"nome": "Primo", "pessoa": "", "luz": True, "sons": True, "robo": "spline", "voz": "francisca", "tts": "edge"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def arquivo() -> Path:
|
|
23
|
+
return Path(os.environ.get("PRIMOCODE_VOZ_CONFIG") or (Path.home() / ".primocode" / "voz.json"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ler() -> dict:
|
|
27
|
+
try:
|
|
28
|
+
d = json.loads(arquivo().read_text("utf-8"))
|
|
29
|
+
cfg = {**PADRAO, **{k: d[k] for k in PADRAO if k in d}}
|
|
30
|
+
if cfg["robo"] not in ("spline", "expressive"): # valor antigo ou torto: o padrão
|
|
31
|
+
cfg["robo"] = PADRAO["robo"]
|
|
32
|
+
return cfg
|
|
33
|
+
except Exception:
|
|
34
|
+
return dict(PADRAO)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def salvar(**campos) -> dict:
|
|
38
|
+
cfg = {**ler(), **campos}
|
|
39
|
+
a = arquivo()
|
|
40
|
+
a.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
a.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
|
|
42
|
+
return cfg
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _nome_valido(x: str) -> Optional[str]:
|
|
46
|
+
x = re.sub(r"[^\wÀ-ÿ \-]", "", x or "").strip()
|
|
47
|
+
palavras = x.split()
|
|
48
|
+
if not 1 <= len(palavras) <= 2 or len(x) > 20 or len(x) < 2:
|
|
49
|
+
return None
|
|
50
|
+
return " ".join(w[:1].upper() + w[1:] for w in palavras)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def aprender(texto: str) -> Optional[dict]:
|
|
54
|
+
""""seu nome agora é Luna" / "pode me chamar de Joel" → o que gravar (ou None)."""
|
|
55
|
+
t = " ".join(str(texto or "").split()).strip(" .!?,")
|
|
56
|
+
m = re.search(r"(?:seu nome (?:agora )?é|vou te chamar de|(?:a partir de agora )?você se chama|te chamo de)\s+(.+)$", t, re.I)
|
|
57
|
+
if m and _nome_valido(m.group(1)):
|
|
58
|
+
return {"nome": _nome_valido(m.group(1))}
|
|
59
|
+
m = re.search(r"(?:pode me chamar de|meu nome é|me chama de|eu sou o|eu sou a)\s+(.+)$", t, re.I)
|
|
60
|
+
if m and _nome_valido(m.group(1)):
|
|
61
|
+
return {"pessoa": _nome_valido(m.group(1))}
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def periodo(hora: int) -> str:
|
|
66
|
+
return "Bom dia" if 5 <= hora < 12 else "Boa tarde" if 12 <= hora < 18 else "Boa noite"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def saudacao(hora: int, pessoa: str = "", nome: str = "Primo", rnd: Optional[random.Random] = None) -> str:
|
|
70
|
+
r = rnd or random
|
|
71
|
+
p = f", {pessoa}" if pessoa else ""
|
|
72
|
+
if hora < 5:
|
|
73
|
+
opcoes = [f"Ainda de pé{p}? Tô aqui.", f"Madrugada, hein{p}. Bora, mas sem exagero."]
|
|
74
|
+
else:
|
|
75
|
+
opcoes = [f"{periodo(hora)}{p}! {nome} na área.", f"{periodo(hora)}{p}. Pode falar.",
|
|
76
|
+
f"{periodo(hora)}{p}, tô ouvindo."]
|
|
77
|
+
return r.choice(opcoes)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
FEITO = ["Feito.", "Pronto.", "Tá na mão.", "Resolvido."]
|
|
81
|
+
ERRO = ["Opa, deu ruim. Olha o painel.", "Travei aqui. O detalhe tá no painel."]
|
|
82
|
+
NAO_ENTENDI = ["Não peguei essa. Repete?", "Hã? Fala de novo."]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def variar(opcoes, ultimo: str = "", rnd: Optional[random.Random] = None) -> str:
|
|
86
|
+
r = rnd or random
|
|
87
|
+
cand = [o for o in opcoes if o != ultimo] or list(opcoes)
|
|
88
|
+
return r.choice(cand)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""luz.py — a luz do teclado reage ao estado do Primo.
|
|
2
|
+
|
|
3
|
+
MacBook: o teclado embutido só tem brilho (branco). O método: o framework
|
|
4
|
+
privado CoreBrightness (KeyboardBrightnessClient) via PyObjC, carregado em
|
|
5
|
+
tempo de execução; guarda o brilho e o modo automático da pessoa e restaura no
|
|
6
|
+
fim. Teclado RGB externo: OpenRGB SDK (servidor local, porta 6742), se houver
|
|
7
|
+
`openrgb` em PATH e o módulo `openrgb` instalado. Sem suporte, ou com
|
|
8
|
+
PRIMOCODE_SEM_LUZ=1 / "luz": false em voz.json, tudo vira silêncio.
|
|
9
|
+
|
|
10
|
+
ouvindo = respira · pensando = pulso duplo · falando = segue a voz · erro = pisca
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def respira(t: float, periodo: float = 4.0, base: float = 0.10, topo: float = 0.45) -> float:
|
|
23
|
+
return base + (topo - base) * (0.5 - 0.5 * math.cos(t / periodo * math.tau))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pulso(t: float, periodo: float = 1.1, base: float = 0.08, topo: float = 0.8) -> float:
|
|
27
|
+
"""dois toques curtos e uma pausa (tipo coração apressado: o Primo está pensando)."""
|
|
28
|
+
x = (t % periodo) / periodo
|
|
29
|
+
forca = max(math.exp(-((x - 0.1) ** 2) / 0.002), 0.7 * math.exp(-((x - 0.3) ** 2) / 0.002))
|
|
30
|
+
return base + (topo - base) * forca
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def alvo(estado: str, t: float, nivel: float = 0.0, erro_ate: float = 0.0, agora: float = 0.0) -> Optional[float]:
|
|
34
|
+
"""Brilho 0..1 que o estado pede agora (None = não mexer)."""
|
|
35
|
+
if erro_ate > agora:
|
|
36
|
+
return 0.9 if int((erro_ate - agora) * 8) % 2 == 0 else 0.0
|
|
37
|
+
if estado == "ouvindo":
|
|
38
|
+
return respira(t)
|
|
39
|
+
if estado in ("pensando", "executando"):
|
|
40
|
+
return pulso(t)
|
|
41
|
+
if estado == "falando":
|
|
42
|
+
return 0.15 + 0.85 * min(1.0, nivel)
|
|
43
|
+
if estado == "interrompido":
|
|
44
|
+
return 0.7
|
|
45
|
+
return 0.0 if estado == "dormindo" else 0.12
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Mac:
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
import objc
|
|
51
|
+
from Foundation import NSBundle
|
|
52
|
+
NSBundle.bundleWithPath_("/System/Library/PrivateFrameworks/CoreBrightness.framework").load()
|
|
53
|
+
self._c = objc.lookUpClass("KeyboardBrightnessClient").alloc().init()
|
|
54
|
+
ids = self._c.copyKeyboardBacklightIDs() or []
|
|
55
|
+
if not ids:
|
|
56
|
+
raise RuntimeError("sem luz de teclado")
|
|
57
|
+
self._k = ids[0]
|
|
58
|
+
self.antes = float(self._c.brightnessForKeyboard_(self._k))
|
|
59
|
+
self.auto = bool(self._c.isAutoBrightnessEnabledForKeyboard_(self._k))
|
|
60
|
+
self._c.enableAutoBrightness_forKeyboard_(False, self._k)
|
|
61
|
+
|
|
62
|
+
def definir(self, v: float) -> None:
|
|
63
|
+
self._c.setBrightness_fadeSpeed_commit_forKeyboard_(min(1.0, max(0.0, v)), 60, False, self._k)
|
|
64
|
+
|
|
65
|
+
def restaurar(self) -> None:
|
|
66
|
+
self._c.setBrightness_fadeSpeed_commit_forKeyboard_(self.antes, 200, False, self._k)
|
|
67
|
+
self._c.enableAutoBrightness_forKeyboard_(self.auto, self._k)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _OpenRGB:
|
|
71
|
+
"""Teclado RGB externo pelo servidor do OpenRGB. Cor ciano, intensidade = brilho."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
from openrgb import OpenRGBClient
|
|
75
|
+
from openrgb.utils import RGBColor
|
|
76
|
+
self._cor = RGBColor
|
|
77
|
+
self._cli = OpenRGBClient(name="Primo")
|
|
78
|
+
self._tec = [d for d in self._cli.devices if "keyboard" in str(d.type).lower()]
|
|
79
|
+
if not self._tec:
|
|
80
|
+
raise RuntimeError("sem teclado RGB")
|
|
81
|
+
|
|
82
|
+
def definir(self, v: float) -> None:
|
|
83
|
+
for d in self._tec:
|
|
84
|
+
d.set_color(self._cor(int(56 * v), int(225 * v), int(255 * v)))
|
|
85
|
+
|
|
86
|
+
def restaurar(self) -> None:
|
|
87
|
+
for d in self._tec:
|
|
88
|
+
d.set_mode("direct")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Luz:
|
|
92
|
+
def __init__(self, ligada: bool = True) -> None:
|
|
93
|
+
self.dev = None
|
|
94
|
+
self.estado = "esperando"
|
|
95
|
+
self.nivel = 0.0
|
|
96
|
+
self._erro_ate = 0.0
|
|
97
|
+
self._vivo = True
|
|
98
|
+
if not ligada or os.environ.get("PRIMOCODE_SEM_LUZ") == "1":
|
|
99
|
+
return
|
|
100
|
+
for fabrica in ((_Mac,) if sys.platform == "darwin" else ()) + (_OpenRGB,):
|
|
101
|
+
try:
|
|
102
|
+
self.dev = fabrica()
|
|
103
|
+
break
|
|
104
|
+
except Exception:
|
|
105
|
+
continue
|
|
106
|
+
if self.dev:
|
|
107
|
+
threading.Thread(target=self._animar, daemon=True).start()
|
|
108
|
+
|
|
109
|
+
def mudar(self, estado: str, nivel: Optional[float] = None) -> None:
|
|
110
|
+
self.estado = estado
|
|
111
|
+
if nivel is not None:
|
|
112
|
+
self.nivel = nivel
|
|
113
|
+
|
|
114
|
+
def erro(self) -> None:
|
|
115
|
+
self._erro_ate = time.time() + 0.9
|
|
116
|
+
|
|
117
|
+
def _animar(self) -> None:
|
|
118
|
+
t0 = time.time()
|
|
119
|
+
while self._vivo:
|
|
120
|
+
agora = time.time()
|
|
121
|
+
v = alvo(self.estado, agora - t0, self.nivel, self._erro_ate, agora)
|
|
122
|
+
if v is not None:
|
|
123
|
+
try:
|
|
124
|
+
self.dev.definir(v)
|
|
125
|
+
except Exception:
|
|
126
|
+
self.dev = None
|
|
127
|
+
return
|
|
128
|
+
time.sleep(1 / 20)
|
|
129
|
+
|
|
130
|
+
def fechar(self) -> None:
|
|
131
|
+
self._vivo = False
|
|
132
|
+
if self.dev:
|
|
133
|
+
time.sleep(0.1)
|
|
134
|
+
try:
|
|
135
|
+
self.dev.restaurar()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|