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.
@@ -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
- self.p = subprocess.Popen([sys.executable, "-m", "primo_voz.orbe"], cwd=str(AQUI.parent),
62
- stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
63
- env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
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
- # O Whisper local carrega em segundo plano desde já: senão a primeira
141
- # frase esperaria o modelo inteiro subir.
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
- # Enquanto ele fala, o microfone só serve para uma coisa: saber se a
161
- # pessoa falou POR CIMA. O que entra aqui é a voz dele saindo da
162
- # caixa de som, e não pode virar pedido.
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._parar_fala.set()
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.bolinha.estado("esperando")
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.bolinha.estado("pensando")
379
+ self._t_acabou = time.time()
208
380
  texto = transcrever.limpar(self._transcrever(wav))
209
- if texto:
210
- # Pensando até o terminal dizer o contrário (a resposta em
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
- self._falar(texto)
254
-
255
- def _falar(self, texto: str) -> None:
256
- limpo = " ".join(str(texto).split())
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
- if len(limpo) > 900:
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 = subprocess.run(
264
- [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
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
- mp3.unlink()
282
- except OSError:
283
- pass
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
- self._falas.put(str(msg.get("texto") or ""))
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
- while not self._falas.empty():
339
- try:
340
- self._falas.get_nowait()
341
- except queue.Empty:
342
- break
701
+ self._limpar_falas()
343
702
  elif tipo == "estado":
344
- self.bolinha.estado(str(msg.get("estado") or "esperando"))
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