primocode 10.15.1 → 10.16.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.
@@ -43,7 +43,7 @@ from pathlib import Path
43
43
  AQUI = Path(__file__).resolve().parent
44
44
  sys.path.insert(0, str(AQUI.parent))
45
45
 
46
- from primo_voz import conversa, escuta, fala, identidade, painel, transcrever # noqa: E402
46
+ from primo_voz import conversa, escuta, fala, fala_pt, identidade, painel, transcrever # noqa: E402
47
47
  from primo_voz import tela as dados_tela # noqa: E402
48
48
  from primo_voz.luz import Luz # noqa: E402
49
49
  from primo_voz.sons import Sons # noqa: E402
@@ -220,14 +220,17 @@ class Voz:
220
220
  self.voz = voz_escolhida
221
221
  self.cfg = identidade.ler()
222
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")
223
+ # Sem palavra de ativação: o microfone só abre no clique do Primo (ptt) e fecha no clique ou ao fim da conversa.
224
+ self._mic = None
225
+ self._mic_aberto = False
225
226
  self.local = transcrever.disponivel()
226
227
  if self.local:
227
228
  transcrever.aquecer()
228
229
  self.edge = fala.Edge()
229
230
  self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
230
231
  self._t_acabou = None
232
+ self._pend = ""
233
+ self._pend_ate = 0.0
231
234
  threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
232
235
  self.gravador = escuta.Gravador()
233
236
  self.gravador.vad = escuta.criar_vad()
@@ -244,7 +247,6 @@ class Voz:
244
247
  self._falando = False
245
248
  self._parar_fala = threading.Event()
246
249
  self._acordado_ate = 0.0 # janela de conversa: sem repetir o nome
247
- self._ptt_ate = 0.0
248
250
  self._ultima_conf = 0.0
249
251
  self._ultimo_ok = ""
250
252
  self.bolinha.enviar({"nome": self.nome})
@@ -263,9 +265,46 @@ class Voz:
263
265
  self.sons.tocar(estado, falando=self._falando)
264
266
 
265
267
  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)
268
+ if acao == "ptt": # clique no Primo: abre o modo voz; clicar de novo fecha
269
+ if self._mic_aberto:
270
+ self._fechar_mic()
271
+ else:
272
+ self._abrir_mic()
273
+
274
+ def _abrir_mic(self) -> bool:
275
+ if self._mic_aberto or not self._vivo:
276
+ return self._mic_aberto
277
+ mic = escuta.Microfone(self._ao_receber)
278
+ try:
279
+ mic.abrir()
280
+ except Exception as e:
281
+ falar_para_o_node({"tipo": "erro", "texto": f"não consegui abrir o microfone: {e}"})
282
+ self._erro("Não consegui abrir o microfone.")
283
+ return False
284
+ self._mic, self._mic_aberto = mic, True
285
+ self._esvaziar_blocos()
286
+ self.gravador.comecar()
287
+ self._acordado_ate = time.time() + 25
288
+ self._estado("ouvindo")
289
+ self.sons.tocar("acordou", falando=self._falando)
290
+ return True
291
+
292
+ def _fechar_mic(self) -> None:
293
+ """Desliga o microfone e volta a dormir: nada é gravado nem transcrito até o próximo clique."""
294
+ mic, self._mic, self._mic_aberto = self._mic, None, False
295
+ if mic:
296
+ mic.fechar()
297
+ self._acordado_ate = 0.0
298
+ self._pend = ""
299
+ self.gravador.espera = escuta.ESPERA_COMECAR
300
+ self._esvaziar_blocos()
301
+ if not self._falando:
302
+ self._estado("dormindo")
303
+
304
+ def _conversa_acabou(self) -> bool:
305
+ """O mesmo critério de antes para voltar a dormir: a janela de conversa passou e ninguém está pensando/falando."""
306
+ return (self._mic_aberto and not self._falando and not self._pend
307
+ and self.maquina.estado == "ouvindo" and time.time() > self._acordado_ate)
269
308
 
270
309
  def _erro(self, texto: str) -> None:
271
310
  self.luz.erro()
@@ -275,7 +314,7 @@ class Voz:
275
314
  # ── o ouvido ─────────────────────────────────────────────────────────
276
315
 
277
316
  def _ao_receber(self, bloco) -> None:
278
- if not self._vivo:
317
+ if not self._vivo or not self._mic_aberto:
279
318
  return
280
319
  nivel = escuta.rms(bloco)
281
320
  if not self._falando and self.bolinha.ultimo: # o volume REAL do mic vai para as barras da tela
@@ -341,17 +380,9 @@ class Voz:
341
380
  threading.Thread(target=trabalho, daemon=True).start()
342
381
 
343
382
  def _ouvir(self) -> None:
