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.
- package/README.md +2 -2
- package/bin/primocode.js +73 -10
- package/lib/act.js +2 -0
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +3 -1
- package/lib/config.js +1 -1
- package/lib/estudio/gosto/tela.html +1349 -0
- package/lib/estudio/gosto.js +264 -0
- package/lib/estudio/index.js +9 -1
- package/lib/estudio/maquina-diretor.js +9 -5
- package/lib/estudio/maquina.js +5 -1
- package/lib/headless.js +15 -3
- package/lib/local/cerebro.json +93 -1
- package/lib/memoria-usuario.js +164 -0
- package/lib/nuvem.js +22 -1
- package/lib/repeticao.js +1 -1
- package/lib/sandbox.js +57 -9
- package/lib/tools.js +20 -4
- package/lib/ui.js +1 -1
- package/package.json +2 -2
- package/voz/primo_voz/__main__.py +119 -46
- package/voz/primo_voz/app.py +582 -24
- package/voz/primo_voz/appmac.py +2 -1
- package/voz/primo_voz/chat.html +1762 -0
- package/voz/primo_voz/conversa.py +17 -32
- package/voz/primo_voz/escuta.py +15 -11
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +10 -4
- package/voz/primo_voz/hud.py +152 -7
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/transcrever.py +9 -3
|
@@ -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
|
-
#
|
|
224
|
-
self.
|
|
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
|
|
267
|
-
self.
|
|
268
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 ("
|
|
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(
|
|
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.
|
|
615
|
-
|
|
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("
|
|
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
|
|
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)
|