primocode 10.14.1 → 10.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
  import json
32
32
  import os
33
33
  import queue
34
+ import shutil
34
35
  import subprocess
35
36
  import sys
36
37
  import tempfile
@@ -41,7 +42,10 @@ from pathlib import Path
41
42
  AQUI = Path(__file__).resolve().parent
42
43
  sys.path.insert(0, str(AQUI.parent))
43
44
 
44
- from primo_voz import escuta, transcrever # noqa: E402
45
+ from primo_voz import conversa, escuta, identidade, painel, transcrever # noqa: E402
46
+ from primo_voz import tela as dados_tela # noqa: E402
47
+ from primo_voz.luz import Luz # noqa: E402
48
+ from primo_voz.sons import Sons # noqa: E402
45
49
 
46
50
 
47
51
  class Bolinha:
@@ -57,13 +61,49 @@ class Bolinha:
57
61
  self.quando = 0.0
58
62
  if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
59
63
  return
64
+ self.ao_acao = None
65
+ # No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
66
+ modulo = "primo_voz.orbe"
67
+ if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
68
+ try:
69
+ import AppKit # noqa: F401
70
+ modulo = "primo_voz.hud"
71
+ except ImportError:
72
+ pass
73
+ self.modulo = modulo
60
74
  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,
75
+ self.p = subprocess.Popen([sys.executable, "-m", modulo], cwd=str(AQUI.parent),
76
+ stdin=subprocess.PIPE,
77
+ stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
78
+ stderr=subprocess.DEVNULL,
63
79
  env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
80
+ if modulo.endswith("hud"):
81
+ threading.Thread(target=self._ler_acoes, daemon=True).start()
64
82
  except Exception:
65
83
  self.p = None
66
84
 
85
+ def _ler_acoes(self) -> None:
86
+ """O HUD manda {"acao":"ptt"} quando a pessoa clica nos anéis."""
87
+ try:
88
+ for linha in self.p.stdout:
89
+ try:
90
+ a = json.loads(linha).get("acao")
91
+ except ValueError:
92
+ continue
93
+ if a and self.ao_acao:
94
+ self.ao_acao(a)
95
+ except Exception:
96
+ pass
97
+
98
+ def enviar(self, obj: dict) -> None:
99
+ if not self.p or self.p.poll() is not None or not self.modulo.endswith("hud"):
100
+ return
101
+ try:
102
+ self.p.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
103
+ self.p.stdin.flush()
104
+ except Exception:
105
+ pass
106
+
67
107
  def estado(self, estado: str, nivel: float = None) -> None:
68
108
  if not self.p or self.p.poll() is not None:
69
109
  return
@@ -71,6 +111,8 @@ class Bolinha:
71
111
  if estado == self.ultimo and (nivel is None or agora - self.quando < 1 / 15):
72
112
  return
73
113
  self.ultimo, self.quando = estado, agora
114
+ if not self.modulo.endswith("hud"):
115
+ estado = {"executando": "pensando", "interrompido": "ouvindo", "dormindo": "esperando"}.get(estado, estado)
74
116
  msg = {"estado": estado}
75
117
  if nivel is not None:
76
118
  msg["nivel"] = round(min(1.0, nivel), 3)
@@ -131,25 +173,60 @@ PS_TOCAR_MP3 = (
131
173
 
132
174
 
133
175
  class Voz:
134
- """Microfone → transcrição → terminal. Terminal → voz."""
176
+ """Microfone → transcrição → terminal. Terminal → voz. O Primo.
177
+
178
+ A conversa (estados, frases, resumo, comandos) é conversa.py, puro e testado;
179
+ o painel é hud.py; luz.py e sons.py são o resto do corpo."""
135
180
 
136
181
  def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
137
182
  self.servidor = servidor
138
183
  self.token = token
139
184
  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.
185
+ self.cfg = identidade.ler()
186
+ self.nome = self.cfg["nome"]
187
+ # nome = só responde se começar pelo nome · sempre = sem palavra · tecla = só ao clicar no painel
188
+ self.modo = os.environ.get("PRIMOCODE_ATIVACAO", "nome")
142
189
  self.local = transcrever.disponivel()
143
190
  if self.local:
144
191
  transcrever.aquecer()
145
192
  self.gravador = escuta.Gravador()
193
+ self.gravador.vad = escuta.criar_vad()
146
194
  self.corte = escuta.Corte()
147
195
  self.bolinha = Bolinha()
196
+ self.bolinha.ao_acao = self._acao_do_painel
197
+ self.luz = Luz(bool(self.cfg.get("luz", True)))
198
+ self.sons = Sons(bool(self.cfg.get("sons", True)))
199
+ self.maquina = conversa.Maquina("ouvindo")
148
200
  self._blocos: "queue.Queue" = queue.Queue()
149
- self._falas: "queue.Queue" = queue.Queue()
201
+ self._falas: "queue.Queue" = queue.Queue() # frases de texto
202
+ self._audios: "queue.Queue" = queue.Queue(maxsize=2) # mp3 já pronto
150
203
  self._vivo = True
151
204
  self._falando = False
152
205
  self._parar_fala = threading.Event()
206
+ self._acordado_ate = 0.0 # janela de conversa: sem repetir o nome
207
+ self._ptt_ate = 0.0
208
+ self._ultima_conf = 0.0
209
+ self._ultimo_ok = ""
210
+ self.bolinha.enviar({"nome": self.nome})
211
+
212
+ # ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
213
+
214
+ def _estado(self, estado: str, nivel: float = None) -> None:
215
+ mudou = estado != self.bolinha.ultimo
216
+ self.bolinha.estado(estado, nivel)
217
+ self.luz.mudar(estado, nivel)
218
+ if mudou and estado in ("ouvindo", "pensando"):
219
+ self.sons.tocar(estado, falando=self._falando)
220
+
221
+ def _acao_do_painel(self, acao: str) -> None:
222
+ if acao == "ptt": # clique nos anéis = falar agora
223
+ self._ptt_ate = time.time() + 10
224
+ self.sons.tocar("acordou", falando=self._falando)
225
+
226
+ def _erro(self, texto: str) -> None:
227
+ self.luz.erro()
228
+ self.sons.tocar("erro")
229
+ self.bolinha.enviar({"resposta": texto})
153
230
 
154
231
  # ── o ouvido ─────────────────────────────────────────────────────────
155
232
 
@@ -157,16 +234,33 @@ class Voz:
157
234
  if not self._vivo:
158
235
  return
159
236
  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.
237
+ if not self._falando and self.bolinha.ultimo: # o volume REAL do mic vai para as barras da tela
238
+ self.bolinha.estado(self.bolinha.ultimo, dados_tela.nivel_do_mic(nivel))
239
+ # Enquanto ele fala, o microfone só serve para saber se a pessoa falou
240
+ # POR CIMA (interrupção). A voz dele na caixa de som não vira pedido.
163
241
  if self._falando:
164
242
  self.corte.ele_falou(nivel)
165
- if self.corte.alguem_cortou(nivel):
166
- self._parar_fala.set()
243
+ if self.maquina.pode_cortar and self.corte.alguem_cortou(nivel):
244
+ self._interromper()
167
245
  return
168
246
  self._blocos.put(bloco)
169
247
 
248
+ def _interromper(self) -> None:
249
+ self.maquina.mandar("cortou")
250
+ self._parar_fala.set()
251
+ self._limpar_falas()
252
+ self._acordado_ate = time.time() + 20 # falou por cima: já está conversando
253
+ self.maquina.mandar("escutar")
254
+ self._estado("ouvindo")
255
+
256
+ def _limpar_falas(self) -> None:
257
+ for fila in (self._falas, self._audios):
258
+ while True:
259
+ try:
260
+ fila.get_nowait()
261
+ except queue.Empty:
262
+ break
263
+
170
264
  def _esvaziar_blocos(self) -> None:
171
265
  while True:
172
266
  try:
@@ -174,6 +268,34 @@ class Voz:
174
268
  except queue.Empty:
175
269
  return
176
270
 
271
+ def _parcial(self) -> None:
272
+ """Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
273
+ if not self.local or getattr(self, "_parcial_rodando", False):
274
+ return
275
+ copia = list(self.gravador._blocos)
276
+ if not copia:
277
+ return
278
+ self._parcial_rodando = True
279
+
280
+ def trabalho():
281
+ try:
282
+ import numpy as np
283
+ import wave as _w
284
+ arq = Path(tempfile.gettempdir()) / "primo-voz-parcial.wav"
285
+ with _w.open(str(arq), "wb") as w:
286
+ w.setnchannels(1)
287
+ w.setsampwidth(2)
288
+ w.setframerate(escuta.TAXA)
289
+ w.writeframes(np.concatenate(copia).astype(np.int16).tobytes())
290
+ t = transcrever.limpar(transcrever.transcrever(arq))
291
+ if t and self.gravador._falando:
292
+ self.bolinha.enviar({"transcricao": t, "parcial": True})
293
+ except Exception:
294
+ pass
295
+ finally:
296
+ self._parcial_rodando = False
297
+ threading.Thread(target=trabalho, daemon=True).start()
298
+
177
299
  def _ouvir(self) -> None:
178
300
  mic = escuta.Microfone(self._ao_receber)
179
301
  try:
@@ -183,20 +305,26 @@ class Voz:
183
305
  self.parar()
184
306
  return
185
307
  falar_para_o_node({"tipo": "pronto"})
186
- self.bolinha.estado("esperando")
308
+ self._estado("ouvindo" if self.modo == "sempre" else "dormindo")
309
+ self.sons.tocar("acordou")
310
+ self._saudar()
187
311
  while self._vivo:
188
312
  self.gravador.comecar()
189
313
  acabou = False
314
+ ultimo_parcial = 0.0
190
315
  while self._vivo and not acabou:
191
316
  try:
192
317
  bloco = self._blocos.get(timeout=0.3)
193
318
  except queue.Empty:
194
319
  continue
195
320
  acabou = self.gravador.alimentar(bloco)
196
- # A bolinha acende com a voz da pessoa, no tamanho da voz.
197
321
  if self.gravador._falando and not self._falando:
198
322
  lim = max(1e-6, self.gravador._limiar())
323
+ self.luz.mudar("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
199
324
  self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
325
+ if time.time() - ultimo_parcial > 1.6 and self.gravador.segundos > 1.0:
326
+ ultimo_parcial = time.time()
327
+ self._parcial()
200
328
  if not self._vivo:
201
329
  break
202
330
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
@@ -204,16 +332,75 @@ class Voz:
204
332
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
205
333
  if not wav:
206
334
  continue
207
- self.bolinha.estado("pensando")
208
335
  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")
336
+ self._tratar(texto)
215
337
  mic.fechar()
216
338
 
339
+ def _tratar(self, texto: str) -> None:
340
+ """O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
341
+ if not texto:
342
+ self._estado(self.maquina.estado if self.maquina.estado != "pensando" else "ouvindo")
343
+ return
344
+ agora = time.time()
345
+ acordado = (self.modo == "sempre" or agora < self._acordado_ate or agora < self._ptt_ate)
346
+ chamou, resto = conversa.ativacao(texto, self.nome)
347
+ if not acordado and not chamou:
348
+ self._estado("dormindo") # não era comigo: não vira pedido
349
+ return
350
+ texto = resto if chamou else texto
351
+ self._acordado_ate = agora + 25
352
+ self.bolinha.enviar({"transcricao": texto or f"({self.nome}?)", "parcial": False})
353
+ if not texto: # só chamou pelo nome
354
+ self.sons.tocar("entendeu")
355
+ self._dizer(identidade.variar(["Oi! Fala.", "Pois não?", "Tô aqui."], self._ultimo_ok))
356
+ self.maquina.mandar("ativar")
357
+ return
358
+ novo = identidade.aprender(texto)
359
+ if novo:
360
+ self.cfg = identidade.salvar(**novo)
361
+ if "nome" in novo:
362
+ self.nome = novo["nome"]
363
+ self.bolinha.enviar({"nome": self.nome})
364
+ self._dizer(f"Fechado. Agora eu me chamo {self.nome}.")
365
+ else:
366
+ self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
367
+ return
368
+ cmd = conversa.comando(texto)
369
+ if cmd == "esconder":
370
+ self.bolinha.enviar({"painel": "esconder"})
371
+ self.sons.tocar("painel_fecha")
372
+ return
373
+ if cmd == "mostrar":
374
+ self.bolinha.enviar({"painel": "mostrar"})
375
+ self.sons.tocar("painel_abre")
376
+ return
377
+ if cmd == "parar":
378
+ self._parar_fala.set()
379
+ self._limpar_falas()
380
+ self.maquina.mandar("parar")
381
+ falar_para_o_node({"tipo": "parar"})
382
+ self._estado("ouvindo")
383
+ return
384
+ if cmd == "olhar":
385
+ texto = "Tire um print da minha tela agora, olhe e me diga em uma ou duas frases o que está acontecendo."
386
+ elif cmd == "resumo_dia":
387
+ texto = ("Faça o resumo do meu dia no projeto atual, em poucas frases faladas: PRs abertos (gh pr list), "
388
+ "se os testes passam e o que mudou hoje no git. O detalhe vai no painel.")
389
+ self.sons.tocar("entendeu", falando=self._falando)
390
+ self.maquina.mandar("ouviu")
391
+ self._estado("pensando")
392
+ falar_para_o_node({"tipo": "ouvi", "texto": texto})
393
+
394
+ def _saudar(self) -> None:
395
+ if self.cfg.get("pessoa") or True:
396
+ self._dizer(identidade.saudacao(time.localtime().tm_hour, self.cfg.get("pessoa", ""), self.nome))
397
+
398
+ def _dizer(self, texto: str) -> None:
399
+ """Fala uma frase curta já pronta (sem passar pelo resumo)."""
400
+ self._ultimo_ok = texto
401
+ self.bolinha.enviar({"resposta": texto})
402
+ self._falas.put(texto)
403
+
217
404
  def _transcrever(self, wav: Path) -> str:
218
405
  """Primeiro aqui, com o Whisper local. O servidor só entra se o local
219
406
  não estiver instalado ou falhar — e diz que caiu."""
@@ -242,45 +429,83 @@ class Voz:
242
429
  avisar(f"não deu para transcrever: {e}")
243
430
  return ""
244
431
 
245
- # ── a boca ───────────────────────────────────────────────────────────
432
+ # ── a boca: frase a frase, a próxima já sendo gerada enquanto a atual toca ──
246
433
 
247
434
  def _falar_em_fila(self) -> None:
435
+ """Sintetiza a próxima frase enquanto a anterior toca (o 1º áudio sai
436
+ depois de UMA frase, não do texto inteiro)."""
437
+ threading.Thread(target=self._tocar_em_fila, daemon=True).start()
248
438
  while self._vivo:
249
439
  try:
250
440
  texto = self._falas.get(timeout=0.3)
251
441
  except queue.Empty:
252
442
  continue
253
- self._falar(texto)
254
-
255
- def _falar(self, texto: str) -> None:
256
- limpo = " ".join(str(texto).split())
443
+ for frase in conversa.dividir_frases(texto):
444
+ mp3 = self._sintetizar(frase)
445
+ if mp3:
446
+ while self._vivo:
447
+ try:
448
+ self._audios.put(mp3, timeout=0.3)
449
+ break
450
+ except queue.Full:
451
+ continue
452
+
453
+ def _sintetizar(self, frase: str) -> "Path | None":
454
+ limpo = " ".join(str(frase).split())[:600]
257
455
  if not limpo:
258
- return
259
- if len(limpo) > 900:
260
- limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
456
+ return None
261
457
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
262
458
  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)
459
+ try:
460
+ r = subprocess.run(
461
+ [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
462
+ capture_output=True, timeout=180)
463
+ except Exception as e:
464
+ avisar(f"não deu para gerar a voz: {e}")
465
+ return None
266
466
  if r.returncode != 0 or not mp3.exists():
467
+ if sys.platform == "darwin" and shutil.which("say"): # reserva: a voz do sistema
468
+ aiff = mp3.with_suffix(".aiff")
469
+ if subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
470
+ return aiff
267
471
  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()
472
+ self._erro("Não consegui gerar a voz. O texto está no painel.")
473
+ return None
474
+ return mp3
475
+
476
+ def _tocar_em_fila(self) -> None:
477
+ while self._vivo:
280
478
  try:
281
- mp3.unlink()
282
- except OSError:
283
- pass
479
+ arq = self._audios.get(timeout=0.3)
480
+ except queue.Empty:
481
+ if self._falando and self._falas.empty():
482
+ self._acabou_de_falar()
483
+ continue
484
+ if not self._falando:
485
+ self._falando = True
486
+ self._parar_fala.clear()
487
+ self.maquina.mandar("resposta")
488
+ self._estado("falando", 0.6)
489
+ try:
490
+ self._tocar(arq)
491
+ finally:
492
+ try:
493
+ arq.unlink()
494
+ except OSError:
495
+ pass
496
+ if self._parar_fala.is_set():
497
+ self._acabou_de_falar()
498
+
499
+ def _acabou_de_falar(self) -> None:
500
+ if not self._falando:
501
+ return
502
+ self._falando = False
503
+ self.maquina.mandar("fim_fala")
504
+ self._estado("ouvindo" if self.modo == "sempre" or time.time() < self._acordado_ate else "dormindo")
505
+ self._acordado_ate = max(self._acordado_ate, time.time() + 20)
506
+ self.sons.tocar("pronto")
507
+ self.corte.nivel_dele = 0.0
508
+ self._esvaziar_blocos() # o que o microfone pegou era a voz dele
284
509
 
285
510
  def _tocar(self, arquivo: Path) -> None:
286
511
  """Toca o mp3 com o que a máquina tiver — interrompível, porque falar
@@ -296,12 +521,19 @@ class Voz:
296
521
  ["mpv", "--no-video", "--really-quiet", str(arquivo)],
297
522
  ["cvlc", "--play-and-exit", "--intf", "dummy", str(arquivo)],
298
523
  ]
524
+ env = dados_tela.envelope(arquivo) # volume real da fala dele: barras da tela e luz do teclado seguem
299
525
  for cmd in tentativas:
300
526
  try:
301
527
  p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
302
528
  except (FileNotFoundError, OSError):
303
529
  continue
530
+ t0 = time.time()
304
531
  while p.poll() is None:
532
+ if env:
533
+ i = int((time.time() - t0) / 0.05)
534
+ nv = env[i] if i < len(env) else 0.0
535
+ self.bolinha.estado("falando", nv)
536
+ self.luz.mudar("falando", nv)
305
537
  if self._parar_fala.is_set() or not self._vivo:
306
538
  try:
307
539
  p.terminate()
@@ -332,16 +564,36 @@ class Voz:
332
564
  continue
333
565
  tipo = msg.get("tipo")
334
566
  if tipo == "falar":
335
- self._falas.put(str(msg.get("texto") or ""))
567
+ texto = str(msg.get("texto") or "")
568
+ if msg.get("bruto"):
569
+ # a voz resume; o texto inteiro (código, listas) vai ao painel
570
+ fala, tela = conversa.resumo(texto)
571
+ self.bolinha.enviar({"resposta": tela[:700]})
572
+ texto = conversa.frase_final(fala)
573
+ self._acordado_ate = time.time() + 25
574
+ self._falas.put(texto)
575
+ elif tipo == "ferramenta":
576
+ self.maquina.mandar("ferramenta")
577
+ self._estado("executando")
578
+ conf = conversa.confirmacao(msg.get("nome", ""), msg.get("args"))
579
+ if conf and time.time() - self._ultima_conf > 8 and not self._falando:
580
+ self._ultima_conf = time.time()
581
+ self._falas.put(conf)
336
582
  elif tipo == "calar":
337
583
  self._parar_fala.set()
338
- while not self._falas.empty():
339
- try:
340
- self._falas.get_nowait()
341
- except queue.Empty:
342
- break
584
+ self._limpar_falas()
343
585
  elif tipo == "estado":
344
- self.bolinha.estado(str(msg.get("estado") or "esperando"))
586
+ est = str(msg.get("estado") or "esperando")
587
+ if est == "erro":
588
+ self._erro("Deu ruim. O detalhe está no terminal.")
589
+ est = "ouvindo"
590
+ self.maquina.mandar("sem_fala")
591
+ self._estado("ouvindo" if est == "esperando" and time.time() < self._acordado_ate else
592
+ ("dormindo" if est == "esperando" else est))
593
+ if est == "esperando":
594
+ self.sons.tocar("pronto", falando=self._falando)
595
+ elif tipo == "painel":
596
+ self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
345
597
  elif tipo == "fim":
346
598
  break
347
599
  self.parar()
@@ -351,6 +603,7 @@ class Voz:
351
603
  return
352
604
  self._vivo = False
353
605
  self._parar_fala.set()
606
+ self.luz.fechar()
354
607
  self.bolinha.fechar()
355
608
  falar_para_o_node({"tipo": "fim"})
356
609