primocode 9.0.0 → 9.2.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 +11 -17
- package/bin/primocode.js +88 -15
- package/lib/act.js +2 -2
- package/lib/boas-vindas.js +13 -5
- package/lib/catalogo.js +40 -1
- package/lib/entrada.js +40 -9
- package/lib/{acervo.js → estudio/acervo.js} +1 -1
- package/lib/estudio/assets.js +1 -1
- package/lib/estudio/identidade.js +2 -2
- package/lib/estudio/index.js +1 -1
- package/lib/{marca.js → estudio/marca.js} +1 -1
- package/lib/estudio/remotion.js +40 -2
- package/lib/fala.js +36 -5
- package/lib/markdown.js +376 -0
- package/lib/pasta.js +15 -4
- package/lib/tools.js +55 -6
- package/lib/voz.js +104 -4
- package/package.json +2 -2
- package/studio/pessoa.py +33 -1
- package/voz/primo_voz/__main__.py +138 -16
- package/voz/primo_voz/desperta.py +195 -0
- package/voz/primo_voz/escuta.py +5 -4
- package/voz/primo_voz/orbe.py +42 -0
- /package/lib/{diretor.js → estudio/diretor.js} +0 -0
package/studio/pessoa.py
CHANGED
|
@@ -36,10 +36,30 @@ Uso:
|
|
|
36
36
|
|
|
37
37
|
import argparse
|
|
38
38
|
import json
|
|
39
|
+
import shutil
|
|
40
|
+
import os
|
|
39
41
|
import subprocess
|
|
40
42
|
import sys
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def achar_ffmpeg():
|
|
46
|
+
"""Onde está o ffmpeg — pelo PATH, e nos lugares onde ele costuma cair.
|
|
47
|
+
|
|
48
|
+
O PATH de um processo aberto pela interface gráfica não é o PATH do
|
|
49
|
+
terminal: no macOS um app aberto pelo ícone não enxerga /opt/homebrew/bin,
|
|
50
|
+
onde o Homebrew instala. Procurar nos lugares conhecidos evita um "não
|
|
51
|
+
achei" para quem tem o programa instalado.
|
|
52
|
+
"""
|
|
53
|
+
achado = shutil.which("ffmpeg")
|
|
54
|
+
if achado:
|
|
55
|
+
return achado
|
|
56
|
+
for c in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg",
|
|
57
|
+
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
|
|
58
|
+
if os.path.exists(c):
|
|
59
|
+
return c
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
43
63
|
def faltando():
|
|
44
64
|
"""O que precisa estar instalado, e não está."""
|
|
45
65
|
falta = []
|
|
@@ -76,8 +96,20 @@ def recortar(entrada, saida, suavizar=7, encolher=2, limiar=0.6):
|
|
|
76
96
|
|
|
77
97
|
# VP9 num .webm é o formato de vídeo com transparência que o Chromium do
|
|
78
98
|
# Remotion abre — e é o Chromium dele que vai desenhar isto.
|
|
99
|
+
# O ffmpeg pelo nome cru estoura FileNotFoundError, e o que chega ao
|
|
100
|
+
# usuário é o traceback do Python — que não diz o que instalar. Pior: aqui
|
|
101
|
+
# o vídeo já foi ABERTO e a segmentação está prestes a começar, então a
|
|
102
|
+
# falha aparece depois da espera, não antes dela.
|
|
103
|
+
binario = achar_ffmpeg()
|
|
104
|
+
if not binario:
|
|
105
|
+
captura.release()
|
|
106
|
+
return {"ok": False,
|
|
107
|
+
"error": "o recorte de pessoa precisa do ffmpeg, e não achei nenhum nesta máquina.",
|
|
108
|
+
"comoResolver": "macOS: brew install ffmpeg · Linux: sudo apt install ffmpeg · "
|
|
109
|
+
"Windows: winget install ffmpeg"}
|
|
110
|
+
|
|
79
111
|
escritor = subprocess.Popen(
|
|
80
|
-
[
|
|
112
|
+
[binario, "-hide_banner", "-loglevel", "error", "-y",
|
|
81
113
|
"-f", "rawvideo", "-pix_fmt", "bgra",
|
|
82
114
|
"-s", f"{largura}x{altura}", "-r", str(fps), "-i", "-",
|
|
83
115
|
"-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "3M",
|
|
@@ -50,7 +50,7 @@ from pathlib import Path
|
|
|
50
50
|
AQUI = Path(__file__).resolve().parent
|
|
51
51
|
sys.path.insert(0, str(AQUI.parent))
|
|
52
52
|
|
|
53
|
-
from primo_voz import escuta, orbe # noqa: E402
|
|
53
|
+
from primo_voz import desperta, escuta, orbe # noqa: E402
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def falar_para_o_node(obj: dict) -> None:
|
|
@@ -65,14 +65,75 @@ def avisar(texto: str) -> None:
|
|
|
65
65
|
sys.stderr.flush()
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
# O PowerShell que toca MP3 no Windows. MediaPlayer é assíncrono: Play()
|
|
69
|
+
# devolve na hora, então é preciso esperar a duração — e esperar com teto, ou
|
|
70
|
+
# um Open que falha deixaria a thread da fala pendurada para sempre.
|
|
71
|
+
def motivo_da_narracao(r) -> str:
|
|
72
|
+
"""O que o narrar.py disse que houve — em vez do código de saída.
|
|
73
|
+
|
|
74
|
+
Ele responde JSON em stdout, com "error" e "comoResolver". Quando esse
|
|
75
|
+
JSON não vem (o processo morreu antes de escrever), sobra o stderr, que
|
|
76
|
+
ainda diz mais que "returned non-zero exit status 1".
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
d = json.loads((r.stdout or b"").decode("utf-8", "replace"))
|
|
80
|
+
if isinstance(d, dict) and d.get("error"):
|
|
81
|
+
comoResolver = d.get("comoResolver") or ""
|
|
82
|
+
return "não deu para gerar a voz: " + str(d["error"]) + (
|
|
83
|
+
" — " + str(comoResolver) if comoResolver else "")
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
erro = (r.stderr or b"").decode("utf-8", "replace").strip()
|
|
87
|
+
ultima = erro.splitlines()[-1] if erro else ""
|
|
88
|
+
return "não deu para gerar a voz" + (f": {ultima[:300]}" if ultima else
|
|
89
|
+
f" (o narrar.py saiu com {r.returncode})")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
PS_TOCAR_MP3 = (
|
|
93
|
+
"Add-Type -AssemblyName PresentationCore; "
|
|
94
|
+
"$p = New-Object System.Windows.Media.MediaPlayer; "
|
|
95
|
+
"$p.Open([uri]'{arquivo}'); "
|
|
96
|
+
"$n = 0; "
|
|
97
|
+
"while (-not $p.NaturalDuration.HasTimeSpan -and $n -lt 100) "
|
|
98
|
+
"{{ Start-Sleep -Milliseconds 50; $n++ }}; "
|
|
99
|
+
"if (-not $p.NaturalDuration.HasTimeSpan) {{ exit 1 }}; "
|
|
100
|
+
"$p.Play(); "
|
|
101
|
+
"Start-Sleep -Seconds $p.NaturalDuration.TimeSpan.TotalSeconds; "
|
|
102
|
+
"$p.Close()"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
68
106
|
class Voz:
|
|
69
107
|
"""A costura: microfone → transcrição → Node → voz → microfone."""
|
|
70
108
|
|
|
71
|
-
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"
|
|
109
|
+
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio",
|
|
110
|
+
esperando: bool = False):
|
|
72
111
|
self.servidor = servidor
|
|
73
112
|
self.token = token
|
|
74
113
|
self.voz = voz_escolhida
|
|
75
114
|
|
|
115
|
+
# ── MODO DE ESPERA ───────────────────────────────────────────
|
|
116
|
+
#
|
|
117
|
+
# Ligado, ele fica dormindo com a orbe escondida até ouvir o nome, e
|
|
118
|
+
# volta a dormir depois de atender. Desligado (o padrão antigo), ele
|
|
119
|
+
# já entra ouvindo — que é o certo para quem digitou /voice e vai
|
|
120
|
+
# falar agora.
|
|
121
|
+
#
|
|
122
|
+
# A espera exige o reconhecedor local. Sem ele instalado, não cai
|
|
123
|
+
# para "mandar tudo para o servidor": cai para o modo de sempre, e
|
|
124
|
+
# DIZ por quê. Silenciosamente transformar uma coisa na outra seria
|
|
125
|
+
# abrir o microfone da pessoa para a internet sem ela saber.
|
|
126
|
+
#
|
|
127
|
+
self.esperando = bool(esperando)
|
|
128
|
+
self.desperta = None
|
|
129
|
+
if self.esperando:
|
|
130
|
+
self.desperta = desperta.Desperta(ao_despertar=lambda: None)
|
|
131
|
+
if not self.desperta.pronto:
|
|
132
|
+
avisar(f"modo de espera indisponível: {self.desperta.motivo} — "
|
|
133
|
+
"abrindo no modo normal, já ouvindo.")
|
|
134
|
+
self.esperando = False
|
|
135
|
+
self.desperta = None
|
|
136
|
+
|
|
76
137
|
self.orb = orbe.Orbe(ao_fechar=self.parar)
|
|
77
138
|
self.gravador = escuta.Gravador()
|
|
78
139
|
self.corte = escuta.Corte()
|
|
@@ -84,6 +145,19 @@ class Voz:
|
|
|
84
145
|
self._parar_fala = threading.Event()
|
|
85
146
|
self._do_node = queue.Queue()
|
|
86
147
|
|
|
148
|
+
def _esvaziar_blocos(self) -> None:
|
|
149
|
+
"""Joga fora o áudio que está na fila.
|
|
150
|
+
|
|
151
|
+
Depois de despertar, os blocos do próprio "ei primo" ainda estão
|
|
152
|
+
enfileirados — e sem isto eles entram na gravação do PEDIDO, que chega
|
|
153
|
+
ao agente com o nome colado na frente.
|
|
154
|
+
"""
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
self._blocos.get_nowait()
|
|
158
|
+
except queue.Empty:
|
|
159
|
+
return
|
|
160
|
+
|
|
87
161
|
# ── microfone ────────────────────────────────────────────────────────
|
|
88
162
|
def _ao_receber(self, bloco) -> None:
|
|
89
163
|
if not self._vivo:
|
|
@@ -115,6 +189,31 @@ class Voz:
|
|
|
115
189
|
return
|
|
116
190
|
|
|
117
191
|
while self._vivo:
|
|
192
|
+
# ── O SONO ───────────────────────────────────────────────
|
|
193
|
+
# No modo de espera a orbe some e o microfone fica ouvindo SÓ o
|
|
194
|
+
# nome, aqui dentro, sem nada sair da máquina (ver desperta.py).
|
|
195
|
+
# Sem isso, o modo de espera seria o microfone da pessoa aberto
|
|
196
|
+
# para a internet o dia inteiro.
|
|
197
|
+
if self.esperando and self.desperta is not None:
|
|
198
|
+
self.orb.esconder()
|
|
199
|
+
self.desperta.zerar()
|
|
200
|
+
acordou = False
|
|
201
|
+
while self._vivo and not acordou:
|
|
202
|
+
try:
|
|
203
|
+
bloco = self._blocos.get(timeout=0.3)
|
|
204
|
+
except queue.Empty:
|
|
205
|
+
continue
|
|
206
|
+
acordou = self.desperta.alimentar(bloco)
|
|
207
|
+
if not self._vivo:
|
|
208
|
+
break
|
|
209
|
+
# Chamou: a orbe aparece e ele passa a ouvir o PEDIDO.
|
|
210
|
+
self.orb.mostrar()
|
|
211
|
+
self.orb.mudar(estado="ouvindo", nivel=0)
|
|
212
|
+
# Sem esvaziar a fila, os blocos do próprio "ei primo" entram
|
|
213
|
+
# na gravação do pedido — e o pedido chega com o nome colado
|
|
214
|
+
# na frente.
|
|
215
|
+
self._esvaziar_blocos()
|
|
216
|
+
|
|
118
217
|
self.orb.mudar(estado="esperando", nivel=0)
|
|
119
218
|
self.gravador.comecar()
|
|
120
219
|
|
|
@@ -227,13 +326,17 @@ class Voz:
|
|
|
227
326
|
|
|
228
327
|
mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
|
|
229
328
|
narrar = AQUI.parent.parent / "studio" / "narrar.py"
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
329
|
+
# O narrar.py IMPRIME um JSON dizendo o que faltou e como resolver
|
|
330
|
+
# ("instale o edge-tts com… "). Com check=True isso virava uma
|
|
331
|
+
# CalledProcessError cujo texto é "Command '[...]' returned non-zero
|
|
332
|
+
# exit status 1" — e era ISSO que chegava ao usuário, com o conselho
|
|
333
|
+
# útil jogado fora dentro do capture_output. Agora a saída é lida.
|
|
334
|
+
r = subprocess.run(
|
|
335
|
+
[sys.executable, str(narrar), "falar", "--texto", limpo,
|
|
336
|
+
"--saida", str(mp3), "--voz", self.voz],
|
|
337
|
+
capture_output=True, timeout=180)
|
|
338
|
+
if r.returncode != 0 or not mp3.exists():
|
|
339
|
+
avisar(motivo_da_narracao(r))
|
|
237
340
|
return
|
|
238
341
|
|
|
239
342
|
self._falando = True
|
|
@@ -260,8 +363,15 @@ class Voz:
|
|
|
260
363
|
if sys.platform == "darwin":
|
|
261
364
|
tentativas.append(["afplay", str(arquivo)])
|
|
262
365
|
elif sys.platform == "win32":
|
|
263
|
-
|
|
264
|
-
|
|
366
|
+
# Media.SoundPlayer NÃO toca MP3 — ele só lê WAV, e o que o
|
|
367
|
+
# narrar.py entrega é MP3. Era a primeira tentativa da fila, e
|
|
368
|
+
# levantava exceção sempre; como o laço abaixo aceitava qualquer
|
|
369
|
+
# processo que tivesse CONSEGUIDO INICIAR, ele dava por tocado e
|
|
370
|
+
# voltava. Resultado: o modo voz era MUDO no Windows, sem nenhum
|
|
371
|
+
# aviso — a orb piscava "falando" e não saía som nenhum.
|
|
372
|
+
# MediaPlayer decodifica MP3; a espera é limitada para não travar
|
|
373
|
+
# a thread se o Open falhar.
|
|
374
|
+
tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
|
|
265
375
|
tentativas += [
|
|
266
376
|
["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
|
|
267
377
|
["mpg123", "-q", str(arquivo)],
|
|
@@ -271,7 +381,7 @@ class Voz:
|
|
|
271
381
|
for cmd in tentativas:
|
|
272
382
|
try:
|
|
273
383
|
p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
274
|
-
except FileNotFoundError:
|
|
384
|
+
except (FileNotFoundError, OSError):
|
|
275
385
|
continue
|
|
276
386
|
while p.poll() is None:
|
|
277
387
|
if self._parar_fala.is_set() or not self._vivo:
|
|
@@ -279,10 +389,19 @@ class Voz:
|
|
|
279
389
|
p.terminate()
|
|
280
390
|
except Exception:
|
|
281
391
|
pass
|
|
282
|
-
return
|
|
392
|
+
return # calado de propósito: falaram por cima
|
|
283
393
|
time.sleep(0.05)
|
|
284
|
-
|
|
285
|
-
|
|
394
|
+
if p.returncode == 0:
|
|
395
|
+
return
|
|
396
|
+
# INICIAR não é TOCAR. O tocador existia e falhou (formato que ele
|
|
397
|
+
# não lê, dispositivo de áudio ocupado): a fila continua. Sair aqui
|
|
398
|
+
# era o que transformava "o primeiro tocador não serve" em silêncio
|
|
399
|
+
# absoluto, com quatro alternativas por tentar logo abaixo.
|
|
400
|
+
continue
|
|
401
|
+
avisar("não achei um tocador de áudio que funcione neste computador."
|
|
402
|
+
+ (" Instale o ffmpeg: winget install ffmpeg" if sys.platform == "win32"
|
|
403
|
+
else " Instale o ffmpeg: brew install ffmpeg" if sys.platform == "darwin"
|
|
404
|
+
else " Instale o ffmpeg: sudo apt install ffmpeg"))
|
|
286
405
|
|
|
287
406
|
# ── ciclo de vida ────────────────────────────────────────────────────
|
|
288
407
|
def parar(self) -> None:
|
|
@@ -304,6 +423,9 @@ def main() -> int:
|
|
|
304
423
|
servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
|
|
305
424
|
token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
|
|
306
425
|
voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
|
|
426
|
+
# O modo de espera vem por variável, como o resto: quem abre este programa
|
|
427
|
+
# é o /voice do Node, não uma pessoa numa linha de comando.
|
|
428
|
+
esperando = os.environ.get("PRIMOCODE_VOZ_ESPERA", "") == "1"
|
|
307
429
|
|
|
308
430
|
if not servidor:
|
|
309
431
|
avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
|
|
@@ -317,7 +439,7 @@ def main() -> int:
|
|
|
317
439
|
f"{sys.executable} -m pip install sounddevice numpy")
|
|
318
440
|
return 3
|
|
319
441
|
|
|
320
|
-
Voz(servidor, token, voz).rodar()
|
|
442
|
+
Voz(servidor, token, voz, esperando=esperando).rodar()
|
|
321
443
|
return 0
|
|
322
444
|
|
|
323
445
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""desperta.py — ouvir o nome, e só o nome, sem mandar áudio para lugar nenhum.
|
|
2
|
+
|
|
3
|
+
── POR QUE OFFLINE, E POR QUE ISSO NÃO É DETALHE ───────────────────────────
|
|
4
|
+
No modo de espera o microfone fica ABERTO o tempo todo. Existem dois jeitos de
|
|
5
|
+
saber se alguém disse "ei, primo":
|
|
6
|
+
|
|
7
|
+
1. Mandar tudo o que o microfone ouve para um servidor transcrever.
|
|
8
|
+
2. Reconhecer a palavra aqui dentro, sem nada sair da máquina.
|
|
9
|
+
|
|
10
|
+
O primeiro funciona e é mais simples de escrever. Também significa que TODA
|
|
11
|
+
conversa perto do computador — a ligação, a reunião, a briga em casa — sobe
|
|
12
|
+
para um servidor, o dia inteiro, para que uma vez por hora alguém diga o nome
|
|
13
|
+
do programa. Isso não é uma troca aceitável, e não é decisão que um programa
|
|
14
|
+
toma pelo dono da máquina.
|
|
15
|
+
|
|
16
|
+
Então é o segundo. O Vosk roda local, o modelo mora no disco da pessoa, e
|
|
17
|
+
NADA sai daqui enquanto o nome não for dito. Depois do nome, sim: a frase vai
|
|
18
|
+
para a transcrição, como no modo normal — mas aí foi a pessoa que chamou.
|
|
19
|
+
|
|
20
|
+
── A GRAMÁTICA RESTRITA ────────────────────────────────────────────────────
|
|
21
|
+
O reconhecedor é criado sabendo APENAS as palavras de despertar. Não é
|
|
22
|
+
otimização: é o que impede ele de "ouvir" o nome no meio de uma frase
|
|
23
|
+
qualquer. Um reconhecedor livre transcreve tudo e depois se procura a palavra
|
|
24
|
+
no texto — e aí "prima", "primeiro" e "exprimo" acordam o assistente. Com a
|
|
25
|
+
gramática restrita ele só pode devolver uma das palavras da lista ou "[unk]".
|
|
26
|
+
|
|
27
|
+
Herdado do Jarvis (src/jarvis/core/wake.py), que resolveu isto primeiro; o
|
|
28
|
+
modelo aqui é o de PORTUGUÊS, porque "primo" é palavra portuguesa — ao
|
|
29
|
+
contrário de "Jarvis", que é nome inglês e por isso lá usava o modelo de
|
|
30
|
+
inglês.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import threading
|
|
37
|
+
import urllib.request
|
|
38
|
+
import zipfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Callable, Optional
|
|
41
|
+
|
|
42
|
+
TAXA = 16000
|
|
43
|
+
|
|
44
|
+
# Onde o modelo mora. Fora do pacote npm de propósito: são 31 MB, e um pacote
|
|
45
|
+
# que engorda 31 MB para todo mundo por causa de um modo que nem todos usam é
|
|
46
|
+
# um pacote que demora a instalar para todo mundo.
|
|
47
|
+
PASTA_MODELO = Path.home() / ".primocode" / "modelos"
|
|
48
|
+
NOME_MODELO = "vosk-model-small-pt-0.3"
|
|
49
|
+
URL_MODELO = f"https://alphacephei.com/vosk/models/{NOME_MODELO}.zip"
|
|
50
|
+
|
|
51
|
+
# O que ACORDA. Variantes porque o reconhecedor pequeno erra a vogal átona, e
|
|
52
|
+
# porque as pessoas falam de jeitos diferentes ("ei primo", "oi primo", "ô
|
|
53
|
+
# primo"). O "primo" sozinho entra: dentro da gramática restrita ele não
|
|
54
|
+
# dispara em conversa comum, porque o reconhecedor não transcreve conversa
|
|
55
|
+
# comum — só consegue devolver estas palavras.
|
|
56
|
+
VOCABULARIO = ("primo", "ei", "oi", "olá", "ô")
|
|
57
|
+
DESPERTA = ("primo",)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def caminho_do_modelo() -> Path:
|
|
61
|
+
return PASTA_MODELO / NOME_MODELO
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tem_modelo() -> bool:
|
|
65
|
+
caminho = caminho_do_modelo()
|
|
66
|
+
return caminho.is_dir() and any(caminho.iterdir())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def baixar_modelo(aoFalar: Optional[Callable[[str], None]] = None) -> dict:
|
|
70
|
+
"""Traz o modelo, uma vez. ~31 MB.
|
|
71
|
+
|
|
72
|
+
Baixa para um arquivo temporário e só então move para o lugar: interromper
|
|
73
|
+
no meio deixaria uma pasta pela metade que `tem_modelo()` daria por boa, e
|
|
74
|
+
o erro apareceria depois, como "falha ao carregar o Vosk".
|
|
75
|
+
"""
|
|
76
|
+
fala = aoFalar or (lambda _t: None)
|
|
77
|
+
if tem_modelo():
|
|
78
|
+
return {"ok": True, "jaTinha": True}
|
|
79
|
+
try:
|
|
80
|
+
PASTA_MODELO.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
temporario = PASTA_MODELO / (NOME_MODELO + ".zip.parcial")
|
|
82
|
+
fala("baixando o reconhecedor de voz (uma vez, ~31 MB)")
|
|
83
|
+
with urllib.request.urlopen(URL_MODELO, timeout=120) as resposta, open(temporario, "wb") as saida:
|
|
84
|
+
while True:
|
|
85
|
+
pedaco = resposta.read(1 << 16)
|
|
86
|
+
if not pedaco:
|
|
87
|
+
break
|
|
88
|
+
saida.write(pedaco)
|
|
89
|
+
fala("descompactando")
|
|
90
|
+
with zipfile.ZipFile(temporario) as z:
|
|
91
|
+
z.extractall(PASTA_MODELO)
|
|
92
|
+
temporario.unlink(missing_ok=True)
|
|
93
|
+
if not tem_modelo():
|
|
94
|
+
return {"ok": False, "error": "o modelo baixou mas não apareceu onde devia"}
|
|
95
|
+
return {"ok": True}
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return {"ok": False, "error": str(e),
|
|
98
|
+
"comoResolver": f"baixe {URL_MODELO} à mão e descompacte em {PASTA_MODELO}"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def contem_nome(texto: str) -> bool:
|
|
102
|
+
"""O texto que o reconhecedor devolveu contém o nome?
|
|
103
|
+
|
|
104
|
+
Comparação por PALAVRA INTEIRA. Com `in` de substring, "prima" e
|
|
105
|
+
"primeiro" acordariam o assistente — e a gramática restrita não protege
|
|
106
|
+
disso sozinha, porque ela devolve as palavras da lista e o resto vira
|
|
107
|
+
"[unk]", que pode vir colado.
|
|
108
|
+
"""
|
|
109
|
+
palavras = set(str(texto or "").lower().replace("[unk]", " ").split())
|
|
110
|
+
return any(n in palavras for n in DESPERTA)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Desperta:
|
|
114
|
+
"""Escuta o nome. Nada sai da máquina até ele ser dito."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, ao_despertar: Callable[[], None]) -> None:
|
|
117
|
+
self.ao_despertar = ao_despertar
|
|
118
|
+
self._rec = None
|
|
119
|
+
self._modelo = None
|
|
120
|
+
self.pronto = False
|
|
121
|
+
self.ligado = True
|
|
122
|
+
self.motivo = ""
|
|
123
|
+
# O reconhecedor do Vosk NÃO é seguro entre threads: a do áudio chama
|
|
124
|
+
# process() dez vezes por segundo enquanto o laço principal pode
|
|
125
|
+
# chamar reset() ao despertar. Sem trava, o estado interno corrompe e
|
|
126
|
+
# ele passa a levantar "Failed to process waveform" — sintoma que não
|
|
127
|
+
# fala de thread nenhuma, e por isso custa caro de achar.
|
|
128
|
+
self._trava = threading.Lock()
|
|
129
|
+
self._carregar()
|
|
130
|
+
|
|
131
|
+
def _carregar(self) -> None:
|
|
132
|
+
if not tem_modelo():
|
|
133
|
+
self.motivo = "o reconhecedor de voz não está baixado"
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
import vosk
|
|
137
|
+
vosk.SetLogLevel(-1)
|
|
138
|
+
self._modelo = vosk.Model(str(caminho_do_modelo()))
|
|
139
|
+
self._rec = self._novo()
|
|
140
|
+
self.pronto = True
|
|
141
|
+
except ImportError:
|
|
142
|
+
self.motivo = "falta o vosk (pip install vosk)"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.motivo = f"não consegui carregar o reconhecedor: {e}"
|
|
145
|
+
|
|
146
|
+
def _novo(self):
|
|
147
|
+
import vosk
|
|
148
|
+
gramatica = json.dumps(sorted({*VOCABULARIO, "[unk]"}))
|
|
149
|
+
return vosk.KaldiRecognizer(self._modelo, TAXA, gramatica)
|
|
150
|
+
|
|
151
|
+
def alimentar(self, bloco) -> bool:
|
|
152
|
+
"""Um bloco de áudio. Devolve True se o nome foi dito."""
|
|
153
|
+
if not self.pronto or self._rec is None or not self.ligado:
|
|
154
|
+
return False
|
|
155
|
+
with self._trava:
|
|
156
|
+
try:
|
|
157
|
+
if self._rec.AcceptWaveform(bloco.tobytes()):
|
|
158
|
+
texto = json.loads(self._rec.Result()).get("text", "")
|
|
159
|
+
else:
|
|
160
|
+
texto = json.loads(self._rec.PartialResult()).get("partial", "")
|
|
161
|
+
except Exception:
|
|
162
|
+
# Reconhecedor travado: recria e segue. Desligar o modo de
|
|
163
|
+
# espera por causa de um bloco ruim seria perder o recurso
|
|
164
|
+
# inteiro por um soluço de áudio.
|
|
165
|
+
try:
|
|
166
|
+
self._rec = self._novo()
|
|
167
|
+
except Exception:
|
|
168
|
+
self.pronto = False
|
|
169
|
+
self.motivo = "o reconhecedor parou e não voltou"
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
achou = bool(texto and contem_nome(texto))
|
|
173
|
+
if achou:
|
|
174
|
+
self._rec.Reset()
|
|
175
|
+
|
|
176
|
+
if achou:
|
|
177
|
+
try:
|
|
178
|
+
self.ao_despertar()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
return achou
|
|
182
|
+
|
|
183
|
+
def zerar(self) -> None:
|
|
184
|
+
"""Esquece o que ouviu até agora.
|
|
185
|
+
|
|
186
|
+
Chamado ao voltar do atendimento: sem isso, o resto da frase que a
|
|
187
|
+
pessoa falou DEPOIS do nome ainda está no reconhecedor, e ele desperta
|
|
188
|
+
de novo no instante seguinte.
|
|
189
|
+
"""
|
|
190
|
+
with self._trava:
|
|
191
|
+
if self._rec is not None:
|
|
192
|
+
try:
|
|
193
|
+
self._rec.Reset()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -20,10 +20,11 @@ mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pr
|
|
|
20
20
|
mim") cai abaixo do limiar e a frase é cortada antes de acabar.
|
|
21
21
|
|
|
22
22
|
── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
|
|
23
|
-
A wake word
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
A wake word saiu DAQUI, não do produto. Este módulo cuida de uma coisa só:
|
|
24
|
+
achar o começo e o fim de uma frase. Quem reconhece o nome é o desperta.py, e
|
|
25
|
+
ele só entra no modo de espera — quando a pessoa pediu para ele ficar ouvindo
|
|
26
|
+
em segundo plano. Quem digita /voice e vai falar agora não diz nome nenhum: a
|
|
27
|
+
orbe já está ouvindo.
|
|
27
28
|
"""
|
|
28
29
|
from __future__ import annotations
|
|
29
30
|
|
package/voz/primo_voz/orbe.py
CHANGED
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import math
|
|
41
41
|
import queue
|
|
42
|
+
import sys
|
|
42
43
|
import threading
|
|
43
44
|
import tkinter as tk
|
|
44
45
|
from typing import Callable, Optional
|
|
@@ -63,6 +64,7 @@ class Orbe:
|
|
|
63
64
|
self.raiz = tk.Tk()
|
|
64
65
|
self.raiz.title("Primo Code Voice")
|
|
65
66
|
self.estado = "esperando"
|
|
67
|
+
self.visivel = True
|
|
66
68
|
self.nivel = 0.0 # 0..1, o quanto a pessoa está falando
|
|
67
69
|
self._fase = 0.0
|
|
68
70
|
self._fila = queue.Queue()
|
|
@@ -130,6 +132,12 @@ class Orbe:
|
|
|
130
132
|
while True:
|
|
131
133
|
try:
|
|
132
134
|
estado, nivel = self._fila.get_nowait()
|
|
135
|
+
if estado == '__esconder__':
|
|
136
|
+
self._aplicar_visibilidade(False)
|
|
137
|
+
continue
|
|
138
|
+
if estado == '__mostrar__':
|
|
139
|
+
self._aplicar_visibilidade(True)
|
|
140
|
+
continue
|
|
133
141
|
except queue.Empty:
|
|
134
142
|
break
|
|
135
143
|
if estado:
|
|
@@ -197,6 +205,40 @@ class Orbe:
|
|
|
197
205
|
principal, então o que chega vai para uma fila e é lido no desenho."""
|
|
198
206
|
self._fila.put((estado, nivel))
|
|
199
207
|
|
|
208
|
+
def esconder(self) -> None:
|
|
209
|
+
"""Some da tela sem morrer.
|
|
210
|
+
|
|
211
|
+
O modo de espera fica MINUTOS ou horas sem nada acontecer, e uma orbe
|
|
212
|
+
parada no meio do monitor todo esse tempo é um estorvo — ela cobre o
|
|
213
|
+
que a pessoa está fazendo. Então ela some e volta quando chamada.
|
|
214
|
+
|
|
215
|
+
Destruir e recriar a janela a cada despertar seria o caminho óbvio, e é
|
|
216
|
+
o errado: recriar uma Tk leva centenas de milissegundos, pisca, e em
|
|
217
|
+
alguns sistemas rouba o foco da janela onde a pessoa estava digitando.
|
|
218
|
+
`withdraw` tira da tela e guarda tudo de pé.
|
|
219
|
+
"""
|
|
220
|
+
self._fila.put(("__esconder__", None))
|
|
221
|
+
|
|
222
|
+
def mostrar(self) -> None:
|
|
223
|
+
"""Volta para a tela, no centro, por cima de tudo."""
|
|
224
|
+
self._fila.put(("__mostrar__", None))
|
|
225
|
+
|
|
226
|
+
def _aplicar_visibilidade(self, visivel: bool) -> None:
|
|
227
|
+
# Só na thread da interface: tkinter não perdoa chamada de fora.
|
|
228
|
+
try:
|
|
229
|
+
if visivel:
|
|
230
|
+
self.veu.deiconify()
|
|
231
|
+
self.raiz.deiconify()
|
|
232
|
+
self.raiz.lift()
|
|
233
|
+
if sys.platform != "linux":
|
|
234
|
+
self.raiz.attributes("-topmost", True)
|
|
235
|
+
else:
|
|
236
|
+
self.raiz.withdraw()
|
|
237
|
+
self.veu.withdraw()
|
|
238
|
+
except Exception:
|
|
239
|
+
pass # janela já fechando: nada a fazer
|
|
240
|
+
self.visivel = visivel
|
|
241
|
+
|
|
200
242
|
def fechar(self) -> None:
|
|
201
243
|
if not self._vivo:
|
|
202
244
|
return
|
|
File without changes
|