primocode 8.42.2 → 9.1.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.
Files changed (104) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +598 -360
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +42 -195
  7. package/lib/catalogo.js +100 -61
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +576 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +644 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +49 -6
  23. package/lib/marca.js +4 -1
  24. package/lib/markdown.js +376 -0
  25. package/lib/marketplace.js +189 -0
  26. package/lib/pasta.js +15 -4
  27. package/lib/permissao.js +137 -0
  28. package/lib/registro.js +229 -0
  29. package/lib/skills.js +329 -0
  30. package/lib/tools.js +106 -187
  31. package/lib/ui.js +7 -3
  32. package/lib/voz.js +239 -0
  33. package/package.json +12 -3
  34. package/skills/acessibilidade/SKILL.md +28 -0
  35. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  36. package/skills/depurar-de-verdade/SKILL.md +29 -0
  37. package/skills/editar-meu-video/SKILL.md +59 -0
  38. package/skills/escrever-em-portugues/SKILL.md +28 -0
  39. package/skills/git-limpo/SKILL.md +28 -0
  40. package/skills/motion-de-primeira/SKILL.md +62 -0
  41. package/skills/pagina-que-converte/SKILL.md +28 -0
  42. package/skills/performance-web/SKILL.md +28 -0
  43. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  44. package/skills/slides-que-prendem/SKILL.md +40 -0
  45. package/studio/edge.py +16 -66
  46. package/studio/narrar.py +274 -0
  47. package/studio/pessoa.py +199 -0
  48. package/voz/primo_voz/__init__.py +0 -0
  49. package/voz/primo_voz/__main__.py +383 -0
  50. package/voz/primo_voz/escuta.py +235 -0
  51. package/voz/primo_voz/orbe.py +216 -0
  52. package/lib/audio-livre.js +0 -177
  53. package/lib/chaves.js +0 -149
  54. package/lib/claude-engine.js +0 -631
  55. package/lib/codex-engine.js +0 -208
  56. package/lib/remotion.js +0 -663
  57. package/lib/studio.js +0 -960
  58. package/studio/PRIMOCODE.md +0 -58
  59. package/studio/README.md +0 -154
  60. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  61. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  62. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  63. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  64. package/studio/ferramentas/00-protocolo.md +0 -125
  65. package/studio/ferramentas/01-modelos.md +0 -270
  66. package/studio/ferramentas/README.md +0 -32
  67. package/studio/ferramentas/corte.md +0 -919
  68. package/studio/ferramentas/grade.md +0 -92
  69. package/studio/ferramentas/prisma.md +0 -107
  70. package/studio/ferramentas/prosa.md +0 -86
  71. package/studio/ferramentas/tela.md +0 -93
  72. package/studio/ferramentas/traco.md +0 -79
  73. package/studio/server.py +0 -2254
  74. package/studio/studio.log +0 -57
  75. package/studio/voz.py +0 -388
  76. package/studio/web/analise.html +0 -52
  77. package/studio/web/core.css +0 -1167
  78. package/studio/web/fontes/bebas.woff2 +0 -0
  79. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  80. package/studio/web/fontes/inter-400.woff2 +0 -0
  81. package/studio/web/fontes/inter-700.woff2 +0 -0
  82. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  83. package/studio/web/fontes/playfair-700.woff2 +0 -0
  84. package/studio/web/fontes.css +0 -23
  85. package/studio/web/home.html +0 -217
  86. package/studio/web/js/analise.js +0 -510
  87. package/studio/web/js/audio.js +0 -607
  88. package/studio/web/js/editor.js +0 -1802
  89. package/studio/web/js/exportar.js +0 -222
  90. package/studio/web/js/ferramentas.js +0 -108
  91. package/studio/web/js/icones.js +0 -120
  92. package/studio/web/js/midia.js +0 -157
  93. package/studio/web/js/narracao.js +0 -325
  94. package/studio/web/js/player.js +0 -903
  95. package/studio/web/js/render-grade.js +0 -243
  96. package/studio/web/js/render-prosa.js +0 -104
  97. package/studio/web/js/render.js +0 -1852
  98. package/studio/web/js/sfx.js +0 -233
  99. package/studio/web/js/video.js +0 -464
  100. package/studio/web/js/voz-neural.js +0 -152
  101. package/studio/web/render.css +0 -1352
  102. package/studio/web/tf.html +0 -15
  103. package/studio/web/tool.html +0 -78
  104. package/studio/web/view.html +0 -27
