primocode 8.42.1 → 9.0.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 +41 -37
- package/bin/primocode.js +599 -327
- package/lib/acervo.js +8 -1
- package/lib/act.js +205 -114
- package/lib/api.js +35 -4
- package/lib/boas-vindas.js +31 -192
- package/lib/catalogo.js +60 -60
- package/lib/config.js +1 -1
- package/lib/conta.js +3 -24
- package/lib/diff.js +161 -0
- package/lib/diretor.js +37 -47
- package/lib/entrada.js +545 -0
- package/lib/especialistas.js +2 -2
- package/lib/estudio/assets.js +343 -0
- package/lib/estudio/identidade.js +560 -0
- package/lib/estudio/index.js +565 -0
- package/lib/estudio/plano.js +327 -0
- package/lib/estudio/projeto.js +246 -0
- package/lib/estudio/remotion.js +606 -0
- package/lib/estudio/slides.js +190 -0
- package/lib/estudio/video.js +296 -0
- package/lib/fala.js +13 -1
- package/lib/marca.js +4 -1
- package/lib/marketplace.js +189 -0
- package/lib/permissao.js +137 -0
- package/lib/registro.js +229 -0
- package/lib/skills.js +329 -0
- package/lib/tools.js +51 -181
- package/lib/ui.js +7 -3
- package/lib/voz.js +239 -0
- package/package.json +12 -3
- package/skills/acessibilidade/SKILL.md +28 -0
- package/skills/api-que-nao-quebra/SKILL.md +27 -0
- package/skills/depurar-de-verdade/SKILL.md +29 -0
- package/skills/editar-meu-video/SKILL.md +59 -0
- package/skills/escrever-em-portugues/SKILL.md +28 -0
- package/skills/git-limpo/SKILL.md +28 -0
- package/skills/motion-de-primeira/SKILL.md +62 -0
- package/skills/pagina-que-converte/SKILL.md +28 -0
- package/skills/performance-web/SKILL.md +28 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
- package/skills/slides-que-prendem/SKILL.md +40 -0
- package/studio/edge.py +16 -66
- package/studio/narrar.py +274 -0
- package/studio/pessoa.py +167 -0
- package/voz/primo_voz/__init__.py +0 -0
- package/voz/primo_voz/__main__.py +325 -0
- package/voz/primo_voz/escuta.py +235 -0
- package/voz/primo_voz/orbe.py +216 -0
- package/lib/audio-livre.js +0 -177
- package/lib/chaves.js +0 -149
- package/lib/claude-engine.js +0 -631
- package/lib/codex-engine.js +0 -208
- package/lib/remotion.js +0 -663
- package/lib/studio.js +0 -960
- package/studio/PRIMOCODE.md +0 -58
- package/studio/README.md +0 -154
- package/studio/__pycache__/edge.cpython-311.pyc +0 -0
- package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
- package/studio/ferramentas/00-protocolo.md +0 -125
- package/studio/ferramentas/01-modelos.md +0 -270
- package/studio/ferramentas/README.md +0 -32
- package/studio/ferramentas/corte.md +0 -919
- package/studio/ferramentas/grade.md +0 -92
- package/studio/ferramentas/prisma.md +0 -107
- package/studio/ferramentas/prosa.md +0 -86
- package/studio/ferramentas/tela.md +0 -93
- package/studio/ferramentas/traco.md +0 -79
- package/studio/server.py +0 -2254
- package/studio/studio.log +0 -57
- package/studio/voz.py +0 -388
- package/studio/web/analise.html +0 -52
- package/studio/web/core.css +0 -1167
- package/studio/web/fontes/bebas.woff2 +0 -0
- package/studio/web/fontes/grotesk-700.woff2 +0 -0
- package/studio/web/fontes/inter-400.woff2 +0 -0
- package/studio/web/fontes/inter-700.woff2 +0 -0
- package/studio/web/fontes/jetbrains-400.woff2 +0 -0
- package/studio/web/fontes/playfair-700.woff2 +0 -0
- package/studio/web/fontes.css +0 -23
- package/studio/web/home.html +0 -217
- package/studio/web/js/analise.js +0 -510
- package/studio/web/js/audio.js +0 -607
- package/studio/web/js/editor.js +0 -1802
- package/studio/web/js/exportar.js +0 -222
- package/studio/web/js/ferramentas.js +0 -108
- package/studio/web/js/icones.js +0 -120
- package/studio/web/js/midia.js +0 -157
- package/studio/web/js/narracao.js +0 -325
- package/studio/web/js/player.js +0 -903
- package/studio/web/js/render-grade.js +0 -243
- package/studio/web/js/render-prosa.js +0 -104
- package/studio/web/js/render.js +0 -1852
- package/studio/web/js/sfx.js +0 -233
- package/studio/web/js/video.js +0 -464
- package/studio/web/js/voz-neural.js +0 -152
- package/studio/web/render.css +0 -1352
- package/studio/web/tf.html +0 -15
- package/studio/web/tool.html +0 -78
- package/studio/web/view.html +0 -27
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""primo_voz — o Primo Code em modo voz.
|
|
2
|
+
|
|
3
|
+
"/voice abre o Primo Code em modo voz. Ele abre como um aplicativozinho,
|
|
4
|
+
não como uma janela de terminal comum. Base: a lógica do Jarvis, que já
|
|
5
|
+
funciona. Não roda mais com Claude por baixo: usa o modelo selecionado
|
|
6
|
+
nos conectores do Primo Code."
|
|
7
|
+
|
|
8
|
+
── A DECISÃO QUE DEFINE ESTE ARQUIVO ───────────────────────────────────────
|
|
9
|
+
Este programa NÃO tem cérebro. Ele ouve, transcreve, fala e desenha a orb — e
|
|
10
|
+
manda o texto para o PrimoCode, que é quem pensa.
|
|
11
|
+
|
|
12
|
+
O caminho óbvio era portar o cérebro para cá: o Jarvis fazia isso, mantendo
|
|
13
|
+
uma sessão do Claude Code num processo filho. Copiar aquilo significaria uma
|
|
14
|
+
segunda implementação do laço do agente, das trinta ferramentas, do portão de
|
|
15
|
+
permissão do computador e da corrente de conectores — em outra linguagem. Duas
|
|
16
|
+
implementações do mesmo laço é a garantia de que uma delas está errada, e de
|
|
17
|
+
que a correção de amanhã vai para só uma.
|
|
18
|
+
|
|
19
|
+
Então o cérebro é UM, e é o mesmo do terminal: o `lib/act.js`. Falar com ele
|
|
20
|
+
custa uma linha de JSON no stdout. E é isto que faz o pedido ser cumprido sem
|
|
21
|
+
gambiarra — "usa o modelo selecionado nos conectores do Primo Code" não é uma
|
|
22
|
+
integração a construir: é consequência de o cérebro ser o Primo Code.
|
|
23
|
+
|
|
24
|
+
── O PROTOCOLO ─────────────────────────────────────────────────────────────
|
|
25
|
+
Uma linha de JSON por mensagem, nas duas direções.
|
|
26
|
+
|
|
27
|
+
para o Node: {"tipo":"pedido","texto":"..."} {"tipo":"fim"}
|
|
28
|
+
do Node: {"tipo":"estado","estado":"pensando"}
|
|
29
|
+
{"tipo":"resposta","texto":"..."}
|
|
30
|
+
{"tipo":"erro","texto":"..."}
|
|
31
|
+
|
|
32
|
+
── O QUE FOI REMOVIDO DO JARVIS ────────────────────────────────────────────
|
|
33
|
+
A wake word "Jarvis" e as variantes dela, a saudação com o nome do dono, a
|
|
34
|
+
pasta "Jarvis Workspace", as preferências pessoais, o gesto de câmera, o
|
|
35
|
+
cursor próprio, a barra de comando e a janela de conversa. O que sobra é o que
|
|
36
|
+
é do produto: ouvir, pensar, falar.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import queue
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
AQUI = Path(__file__).resolve().parent
|
|
51
|
+
sys.path.insert(0, str(AQUI.parent))
|
|
52
|
+
|
|
53
|
+
from primo_voz import escuta, orbe # noqa: E402
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def falar_para_o_node(obj: dict) -> None:
|
|
57
|
+
"""Uma linha de JSON no stdout. Nada mais pode sair por aí."""
|
|
58
|
+
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
59
|
+
sys.stdout.flush()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def avisar(texto: str) -> None:
|
|
63
|
+
"""Recado para o terminal, na saída de ERRO — o stdout é do protocolo."""
|
|
64
|
+
sys.stderr.write(texto.rstrip() + "\n")
|
|
65
|
+
sys.stderr.flush()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Voz:
|
|
69
|
+
"""A costura: microfone → transcrição → Node → voz → microfone."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
|
|
72
|
+
self.servidor = servidor
|
|
73
|
+
self.token = token
|
|
74
|
+
self.voz = voz_escolhida
|
|
75
|
+
|
|
76
|
+
self.orb = orbe.Orbe(ao_fechar=self.parar)
|
|
77
|
+
self.gravador = escuta.Gravador()
|
|
78
|
+
self.corte = escuta.Corte()
|
|
79
|
+
|
|
80
|
+
self._blocos = queue.Queue()
|
|
81
|
+
self._vivo = True
|
|
82
|
+
self._falando = False
|
|
83
|
+
self._pensando = False
|
|
84
|
+
self._parar_fala = threading.Event()
|
|
85
|
+
self._do_node = queue.Queue()
|
|
86
|
+
|
|
87
|
+
# ── microfone ────────────────────────────────────────────────────────
|
|
88
|
+
def _ao_receber(self, bloco) -> None:
|
|
89
|
+
if not self._vivo:
|
|
90
|
+
return
|
|
91
|
+
nivel = escuta.rms(bloco)
|
|
92
|
+
|
|
93
|
+
# Falar por cima CORTA a resposta. Sem isto, a única forma de
|
|
94
|
+
# interromper é esperar ele terminar — e esperar um assistente
|
|
95
|
+
# terminar de falar uma coisa errada é a pior parte de usar um.
|
|
96
|
+
if self._falando:
|
|
97
|
+
self.corte.ele_falou(nivel)
|
|
98
|
+
if self.corte.alguem_cortou(nivel):
|
|
99
|
+
self._parar_fala.set()
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
self._blocos.put(bloco)
|
|
103
|
+
if self.orb:
|
|
104
|
+
self.orb.mudar(nivel=min(1.0, nivel / 2500.0))
|
|
105
|
+
|
|
106
|
+
# ── o laço ───────────────────────────────────────────────────────────
|
|
107
|
+
def _laco(self) -> None:
|
|
108
|
+
mic = escuta.Microfone(self._ao_receber)
|
|
109
|
+
try:
|
|
110
|
+
mic.abrir()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
avisar(f"não consegui abrir o microfone: {e}")
|
|
113
|
+
falar_para_o_node({"tipo": "erro", "texto": f"microfone: {e}"})
|
|
114
|
+
self.parar()
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
while self._vivo:
|
|
118
|
+
self.orb.mudar(estado="esperando", nivel=0)
|
|
119
|
+
self.gravador.comecar()
|
|
120
|
+
|
|
121
|
+
# Esperar a frase.
|
|
122
|
+
acabou = False
|
|
123
|
+
while self._vivo and not acabou:
|
|
124
|
+
try:
|
|
125
|
+
bloco = self._blocos.get(timeout=0.3)
|
|
126
|
+
except queue.Empty:
|
|
127
|
+
continue
|
|
128
|
+
if self.gravador._falando:
|
|
129
|
+
self.orb.mudar(estado="ouvindo")
|
|
130
|
+
acabou = self.gravador.alimentar(bloco)
|
|
131
|
+
|
|
132
|
+
if not self._vivo:
|
|
133
|
+
break
|
|
134
|
+
if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
|
|
138
|
+
if not wav:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
self.orb.mudar(estado="pensando")
|
|
142
|
+
texto = self._transcrever(wav)
|
|
143
|
+
if not texto or len(texto.strip()) < 2:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
falar_para_o_node({"tipo": "pedido", "texto": texto})
|
|
147
|
+
resposta = self._esperar_resposta()
|
|
148
|
+
if resposta:
|
|
149
|
+
self._falar(resposta)
|
|
150
|
+
|
|
151
|
+
mic.fechar()
|
|
152
|
+
|
|
153
|
+
# ── transcrição ──────────────────────────────────────────────────────
|
|
154
|
+
def _transcrever(self, wav: Path) -> str:
|
|
155
|
+
"""Quem tem a chave é o servidor do PrimoCode, não este programa.
|
|
156
|
+
|
|
157
|
+
É a mesma rota que a legenda de vídeo usa. Duas formas de transcrever
|
|
158
|
+
seriam duas contas para pagar e duas para consertar.
|
|
159
|
+
"""
|
|
160
|
+
import base64
|
|
161
|
+
import urllib.request
|
|
162
|
+
|
|
163
|
+
# O wav de 16 kHz mono já é pequeno; converter para mp3 exigiria
|
|
164
|
+
# ffmpeg, que nem toda máquina tem — e uma frase de dez segundos cabe
|
|
165
|
+
# em ~300 KB, que atravessa a rede sem dor.
|
|
166
|
+
dados = base64.b64encode(wav.read_bytes()).decode("ascii")
|
|
167
|
+
corpo = json.dumps({"audio": dados, "nome": "fala.wav", "idioma": "pt"}).encode("utf-8")
|
|
168
|
+
pedido = urllib.request.Request(
|
|
169
|
+
self.servidor.rstrip("/") + "/api/transcrever",
|
|
170
|
+
data=corpo,
|
|
171
|
+
headers={"Content-Type": "application/json", "x-primocode-token": self.token},
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
with urllib.request.urlopen(pedido, timeout=90) as r:
|
|
175
|
+
j = json.loads(r.read().decode("utf-8"))
|
|
176
|
+
return str(j.get("texto") or "").strip()
|
|
177
|
+
except Exception as e:
|
|
178
|
+
avisar(f"não deu para transcrever: {e}")
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
# ── resposta do Node ─────────────────────────────────────────────────
|
|
182
|
+
def _esperar_resposta(self) -> str:
|
|
183
|
+
while self._vivo:
|
|
184
|
+
try:
|
|
185
|
+
msg = self._do_node.get(timeout=0.5)
|
|
186
|
+
except queue.Empty:
|
|
187
|
+
continue
|
|
188
|
+
tipo = msg.get("tipo")
|
|
189
|
+
if tipo == "estado":
|
|
190
|
+
self.orb.mudar(estado=msg.get("estado") or "pensando")
|
|
191
|
+
elif tipo == "resposta":
|
|
192
|
+
return str(msg.get("texto") or "")
|
|
193
|
+
elif tipo == "erro":
|
|
194
|
+
return str(msg.get("texto") or "não deu certo")
|
|
195
|
+
elif tipo == "fim":
|
|
196
|
+
self.parar()
|
|
197
|
+
return ""
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
def ouvir_o_node(self) -> None:
|
|
201
|
+
"""Lê o stdin, linha a linha, até ele fechar."""
|
|
202
|
+
for linha in sys.stdin:
|
|
203
|
+
linha = linha.strip()
|
|
204
|
+
if not linha:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
self._do_node.put(json.loads(linha))
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
continue
|
|
210
|
+
self.parar()
|
|
211
|
+
|
|
212
|
+
# ── voz ──────────────────────────────────────────────────────────────
|
|
213
|
+
def _falar(self, texto: str) -> None:
|
|
214
|
+
"""A resposta sai em voz — e SÓ em voz.
|
|
215
|
+
|
|
216
|
+
O texto não aparece em lugar nenhum da interface. É a regra do modo:
|
|
217
|
+
ler a resposta é trabalho, ouvir não é.
|
|
218
|
+
"""
|
|
219
|
+
limpo = " ".join(str(texto).split())
|
|
220
|
+
if not limpo:
|
|
221
|
+
return
|
|
222
|
+
# Resposta longa demais em voz é castigo: quem ouve não pode passar o
|
|
223
|
+
# olho. O agente já responde curto no modo voz (o Node manda a
|
|
224
|
+
# instrução), e isto é a rede embaixo.
|
|
225
|
+
if len(limpo) > 900:
|
|
226
|
+
limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
|
|
227
|
+
|
|
228
|
+
mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
|
|
229
|
+
narrar = AQUI.parent.parent / "studio" / "narrar.py"
|
|
230
|
+
try:
|
|
231
|
+
subprocess.run(
|
|
232
|
+
[sys.executable, str(narrar), "falar", "--texto", limpo,
|
|
233
|
+
"--saida", str(mp3), "--voz", self.voz],
|
|
234
|
+
capture_output=True, timeout=180, check=True)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
avisar(f"não deu para gerar a voz: {e}")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
self._falando = True
|
|
240
|
+
self._parar_fala.clear()
|
|
241
|
+
self.orb.mudar(estado="falando")
|
|
242
|
+
try:
|
|
243
|
+
self._tocar(mp3)
|
|
244
|
+
finally:
|
|
245
|
+
self._falando = False
|
|
246
|
+
self.corte.nivel_dele = 0.0
|
|
247
|
+
try:
|
|
248
|
+
mp3.unlink()
|
|
249
|
+
except OSError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def _tocar(self, arquivo: Path) -> None:
|
|
253
|
+
"""Toca o mp3 com o que a máquina tiver.
|
|
254
|
+
|
|
255
|
+
Nenhum tocador é garantido em todo sistema, então são vários, na ordem
|
|
256
|
+
de quem está mais presente — e o processo é interrompível, porque
|
|
257
|
+
falar por cima precisa calar a voz na hora.
|
|
258
|
+
"""
|
|
259
|
+
tentativas = []
|
|
260
|
+
if sys.platform == "darwin":
|
|
261
|
+
tentativas.append(["afplay", str(arquivo)])
|
|
262
|
+
elif sys.platform == "win32":
|
|
263
|
+
tentativas.append(["powershell", "-c",
|
|
264
|
+
f"(New-Object Media.SoundPlayer '{arquivo}').PlaySync()"])
|
|
265
|
+
tentativas += [
|
|
266
|
+
["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
|
|
267
|
+
["mpg123", "-q", str(arquivo)],
|
|
268
|
+
["mpv", "--no-video", "--really-quiet", str(arquivo)],
|
|
269
|
+
["cvlc", "--play-and-exit", "--intf", "dummy", str(arquivo)],
|
|
270
|
+
]
|
|
271
|
+
for cmd in tentativas:
|
|
272
|
+
try:
|
|
273
|
+
p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
274
|
+
except FileNotFoundError:
|
|
275
|
+
continue
|
|
276
|
+
while p.poll() is None:
|
|
277
|
+
if self._parar_fala.is_set() or not self._vivo:
|
|
278
|
+
try:
|
|
279
|
+
p.terminate()
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
return
|
|
283
|
+
time.sleep(0.05)
|
|
284
|
+
return
|
|
285
|
+
avisar("não achei um tocador de áudio (ffplay, mpg123, mpv ou afplay).")
|
|
286
|
+
|
|
287
|
+
# ── ciclo de vida ────────────────────────────────────────────────────
|
|
288
|
+
def parar(self) -> None:
|
|
289
|
+
if not self._vivo:
|
|
290
|
+
return
|
|
291
|
+
self._vivo = False
|
|
292
|
+
self._parar_fala.set()
|
|
293
|
+
falar_para_o_node({"tipo": "fim"})
|
|
294
|
+
|
|
295
|
+
def rodar(self) -> None:
|
|
296
|
+
threading.Thread(target=self._laco, daemon=True).start()
|
|
297
|
+
threading.Thread(target=self.ouvir_o_node, daemon=True).start()
|
|
298
|
+
# A interface fica na thread principal, porque em todo sistema é lá
|
|
299
|
+
# que uma janela pode existir.
|
|
300
|
+
self.orb.rodar()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main() -> int:
|
|
304
|
+
servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
|
|
305
|
+
token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
|
|
306
|
+
voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
|
|
307
|
+
|
|
308
|
+
if not servidor:
|
|
309
|
+
avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
|
|
310
|
+
return 2
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
import numpy # noqa: F401
|
|
314
|
+
import sounddevice # noqa: F401
|
|
315
|
+
except ImportError as e:
|
|
316
|
+
avisar(f"falta um pacote de áudio ({e.name}). Rode: "
|
|
317
|
+
f"{sys.executable} -m pip install sounddevice numpy")
|
|
318
|
+
return 3
|
|
319
|
+
|
|
320
|
+
Voz(servidor, token, voz).rodar()
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main())
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""escuta.py — ouvir a pessoa falar, e saber quando ela terminou.
|
|
2
|
+
|
|
3
|
+
Adaptado do Jarvis (src/jarvis/core/vad.py e audio_io.py), que é a parte dele
|
|
4
|
+
que não tem nada de macOS: numpy e sounddevice, e mais nada.
|
|
5
|
+
|
|
6
|
+
── AS DUAS COISAS QUE ERRAM FEIO SE FOREM INGÊNUAS ─────────────────────────
|
|
7
|
+
|
|
8
|
+
**O limiar de "isso é fala"** não pode ser um número fixo. Num quarto
|
|
9
|
+
silencioso, um número fixo fica surdo; numa sala com televisão, ele acha que a
|
|
10
|
+
televisão está falando com ele. Então o limiar é relativo ao barulho do
|
|
11
|
+
ambiente, medido um instante antes de cada frase.
|
|
12
|
+
|
|
13
|
+
**O fim da frase** não é o primeiro silêncio. Quem explica alguma coisa pausa
|
|
14
|
+
para pensar, e pausa de pensar passa fácil de um segundo — cortar ali é cortar
|
|
15
|
+
a pessoa no meio. Por isso o rabo de silêncio é de dois segundos, e cresce
|
|
16
|
+
quando a fala já vem longa: quem está explicando pausa mais.
|
|
17
|
+
|
|
18
|
+
E há a **histerese**: começar a gravar exige som claramente acima do ambiente,
|
|
19
|
+
mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pra
|
|
20
|
+
mim") cai abaixo do limiar e a frase é cortada antes de acabar.
|
|
21
|
+
|
|
22
|
+
── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
|
|
23
|
+
A wake word. Lá ela existia porque o assistente ficava ligado o dia inteiro
|
|
24
|
+
esperando ser chamado. Aqui a pessoa abriu o modo voz de propósito: ele já
|
|
25
|
+
está ouvindo, e não há nome para dizer. Some com isso o Vosk e os 40 MB de
|
|
26
|
+
modelo que ele baixava.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import wave
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import List, Optional
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
TAXA = 16000
|
|
39
|
+
BLOCO_MS = 100
|
|
40
|
+
BLOCO = int(TAXA * BLOCO_MS / 1000)
|
|
41
|
+
|
|
42
|
+
PISO_FALA = 380.0 # RMS mínimo para começar a contar como fala
|
|
43
|
+
MULT_AMBIENTE = 1.7 # precisa se destacar do ambiente nessa proporção
|
|
44
|
+
TETO_FALA = 700.0 # nunca exige mais que isso (não ficar surdo)
|
|
45
|
+
FATOR_CONTINUAR = 0.5 # a histerese: depois de começar, o limiar cai
|
|
46
|
+
PRE_ROLL = 4 # blocos guardados ANTES do início da fala
|
|
47
|
+
|
|
48
|
+
RABO_SILENCIO = 2.0 # silêncio que encerra a frase
|
|
49
|
+
RABO_SILENCIO_LONGA = 2.8 # …e quando a fala já vem longa
|
|
50
|
+
FALA_LONGA = 6.0
|
|
51
|
+
ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
|
|
52
|
+
MAX_FRASE = 45.0
|
|
53
|
+
MAX_FRASE_ESTENDIDA = 100.0
|
|
54
|
+
|
|
55
|
+
# Interromper ele falando: o microfone também ouve a voz DELE saindo pelo
|
|
56
|
+
# alto-falante, então o limiar é relativo ao nível da própria fala dele.
|
|
57
|
+
CORTE_FATOR = 2.3
|
|
58
|
+
CORTE_BLOCOS = 3
|
|
59
|
+
CORTE_MINIMO = 560.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rms(bloco: np.ndarray) -> float:
|
|
63
|
+
"""Energia do bloco. Em float para não estourar em int16."""
|
|
64
|
+
if bloco is None or len(bloco) == 0:
|
|
65
|
+
return 0.0
|
|
66
|
+
x = bloco.astype(np.float64)
|
|
67
|
+
return float(np.sqrt(np.mean(x * x)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Gravador:
|
|
71
|
+
"""Junta os blocos de uma frase, do começo ao silêncio que a encerra.
|
|
72
|
+
|
|
73
|
+
── O RELÓGIO É O ÁUDIO, NÃO A PAREDE ───────────────────────────────────
|
|
74
|
+
O Jarvis media tudo com `time.monotonic()`, porque lá o áudio chegava em
|
|
75
|
+
tempo real do microfone e as duas coisas andavam juntas. Só que elas nem
|
|
76
|
+
sempre andam: o sistema entrega áudio em rajada quando a thread perde a
|
|
77
|
+
vez, e aí o relógio de parede diz "dois segundos de silêncio" no meio de
|
|
78
|
+
uma frase que acabou de chegar inteira de uma vez.
|
|
79
|
+
|
|
80
|
+
Aqui cada bloco vale exatamente 100 ms de áudio, e o tempo avança por
|
|
81
|
+
bloco consumido. A conta passa a ser exata, imune a soluço de
|
|
82
|
+
agendamento — e, de quebra, testável: dá para alimentar uma frase inteira
|
|
83
|
+
num laço e medir onde ela seria cortada, sem esperar em tempo real.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._blocos: List[np.ndarray] = []
|
|
88
|
+
self._pre: List[np.ndarray] = []
|
|
89
|
+
self._falando = False
|
|
90
|
+
self._relogio = 0.0 # segundos de ÁUDIO consumidos
|
|
91
|
+
self._ultimo_som = 0.0
|
|
92
|
+
self._ambiente = PISO_FALA
|
|
93
|
+
self._amostras_ambiente: List[float] = []
|
|
94
|
+
self.encerrou_por = ""
|
|
95
|
+
|
|
96
|
+
# O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
|
|
97
|
+
# abrir o microfone daria o silêncio do processo, não o do cômodo.
|
|
98
|
+
def _limiar(self) -> float:
|
|
99
|
+
base = max(PISO_FALA, min(TETO_FALA, self._ambiente * MULT_AMBIENTE))
|
|
100
|
+
return base * (FATOR_CONTINUAR if self._falando else 1.0)
|
|
101
|
+
|
|
102
|
+
def comecar(self) -> None:
|
|
103
|
+
self._blocos = []
|
|
104
|
+
self._pre = []
|
|
105
|
+
self._falando = False
|
|
106
|
+
self._relogio = 0.0
|
|
107
|
+
self._ultimo_som = 0.0
|
|
108
|
+
self._amostras_ambiente = []
|
|
109
|
+
self.encerrou_por = ""
|
|
110
|
+
|
|
111
|
+
def alimentar(self, bloco: np.ndarray) -> bool:
|
|
112
|
+
"""Devolve True quando a frase acabou."""
|
|
113
|
+
self._relogio += BLOCO_MS / 1000.0
|
|
114
|
+
agora = self._relogio
|
|
115
|
+
nivel = rms(bloco)
|
|
116
|
+
|
|
117
|
+
if not self._falando:
|
|
118
|
+
# Guarda os últimos blocos: sem eles, a frase começa cortada na
|
|
119
|
+
# primeira sílaba, que é a que o reconhecedor mais precisa.
|
|
120
|
+
self._pre.append(bloco)
|
|
121
|
+
if len(self._pre) > PRE_ROLL:
|
|
122
|
+
self._pre.pop(0)
|
|
123
|
+
|
|
124
|
+
self._amostras_ambiente.append(nivel)
|
|
125
|
+
if len(self._amostras_ambiente) > 20:
|
|
126
|
+
self._amostras_ambiente.pop(0)
|
|
127
|
+
if len(self._amostras_ambiente) >= 5:
|
|
128
|
+
self._ambiente = float(np.median(self._amostras_ambiente))
|
|
129
|
+
|
|
130
|
+
if nivel > self._limiar():
|
|
131
|
+
self._falando = True
|
|
132
|
+
self._blocos = list(self._pre)
|
|
133
|
+
self._ultimo_som = agora
|
|
134
|
+
elif agora > ESPERA_COMECAR:
|
|
135
|
+
self.encerrou_por = "ninguem-falou"
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
self._blocos.append(bloco)
|
|
140
|
+
if nivel > self._limiar():
|
|
141
|
+
self._ultimo_som = agora
|
|
142
|
+
|
|
143
|
+
duracao = len(self._blocos) * BLOCO_MS / 1000.0
|
|
144
|
+
rabo = RABO_SILENCIO_LONGA if duracao > FALA_LONGA else RABO_SILENCIO
|
|
145
|
+
|
|
146
|
+
if agora - self._ultimo_som > rabo:
|
|
147
|
+
self.encerrou_por = "silencio"
|
|
148
|
+
return True
|
|
149
|
+
# Ainda falando ao bater o limite: estende em vez de cortar no meio da
|
|
150
|
+
# frase. Cortar no meio devolve um pedido pela metade, e o agente
|
|
151
|
+
# obedece ao pedido pela metade.
|
|
152
|
+
if duracao > MAX_FRASE and agora - self._ultimo_som > 0.6:
|
|
153
|
+
self.encerrou_por = "longa-demais"
|
|
154
|
+
return True
|
|
155
|
+
if duracao > MAX_FRASE_ESTENDIDA:
|
|
156
|
+
self.encerrou_por = "teto"
|
|
157
|
+
return True
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def segundos(self) -> float:
|
|
162
|
+
return len(self._blocos) * BLOCO_MS / 1000.0
|
|
163
|
+
|
|
164
|
+
def gravar_wav(self, caminho: Path) -> Optional[Path]:
|
|
165
|
+
if not self._blocos:
|
|
166
|
+
return None
|
|
167
|
+
dados = np.concatenate(self._blocos)
|
|
168
|
+
caminho.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
with wave.open(str(caminho), "wb") as w:
|
|
170
|
+
w.setnchannels(1)
|
|
171
|
+
w.setsampwidth(2)
|
|
172
|
+
w.setframerate(TAXA)
|
|
173
|
+
w.writeframes(dados.astype(np.int16).tobytes())
|
|
174
|
+
return caminho
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Corte:
|
|
178
|
+
"""Percebe a pessoa falando por cima enquanto ele responde.
|
|
179
|
+
|
|
180
|
+
O limiar é relativo ao nível da fala DELE, medido ao vivo — com limiar
|
|
181
|
+
fixo, ou ele nunca é interrompido (alto-falante alto) ou se interrompe
|
|
182
|
+
sozinho o tempo todo (a própria voz dele passa do limiar).
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self) -> None:
|
|
186
|
+
self.nivel_dele = 0.0
|
|
187
|
+
self._seguidos = 0
|
|
188
|
+
|
|
189
|
+
def ele_falou(self, nivel: float) -> None:
|
|
190
|
+
# Média móvel: a voz dele oscila, e o limiar não pode oscilar junto.
|
|
191
|
+
self.nivel_dele = self.nivel_dele * 0.85 + nivel * 0.15
|
|
192
|
+
|
|
193
|
+
def alguem_cortou(self, nivel: float) -> bool:
|
|
194
|
+
limiar = max(CORTE_MINIMO, self.nivel_dele * CORTE_FATOR)
|
|
195
|
+
if nivel > limiar:
|
|
196
|
+
self._seguidos += 1
|
|
197
|
+
else:
|
|
198
|
+
self._seguidos = 0
|
|
199
|
+
if self._seguidos >= CORTE_BLOCOS:
|
|
200
|
+
self._seguidos = 0
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Microfone:
|
|
206
|
+
"""O microfone aberto, entregando blocos de 100 ms.
|
|
207
|
+
|
|
208
|
+
Um único fluxo para a sessão inteira: abrir e fechar a cada frase custa
|
|
209
|
+
centenas de milissegundos e, em alguns sistemas, faz um estalo.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, ao_receber):
|
|
213
|
+
import sounddevice as sd
|
|
214
|
+
self._sd = sd
|
|
215
|
+
self._ao_receber = ao_receber
|
|
216
|
+
self._fluxo = None
|
|
217
|
+
|
|
218
|
+
def abrir(self) -> None:
|
|
219
|
+
def retorno(dados, quadros, tempo, estado): # noqa: ARG001
|
|
220
|
+
self._ao_receber(dados[:, 0].copy())
|
|
221
|
+
|
|
222
|
+
self._fluxo = self._sd.InputStream(
|
|
223
|
+
samplerate=TAXA, channels=1, dtype="int16",
|
|
224
|
+
blocksize=BLOCO, callback=retorno,
|
|
225
|
+
)
|
|
226
|
+
self._fluxo.start()
|
|
227
|
+
|
|
228
|
+
def fechar(self) -> None:
|
|
229
|
+
if self._fluxo is not None:
|
|
230
|
+
try:
|
|
231
|
+
self._fluxo.stop()
|
|
232
|
+
self._fluxo.close()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
self._fluxo = None
|