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
|
@@ -31,6 +31,8 @@ from __future__ import annotations
|
|
|
31
31
|
import json
|
|
32
32
|
import os
|
|
33
33
|
import queue
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
34
36
|
import subprocess
|
|
35
37
|
import sys
|
|
36
38
|
import tempfile
|
|
@@ -41,7 +43,34 @@ from pathlib import Path
|
|
|
41
43
|
AQUI = Path(__file__).resolve().parent
|
|
42
44
|
sys.path.insert(0, str(AQUI.parent))
|
|
43
45
|
|
|
44
|
-
from primo_voz import escuta, transcrever # noqa: E402
|
|
46
|
+
from primo_voz import conversa, escuta, fala, identidade, painel, transcrever # noqa: E402
|
|
47
|
+
from primo_voz import tela as dados_tela # noqa: E402
|
|
48
|
+
from primo_voz.luz import Luz # noqa: E402
|
|
49
|
+
from primo_voz.sons import Sons # noqa: E402
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _PonteHud:
|
|
53
|
+
"""No modo app o painel mora no processo-pai (app.py): as mensagens dele vão pelo stdout do protocolo."""
|
|
54
|
+
|
|
55
|
+
class _Entrada:
|
|
56
|
+
def write(self, linha: str) -> None:
|
|
57
|
+
try:
|
|
58
|
+
falar_para_o_node({"tipo": "hud", "msg": json.loads(linha)})
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def flush(self) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.stdin = self._Entrada()
|
|
70
|
+
self.stdout = iter(())
|
|
71
|
+
|
|
72
|
+
def poll(self):
|
|
73
|
+
return None
|
|
45
74
|
|
|
46
75
|
|
|
47
76
|
class Bolinha:
|
|
@@ -57,13 +86,60 @@ class Bolinha:
|
|
|
57
86
|
self.quando = 0.0
|
|
58
87
|
if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
|
|
59
88
|
return
|
|
89
|
+
self.ao_acao = None
|
|
90
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
91
|
+
self.modulo, self.p = "primo_voz.hud", _PonteHud()
|
|
92
|
+
return
|
|
93
|
+
# No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
|
|
94
|
+
modulo = "primo_voz.orbe"
|
|
95
|
+
if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
|
|
96
|
+
try:
|
|
97
|
+
import AppKit # noqa: F401
|
|
98
|
+
modulo = "primo_voz.hud"
|
|
99
|
+
except ImportError:
|
|
100
|
+
pass
|
|
101
|
+
self.modulo = modulo
|
|
60
102
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
103
|
+
cmd = [sys.executable, "-m", modulo]
|
|
104
|
+
env_extra = {"PYTHONPATH": str(AQUI.parent)}
|
|
105
|
+
if modulo.endswith("hud"):
|
|
106
|
+
from primo_voz import appmac # Primo.app: nome e ícone no Dock; cai no python se não der
|
|
107
|
+
exe = appmac.preparar()
|
|
108
|
+
if exe:
|
|
109
|
+
cmd = [exe, "-m", "primo_voz.hud"]
|
|
110
|
+
env_extra = {"PYTHONPATH": appmac.pythonpath()}
|
|
111
|
+
self.p = subprocess.Popen(cmd, cwd=str(AQUI.parent),
|
|
112
|
+
stdin=subprocess.PIPE,
|
|
113
|
+
stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
|
|
114
|
+
stderr=subprocess.DEVNULL,
|
|
115
|
+
env={**os.environ, **env_extra}, text=True)
|
|
116
|
+
if modulo.endswith("hud"):
|
|
117
|
+
threading.Thread(target=self._ler_acoes, daemon=True).start()
|
|
64
118
|
except Exception:
|
|
65
119
|
self.p = None
|
|
66
120
|
|
|
121
|
+
def _ler_acoes(self) -> None:
|
|
122
|
+
"""O HUD manda {"acao":"ptt"} quando a pessoa clica nos anéis."""
|
|
123
|
+
try:
|
|
124
|
+
for linha in self.p.stdout:
|
|
125
|
+
try:
|
|
126
|
+
a = json.loads(linha).get("acao")
|
|
127
|
+
except ValueError:
|
|
128
|
+
continue
|
|
129
|
+
if a and self.ao_acao:
|
|
130
|
+
self.ao_acao(a)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def enviar(self, obj: dict) -> None:
|
|
135
|
+
if not self.p or self.p.poll() is not None or not self.modulo.endswith("hud"):
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
self.p.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
139
|
+
self.p.stdin.flush()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
67
143
|
def estado(self, estado: str, nivel: float = None) -> None:
|
|
68
144
|
if not self.p or self.p.poll() is not None:
|
|
69
145
|
return
|
|
@@ -71,6 +147,8 @@ class Bolinha:
|
|
|
71
147
|
if estado == self.ultimo and (nivel is None or agora - self.quando < 1 / 15):
|
|
72
148
|
return
|
|
73
149
|
self.ultimo, self.quando = estado, agora
|
|
150
|
+
if not self.modulo.endswith("hud"):
|
|
151
|
+
estado = {"executando": "pensando", "interrompido": "ouvindo", "dormindo": "esperando"}.get(estado, estado)
|
|
74
152
|
msg = {"estado": estado}
|
|
75
153
|
if nivel is not None:
|
|
76
154
|
msg["nivel"] = round(min(1.0, nivel), 3)
|
|
@@ -131,25 +209,68 @@ PS_TOCAR_MP3 = (
|
|
|
131
209
|
|
|
132
210
|
|
|
133
211
|
class Voz:
|
|
134
|
-
"""Microfone → transcrição → terminal. Terminal → voz.
|
|
212
|
+
"""Microfone → transcrição → terminal. Terminal → voz. O Primo.
|
|
213
|
+
|
|
214
|
+
A conversa (estados, frases, resumo, comandos) é conversa.py, puro e testado;
|
|
215
|
+
o painel é hud.py; luz.py e sons.py são o resto do corpo."""
|
|
135
216
|
|
|
136
217
|
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
|
|
137
218
|
self.servidor = servidor
|
|
138
219
|
self.token = token
|
|
139
220
|
self.voz = voz_escolhida
|
|
140
|
-
|
|
141
|
-
|
|
221
|
+
self.cfg = identidade.ler()
|
|
222
|
+
self.nome = self.cfg["nome"]
|
|
223
|
+
# nome = só responde se começar pelo nome · sempre = sem palavra · tecla = só ao clicar no painel
|
|
224
|
+
self.modo = os.environ.get("PRIMOCODE_ATIVACAO", "nome")
|
|
142
225
|
self.local = transcrever.disponivel()
|
|
143
226
|
if self.local:
|
|
144
227
|
transcrever.aquecer()
|
|
228
|
+
self.edge = fala.Edge()
|
|
229
|
+
self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
|
|
230
|
+
self._t_acabou = None
|
|
231
|
+
threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
|
|
145
232
|
self.gravador = escuta.Gravador()
|
|
233
|
+
self.gravador.vad = escuta.criar_vad()
|
|
146
234
|
self.corte = escuta.Corte()
|
|
147
235
|
self.bolinha = Bolinha()
|
|
236
|
+
self.bolinha.ao_acao = self._acao_do_painel
|
|
237
|
+
self.luz = Luz(bool(self.cfg.get("luz", True)))
|
|
238
|
+
self.sons = Sons(bool(self.cfg.get("sons", True)))
|
|
239
|
+
self.maquina = conversa.Maquina("ouvindo")
|
|
148
240
|
self._blocos: "queue.Queue" = queue.Queue()
|
|
149
|
-
self._falas: "queue.Queue" = queue.Queue()
|
|
241
|
+
self._falas: "queue.Queue" = queue.Queue() # frases de texto
|
|
242
|
+
self._audios: "queue.Queue" = queue.Queue(maxsize=2) # mp3 já pronto
|
|
150
243
|
self._vivo = True
|
|
151
244
|
self._falando = False
|
|
152
245
|
self._parar_fala = threading.Event()
|
|
246
|
+
self._acordado_ate = 0.0 # janela de conversa: sem repetir o nome
|
|
247
|
+
self._ptt_ate = 0.0
|
|
248
|
+
self._ultima_conf = 0.0
|
|
249
|
+
self._ultimo_ok = ""
|
|
250
|
+
self.bolinha.enviar({"nome": self.nome})
|
|
251
|
+
|
|
252
|
+
def lat(self, texto: str) -> None:
|
|
253
|
+
if os.environ.get("PRIMOCODE_LAT") == "1":
|
|
254
|
+
avisar("lat: " + texto)
|
|
255
|
+
|
|
256
|
+
# ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
|
|
257
|
+
|
|
258
|
+
def _estado(self, estado: str, nivel: float = None) -> None:
|
|
259
|
+
mudou = estado != self.bolinha.ultimo
|
|
260
|
+
self.bolinha.estado(estado, nivel)
|
|
261
|
+
self.luz.mudar(estado, nivel)
|
|
262
|
+
if mudou and estado in ("ouvindo", "pensando"):
|
|
263
|
+
self.sons.tocar(estado, falando=self._falando)
|
|
264
|
+
|
|
265
|
+
def _acao_do_painel(self, acao: str) -> None:
|
|
266
|
+
if acao == "ptt": # clique nos anéis = falar agora
|
|
267
|
+
self._ptt_ate = time.time() + 10
|
|
268
|
+
self.sons.tocar("acordou", falando=self._falando)
|
|
269
|
+
|
|
270
|
+
def _erro(self, texto: str) -> None:
|
|
271
|
+
self.luz.erro()
|
|
272
|
+
self.sons.tocar("erro")
|
|
273
|
+
self.bolinha.enviar({"resposta": texto})
|
|
153
274
|
|
|
154
275
|
# ── o ouvido ─────────────────────────────────────────────────────────
|
|
155
276
|
|
|
@@ -157,16 +278,33 @@ class Voz:
|
|
|
157
278
|
if not self._vivo:
|
|
158
279
|
return
|
|
159
280
|
nivel = escuta.rms(bloco)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
#
|
|
281
|
+
if not self._falando and self.bolinha.ultimo: # o volume REAL do mic vai para as barras da tela
|
|
282
|
+
self.bolinha.estado(self.bolinha.ultimo, dados_tela.nivel_do_mic(nivel))
|
|
283
|
+
# Enquanto ele fala, o microfone só serve para saber se a pessoa falou
|
|
284
|
+
# POR CIMA (interrupção). A voz dele na caixa de som não vira pedido.
|
|
163
285
|
if self._falando:
|
|
164
286
|
self.corte.ele_falou(nivel)
|
|
165
|
-
if self.corte.alguem_cortou(nivel):
|
|
166
|
-
self.
|
|
287
|
+
if self.maquina.pode_cortar and self.corte.alguem_cortou(nivel):
|
|
288
|
+
self._interromper()
|
|
167
289
|
return
|
|
168
290
|
self._blocos.put(bloco)
|
|
169
291
|
|
|
292
|
+
def _interromper(self) -> None:
|
|
293
|
+
self.maquina.mandar("cortou")
|
|
294
|
+
self._parar_fala.set()
|
|
295
|
+
self._limpar_falas()
|
|
296
|
+
self._acordado_ate = time.time() + 20 # falou por cima: já está conversando
|
|
297
|
+
self.maquina.mandar("escutar")
|
|
298
|
+
self._estado("ouvindo")
|
|
299
|
+
|
|
300
|
+
def _limpar_falas(self) -> None:
|
|
301
|
+
for fila in (self._falas, self._audios):
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
fila.get_nowait()
|
|
305
|
+
except queue.Empty:
|
|
306
|
+
break
|
|
307
|
+
|
|
170
308
|
def _esvaziar_blocos(self) -> None:
|
|
171
309
|
while True:
|
|
172
310
|
try:
|
|
@@ -174,6 +312,34 @@ class Voz:
|
|
|
174
312
|
except queue.Empty:
|
|
175
313
|
return
|
|
176
314
|
|
|
315
|
+
def _parcial(self) -> None:
|
|
316
|
+
"""Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
|
|
317
|
+
if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
|
|
318
|
+
return
|
|
319
|
+
copia = list(self.gravador._blocos)
|
|
320
|
+
if not copia:
|
|
321
|
+
return
|
|
322
|
+
self._parcial_rodando = True
|
|
323
|
+
|
|
324
|
+
def trabalho():
|
|
325
|
+
try:
|
|
326
|
+
import numpy as np
|
|
327
|
+
import wave as _w
|
|
328
|
+
arq = Path(tempfile.gettempdir()) / "primo-voz-parcial.wav"
|
|
329
|
+
with _w.open(str(arq), "wb") as w:
|
|
330
|
+
w.setnchannels(1)
|
|
331
|
+
w.setsampwidth(2)
|
|
332
|
+
w.setframerate(escuta.TAXA)
|
|
333
|
+
w.writeframes(np.concatenate(copia).astype(np.int16).tobytes())
|
|
334
|
+
t = transcrever.limpar(transcrever.transcrever(arq))
|
|
335
|
+
if t and self.gravador._falando:
|
|
336
|
+
self.bolinha.enviar({"transcricao": t, "parcial": True})
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
finally:
|
|
340
|
+
self._parcial_rodando = False
|
|
341
|
+
threading.Thread(target=trabalho, daemon=True).start()
|
|
342
|
+
|
|
177
343
|
def _ouvir(self) -> None:
|
|
178
344
|
mic = escuta.Microfone(self._ao_receber)
|
|
179
345
|
try:
|
|
@@ -183,20 +349,26 @@ class Voz:
|
|
|
183
349
|
self.parar()
|
|
184
350
|
return
|
|
185
351
|
falar_para_o_node({"tipo": "pronto"})
|
|
186
|
-
self.
|
|
352
|
+
self._estado("ouvindo" if self.modo == "sempre" else "dormindo")
|
|
353
|
+
self.sons.tocar("acordou")
|
|
354
|
+
self._saudar()
|
|
187
355
|
while self._vivo:
|
|
188
356
|
self.gravador.comecar()
|
|
189
357
|
acabou = False
|
|
358
|
+
ultimo_parcial = 0.0
|
|
190
359
|
while self._vivo and not acabou:
|
|
191
360
|
try:
|
|
192
361
|
bloco = self._blocos.get(timeout=0.3)
|
|
193
362
|
except queue.Empty:
|
|
194
363
|
continue
|
|
195
364
|
acabou = self.gravador.alimentar(bloco)
|
|
196
|
-
# A bolinha acende com a voz da pessoa, no tamanho da voz.
|
|
197
365
|
if self.gravador._falando and not self._falando:
|
|
198
366
|
lim = max(1e-6, self.gravador._limiar())
|
|
367
|
+
self.luz.mudar("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
|
|
199
368
|
self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
|
|
369
|
+
if time.time() - ultimo_parcial > 1.6 and self.gravador.segundos > 1.0:
|
|
370
|
+
ultimo_parcial = time.time()
|
|
371
|
+
self._parcial()
|
|
200
372
|
if not self._vivo:
|
|
201
373
|
break
|
|
202
374
|
if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
|
|
@@ -204,16 +376,83 @@ class Voz:
|
|
|
204
376
|
wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
|
|
205
377
|
if not wav:
|
|
206
378
|
continue
|
|
207
|
-
self.
|
|
379
|
+
self._t_acabou = time.time()
|
|
208
380
|
texto = transcrever.limpar(self._transcrever(wav))
|
|
209
|
-
if texto
|
|
210
|
-
|
|
211
|
-
# voz, ou {"tipo":"estado"} quando ele termina sem falar).
|
|
212
|
-
falar_para_o_node({"tipo": "ouvi", "texto": texto})
|
|
213
|
-
else:
|
|
214
|
-
self.bolinha.estado("esperando")
|
|
381
|
+
self.lat(f"transcrição {time.time() - self._t_acabou:.2f}s (a frase fechou após {escuta.RABO_VAD if self.gravador.vad else escuta.RABO_SILENCIO:.1f}s de silêncio) -> {texto!r}")
|
|
382
|
+
self._tratar(texto)
|
|
215
383
|
mic.fechar()
|
|
216
384
|
|
|
385
|
+
def _tratar(self, texto: str) -> None:
|
|
386
|
+
"""O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
|
|
387
|
+
if not texto:
|
|
388
|
+
self._estado(self.maquina.estado if self.maquina.estado != "pensando" else "ouvindo")
|
|
389
|
+
return
|
|
390
|
+
agora = time.time()
|
|
391
|
+
acordado = (self.modo == "sempre" or agora < self._acordado_ate or agora < self._ptt_ate)
|
|
392
|
+
chamou, resto = conversa.ativacao(texto, self.nome)
|
|
393
|
+
if not acordado and not chamou:
|
|
394
|
+
self._estado("dormindo") # não era comigo: não vira pedido
|
|
395
|
+
return
|
|
396
|
+
texto = resto if chamou else texto
|
|
397
|
+
self._acordado_ate = agora + 25
|
|
398
|
+
self.bolinha.enviar({"transcricao": texto or f"({self.nome}?)", "parcial": False})
|
|
399
|
+
if not texto: # só chamou pelo nome
|
|
400
|
+
self.sons.tocar("entendeu")
|
|
401
|
+
self._dizer(identidade.variar(["Oi! Fala.", "Pois não?", "Tô aqui."], self._ultimo_ok))
|
|
402
|
+
self.maquina.mandar("ativar")
|
|
403
|
+
return
|
|
404
|
+
novo = identidade.aprender(texto)
|
|
405
|
+
if novo:
|
|
406
|
+
self.cfg = identidade.salvar(**novo)
|
|
407
|
+
if "nome" in novo:
|
|
408
|
+
self.nome = novo["nome"]
|
|
409
|
+
self.bolinha.enviar({"nome": self.nome})
|
|
410
|
+
self._dizer(f"Fechado. Agora eu me chamo {self.nome}.")
|
|
411
|
+
else:
|
|
412
|
+
self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
|
|
413
|
+
return
|
|
414
|
+
nova = fala.voz_pedida(texto, self.voz_id)
|
|
415
|
+
if nova:
|
|
416
|
+
self.voz_id = nova
|
|
417
|
+
self.cfg = identidade.salvar(voz=nova)
|
|
418
|
+
self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
|
|
419
|
+
return
|
|
420
|
+
cmd = conversa.comando(texto)
|
|
421
|
+
if cmd == "esconder":
|
|
422
|
+
self.bolinha.enviar({"painel": "esconder"})
|
|
423
|
+
self.sons.tocar("painel_fecha")
|
|
424
|
+
return
|
|
425
|
+
if cmd == "mostrar":
|
|
426
|
+
self.bolinha.enviar({"painel": "mostrar"})
|
|
427
|
+
self.sons.tocar("painel_abre")
|
|
428
|
+
return
|
|
429
|
+
if cmd == "parar":
|
|
430
|
+
self._parar_fala.set()
|
|
431
|
+
self._limpar_falas()
|
|
432
|
+
self.maquina.mandar("parar")
|
|
433
|
+
falar_para_o_node({"tipo": "parar"})
|
|
434
|
+
self._estado("ouvindo")
|
|
435
|
+
return
|
|
436
|
+
if cmd == "olhar":
|
|
437
|
+
texto = "Tire um print da minha tela agora, olhe e me diga em uma ou duas frases o que está acontecendo."
|
|
438
|
+
elif cmd == "resumo_dia":
|
|
439
|
+
texto = ("Faça o resumo do meu dia no projeto atual, em poucas frases faladas: PRs abertos (gh pr list), "
|
|
440
|
+
"se os testes passam e o que mudou hoje no git. O detalhe vai no painel.")
|
|
441
|
+
self.sons.tocar("entendeu", falando=self._falando)
|
|
442
|
+
self.maquina.mandar("ouviu")
|
|
443
|
+
self._estado("pensando")
|
|
444
|
+
falar_para_o_node({"tipo": "ouvi", "texto": texto})
|
|
445
|
+
|
|
446
|
+
def _saudar(self) -> None:
|
|
447
|
+
if self.cfg.get("pessoa") or True:
|
|
448
|
+
self._dizer(identidade.saudacao(time.localtime().tm_hour, self.cfg.get("pessoa", ""), self.nome))
|
|
449
|
+
|
|
450
|
+
def _dizer(self, texto: str) -> None:
|
|
451
|
+
"""Fala uma frase curta já pronta (sem passar pelo resumo)."""
|
|
452
|
+
self._ultimo_ok = texto
|
|
453
|
+
self.bolinha.enviar({"resposta": texto})
|
|
454
|
+
self._falas.put(texto)
|
|
455
|
+
|
|
217
456
|
def _transcrever(self, wav: Path) -> str:
|
|
218
457
|
"""Primeiro aqui, com o Whisper local. O servidor só entra se o local
|
|
219
458
|
não estiver instalado ou falhar — e diz que caiu."""
|
|
@@ -242,45 +481,141 @@ class Voz:
|
|
|
242
481
|
avisar(f"não deu para transcrever: {e}")
|
|
243
482
|
return ""
|
|
244
483
|
|
|
245
|
-
# ── a boca
|
|
484
|
+
# ── a boca: frase a frase, a próxima já sendo gerada enquanto a atual toca ──
|
|
246
485
|
|
|
247
486
|
def _falar_em_fila(self) -> None:
|
|
487
|
+
"""Sintetiza a próxima frase enquanto a anterior toca (o 1º áudio sai
|
|
488
|
+
depois de UMA frase, não do texto inteiro)."""
|
|
489
|
+
threading.Thread(target=self._tocar_em_fila, daemon=True).start()
|
|
248
490
|
while self._vivo:
|
|
249
491
|
try:
|
|
250
492
|
texto = self._falas.get(timeout=0.3)
|
|
251
493
|
except queue.Empty:
|
|
252
494
|
continue
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
495
|
+
for frase in conversa.dividir_frases(texto):
|
|
496
|
+
mp3 = self._sintetizar(frase)
|
|
497
|
+
if mp3:
|
|
498
|
+
while self._vivo:
|
|
499
|
+
try:
|
|
500
|
+
self._audios.put(mp3, timeout=0.3)
|
|
501
|
+
break
|
|
502
|
+
except queue.Full:
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
def _cache_de(self, frase: str) -> "Path | None":
|
|
506
|
+
"""Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
|
|
507
|
+
if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
|
|
508
|
+
return None
|
|
509
|
+
import hashlib
|
|
510
|
+
pasta = Path.home() / ".primocode" / "voz-cache"
|
|
511
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
if re.match(r"^São \d+ (?:e \d\d|horas em ponto)\.$", frase):
|
|
513
|
+
return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
|
|
514
|
+
return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
|
|
515
|
+
|
|
516
|
+
def _cache_horas(self) -> None:
|
|
517
|
+
agora = time.time()
|
|
518
|
+
for dm in (0, 1, 2):
|
|
519
|
+
f = fala.hora_falada(time.localtime(agora + 60 * dm))
|
|
520
|
+
c = self._cache_de(f)
|
|
521
|
+
if c is not None and not c.exists():
|
|
522
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
523
|
+
|
|
524
|
+
def _pre_aquecer_frases(self) -> None:
|
|
525
|
+
"""As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
|
|
526
|
+
self.edge.aquecer()
|
|
527
|
+
self._cache_horas()
|
|
528
|
+
for f in ("Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar.", "Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
|
|
529
|
+
"Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
|
|
530
|
+
c = self._cache_de(f)
|
|
531
|
+
if c is not None and not c.exists():
|
|
532
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
533
|
+
while self._vivo: # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
|
|
534
|
+
self._cache_horas()
|
|
535
|
+
for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"): # só os minutos que já passaram
|
|
536
|
+
try:
|
|
537
|
+
if time.time() - velho.stat().st_mtime > 600:
|
|
538
|
+
velho.unlink()
|
|
539
|
+
except OSError:
|
|
540
|
+
pass
|
|
541
|
+
time.sleep(20)
|
|
542
|
+
|
|
543
|
+
def _sintetizar(self, frase: str) -> "Path | None":
|
|
544
|
+
limpo = " ".join(str(frase).split())[:600]
|
|
257
545
|
if not limpo:
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
|
|
546
|
+
return None
|
|
547
|
+
t0 = time.time()
|
|
261
548
|
mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
|
|
549
|
+
# 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
|
|
550
|
+
guardada = self._cache_de(limpo)
|
|
551
|
+
if guardada is not None and guardada.exists():
|
|
552
|
+
shutil.copyfile(guardada, mp3)
|
|
553
|
+
self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
|
|
554
|
+
return mp3
|
|
555
|
+
# 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
|
|
556
|
+
if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
|
|
557
|
+
self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
|
|
558
|
+
if guardada is not None:
|
|
559
|
+
try:
|
|
560
|
+
shutil.copyfile(mp3, guardada)
|
|
561
|
+
except OSError:
|
|
562
|
+
pass
|
|
563
|
+
return mp3
|
|
564
|
+
# 2. ElevenLabs, só se a pessoa pediu
|
|
262
565
|
narrar = AQUI.parent.parent / "studio" / "narrar.py"
|
|
263
|
-
r =
|
|
264
|
-
|
|
265
|
-
capture_output=True, timeout=180)
|
|
266
|
-
if r.returncode != 0 or not mp3.exists():
|
|
267
|
-
avisar(motivo_da_narracao(r))
|
|
268
|
-
return
|
|
269
|
-
self._falando = True
|
|
270
|
-
self._parar_fala.clear()
|
|
271
|
-
self.bolinha.estado("falando", 0.6)
|
|
272
|
-
try:
|
|
273
|
-
self._tocar(mp3)
|
|
274
|
-
finally:
|
|
275
|
-
self._falando = False
|
|
276
|
-
self.bolinha.estado("esperando")
|
|
277
|
-
self.corte.nivel_dele = 0.0
|
|
278
|
-
# O que o microfone pegou enquanto ele falava é a voz dele.
|
|
279
|
-
self._esvaziar_blocos()
|
|
566
|
+
r = None
|
|
567
|
+
if self.cfg.get("tts") == "elevenlabs":
|
|
280
568
|
try:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
569
|
+
r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
|
|
570
|
+
capture_output=True, timeout=60)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
avisar(f"não deu para gerar a voz: {e}")
|
|
573
|
+
if r is not None and r.returncode == 0 and mp3.exists():
|
|
574
|
+
return mp3
|
|
575
|
+
# 3. offline: a voz do sistema
|
|
576
|
+
if sys.platform == "darwin" and shutil.which("say"):
|
|
577
|
+
aiff = mp3.with_suffix(".aiff")
|
|
578
|
+
if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
|
|
579
|
+
subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
|
|
580
|
+
self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
|
|
581
|
+
return aiff
|
|
582
|
+
avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
|
|
583
|
+
self._erro("Não consegui gerar a voz. O texto está no painel.")
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
def _tocar_em_fila(self) -> None:
|
|
587
|
+
while self._vivo:
|
|
588
|
+
try:
|
|
589
|
+
arq = self._audios.get(timeout=0.3)
|
|
590
|
+
except queue.Empty:
|
|
591
|
+
if self._falando and self._falas.empty():
|
|
592
|
+
self._acabou_de_falar()
|
|
593
|
+
continue
|
|
594
|
+
if not self._falando:
|
|
595
|
+
self._falando = True
|
|
596
|
+
self._parar_fala.clear()
|
|
597
|
+
self.maquina.mandar("resposta")
|
|
598
|
+
self._estado("falando", 0.6)
|
|
599
|
+
try:
|
|
600
|
+
self._tocar(arq)
|
|
601
|
+
finally:
|
|
602
|
+
try:
|
|
603
|
+
arq.unlink()
|
|
604
|
+
except OSError:
|
|
605
|
+
pass
|
|
606
|
+
if self._parar_fala.is_set():
|
|
607
|
+
self._acabou_de_falar()
|
|
608
|
+
|
|
609
|
+
def _acabou_de_falar(self) -> None:
|
|
610
|
+
if not self._falando:
|
|
611
|
+
return
|
|
612
|
+
self._falando = False
|
|
613
|
+
self.maquina.mandar("fim_fala")
|
|
614
|
+
self._estado("ouvindo" if self.modo == "sempre" or time.time() < self._acordado_ate else "dormindo")
|
|
615
|
+
self._acordado_ate = max(self._acordado_ate, time.time() + 20)
|
|
616
|
+
self.sons.tocar("pronto")
|
|
617
|
+
self.corte.nivel_dele = 0.0
|
|
618
|
+
self._esvaziar_blocos() # o que o microfone pegou era a voz dele
|
|
284
619
|
|
|
285
620
|
def _tocar(self, arquivo: Path) -> None:
|
|
286
621
|
"""Toca o mp3 com o que a máquina tiver — interrompível, porque falar
|
|
@@ -296,12 +631,24 @@ class Voz:
|
|
|
296
631
|
["mpv", "--no-video", "--really-quiet", str(arquivo)],
|
|
297
632
|
["cvlc", "--play-and-exit", "--intf", "dummy", str(arquivo)],
|
|
298
633
|
]
|
|
634
|
+
env = dados_tela.envelope(arquivo) # volume real da fala dele: barras da tela e luz do teclado seguem
|
|
299
635
|
for cmd in tentativas:
|
|
300
636
|
try:
|
|
301
637
|
p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
302
638
|
except (FileNotFoundError, OSError):
|
|
303
639
|
continue
|
|
640
|
+
t0 = time.time()
|
|
641
|
+
if self._t_acabou:
|
|
642
|
+
self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
|
|
643
|
+
self._t_acabou = None
|
|
644
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
645
|
+
avisar(f"falando ({cmd[0]}): {arquivo.name}")
|
|
304
646
|
while p.poll() is None:
|
|
647
|
+
if env:
|
|
648
|
+
i = int((time.time() - t0) / 0.05)
|
|
649
|
+
nv = env[i] if i < len(env) else 0.0
|
|
650
|
+
self.bolinha.estado("falando", nv)
|
|
651
|
+
self.luz.mudar("falando", nv)
|
|
305
652
|
if self._parar_fala.is_set() or not self._vivo:
|
|
306
653
|
try:
|
|
307
654
|
p.terminate()
|
|
@@ -309,6 +656,8 @@ class Voz:
|
|
|
309
656
|
pass
|
|
310
657
|
return
|
|
311
658
|
time.sleep(0.05)
|
|
659
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
660
|
+
avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
|
|
312
661
|
if p.returncode == 0:
|
|
313
662
|
return
|
|
314
663
|
# INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
|
|
@@ -332,16 +681,38 @@ class Voz:
|
|
|
332
681
|
continue
|
|
333
682
|
tipo = msg.get("tipo")
|
|
334
683
|
if tipo == "falar":
|
|
335
|
-
|
|
684
|
+
texto = str(msg.get("texto") or "")
|
|
685
|
+
if msg.get("bruto"):
|
|
686
|
+
# a voz resume; o texto inteiro (código, listas) vai ao painel
|
|
687
|
+
fala, tela = conversa.resumo(texto)
|
|
688
|
+
self.bolinha.enviar({"resposta": tela[:700]})
|
|
689
|
+
texto = conversa.frase_final(fala)
|
|
690
|
+
self._acordado_ate = time.time() + 25
|
|
691
|
+
self._falas.put(texto)
|
|
692
|
+
elif tipo == "ferramenta":
|
|
693
|
+
self.maquina.mandar("ferramenta")
|
|
694
|
+
self._estado("executando")
|
|
695
|
+
conf = conversa.confirmacao(msg.get("nome", ""), msg.get("args"))
|
|
696
|
+
if conf and time.time() - self._ultima_conf > 8 and not self._falando:
|
|
697
|
+
self._ultima_conf = time.time()
|
|
698
|
+
self._falas.put(conf)
|
|
336
699
|
elif tipo == "calar":
|
|
337
700
|
self._parar_fala.set()
|
|
338
|
-
|
|
339
|
-
try:
|
|
340
|
-
self._falas.get_nowait()
|
|
341
|
-
except queue.Empty:
|
|
342
|
-
break
|
|
701
|
+
self._limpar_falas()
|
|
343
702
|
elif tipo == "estado":
|
|
344
|
-
|
|
703
|
+
est = str(msg.get("estado") or "esperando")
|
|
704
|
+
if est == "erro":
|
|
705
|
+
self._erro("Deu ruim. O detalhe está no terminal.")
|
|
706
|
+
est = "ouvindo"
|
|
707
|
+
self.maquina.mandar("sem_fala")
|
|
708
|
+
self._estado("ouvindo" if est == "esperando" and time.time() < self._acordado_ate else
|
|
709
|
+
("dormindo" if est == "esperando" else est))
|
|
710
|
+
if est == "esperando":
|
|
711
|
+
self.sons.tocar("pronto", falando=self._falando)
|
|
712
|
+
elif tipo == "acao":
|
|
713
|
+
self._acao_do_painel(str(msg.get("acao") or ""))
|
|
714
|
+
elif tipo == "painel":
|
|
715
|
+
self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
|
|
345
716
|
elif tipo == "fim":
|
|
346
717
|
break
|
|
347
718
|
self.parar()
|
|
@@ -351,6 +722,7 @@ class Voz:
|
|
|
351
722
|
return
|
|
352
723
|
self._vivo = False
|
|
353
724
|
self._parar_fala.set()
|
|
725
|
+
self.luz.fechar()
|
|
354
726
|
self.bolinha.fechar()
|
|
355
727
|
falar_para_o_node({"tipo": "fim"})
|
|
356
728
|
|