344
- mic = escuta.Microfone(self._ao_receber)
345
- try:
346
- mic.abrir()
347
- except Exception as e:
348
- falar_para_o_node({"tipo": "erro", "texto": f"não consegui abrir o microfone: {e}"})
349
- self.parar()
350
- return
383
+ # O microfone NÃO abre aqui: só no clique (_acao_do_painel). Sem clique, nada grava nem transcreve.
351
384
  falar_para_o_node({"tipo": "pronto"})
352
- self._estado("ouvindo" if self.modo == "sempre" else "dormindo")
353
- self.sons.tocar("acordou")
354
- self._saudar()
385
+ self._estado("dormindo")
355
386
  while self._vivo:
356
387
  self.gravador.comecar()
357
388
  acabou = False
@@ -360,6 +391,8 @@ class Voz:
360
391
  try:
361
392
  bloco = self._blocos.get(timeout=0.3)
362
393
  except queue.Empty:
394
+ if self._conversa_acabou():
395
+ self._fechar_mic()
363
396
  continue
364
397
  acabou = self.gravador.alimentar(bloco)
365
398
  if self.gravador._falando and not self._falando:
@@ -372,6 +405,11 @@ class Voz:
372
405
  if not self._vivo:
373
406
  break
374
407
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
408
+ if self._pend and time.time() >= self._pend_ate: # ninguém continuou: agora sim vira pedido
409
+ pend, self._pend = self._pend, ""
410
+ self.gravador.espera = escuta.ESPERA_COMECAR
411
+ self.lat(f"frase completa após a pausa extra: {pend!r}")
412
+ self._tratar(pend)
375
413
  continue
376
414
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
377
415
  if not wav:
@@ -379,28 +417,60 @@ class Voz:
379
417
  self._t_acabou = time.time()
380
418
  texto = transcrever.limpar(self._transcrever(wav))
381
419
  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}")
420
+ self._juntar_e_tratar(texto)
421
+ self._fechar_mic()
422
+
423
+ # "espera": a pessoa segura a vez. Palavras que, sozinhas no fim, prometem continuação.
424
+ _SEGURA = re.compile(r"(?:^|[\s,.])(?:espera|espere|pera ai|perai|aguarda|aguarde|calma|deixa eu pensar)[\s.!?,]*$", re.I)
425
+ _CONTINUA = re.compile(r"\b(?:e|ou|mas|ent[aã]o|que|tipo|porque|pois|a[ií]|pra|para|de|do|da|no|na|com|como|se|quando|ent[aã]o|tamb[eé]m|a|o|um|uma|meu|minha)\W*$", re.I)
426
+
427
+ def _incompleta(self, texto: str) -> bool:
428
+ t = texto.strip()
429
+ if not t:
430
+ return False
431
+ if t[-1] in ".?!":
432
+ return False
433
+ if t[-1] in ",;:…-" or self._CONTINUA.search(t):
434
+ return True
435
+ return len(t.split()) >= 5 # sem pontuação de fim numa frase já longa: provavelmente vem mais; comando curto não espera
436
+
437
+ def _juntar_e_tratar(self, texto: str) -> None:
438
+ """Fim de fala ADAPTATIVO: 1,1 s de silêncio já fechou; se a frase soa incompleta ("e…", "então…", sem ponto)
439
+ ou a pessoa disse "espera", ele segura mais um pouco e junta com o que vier. Nunca corta no meio."""
440
+ if self._pend:
441
+ texto = (self._pend + " " + texto).strip()
442
+ self._pend = ""
443
+ if not texto:
444
+ self.gravador.espera = escuta.ESPERA_COMECAR
382
445
  self._tratar(texto)
383
- mic.fechar()
446
+ return
447
+ if self._SEGURA.search(texto):
448
+ resto = self._SEGURA.sub("", texto).strip(" ,")
449
+ self._pend, self._pend_ate = resto or " ", time.time() + 10
450
+ self.gravador.espera = 10.0
451
+ self.lat("a pessoa pediu para esperar: segurando até 10 s")
452
+ self.bolinha.enviar({"transcricao": (resto + " …") if resto else "…", "parcial": True})
453
+ self._estado("ouvindo")
454
+ return
455
+ if self._incompleta(texto):
456
+ self._pend, self._pend_ate = texto, time.time() + 1.6
457
+ self._acordado_ate = max(self._acordado_ate, time.time() + 30)
458
+ self.gravador.espera = 1.6
459
+ self.lat(f"frase incompleta, esperando mais 1,6 s: {texto!r}")
460
+ self.bolinha.enviar({"transcricao": texto + " …", "parcial": True})
461
+ self._estado("ouvindo")
462
+ return
463
+ self.gravador.espera = escuta.ESPERA_COMECAR
464
+ self._tratar(texto)
384
465
 
385
466
  def _tratar(self, texto: str) -> None:
