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.
- package/LICENSES/robo.txt +19 -0
- package/README.md +6 -2
- package/bin/primocode.js +46 -3
- package/lib/act.js +1 -0
- package/lib/preparar.js +2 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +75 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +307 -54
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +32 -2
- package/voz/primo_voz/hud.html +550 -0
- package/voz/primo_voz/hud.py +224 -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 +337 -0
- package/voz/primo_voz/vendor/RobotExpressive.glb +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,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",
|
|
62
|
-
stdin=subprocess.PIPE,
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
282
|
-
except
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
try:
|
|
340
|
-
self._falas.get_nowait()
|
|
341
|
-
except queue.Empty:
|
|
342
|
-
break
|
|
584
|
+
self._limpar_falas()
|
|
343
585
|
elif tipo == "estado":
|
|
344
|
-
|
|
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
|
|