primocode 8.42.2 → 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.
Files changed (102) 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 +31 -192
  7. package/lib/catalogo.js +60 -60
  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 +545 -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 +606 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +13 -1
  23. package/lib/marca.js +4 -1
  24. package/lib/marketplace.js +189 -0
  25. package/lib/permissao.js +137 -0
  26. package/lib/registro.js +229 -0
  27. package/lib/skills.js +329 -0
  28. package/lib/tools.js +51 -181
  29. package/lib/ui.js +7 -3
  30. package/lib/voz.js +239 -0
  31. package/package.json +12 -3
  32. package/skills/acessibilidade/SKILL.md +28 -0
  33. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  34. package/skills/depurar-de-verdade/SKILL.md +29 -0
  35. package/skills/editar-meu-video/SKILL.md +59 -0
  36. package/skills/escrever-em-portugues/SKILL.md +28 -0
  37. package/skills/git-limpo/SKILL.md +28 -0
  38. package/skills/motion-de-primeira/SKILL.md +62 -0
  39. package/skills/pagina-que-converte/SKILL.md +28 -0
  40. package/skills/performance-web/SKILL.md +28 -0
  41. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  42. package/skills/slides-que-prendem/SKILL.md +40 -0
  43. package/studio/edge.py +16 -66
  44. package/studio/narrar.py +274 -0
  45. package/studio/pessoa.py +167 -0
  46. package/voz/primo_voz/__init__.py +0 -0
  47. package/voz/primo_voz/__main__.py +325 -0
  48. package/voz/primo_voz/escuta.py +235 -0
  49. package/voz/primo_voz/orbe.py +216 -0
  50. package/lib/audio-livre.js +0 -177
  51. package/lib/chaves.js +0 -149
  52. package/lib/claude-engine.js +0 -631
  53. package/lib/codex-engine.js +0 -208
  54. package/lib/remotion.js +0 -663
  55. package/lib/studio.js +0 -960
  56. package/studio/PRIMOCODE.md +0 -58
  57. package/studio/README.md +0 -154
  58. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  59. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  60. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  61. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  62. package/studio/ferramentas/00-protocolo.md +0 -125
  63. package/studio/ferramentas/01-modelos.md +0 -270
  64. package/studio/ferramentas/README.md +0 -32
  65. package/studio/ferramentas/corte.md +0 -919
  66. package/studio/ferramentas/grade.md +0 -92
  67. package/studio/ferramentas/prisma.md +0 -107
  68. package/studio/ferramentas/prosa.md +0 -86
  69. package/studio/ferramentas/tela.md +0 -93
  70. package/studio/ferramentas/traco.md +0 -79
  71. package/studio/server.py +0 -2254
  72. package/studio/studio.log +0 -57
  73. package/studio/voz.py +0 -388
  74. package/studio/web/analise.html +0 -52
  75. package/studio/web/core.css +0 -1167
  76. package/studio/web/fontes/bebas.woff2 +0 -0
  77. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  78. package/studio/web/fontes/inter-400.woff2 +0 -0
  79. package/studio/web/fontes/inter-700.woff2 +0 -0
  80. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  81. package/studio/web/fontes/playfair-700.woff2 +0 -0
  82. package/studio/web/fontes.css +0 -23
  83. package/studio/web/home.html +0 -217
  84. package/studio/web/js/analise.js +0 -510
  85. package/studio/web/js/audio.js +0 -607
  86. package/studio/web/js/editor.js +0 -1802
  87. package/studio/web/js/exportar.js +0 -222
  88. package/studio/web/js/ferramentas.js +0 -108
  89. package/studio/web/js/icones.js +0 -120
  90. package/studio/web/js/midia.js +0 -157
  91. package/studio/web/js/narracao.js +0 -325
  92. package/studio/web/js/player.js +0 -903
  93. package/studio/web/js/render-grade.js +0 -243
  94. package/studio/web/js/render-prosa.js +0 -104
  95. package/studio/web/js/render.js +0 -1852
  96. package/studio/web/js/sfx.js +0 -233
  97. package/studio/web/js/video.js +0 -464
  98. package/studio/web/js/voz-neural.js +0 -152
  99. package/studio/web/render.css +0 -1352
  100. package/studio/web/tf.html +0 -15
  101. package/studio/web/tool.html +0 -78
  102. 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