@@ -0,0 +1,383 @@
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
+ # 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
+
106
+ class Voz:
107
+ """A costura: microfone → transcrição → Node → voz → microfone."""
108
+
109
+ def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
110
+ self.servidor = servidor
111
+ self.token = token
112
+ self.voz = voz_escolhida
113
+
114
+ self.orb = orbe.Orbe(ao_fechar=self.parar)
115
+ self.gravador = escuta.Gravador()
116
+ self.corte = escuta.Corte()
117
+
118
+ self._blocos = queue.Queue()
119
+ self._vivo = True
120
+ self._falando = False
121
+ self._pensando = False
122
+ self._parar_fala = threading.Event()
123
+ self._do_node = queue.Queue()
124
+
125
+ # ── microfone ────────────────────────────────────────────────────────
126
+ def _ao_receber(self, bloco) -> None:
127
+ if not self._vivo:
128
+ return
129
+ nivel = escuta.rms(bloco)
130
+
131
+ # Falar por cima CORTA a resposta. Sem isto, a única forma de
132
+ # interromper é esperar ele terminar — e esperar um assistente
133
+ # terminar de falar uma coisa errada é a pior parte de usar um.
134
+ if self._falando:
135
+ self.corte.ele_falou(nivel)
136
+ if self.corte.alguem_cortou(nivel):
137
+ self._parar_fala.set()
138
+ return
139
+
140
+ self._blocos.put(bloco)
141
+ if self.orb:
142
+ self.orb.mudar(nivel=min(1.0, nivel / 2500.0))
143
+
144
+ # ── o laço ───────────────────────────────────────────────────────────
145
+ def _laco(self) -> None:
146
+ mic = escuta.Microfone(self._ao_receber)
147
+ try:
148
+ mic.abrir()
149
+ except Exception as e:
150
+ avisar(f"não consegui abrir o microfone: {e}")
151
+ falar_para_o_node({"tipo": "erro", "texto": f"microfone: {e}"})
152
+ self.parar()
153
+ return
154
+
155
+ while self._vivo:
156
+ self.orb.mudar(estado="esperando", nivel=0)
157
+ self.gravador.comecar()
158
+
159
+ # Esperar a frase.
160
+ acabou = False
161
+ while self._vivo and not acabou:
162
+ try:
163
+ bloco = self._blocos.get(timeout=0.3)
164
+ except queue.Empty:
165
+ continue
166
+ if self.gravador._falando:
167
+ self.orb.mudar(estado="ouvindo")
168
+ acabou = self.gravador.alimentar(bloco)
169
+
170
+ if not self._vivo:
171
+ break
172
+ if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
173
+ continue
174
+
175
+ wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
176
+ if not wav:
177
+ continue
178
+
179
+ self.orb.mudar(estado="pensando")
180
+ texto = self._transcrever(wav)
181
+ if not texto or len(texto.strip()) < 2:
182
+ continue
183
+
184
+ falar_para_o_node({"tipo": "pedido", "texto": texto})
185
+ resposta = self._esperar_resposta()
186
+ if resposta:
187
+ self._falar(resposta)
188
+
189
+ mic.fechar()
190
+
191
+ # ── transcrição ──────────────────────────────────────────────────────
192
+ def _transcrever(self, wav: Path) -> str:
193
+ """Quem tem a chave é o servidor do PrimoCode, não este programa.
194
+
195
+ É a mesma rota que a legenda de vídeo usa. Duas formas de transcrever
196
+ seriam duas contas para pagar e duas para consertar.
197
+ """
198
+ import base64
199
+ import urllib.request
200
+
201
+ # O wav de 16 kHz mono já é pequeno; converter para mp3 exigiria
202
+ # ffmpeg, que nem toda máquina tem — e uma frase de dez segundos cabe
203
+ # em ~300 KB, que atravessa a rede sem dor.
204
+ dados = base64.b64encode(wav.read_bytes()).decode("ascii")
205
+ corpo = json.dumps({"audio": dados, "nome": "fala.wav", "idioma": "pt"}).encode("utf-8")
206
+ pedido = urllib.request.Request(
207
+ self.servidor.rstrip("/") + "/api/transcrever",
208
+ data=corpo,
209
+ headers={"Content-Type": "application/json", "x-primocode-token": self.token},
210
+ )
211
+ try:
212
+ with urllib.request.urlopen(pedido, timeout=90) as r:
213
+ j = json.loads(r.read().decode("utf-8"))
214
+ return str(j.get("texto") or "").strip()
215
+ except Exception as e:
216
+ avisar(f"não deu para transcrever: {e}")
217
+ return ""
218
+
219
+ # ── resposta do Node ─────────────────────────────────────────────────
220
+ def _esperar_resposta(self) -> str:
221
+ while self._vivo:
222
+ try:
223
+ msg = self._do_node.get(timeout=0.5)
224
+ except queue.Empty:
225
+ continue
226
+ tipo = msg.get("tipo")
227
+ if tipo == "estado":
228
+ self.orb.mudar(estado=msg.get("estado") or "pensando")
229
+ elif tipo == "resposta":
230
+ return str(msg.get("texto") or "")
231
+ elif tipo == "erro":
232
+ return str(msg.get("texto") or "não deu certo")
233
+ elif tipo == "fim":
234
+ self.parar()
235
+ return ""
236
+ return ""
237
+
238
+ def ouvir_o_node(self) -> None:
239
+ """Lê o stdin, linha a linha, até ele fechar."""
240
+ for linha in sys.stdin:
241
+ linha = linha.strip()
242
+ if not linha:
243
+ continue
244
+ try:
245
+ self._do_node.put(json.loads(linha))
246
+ except json.JSONDecodeError:
247
+ continue
248
+ self.parar()
249
+
250
+ # ── voz ──────────────────────────────────────────────────────────────
251
+ def _falar(self, texto: str) -> None:
252
+ """A resposta sai em voz — e SÓ em voz.
253
+
254
+ O texto não aparece em lugar nenhum da interface. É a regra do modo:
255
+ ler a resposta é trabalho, ouvir não é.
256
+ """
257
+ limpo = " ".join(str(texto).split())
258
+ if not limpo:
259
+ return
260
+ # Resposta longa demais em voz é castigo: quem ouve não pode passar o
261
+ # olho. O agente já responde curto no modo voz (o Node manda a
262
+ # instrução), e isto é a rede embaixo.
263
+ if len(limpo) > 900:
264
+ limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
265
+
266
+ mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
267
+ narrar = AQUI.parent.parent / "studio" / "narrar.py"
268
+ # O narrar.py IMPRIME um JSON dizendo o que faltou e como resolver
269
+ # ("instale o edge-tts com… "). Com check=True isso virava uma
270
+ # CalledProcessError cujo texto é "Command '[...]' returned non-zero
271
+ # exit status 1" — e era ISSO que chegava ao usuário, com o conselho
272
+ # útil jogado fora dentro do capture_output. Agora a saída é lida.
273
+ r = subprocess.run(
274
+ [sys.executable, str(narrar), "falar", "--texto", limpo,
275
+ "--saida", str(mp3), "--voz", self.voz],
276
+ capture_output=True, timeout=180)
277
+ if r.returncode != 0 or not mp3.exists():
278
+ avisar(motivo_da_narracao(r))
279
+ return
280
+
281
+ self._falando = True
282
+ self._parar_fala.clear()
283
+ self.orb.mudar(estado="falando")
284
+ try:
285
+ self._tocar(mp3)
286
+ finally:
287
+ self._falando = False
288
+ self.corte.nivel_dele = 0.0
289
+ try:
290
+ mp3.unlink()
291
+ except OSError:
292
+ pass
293
+
294
+ def _tocar(self, arquivo: Path) -> None:
295
+ """Toca o mp3 com o que a máquina tiver.
296
+
297
+ Nenhum tocador é garantido em todo sistema, então são vários, na ordem
298
+ de quem está mais presente — e o processo é interrompível, porque
299
+ falar por cima precisa calar a voz na hora.
300
+ """
301
+ tentativas = []
302
+ if sys.platform == "darwin":
303
+ tentativas.append(["afplay", str(arquivo)])
304
+ elif sys.platform == "win32":
305
+ # Media.SoundPlayer NÃO toca MP3 — ele só lê WAV, e o que o
306
+ # narrar.py entrega é MP3. Era a primeira tentativa da fila, e
307
+ # levantava exceção sempre; como o laço abaixo aceitava qualquer
308
+ # processo que tivesse CONSEGUIDO INICIAR, ele dava por tocado e
309
+ # voltava. Resultado: o modo voz era MUDO no Windows, sem nenhum
310
+ # aviso — a orb piscava "falando" e não saía som nenhum.
311
+ # MediaPlayer decodifica MP3; a espera é limitada para não travar
312
+ # a thread se o Open falhar.
313
+ tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
314
+ tentativas += [
315
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
316
+ ["mpg123", "-q", str(arquivo)],
317
+ ["mpv", "--no-video", "--really-quiet", str(arquivo)],
318
+ ["cvlc", "--play-and-exit", "--intf", "dummy", str(arquivo)],
319
+ ]
320
+ for cmd in tentativas:
321
+ try:
322
+ p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
323
+ except (FileNotFoundError, OSError):
324
+ continue
325
+ while p.poll() is None:
326
+ if self._parar_fala.is_set() or not self._vivo:
327
+ try:
328
+ p.terminate()
329
+ except Exception:
330
+ pass
331
+ return # calado de propósito: falaram por cima
332
+ time.sleep(0.05)
333
+ if p.returncode == 0:
334
+ return
335
+ # INICIAR não é TOCAR. O tocador existia e falhou (formato que ele
336
+ # não lê, dispositivo de áudio ocupado): a fila continua. Sair aqui
337
+ # era o que transformava "o primeiro tocador não serve" em silêncio
338
+ # absoluto, com quatro alternativas por tentar logo abaixo.
339
+ continue
340
+ avisar("não achei um tocador de áudio que funcione neste computador."
341
+ + (" Instale o ffmpeg: winget install ffmpeg" if sys.platform == "win32"
342
+ else " Instale o ffmpeg: brew install ffmpeg" if sys.platform == "darwin"
343
+ else " Instale o ffmpeg: sudo apt install ffmpeg"))
344
+
345
+ # ── ciclo de vida ────────────────────────────────────────────────────
346
+ def parar(self) -> None:
347
+ if not self._vivo:
348
+ return
349
+ self._vivo = False
350
+ self._parar_fala.set()
351
+ falar_para_o_node({"tipo": "fim"})
352
+
353
+ def rodar(self) -> None:
354
+ threading.Thread(target=self._laco, daemon=True).start()
355
+ threading.Thread(target=self.ouvir_o_node, daemon=True).start()
356
+ # A interface fica na thread principal, porque em todo sistema é lá
357
+ # que uma janela pode existir.
358
+ self.orb.rodar()
359
+
360
+
361
+ def main() -> int:
362
+ servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
363
+ token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
364
+ voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
365
+
366
+ if not servidor:
367
+ avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
368
+ return 2
369
+
370
+ try:
371
+ import numpy # noqa: F401
372
+ import sounddevice # noqa: F401
373
+ except ImportError as e:
374
+ avisar(f"falta um pacote de áudio ({e.name}). Rode: "
375
+ f"{sys.executable} -m pip install sounddevice numpy")
376
+ return 3
377
+
378
+ Voz(servidor, token, voz).rodar()
379
+ return 0
380
+
381
+
382
+ if __name__ == "__main__":
383
+ 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