386
- """O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
467
+ """O que foi dito (o microfone só está aberto se a pessoa clicou): apresentação, comando local ou pedido ao terminal."""
387
468
  if not texto:
388
469
  self._estado(self.maquina.estado if self.maquina.estado != "pensando" else "ouvindo")
389
470
  return
390
471
  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
472
  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
473
+ self.bolinha.enviar({"transcricao": texto, "parcial": False})
404
474
  novo = identidade.aprender(texto)
405
475
  if novo:
406
476
  self.cfg = identidade.salvar(**novo)
@@ -443,10 +513,6 @@ class Voz:
443
513
  self._estado("pensando")
444
514
  falar_para_o_node({"tipo": "ouvi", "texto": texto})
445
515
 
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
516
  def _dizer(self, texto: str) -> None:
451
517
  """Fala uma frase curta já pronta (sem passar pelo resumo)."""
452
518
  self._ultimo_ok = texto
@@ -502,22 +568,22 @@ class Voz:
502
568
  except queue.Full:
503
569
  continue
504
570
 
505
- def _cache_de(self, frase: str) -> "Path | None":
571
+ def _cache_de(self, frase: str, hora: bool = False) -> "Path | None":
506
572
  """Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
507
573
  if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
508
574
  return None
509
575
  import hashlib
510
576
  pasta = Path.home() / ".primocode" / "voz-cache"
511
577
  pasta.mkdir(parents=True, exist_ok=True)
512
- if re.match(r"^São \d+ (?:e \d\d|horas em ponto)\.$", frase):
578
+ if hora:
513
579
  return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
514
580
  return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
515
581
 
516
582
  def _cache_horas(self) -> None:
517
583
  agora = time.time()
518
584
  for dm in (0, 1, 2):
519
- f = fala.hora_falada(time.localtime(agora + 60 * dm))
520
- c = self._cache_de(f)
585
+ f = " ".join(fala_pt.normalizar(fala.hora_falada(time.localtime(agora + 60 * dm))).split())
586
+ c = self._cache_de(f, hora=True)
521
587
  if c is not None and not c.exists():
522
588
  self.edge.sintetizar(f, self.voz_id, c)
523
589
 
@@ -525,7 +591,7 @@ class Voz:
525
591
  """As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
526
592
  self.edge.aquecer()
527
593
  self._cache_horas()
528
- for f in ("Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar.", "Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
594
+ for f in ("Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
529
595
  "Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
530
596
  c = self._cache_de(f)
531
597
  if c is not None and not c.exists():
@@ -541,13 +607,16 @@ class Voz:
541
607
  time.sleep(20)
542
608
 
543
609
  def _sintetizar(self, frase: str) -> "Path | None":
544
- limpo = " ".join(str(frase).split())[:600]
610
+ limpo = " ".join(fala_pt.normalizar(frase).split())[:900] # números, unidades, siglas: por extenso, como se fala
545
611
  if not limpo:
546
612
  return None
547
613
  t0 = time.time()
614
+ self.lat(f"vai falar: {limpo[:140]!r}")
548
615
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
549
616
  # 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
550
617
  guardada = self._cache_de(limpo)
618
+ if guardada is not None and not guardada.exists():
619
+ guardada = self._cache_de(limpo, hora=True) if self._cache_de(limpo, hora=True).exists() else guardada
551
620
  if guardada is not None and guardada.exists():
552
621
  shutil.copyfile(guardada, mp3)
553
622
  self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
@@ -611,8 +680,9 @@ class Voz:
611
680
  return
612
681
  self._falando = False
613
682
  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)
683
+ self._estado("ouvindo" if self._mic_aberto else "dormindo")
684
+ if self._mic_aberto:
685
+ self._acordado_ate = max(self._acordado_ate, time.time() + 90) # conversa em andamento: 90 s até voltar a dormir
616
686
  self.sons.tocar("pronto")
617
687
  self.corte.nivel_dele = 0.0
618
688
  self._esvaziar_blocos() # o que o microfone pegou era a voz dele
@@ -682,7 +752,10 @@ class Voz:
682
752
  tipo = msg.get("tipo")
683
753
  if tipo == "falar":
684
754
  texto = str(msg.get("texto") or "")
685
- if msg.get("bruto"):
755
+ if msg.get("painel"): # o cérebro já mandou o texto FALADO e o ESCRITO separados
756
+ self.bolinha.enviar({"resposta": str(msg["painel"])[:1500]})
757
+ self._acordado_ate = time.time() + 90
758
+ elif msg.get("bruto"):
686
759
  # a voz resume; o texto inteiro (código, listas) vai ao painel
687
760
  fala, tela = conversa.resumo(texto)
688
761
  self.bolinha.enviar({"resposta": tela[:700]})
@@ -705,7 +778,7 @@ class Voz:
705
778
  self._erro("Deu ruim. O detalhe está no terminal.")
706
779
  est = "ouvindo"
707
780
  self.maquina.mandar("sem_fala")
708
- self._estado("ouvindo" if est == "esperando" and time.time() < self._acordado_ate else
781
+ self._estado("ouvindo" if est == "esperando" and self._mic_aberto else
709
782
  ("dormindo" if est == "esperando" else est))
710
783
  if est == "esperando":
711
784
  self.sons.tocar("pronto", falando=self._falando)