primocode 10.15.0 → 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/README.md +2 -2
- package/bin/primocode.js +9 -1
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/tools.js +1 -0
- package/lib/voz.js +27 -3
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +139 -20
- 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/escuta.py +25 -3
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/hud.html +58 -24
- package/voz/primo_voz/hud.py +61 -10
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/tela.py +36 -54
- package/voz/primo_voz/transcrever.py +13 -2
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""app.py — o Primo como app (Finder, Launchpad, Dock): abre sem terminal e funciona inteiro.
|
|
2
|
+
|
|
3
|
+
O processo do app é o painel (hud.py). Daqui sobem os filhos, todos mortos junto com o app:
|
|
4
|
+
· o motor de voz (`primo_voz`: microfone, Whisper, voz) — o painel dele é este processo (PRIMOCODE_HUD_PONTE);
|
|
5
|
+
· o cérebro: cada frase vira `primocode -p "<frase>" --output-format json` na pasta de trabalho, e o
|
|
6
|
+
resultado volta falado. Node e primocode vêm de ~/.primocode/primo-app.json (o app não tem o PATH do terminal).
|
|
7
|
+
Log: ~/.primocode/logs/primo-app.log
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import unicodedata
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
HOME = Path.home()
|
|
23
|
+
PASTA_PC = HOME / ".primocode"
|
|
24
|
+
LOG = PASTA_PC / "logs" / "primo-app.log"
|
|
25
|
+
AQUI = Path(__file__).resolve().parent
|
|
26
|
+
PATH_BASE = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(msg: str) -> None:
|
|
30
|
+
try:
|
|
31
|
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with open(LOG, "a", encoding="utf-8") as f:
|
|
33
|
+
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + str(msg).rstrip() + "\n")
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _json(caminho: Path) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(caminho.read_text(encoding="utf-8"))
|
|
41
|
+
except Exception:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def logado() -> bool:
|
|
46
|
+
c = _json(PASTA_PC / "conta.json")
|
|
47
|
+
return bool(c.get("refreshToken") or c.get("idToken") or c.get("token"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sem_acento(t: str) -> str:
|
|
51
|
+
return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pasta_atual() -> Path:
|
|
55
|
+
d = _json(PASTA_PC / "projeto-ativo.json").get("dir")
|
|
56
|
+
if d and Path(d).is_dir():
|
|
57
|
+
return Path(d)
|
|
58
|
+
pc = HOME / "primocode"
|
|
59
|
+
return pc if pc.is_dir() else HOME
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def gravar_pasta(d: Path) -> None:
|
|
63
|
+
"""Mesmo arquivo que o lib/config.js (lembrarProjeto) lê: a próxima sessão do PrimoCode cai aqui também."""
|
|
64
|
+
j = _json(PASTA_PC / "projeto-ativo.json")
|
|
65
|
+
rec = [str(d)] + [x for x in (j.get("recentes") or []) if x != str(d)][:9]
|
|
66
|
+
(PASTA_PC / "projeto-ativo.json").write_text(
|
|
67
|
+
json.dumps({"dir": str(d), "origem": "/dir", "quando": int(time.time() * 1000), "recentes": rec}, indent=2), encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def achar_projeto(nome: str) -> "Path | None":
|
|
71
|
+
palavras = _sem_acento(nome).replace("-", " ").split()
|
|
72
|
+
if not palavras:
|
|
73
|
+
return None
|
|
74
|
+
cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
|
|
75
|
+
for base in (HOME / "primocode", HOME):
|
|
76
|
+
try:
|
|
77
|
+
cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
for c in cand:
|
|
81
|
+
n = _sem_acento(c.name).replace("-", " ").replace("_", " ")
|
|
82
|
+
if c.is_dir() and all(p in n for p in palavras):
|
|
83
|
+
return c
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
PEDIDO_PASTA = re.compile(r"^(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+(.+?)[.!?]*$", re.I)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _hora_falada(t: "time.struct_time") -> str:
|
|
91
|
+
from primo_voz.fala import hora_falada
|
|
92
|
+
return hora_falada(t)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class App:
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self.cfg = _json(PASTA_PC / "primo-app.json")
|
|
98
|
+
self.node = self.cfg.get("node") or ""
|
|
99
|
+
self.primocode = self.cfg.get("primocode") or ""
|
|
100
|
+
self.pasta = pasta_atual()
|
|
101
|
+
self.motor: "subprocess.Popen | None" = None
|
|
102
|
+
self.cerebro: "subprocess.Popen | None" = None
|
|
103
|
+
self.entrada: "queue.Queue" = queue.Queue()
|
|
104
|
+
self.js = lambda m: None
|
|
105
|
+
self.turnos = 0
|
|
106
|
+
self.vivo = True
|
|
107
|
+
self.trava = threading.Lock()
|
|
108
|
+
self.cache_clima: dict = {}
|
|
109
|
+
|
|
110
|
+
# ── entrada do painel (iterável de linhas JSON) ──
|
|
111
|
+
def __iter__(self):
|
|
112
|
+
while True:
|
|
113
|
+
yield self.entrada.get()
|
|
114
|
+
|
|
115
|
+
def painel(self, msg: dict) -> None:
|
|
116
|
+
self.entrada.put(json.dumps(msg, ensure_ascii=False))
|
|
117
|
+
|
|
118
|
+
def saida_do_painel(self, obj: dict) -> None:
|
|
119
|
+
a = obj.get("acao")
|
|
120
|
+
if a == "ptt":
|
|
121
|
+
self.motor_envia({"tipo": "acao", "acao": "ptt"})
|
|
122
|
+
elif a == "entrar":
|
|
123
|
+
self.entrar()
|
|
124
|
+
|
|
125
|
+
# ── login ──
|
|
126
|
+
def entrar(self) -> None:
|
|
127
|
+
raiz = Path(self.primocode).resolve().parent.parent if self.primocode else None
|
|
128
|
+
if not (self.node and raiz and (raiz / "lib" / "conta.js").exists()):
|
|
129
|
+
log("entrar: node/primocode não registrados")
|
|
130
|
+
self.painel({"resposta": "Não achei o PrimoCode instalado. Rode `primocode voz instalar-app` no terminal."})
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
def trabalho():
|
|
134
|
+
log("login: abrindo o navegador")
|
|
135
|
+
r = subprocess.run([self.node, "-e", "require('./lib/conta.js').entrarPeloNavegador({aviso:()=>{}}).then(r=>process.exit(r&&r.ok?0:1))"],
|
|
136
|
+
cwd=str(raiz), env={**self.ambiente(), "PRIMOCODE_SEM_NAVEGADOR": "0"}, capture_output=True, timeout=600)
|
|
137
|
+
log(f"login: saiu com {r.returncode}")
|
|
138
|
+
self.painel({"login": logado()})
|
|
139
|
+
threading.Thread(target=trabalho, daemon=True).start()
|
|
140
|
+
|
|
141
|
+
def ambiente(self) -> dict:
|
|
142
|
+
env = dict(os.environ)
|
|
143
|
+
extra = [str(Path(self.node).parent)] if self.node else []
|
|
144
|
+
env["PATH"] = ":".join([x for x in extra + [self.cfg.get("path") or "", PATH_BASE, env.get("PATH", "")] if x])
|
|
145
|
+
env["HOME"] = str(HOME)
|
|
146
|
+
env["PRIMOCODE_SEM_NAVEGADOR"] = "1" # o cérebro nunca abre o navegador sozinho
|
|
147
|
+
return env
|
|
148
|
+
|
|
149
|
+
# ── motor de voz ──
|
|
150
|
+
def motor_envia(self, obj: dict) -> None:
|
|
151
|
+
try:
|
|
152
|
+
self.motor.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
153
|
+
self.motor.stdin.flush()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def subir_motor(self) -> None:
|
|
158
|
+
env = self.ambiente()
|
|
159
|
+
cfg_srv = self.cfg.get("servidor") or ""
|
|
160
|
+
conta = _json(PASTA_PC / "conta.json")
|
|
161
|
+
env.update({"PRIMOCODE_HUD_PONTE": "1", "PRIMOCODE_SERVER": cfg_srv or "https://primocode.web.app",
|
|
162
|
+
"PRIMOCODE_TOKEN": str(conta.get("idToken") or conta.get("token") or ""),
|
|
163
|
+
"PRIMOCODE_VOZ": str(self.cfg.get("voz") or "antonio"), "PRIMOCODE_CWD": str(self.pasta),
|
|
164
|
+
"PYTHONUNBUFFERED": "1", "PRIMOCODE_LAT": "1", "PRIMOCODE_SEM_PARCIAL": "1", **({"PRIMOCODE_TESTE_WAV": str(self.cfg["teste_wav"]), "PRIMOCODE_TESTE_ATRASO": str(self.cfg.get("teste_atraso", 12))} if self.cfg.get("teste_wav") else {}), "PYTHONPATH": os.environ.get("PYTHONPATH", str(AQUI.parent))})
|
|
165
|
+
log(f"motor de voz: subindo ({sys.executable} -m primo_voz) pasta={self.pasta}")
|
|
166
|
+
self.motor = subprocess.Popen([sys.executable, "-m", "primo_voz"], cwd=str(AQUI.parent), env=env, text=True,
|
|
167
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
168
|
+
threading.Thread(target=self.ler_motor, daemon=True).start()
|
|
169
|
+
threading.Thread(target=lambda: [log("motor: " + l) for l in self.motor.stderr], daemon=True).start()
|
|
170
|
+
|
|
171
|
+
def ler_motor(self) -> None:
|
|
172
|
+
for linha in self.motor.stdout:
|
|
173
|
+
try:
|
|
174
|
+
m = json.loads(linha)
|
|
175
|
+
except ValueError:
|
|
176
|
+
continue
|
|
177
|
+
t = m.get("tipo")
|
|
178
|
+
if t == "hud":
|
|
179
|
+
self.painel(m.get("msg") or {})
|
|
180
|
+
elif t == "pronto":
|
|
181
|
+
log("motor de voz: PRONTO (microfone aberto)")
|
|
182
|
+
self.painel({"pasta": self.pasta.name, "login": logado()})
|
|
183
|
+
elif t == "erro":
|
|
184
|
+
log("motor de voz: erro: " + str(m.get("texto")))
|
|
185
|
+
self.painel({"resposta": str(m.get("texto"))})
|
|
186
|
+
elif t == "parar":
|
|
187
|
+
self.matar_cerebro()
|
|
188
|
+
elif t == "ouvi" and str(m.get("texto") or "").strip():
|
|
189
|
+
threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
|
|
190
|
+
elif t == "fim":
|
|
191
|
+
break
|
|
192
|
+
log("motor de voz: saiu")
|
|
193
|
+
if self.vivo:
|
|
194
|
+
self.vivo = False
|
|
195
|
+
os._exit(0)
|
|
196
|
+
|
|
197
|
+
# ── cérebro ──
|
|
198
|
+
def matar_cerebro(self) -> None:
|
|
199
|
+
p = self.cerebro
|
|
200
|
+
if p and p.poll() is None:
|
|
201
|
+
p.terminate()
|
|
202
|
+
|
|
203
|
+
def dizer(self, texto: str) -> None:
|
|
204
|
+
self.motor_envia({"tipo": "falar", "texto": texto})
|
|
205
|
+
self.motor_envia({"tipo": "estado", "estado": "esperando"})
|
|
206
|
+
|
|
207
|
+
def responder_local(self, texto: str) -> "str | None":
|
|
208
|
+
"""Pergunta simples respondida aqui, sem passar pelo agente (hora, data, clima, música, agenda, volume)."""
|
|
209
|
+
from primo_voz import painel as pn, tela as td
|
|
210
|
+
t = _sem_acento(texto).strip(" .?!")
|
|
211
|
+
agora = time.time()
|
|
212
|
+
if re.search(r"\bque horas\b|\bhoras sao\b|\bme (?:da|diz|diga) a hora\b|\bque hora (?:e|eh)\b", t):
|
|
213
|
+
return _hora_falada(time.localtime(agora))
|
|
214
|
+
if re.search(r"\bque dia (?:e|eh)\b|\bdata de hoje\b|\bhoje e que dia\b", t):
|
|
215
|
+
dias = ["segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo"]
|
|
216
|
+
meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
|
|
217
|
+
lt = time.localtime(agora)
|
|
218
|
+
return f"Hoje é {dias[lt.tm_wday]}, {lt.tm_mday} de {meses[lt.tm_mon - 1]}."
|
|
219
|
+
if re.search(r"\bclima\b|\bvai chover\b|\btemperatura\b|\btempo (?:hoje|agora|la fora)\b", t):
|
|
220
|
+
c = td.clima_widget(self.cache_clima)
|
|
221
|
+
if c and c.get("temp") is not None:
|
|
222
|
+
return f"Em {c.get('cidade', 'sua cidade')} faz {c['temp']} graus, {str(c.get('cond', '')).lower()}. Máxima de {c['max']}, mínima de {c['min']}."
|
|
223
|
+
return None
|
|
224
|
+
if re.search(r"o que (?:esta|ta) tocando|que musica (?:e|eh) essa|qual (?:e )?a musica", t):
|
|
225
|
+
m = td.musica()
|
|
226
|
+
return f"Tocando {m['faixa']}, de {m['artista']}." if m and m.get("faixa") else "Não tem nada tocando agora."
|
|
227
|
+
if re.search(r"\b(?:pausa|pausar|pare|para) (?:a )?musica\b|\b(?:toca|tocar|continua|retoma) (?:a )?musica\b|\bpula (?:a )?musica\b|\bproxima musica\b|\bmusica anterior\b", t):
|
|
228
|
+
acao = "proxima" if re.search(r"pula|proxima", t) else "anterior" if "anterior" in t else "tocar"
|
|
229
|
+
pn.controle_midia(acao)
|
|
230
|
+
return {"proxima": "Pulei.", "anterior": "Voltei.", "tocar": "Feito."}[acao]
|
|
231
|
+
if re.search(r"\b(?:aumenta|sobe|aumentar|mais)\b.*\bvolume\b|\bvolume\b.*\b(?:mais alto|maior)\b", t):
|
|
232
|
+
pn.executar_acao({"acao": "volume", "arg": "mais"})
|
|
233
|
+
return "Aumentei."
|
|
234
|
+
if re.search(r"\b(?:abaixa|diminui|baixa|diminuir|menos)\b.*\bvolume\b|\bvolume\b.*\b(?:mais baixo|menor)\b", t):
|
|
235
|
+
pn.executar_acao({"acao": "volume", "arg": "menos"})
|
|
236
|
+
return "Abaixei."
|
|
237
|
+
if re.search(r"\bagenda\b|\bcompromissos?\b|o que (?:eu )?tenho hoje|\breunioes? hoje\b", t):
|
|
238
|
+
ev = td.eventos_do_mes()
|
|
239
|
+
if ev.get("permissao") != "ok":
|
|
240
|
+
return "Preciso do acesso ao Calendário. Clique em Liberar o Calendário no painel."
|
|
241
|
+
hj = td.hoje_lista(ev["eventos"], agora)
|
|
242
|
+
if not hj:
|
|
243
|
+
return "Você não tem mais nada marcado para hoje."
|
|
244
|
+
return "Hoje: " + "; ".join(f"{e['titulo']} às {e['hora']}" if e["hora"] != "dia todo" else f"{e['titulo']}, o dia todo" for e in hj[:4]) + "."
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def pensar(self, texto: str) -> None:
|
|
248
|
+
log(f"ouvi: {texto}")
|
|
249
|
+
m = PEDIDO_PASTA.match(texto.strip())
|
|
250
|
+
if m:
|
|
251
|
+
d = achar_projeto(m.group(1))
|
|
252
|
+
if d:
|
|
253
|
+
self.pasta = d
|
|
254
|
+
gravar_pasta(d)
|
|
255
|
+
self.turnos = 0
|
|
256
|
+
self.painel({"pasta": d.name})
|
|
257
|
+
log(f"pasta: {d}")
|
|
258
|
+
self.dizer(f"Fechado. Agora trabalho no {d.name}.")
|
|
259
|
+
else:
|
|
260
|
+
self.dizer(f"Não achei um projeto chamado {m.group(1)}.")
|
|
261
|
+
return
|
|
262
|
+
t_ini = time.time()
|
|
263
|
+
try:
|
|
264
|
+
local = self.responder_local(texto)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
log(f"resposta local falhou: {e}")
|
|
267
|
+
local = None
|
|
268
|
+
if local:
|
|
269
|
+
log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
|
|
270
|
+
self.dizer(local)
|
|
271
|
+
return
|
|
272
|
+
if len(texto.split()) >= 3: # pedido que vai ao agente: confirma na hora, enquanto ele pensa
|
|
273
|
+
self.motor_envia({"tipo": "falar", "texto": ["Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar."][self.turnos % 4]})
|
|
274
|
+
if not (self.node and self.primocode and Path(self.primocode).exists()):
|
|
275
|
+
log("cérebro: node/primocode não registrados em primo-app.json")
|
|
276
|
+
self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
|
|
277
|
+
return
|
|
278
|
+
if not logado():
|
|
279
|
+
self.painel({"login": False})
|
|
280
|
+
self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
|
|
281
|
+
return
|
|
282
|
+
with self.trava:
|
|
283
|
+
args = [self.node, self.primocode, "-p", texto, "--output-format", "json"] + (["-c"] if self.turnos else [])
|
|
284
|
+
log("cérebro: " + " ".join(args[:3]) + " ... cwd=" + str(self.pasta))
|
|
285
|
+
t0 = time.time()
|
|
286
|
+
try:
|
|
287
|
+
self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=self.ambiente(), text=True,
|
|
288
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
289
|
+
out, err = self.cerebro.communicate(timeout=900)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
log(f"cérebro: falhou: {e}")
|
|
292
|
+
self.dizer("O cérebro não respondeu.")
|
|
293
|
+
return
|
|
294
|
+
self.turnos += 1
|
|
295
|
+
resp = ""
|
|
296
|
+
for l in reversed((out or "").strip().splitlines()):
|
|
297
|
+
try:
|
|
298
|
+
j = json.loads(l)
|
|
299
|
+
resp = str(j.get("result") or "")
|
|
300
|
+
break
|
|
301
|
+
except ValueError:
|
|
302
|
+
continue
|
|
303
|
+
if err and err.strip():
|
|
304
|
+
log("cérebro stderr: " + err.strip()[-400:])
|
|
305
|
+
# tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
|
|
306
|
+
resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
|
|
307
|
+
log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
|
|
308
|
+
if not resp.strip():
|
|
309
|
+
self.dizer("Não consegui uma resposta agora.")
|
|
310
|
+
return
|
|
311
|
+
self.motor_envia({"tipo": "falar", "texto": resp, "bruto": True})
|
|
312
|
+
self.motor_envia({"tipo": "estado", "estado": "esperando"})
|
|
313
|
+
|
|
314
|
+
def sair(self) -> None:
|
|
315
|
+
self.vivo = False
|
|
316
|
+
self.matar_cerebro()
|
|
317
|
+
if self.motor and self.motor.poll() is None:
|
|
318
|
+
self.motor_envia({"tipo": "fim"})
|
|
319
|
+
try:
|
|
320
|
+
self.motor.wait(timeout=2)
|
|
321
|
+
except Exception:
|
|
322
|
+
self.motor.kill()
|
|
323
|
+
log("app: encerrado")
|
|
324
|
+
|
|
325
|
+
def iniciar(self, js, p) -> None:
|
|
326
|
+
self.js = js
|
|
327
|
+
threading.Timer(0.5, self.subir_motor).start()
|
|
328
|
+
threading.Timer(2.0, lambda: self.painel({"pasta": self.pasta.name, "login": logado()})).start()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main() -> int:
|
|
332
|
+
import fcntl
|
|
333
|
+
import atexit
|
|
334
|
+
PASTA_PC.mkdir(exist_ok=True)
|
|
335
|
+
os.environ["PATH"] = PATH_BASE + ":" + os.environ.get("PATH", "")
|
|
336
|
+
lock = open(PASTA_PC / "primo-app.lock", "w")
|
|
337
|
+
try:
|
|
338
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
339
|
+
except OSError:
|
|
340
|
+
return 0 # já aberto: o macOS só traz a janela de volta
|
|
341
|
+
sys.stderr = open(LOG, "a", buffering=1) if (LOG.parent.mkdir(parents=True, exist_ok=True) or True) else sys.stderr
|
|
342
|
+
log("app: abrindo")
|
|
343
|
+
app = App()
|
|
344
|
+
atexit.register(app.sair)
|
|
345
|
+
from primo_voz import hud
|
|
346
|
+
return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""appmac.py — o Primo como app de verdade no macOS (Dock e Cmd+Tab dizem "Primo", com o ícone).
|
|
2
|
+
|
|
3
|
+
Gera sob demanda ~/.primocode/Primo.app (bundle leve: Info.plist, ícone .icns e um script que
|
|
4
|
+
roda o python do venv com `-m primo_voz.hud`). Quem abre o painel executa o binário do bundle
|
|
5
|
+
direto (spawn), então o canal stdin/stdout continua o mesmo.
|
|
6
|
+
|
|
7
|
+
python -m primo_voz.appmac # gera (se precisar) e imprime o caminho do executável
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import plistlib
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
AQUI = Path(__file__).resolve().parent
|
|
20
|
+
ICONE = AQUI / "vendor" / "primo-icone.png"
|
|
21
|
+
VERSAO = "6" # sobe quando o bundle muda: força regerar
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pasta_app() -> Path:
|
|
25
|
+
return Path(os.environ.get("PRIMOCODE_APP", Path.home() / "Applications" / "Primo.app"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _icns(destino: Path) -> bool:
|
|
29
|
+
with tempfile.TemporaryDirectory() as t:
|
|
30
|
+
iset = Path(t) / "Primo.iconset"
|
|
31
|
+
iset.mkdir()
|
|
32
|
+
for base in (16, 32, 128, 256, 512):
|
|
33
|
+
for k in (1, 2):
|
|
34
|
+
px = base * k
|
|
35
|
+
nome = f"icon_{base}x{base}{'@2x' if k == 2 else ''}.png"
|
|
36
|
+
subprocess.run(["sips", "-z", str(px), str(px), str(ICONE), "--out", str(iset / nome)],
|
|
37
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
38
|
+
r = subprocess.run(["iconutil", "-c", "icns", str(iset), "-o", str(destino)],
|
|
39
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
40
|
+
return r.returncode == 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _no_dock(app: Path) -> None:
|
|
44
|
+
"""Fixa o Primo no Dock (só com --dock)."""
|
|
45
|
+
item = ("<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>%s</string>"
|
|
46
|
+
"<key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>" % str(app))
|
|
47
|
+
subprocess.run(["defaults", "write", "com.apple.dock", "persistent-apps", "-array-add", item], check=False)
|
|
48
|
+
subprocess.run(["killall", "Dock"], check=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pythonpath() -> str:
|
|
52
|
+
"""O PYTHONPATH que o binário do bundle precisa (o primo_voz e os pacotes do venv, já que ele não é o python do venv)."""
|
|
53
|
+
import sysconfig
|
|
54
|
+
dirs = [str(AQUI.parent), sysconfig.get_path("purelib"), sysconfig.get_path("platlib")]
|
|
55
|
+
return os.pathsep.join(dict.fromkeys(d for d in dirs if d))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def preparar() -> str | None:
|
|
59
|
+
"""Devolve o caminho do executável do Primo.app (criando o bundle se faltar); None se não deu."""
|
|
60
|
+
if sys.platform != "darwin" or not ICONE.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
app = pasta_app()
|
|
64
|
+
exe = app / "Contents" / "MacOS" / "Primo" # o binário de verdade (o processo se chama Primo); o lançador só dá exec nele
|
|
65
|
+
lanc = app / "Contents" / "MacOS" / "Primo-iniciar"
|
|
66
|
+
marca = app / "Contents" / ".versao"
|
|
67
|
+
py = sys.executable
|
|
68
|
+
chave = f"{VERSAO}|{py}|{AQUI.parent}"
|
|
69
|
+
if exe.exists() and marca.exists() and marca.read_text() == chave and (app / "Contents/Resources/Primo.icns").exists():
|
|
70
|
+
return str(exe)
|
|
71
|
+
if app.exists():
|
|
72
|
+
shutil.rmtree(app)
|
|
73
|
+
antigo = Path.home() / ".primocode" / "Primo.app" # onde ficava até a 10.15.1
|
|
74
|
+
if antigo.exists():
|
|
75
|
+
shutil.rmtree(antigo, ignore_errors=True)
|
|
76
|
+
app.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
(app / "Contents" / "MacOS").mkdir(parents=True)
|
|
78
|
+
(app / "Contents" / "Resources").mkdir()
|
|
79
|
+
if not _icns(app / "Contents" / "Resources" / "Primo.icns"):
|
|
80
|
+
return None
|
|
81
|
+
info = {
|
|
82
|
+
"CFBundleName": "Primo", "CFBundleDisplayName": "Primo", "CFBundleIdentifier": "ai.primocode.primo",
|
|
83
|
+
"CFBundleExecutable": "Primo-iniciar", "CFBundleIconFile": "Primo", "CFBundlePackageType": "APPL",
|
|
84
|
+
"CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "LSMinimumSystemVersion": "11.0",
|
|
85
|
+
"NSHighResolutionCapable": True, "NSPrincipalClass": "NSApplication",
|
|
86
|
+
"NSMicrophoneUsageDescription": "O Primo ouve você pelo microfone para atender os seus pedidos por voz.",
|
|
87
|
+
"NSCalendarsFullAccessUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
|
|
88
|
+
"NSCalendarsUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
|
|
89
|
+
"NSAppleEventsUsageDescription": "O Primo controla a música (Spotify/Music) pelo painel.",
|
|
90
|
+
}
|
|
91
|
+
with open(app / "Contents" / "Info.plist", "wb") as f:
|
|
92
|
+
plistlib.dump(info, f)
|
|
93
|
+
# binário de verdade (cópia do Python.app do framework): o macOS passa a chamar o processo de "Primo"
|
|
94
|
+
real = Path(os.path.realpath(py))
|
|
95
|
+
mach = next((c for c in (real.parents[1] / "Resources/Python.app/Contents/MacOS/Python",
|
|
96
|
+
real.parent / "../Resources/Python.app/Contents/MacOS/Python") if c.exists()), None)
|
|
97
|
+
if mach:
|
|
98
|
+
shutil.copy2(mach, exe)
|
|
99
|
+
# duplo clique no Finder/Launchpad: sem argumentos = sobe o app completo (voz + painel + cérebro)
|
|
100
|
+
lanc.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\nD="$(cd "$(dirname "$0")" && pwd)"\n'
|
|
101
|
+
f'[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "$D/Primo" "$@"\n')
|
|
102
|
+
lanc.chmod(0o755)
|
|
103
|
+
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
104
|
+
else:
|
|
105
|
+
exe.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\n[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "{py}" "$@"\n')
|
|
106
|
+
exe.chmod(0o755)
|
|
107
|
+
shutil.copy2(exe, lanc)
|
|
108
|
+
marca.write_text(chave)
|
|
109
|
+
if os.environ.get("PRIMOCODE_APP_DOCK") == "1":
|
|
110
|
+
_no_dock(app)
|
|
111
|
+
subprocess.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
|
112
|
+
"-f", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
113
|
+
return str(exe)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
sys.stderr.write(f"appmac: {e}\n")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
r = preparar()
|
|
121
|
+
if r:
|
|
122
|
+
print(r)
|
|
123
|
+
print(pythonpath())
|
|
124
|
+
sys.exit(0 if r else 1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -4 18 18" shape-rendering="crispEdges"><rect x="2" y="0" width="1" height="1" fill="#22d3ee"/><rect x="2" y="1" width="1" height="1" fill="#22d3ee"/><rect x="3" y="0" width="1" height="1" fill="#22d3ee"/><rect x="3" y="1" width="1" height="1" fill="#22d3ee"/><rect x="4" y="0" width="1" height="1" fill="#22d3ee"/><rect x="4" y="1" width="1" height="1" fill="#22d3ee"/><rect x="5" y="0" width="1" height="1" fill="#22d3ee"/><rect x="5" y="1" width="1" height="1" fill="#22d3ee"/><rect x="6" y="0" width="1" height="1" fill="#22d3ee"/><rect x="6" y="1" width="1" height="1" fill="#22d3ee"/><rect x="7" y="0" width="1" height="1" fill="#22d3ee"/><rect x="7" y="1" width="1" height="1" fill="#22d3ee"/><rect x="8" y="0" width="1" height="1" fill="#22d3ee"/><rect x="8" y="1" width="1" height="1" fill="#22d3ee"/><rect x="9" y="0" width="1" height="1" fill="#22d3ee"/><rect x="9" y="1" width="1" height="1" fill="#22d3ee"/><rect x="10" y="0" width="1" height="1" fill="#22d3ee"/><rect x="10" y="1" width="1" height="1" fill="#22d3ee"/><rect x="11" y="0" width="1" height="1" fill="#22d3ee"/><rect x="11" y="1" width="1" height="1" fill="#22d3ee"/><rect x="12" y="0" width="1" height="1" fill="#22d3ee"/><rect x="12" y="1" width="1" height="1" fill="#22d3ee"/><rect x="13" y="0" width="1" height="1" fill="#22d3ee"/><rect x="13" y="1" width="1" height="1" fill="#22d3ee"/><rect x="2" y="2" width="1" height="1" fill="#22d3ee"/><rect x="2" y="3" width="1" height="1" fill="#22d3ee"/><rect x="3" y="2" width="1" height="1" fill="#22d3ee"/><rect x="3" y="3" width="1" height="1" fill="#22d3ee"/><rect x="5" y="2" width="1" height="1" fill="#22d3ee"/><rect x="5" y="3" width="1" height="1" fill="#22d3ee"/><rect x="6" y="2" width="1" height="1" fill="#22d3ee"/><rect x="6" y="3" width="1" height="1" fill="#22d3ee"/><rect x="7" y="2" width="1" height="1" fill="#22d3ee"/><rect x="7" y="3" width="1" height="1" fill="#22d3ee"/><rect x="8" y="2" width="1" height="1" fill="#22d3ee"/><rect x="8" y="3" width="1" height="1" fill="#22d3ee"/><rect x="9" y="2" width="1" height="1" fill="#22d3ee"/><rect x="9" y="3" width="1" height="1" fill="#22d3ee"/><rect x="10" y="2" width="1" height="1" fill="#22d3ee"/><rect x="10" y="3" width="1" height="1" fill="#22d3ee"/><rect x="12" y="2" width="1" height="1" fill="#22d3ee"/><rect x="12" y="3" width="1" height="1" fill="#22d3ee"/><rect x="13" y="2" width="1" height="1" fill="#22d3ee"/><rect x="13" y="3" width="1" height="1" fill="#22d3ee"/><rect x="0" y="4" width="1" height="1" fill="#22d3ee"/><rect x="0" y="5" width="1" height="1" fill="#22d3ee"/><rect x="1" y="4" width="1" height="1" fill="#22d3ee"/><rect x="1" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="4" width="1" height="1" fill="#22d3ee"/><rect x="2" y="5" width="1" height="1" fill="#22d3ee"/><rect x="3" y="4" width="1" height="1" fill="#22d3ee"/><rect x="3" y="5" width="1" height="1" fill="#22d3ee"/><rect x="4" y="4" width="1" height="1" fill="#22d3ee"/><rect x="4" y="5" width="1" height="1" fill="#22d3ee"/><rect x="5" y="4" width="1" height="1" fill="#22d3ee"/><rect x="5" y="5" width="1" height="1" fill="#22d3ee"/><rect x="6" y="4" width="1" height="1" fill="#22d3ee"/><rect x="6" y="5" width="1" height="1" fill="#22d3ee"/><rect x="7" y="4" width="1" height="1" fill="#22d3ee"/><rect x="7" y="5" width="1" height="1" fill="#22d3ee"/><rect x="8" y="4" width="1" height="1" fill="#22d3ee"/><rect x="8" y="5" width="1" height="1" fill="#22d3ee"/><rect x="9" y="4" width="1" height="1" fill="#22d3ee"/><rect x="9" y="5" width="1" height="1" fill="#22d3ee"/><rect x="10" y="4" width="1" height="1" fill="#22d3ee"/><rect x="10" y="5" width="1" height="1" fill="#22d3ee"/><rect x="11" y="4" width="1" height="1" fill="#22d3ee"/><rect x="11" y="5" width="1" height="1" fill="#22d3ee"/><rect x="12" y="4" width="1" height="1" fill="#22d3ee"/><rect x="12" y="5" width="1" height="1" fill="#22d3ee"/><rect x="13" y="4" width="1" height="1" fill="#22d3ee"/><rect x="13" y="5" width="1" height="1" fill="#22d3ee"/><rect x="14" y="4" width="1" height="1" fill="#22d3ee"/><rect x="14" y="5" width="1" height="1" fill="#22d3ee"/><rect x="15" y="4" width="1" height="1" fill="#22d3ee"/><rect x="15" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="6" width="1" height="1" fill="#22d3ee"/><rect x="2" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="6" width="1" height="1" fill="#22d3ee"/><rect x="3" y="7" width="1" height="1" fill="#22d3ee"/><rect x="4" y="6" width="1" height="1" fill="#22d3ee"/><rect x="4" y="7" width="1" height="1" fill="#22d3ee"/><rect x="5" y="6" width="1" height="1" fill="#22d3ee"/><rect x="5" y="7" width="1" height="1" fill="#22d3ee"/><rect x="6" y="6" width="1" height="1" fill="#22d3ee"/><rect x="6" y="7" width="1" height="1" fill="#22d3ee"/><rect x="7" y="6" width="1" height="1" fill="#22d3ee"/><rect x="7" y="7" width="1" height="1" fill="#22d3ee"/><rect x="8" y="6" width="1" height="1" fill="#22d3ee"/><rect x="8" y="7" width="1" height="1" fill="#22d3ee"/><rect x="9" y="6" width="1" height="1" fill="#22d3ee"/><rect x="9" y="7" width="1" height="1" fill="#22d3ee"/><rect x="10" y="6" width="1" height="1" fill="#22d3ee"/><rect x="10" y="7" width="1" height="1" fill="#22d3ee"/><rect x="11" y="6" width="1" height="1" fill="#22d3ee"/><rect x="11" y="7" width="1" height="1" fill="#22d3ee"/><rect x="12" y="6" width="1" height="1" fill="#22d3ee"/><rect x="12" y="7" width="1" height="1" fill="#22d3ee"/><rect x="13" y="6" width="1" height="1" fill="#22d3ee"/><rect x="13" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="8" width="1" height="1" fill="#22d3ee"/><rect x="3" y="9" width="1" height="1" fill="#22d3ee"/><rect x="5" y="8" width="1" height="1" fill="#22d3ee"/><rect x="5" y="9" width="1" height="1" fill="#22d3ee"/><rect x="10" y="8" width="1" height="1" fill="#22d3ee"/><rect x="10" y="9" width="1" height="1" fill="#22d3ee"/><rect x="12" y="8" width="1" height="1" fill="#22d3ee"/><rect x="12" y="9" width="1" height="1" fill="#22d3ee"/></svg>
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -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 =
|
|
50
|
-
RABO_SILENCIO_LONGA =
|
|
50
|
+
RABO_SILENCIO = 1.0 # silêncio que encerra a frase
|
|
51
|
+
RABO_SILENCIO_LONGA = 1.5 # …e quando a fala já vem longa
|
|
51
52
|
FALA_LONGA = 6.0
|
|
52
|
-
RABO_VAD = 0.
|
|
53
|
+
RABO_VAD = 0.5 # 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
|
|
@@ -228,7 +229,28 @@ class Microfone:
|
|
|
228
229
|
self._ao_receber = ao_receber
|
|
229
230
|
self._fluxo = None
|
|
230
231
|
|
|
232
|
+
def _abrir_de_arquivo(self, caminho: str) -> None:
|
|
233
|
+
"""Modo de teste (PRIMOCODE_TESTE_WAV): em vez do microfone, toca um WAV 16 kHz mono na mesma entrada,
|
|
234
|
+
depois de 12 s de silêncio (a saudação termina e o motor assenta) e com silêncio no fim."""
|
|
235
|
+
import threading
|
|
236
|
+
import time as _t
|
|
237
|
+
import numpy as _np
|
|
238
|
+
|
|
239
|
+
def alimentar():
|
|
240
|
+
with wave.open(caminho, "rb") as w:
|
|
241
|
+
dados = _np.frombuffer(w.readframes(w.getnframes()), dtype=_np.int16)
|
|
242
|
+
_t.sleep(float(os.environ.get("PRIMOCODE_TESTE_ATRASO", "12")))
|
|
243
|
+
silencio = _np.zeros(BLOCO, dtype=_np.int16)
|
|
244
|
+
for i in range(0, len(dados) + BLOCO * 20, BLOCO):
|
|
245
|
+
bloco = dados[i:i + BLOCO]
|
|
246
|
+
self._ao_receber(bloco if len(bloco) == BLOCO else (_np.concatenate([bloco, silencio])[:BLOCO]))
|
|
247
|
+
_t.sleep(BLOCO_MS / 1000)
|
|
248
|
+
threading.Thread(target=alimentar, daemon=True).start()
|
|
249
|
+
|
|
231
250
|
def abrir(self) -> None:
|
|
251
|
+
if os.environ.get("PRIMOCODE_TESTE_WAV"):
|
|
252
|
+
return self._abrir_de_arquivo(os.environ["PRIMOCODE_TESTE_WAV"])
|
|
253
|
+
|
|
232
254
|
def retorno(dados, quadros, tempo, estado): # noqa: ARG001
|
|
233
255
|
self._ao_receber(dados[:, 0].copy())
|
|
234
256
|
|
|
@@ -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."
|