primocode 9.9.0 → 10.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 (63) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/ende.js +2 -0
  12. package/lib/estudio/estilo.js +51 -0
  13. package/lib/estudio/glifos.js +215 -0
  14. package/lib/estudio/index.js +63 -11
  15. package/lib/estudio/kit/base.jsx +285 -0
  16. package/lib/estudio/kit/index.jsx +11 -0
  17. package/lib/estudio/kit/marca.jsx +741 -0
  18. package/lib/estudio/kit/vox.jsx +357 -0
  19. package/lib/estudio/kit.js +72 -0
  20. package/lib/estudio/lancamento.js +413 -0
  21. package/lib/estudio/morfar.js +191 -0
  22. package/lib/estudio/motion.js +333 -0
  23. package/lib/estudio/plano.js +54 -7
  24. package/lib/estudio/remotion.js +167 -52
  25. package/lib/estudio/ritmo.js +77 -0
  26. package/lib/estudio/sons-leves.js +62 -0
  27. package/lib/estudio/vox.js +385 -0
  28. package/lib/fala.js +1 -94
  29. package/lib/pipeline.js +55 -56
  30. package/lib/repeticao.js +3 -1
  31. package/lib/skills.js +8 -2
  32. package/lib/snapshot.js +111 -0
  33. package/lib/tools.js +79 -20
  34. package/lib/ui.js +13 -6
  35. package/lib/video/analise.js +89 -0
  36. package/lib/video/cli.js +74 -0
  37. package/lib/video/delta.js +90 -0
  38. package/lib/video/edits.js +379 -0
  39. package/lib/video/filters.js +247 -0
  40. package/lib/video/ops.js +322 -0
  41. package/lib/video/projeto.js +133 -0
  42. package/lib/video/render.js +221 -0
  43. package/lib/video/texto.js +142 -0
  44. package/lib/video/timeline.js +310 -0
  45. package/lib/video/transcript.js +124 -0
  46. package/lib/voz.js +75 -223
  47. package/package.json +2 -3
  48. package/skills/motion-de-primeira/SKILL.md +21 -1
  49. package/studio/texto.py +208 -0
  50. package/voz/primo_voz/__main__.py +96 -236
  51. package/voz/primo_voz/transcrever.py +31 -1
  52. package/app/ansi.js +0 -55
  53. package/app/app.css +0 -500
  54. package/app/app.js +0 -1017
  55. package/app/icone.svg +0 -6
  56. package/app/index.html +0 -220
  57. package/app/primo-marca.js +0 -192
  58. package/lib/app.js +0 -615
  59. package/lib/icone.js +0 -263
  60. package/lib/instalar-app.js +0 -231
  61. package/lib/painel.js +0 -291
  62. package/voz/primo_voz/desperta.py +0 -195
  63. package/voz/primo_voz/orbe.py +0 -406
@@ -1,40 +1,31 @@
1
- """primo_voz — o Primo Code em modo voz.
1
+ """primo_voz — o ouvido e a boca do PrimoCode. Não tem cérebro.
2
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."
3
+ "As funções do modo voz serão implementadas na CLI; o modo voz será só
4
+ para falar e ele responder em voz alta."
7
5
 
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.
6
+ ── O QUE ESTE PROCESSO FAZ ─────────────────────────────────────────────────
7
+ Ouve o microfone, transcreve o que a pessoa disse e entrega o TEXTO ao
8
+ terminal, que trata como se tivesse sido digitado: vai para a mesma fila, a
9
+ mesma caixa de recados (lib/act.js), o mesmo roteador de conversa. Quando o
10
+ terminal manda falar, ele fala — e cala na hora se a pessoa falar por cima.
11
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.
12
+ O que saiu (10.0): a orb (uma janela Tk ao lado do terminal), o "ei, primo"
13
+ (o Vosk, um download de 31 MB para um microfone que já está aberto) e o
14
+ cérebro paralelo — o /voice chamava o agente por conta própria, ao lado do
15
+ terminal, e dois agentes na mesma pasta escreviam um por cima do outro.
23
16
 
24
17
  ── O PROTOCOLO ─────────────────────────────────────────────────────────────
25
- Uma linha de JSON por mensagem, nas duas direções.
18
+ Uma linha de JSON por mensagem.
26
19
 
27
- para o Node: {"tipo":"pedido","texto":"..."} {"tipo":"fim"}
28
- do Node: {"tipo":"estado","estado":"pensando"}
29
- {"tipo":"resposta","texto":"..."}
20
+ para o Node: {"tipo":"pronto"} o microfone abriu
21
+ {"tipo":"ouvi","texto":"..."} a pessoa disse isto
30
22
  {"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.
23
+ {"tipo":"fim"}
24
+ do Node: {"tipo":"falar","texto":"..."} diga isto em voz alta
25
+ {"tipo":"calar"} pare de falar agora
26
+ {"tipo":"fim"}
37
27
  """
28
+
38
29
  from __future__ import annotations
39
30
 
40
31
  import json
@@ -50,7 +41,7 @@ from pathlib import Path
50
41
  AQUI = Path(__file__).resolve().parent
51
42
  sys.path.insert(0, str(AQUI.parent))
52
43
 
53
- from primo_voz import desperta, escuta, orbe, transcrever # noqa: E402
44
+ from primo_voz import escuta, transcrever # noqa: E402
54
45
 
55
46
 
56
47
  def falar_para_o_node(obj: dict) -> None:
@@ -65,30 +56,22 @@ def avisar(texto: str) -> None:
65
56
  sys.stderr.flush()
66
57
 
67
58
 
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
59
  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
- """
60
+ """O que o narrar.py disse que houve — em vez do código de saída."""
78
61
  try:
79
62
  d = json.loads((r.stdout or b"").decode("utf-8", "replace"))
80
63
  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 "")
64
+ como = d.get("comoResolver") or ""
65
+ return "não deu para gerar a voz: " + str(d["error"]) + (" — " + str(como) if como else "")
84
66
  except Exception:
85
67
  pass
86
68
  erro = (r.stderr or b"").decode("utf-8", "replace").strip()
87
69
  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})")
70
+ return "não deu para gerar a voz" + (f": {ultima[:300]}" if ultima else f" (o narrar.py saiu com {r.returncode})")
90
71
 
91
72
 
73
+ # O PowerShell que toca MP3 no Windows. MediaPlayer é assíncrono: Play()
74
+ # devolve na hora, então é preciso esperar a duração — com teto.
92
75
  PS_TOCAR_MP3 = (
93
76
  "Add-Type -AssemblyName PresentationCore; "
94
77
  "$p = New-Object System.Windows.Media.MediaPlayer; "
@@ -104,37 +87,12 @@ PS_TOCAR_MP3 = (
104
87
 
105
88
 
106
89
  class Voz:
107
- """A costura: microfone → transcrição → Node → voz → microfone."""
90
+ """Microfone → transcrição → terminal. Terminal → voz."""
108
91
 
109
- def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio",
110
- esperando: bool = False):
92
+ def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
111
93
  self.servidor = servidor
112
94
  self.token = token
113
95
  self.voz = voz_escolhida
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
-
137
- self.orb = orbe.Orbe(ao_fechar=self.parar)
138
96
  # O Whisper local carrega em segundo plano desde já: senão a primeira
139
97
  # frase esperaria o modelo inteiro subir.
140
98
  self.local = transcrever.disponivel()
@@ -142,125 +100,68 @@ class Voz:
142
100
  transcrever.aquecer()
143
101
  self.gravador = escuta.Gravador()
144
102
  self.corte = escuta.Corte()
145
-
146
- self._blocos = queue.Queue()
103
+ self._blocos: "queue.Queue" = queue.Queue()
104
+ self._falas: "queue.Queue" = queue.Queue()
147
105
  self._vivo = True
148
106
  self._falando = False
149
- self._pensando = False
150
107
  self._parar_fala = threading.Event()
151
- self._do_node = queue.Queue()
152
-
153
- def _esvaziar_blocos(self) -> None:
154
- """Joga fora o áudio que está na fila.
155
108
 
156
- Depois de despertar, os blocos do próprio "ei primo" ainda estão
157
- enfileirados — e sem isto eles entram na gravação do PEDIDO, que chega
158
- ao agente com o nome colado na frente.
159
- """
160
- while True:
161
- try:
162
- self._blocos.get_nowait()
163
- except queue.Empty:
164
- return
109
+ # ── o ouvido ─────────────────────────────────────────────────────────
165
110
 
166
- # ── microfone ────────────────────────────────────────────────────────
167
111
  def _ao_receber(self, bloco) -> None:
168
112
  if not self._vivo:
169
113
  return
170
114
  nivel = escuta.rms(bloco)
171
-
172
- # Falar por cima CORTA a resposta. Sem isto, a única forma de
173
- # interromper é esperar ele terminar — e esperar um assistente
174
- # terminar de falar uma coisa errada é a pior parte de usar um.
115
+ # Enquanto ele fala, o microfone só serve para uma coisa: saber se a
116
+ # pessoa falou POR CIMA. O que entra aqui é a voz dele saindo da
117
+ # caixa de som, e não pode virar pedido.
175
118
  if self._falando:
176
119
  self.corte.ele_falou(nivel)
177
120
  if self.corte.alguem_cortou(nivel):
178
121
  self._parar_fala.set()
179
122
  return
180
-
181
123
  self._blocos.put(bloco)
182
- if self.orb:
183
- self.orb.mudar(nivel=min(1.0, nivel / 2500.0))
184
124
 
185
- # ── o laço ───────────────────────────────────────────────────────────
186
- def _laco(self) -> None:
125
+ def _esvaziar_blocos(self) -> None:
126
+ while True:
127
+ try:
128
+ self._blocos.get_nowait()
129
+ except queue.Empty:
130
+ return
131
+
132
+ def _ouvir(self) -> None:
187
133
  mic = escuta.Microfone(self._ao_receber)
188
134
  try:
189
135
  mic.abrir()
190
136
  except Exception as e:
191
- avisar(f"não consegui abrir o microfone: {e}")
192
- falar_para_o_node({"tipo": "erro", "texto": f"microfone: {e}"})
137
+ falar_para_o_node({"tipo": "erro", "texto": f"não consegui abrir o microfone: {e}"})
193
138
  self.parar()
194
139
  return
195
-
140
+ falar_para_o_node({"tipo": "pronto"})
196
141
  while self._vivo:
197
- # ── O SONO ───────────────────────────────────────────────
198
- # No modo de espera a orbe some e o microfone fica ouvindo SÓ o
199
- # nome, aqui dentro, sem nada sair da máquina (ver desperta.py).
200
- # Sem isso, o modo de espera seria o microfone da pessoa aberto
201
- # para a internet o dia inteiro.
202
- if self.esperando and self.desperta is not None:
203
- self.orb.esconder()
204
- self.desperta.zerar()
205
- acordou = False
206
- while self._vivo and not acordou:
207
- try:
208
- bloco = self._blocos.get(timeout=0.3)
209
- except queue.Empty:
210
- continue
211
- acordou = self.desperta.alimentar(bloco)
212
- if not self._vivo:
213
- break
214
- # Chamou: a orbe aparece e ele passa a ouvir o PEDIDO.
215
- self.orb.mostrar()
216
- self.orb.mudar(estado="ouvindo", nivel=0)
217
- # Sem esvaziar a fila, os blocos do próprio "ei primo" entram
218
- # na gravação do pedido — e o pedido chega com o nome colado
219
- # na frente.
220
- self._esvaziar_blocos()
221
-
222
- self.orb.mudar(estado="esperando", nivel=0)
223
142
  self.gravador.comecar()
224
-
225
- # Esperar a frase.
226
143
  acabou = False
227
144
  while self._vivo and not acabou:
228
145
  try:
229
146
  bloco = self._blocos.get(timeout=0.3)
230
147
  except queue.Empty:
231
148
  continue
232
- if self.gravador._falando:
233
- self.orb.mudar(estado="ouvindo")
234
149
  acabou = self.gravador.alimentar(bloco)
235
-
236
150
  if not self._vivo:
237
151
  break
238
152
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
239
153
  continue
240
-
241
154
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
242
155
  if not wav:
243
156
  continue
244
-
245
- self.orb.mudar(estado="pensando")
246
- # O servidor também transcreve com Whisper: a mesma limpeza vale.
247
157
  texto = transcrever.limpar(self._transcrever(wav))
248
- if not texto:
249
- self.orb.mudar(estado="esperando")
250
- continue
251
-
252
- falar_para_o_node({"tipo": "pedido", "texto": texto})
253
- resposta = self._esperar_resposta()
254
- if resposta:
255
- self._falar(resposta)
256
-
158
+ if texto:
159
+ falar_para_o_node({"tipo": "ouvi", "texto": texto})
257
160
  mic.fechar()
258
161
 
259
- # ── transcrição ──────────────────────────────────────────────────────
260
162
  def _transcrever(self, wav: Path) -> str:
261
- """Primeiro aqui, com o Whisper local (ver transcrever.py). O servidor
262
- só entra se o local não estiver instalado ou falhar — e diz que caiu.
263
- """
163
+ """Primeiro aqui, com o Whisper local. O servidor só entra se o local
164
+ não estiver instalado ou falhar — e diz que caiu."""
264
165
  if self.local:
265
166
  try:
266
167
  return transcrever.transcrever(wav)
@@ -269,13 +170,8 @@ class Voz:
269
170
  return self._transcrever_no_servidor(wav)
270
171
 
271
172
  def _transcrever_no_servidor(self, wav: Path) -> str:
272
- """A rota do servidor, a mesma que a legenda de vídeo usa."""
273
173
  import base64
274
174
  import urllib.request
275
-
276
- # O wav de 16 kHz mono já é pequeno; converter para mp3 exigiria
277
- # ffmpeg, que nem toda máquina tem — e uma frase de dez segundos cabe
278
- # em ~300 KB, que atravessa a rede sem dor.
279
175
  dados = base64.b64encode(wav.read_bytes()).decode("ascii")
280
176
  corpo = json.dumps({"audio": dados, "nome": "fala.wav", "idioma": "pt"}).encode("utf-8")
281
177
  pedido = urllib.request.Request(
@@ -291,100 +187,51 @@ class Voz:
291
187
  avisar(f"não deu para transcrever: {e}")
292
188
  return ""
293
189
 
294
- # ── resposta do Node ─────────────────────────────────────────────────
295
- def _esperar_resposta(self) -> str:
190
+ # ── a boca ───────────────────────────────────────────────────────────
191
+
192
+ def _falar_em_fila(self) -> None:
296
193
  while self._vivo:
297
194
  try:
298
- msg = self._do_node.get(timeout=0.5)
195
+ texto = self._falas.get(timeout=0.3)
299
196
  except queue.Empty:
300
197
  continue
301
- tipo = msg.get("tipo")
302
- if tipo == "estado":
303
- self.orb.mudar(estado=msg.get("estado") or "pensando")
304
- elif tipo == "resposta":
305
- return str(msg.get("texto") or "")
306
- elif tipo == "erro":
307
- return str(msg.get("texto") or "não deu certo")
308
- elif tipo == "fim":
309
- self.parar()
310
- return ""
311
- return ""
312
-
313
- def ouvir_o_node(self) -> None:
314
- """Lê o stdin, linha a linha, até ele fechar."""
315
- for linha in sys.stdin:
316
- linha = linha.strip()
317
- if not linha:
318
- continue
319
- try:
320
- self._do_node.put(json.loads(linha))
321
- except json.JSONDecodeError:
322
- continue
323
- self.parar()
198
+ self._falar(texto)
324
199
 
325
- # ── voz ──────────────────────────────────────────────────────────────
326
200
  def _falar(self, texto: str) -> None:
327
- """A resposta sai em voz — e SÓ em voz.
328
-
329
- O texto não aparece em lugar nenhum da interface. É a regra do modo:
330
- ler a resposta é trabalho, ouvir não é.
331
- """
332
201
  limpo = " ".join(str(texto).split())
333
202
  if not limpo:
334
203
  return
335
- # Resposta longa demais em voz é castigo: quem ouve não pode passar o
336
- # olho. O agente já responde curto no modo voz (o Node manda a
337
- # instrução), e isto é a rede embaixo.
338
204
  if len(limpo) > 900:
339
205
  limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
340
-
341
206
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
342
207
  narrar = AQUI.parent.parent / "studio" / "narrar.py"
343
- # O narrar.py IMPRIME um JSON dizendo o que faltou e como resolver
344
- # ("instale o edge-tts com… "). Com check=True isso virava uma
345
- # CalledProcessError cujo texto é "Command '[...]' returned non-zero
346
- # exit status 1" — e era ISSO que chegava ao usuário, com o conselho
347
- # útil jogado fora dentro do capture_output. Agora a saída é lida.
348
208
  r = subprocess.run(
349
- [sys.executable, str(narrar), "falar", "--texto", limpo,
350
- "--saida", str(mp3), "--voz", self.voz],
209
+ [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
351
210
  capture_output=True, timeout=180)
352
211
  if r.returncode != 0 or not mp3.exists():
353
212
  avisar(motivo_da_narracao(r))
354
213
  return
355
-
356
214
  self._falando = True
357
215
  self._parar_fala.clear()
358
- self.orb.mudar(estado="falando")
359
216
  try:
360
217
  self._tocar(mp3)
361
218
  finally:
362
219
  self._falando = False
363
220
  self.corte.nivel_dele = 0.0
221
+ # O que o microfone pegou enquanto ele falava é a voz dele.
222
+ self._esvaziar_blocos()
364
223
  try:
365
224
  mp3.unlink()
366
225
  except OSError:
367
226
  pass
368
227
 
369
228
  def _tocar(self, arquivo: Path) -> None:
370
- """Toca o mp3 com o que a máquina tiver.
371
-
372
- Nenhum tocador é garantido em todo sistema, então são vários, na ordem
373
- de quem está mais presente — e o processo é interrompível, porque
374
- falar por cima precisa calar a voz na hora.
375
- """
229
+ """Toca o mp3 com o que a máquina tiver — interrompível, porque falar
230
+ por cima precisa calar a voz na hora."""
376
231
  tentativas = []
377
232
  if sys.platform == "darwin":
378
233
  tentativas.append(["afplay", str(arquivo)])
379
234
  elif sys.platform == "win32":
380
- # Media.SoundPlayer NÃO toca MP3 — ele só lê WAV, e o que o
381
- # narrar.py entrega é MP3. Era a primeira tentativa da fila, e
382
- # levantava exceção sempre; como o laço abaixo aceitava qualquer
383
- # processo que tivesse CONSEGUIDO INICIAR, ele dava por tocado e
384
- # voltava. Resultado: o modo voz era MUDO no Windows, sem nenhum
385
- # aviso — a orb piscava "falando" e não saía som nenhum.
386
- # MediaPlayer decodifica MP3; a espera é limitada para não travar
387
- # a thread se o Open falhar.
388
235
  tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
389
236
  tentativas += [
390
237
  ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
@@ -403,21 +250,43 @@ class Voz:
403
250
  p.terminate()
404
251
  except Exception:
405
252
  pass
406
- return # calado de propósito: falaram por cima
253
+ return
407
254
  time.sleep(0.05)
408
255
  if p.returncode == 0:
409
256
  return
410
- # INICIAR não é TOCAR. O tocador existia e falhou (formato que ele
411
- # não lê, dispositivo de áudio ocupado): a fila continua. Sair aqui
412
- # era o que transformava "o primeiro tocador não serve" em silêncio
413
- # absoluto, com quatro alternativas por tentar logo abaixo.
257
+ # INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
258
+ # próximo da fila é tentado.
414
259
  continue
415
- avisar("não achei um tocador de áudio que funcione neste computador."
416
- + (" Instale o ffmpeg: winget install ffmpeg" if sys.platform == "win32"
417
- else " Instale o ffmpeg: brew install ffmpeg" if sys.platform == "darwin"
418
- else " Instale o ffmpeg: sudo apt install ffmpeg"))
260
+ avisar("não achei um tocador de áudio que funcione neste computador. Instale o ffmpeg"
261
+ + (": winget install ffmpeg" if sys.platform == "win32"
262
+ else ": brew install ffmpeg" if sys.platform == "darwin"
263
+ else ": sudo apt install ffmpeg"))
264
+
265
+ # ── o terminal ───────────────────────────────────────────────────────
266
+
267
+ def ouvir_o_node(self) -> None:
268
+ for linha in sys.stdin:
269
+ linha = linha.strip()
270
+ if not linha:
271
+ continue
272
+ try:
273
+ msg = json.loads(linha)
274
+ except json.JSONDecodeError:
275
+ continue
276
+ tipo = msg.get("tipo")
277
+ if tipo == "falar":
278
+ self._falas.put(str(msg.get("texto") or ""))
279
+ elif tipo == "calar":
280
+ self._parar_fala.set()
281
+ while not self._falas.empty():
282
+ try:
283
+ self._falas.get_nowait()
284
+ except queue.Empty:
285
+ break
286
+ elif tipo == "fim":
287
+ break
288
+ self.parar()
419
289
 
420
- # ── ciclo de vida ────────────────────────────────────────────────────
421
290
  def parar(self) -> None:
422
291
  if not self._vivo:
423
292
  return
@@ -426,34 +295,25 @@ class Voz:
426
295
  falar_para_o_node({"tipo": "fim"})
427
296
 
428
297
  def rodar(self) -> None:
429
- threading.Thread(target=self._laco, daemon=True).start()
430
- threading.Thread(target=self.ouvir_o_node, daemon=True).start()
431
- # A interface fica na thread principal, porque em todo sistema é lá
432
- # que uma janela pode existir.
433
- self.orb.rodar()
298
+ threading.Thread(target=self._ouvir, daemon=True).start()
299
+ threading.Thread(target=self._falar_em_fila, daemon=True).start()
300
+ self.ouvir_o_node()
434
301
 
435
302
 
436
303
  def main() -> int:
437
304
  servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
438
305
  token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
439
306
  voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
440
- # O modo de espera vem por variável, como o resto: quem abre este programa
441
- # é o /voice do Node, não uma pessoa numa linha de comando.
442
- esperando = os.environ.get("PRIMOCODE_VOZ_ESPERA", "") == "1"
443
-
444
307
  if not servidor:
445
308
  avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
446
309
  return 2
447
-
448
310
  try:
449
311
  import numpy # noqa: F401
450
312
  import sounddevice # noqa: F401
451
313
  except ImportError as e:
452
- avisar(f"falta um pacote de áudio ({e.name}). Rode: "
453
- f"{sys.executable} -m pip install sounddevice numpy")
314
+ avisar(f"falta um pacote de áudio ({e.name}). Rode: {sys.executable} -m pip install sounddevice numpy")
454
315
  return 3
455
-
456
- Voz(servidor, token, voz, esperando=esperando).rodar()
316
+ Voz(servidor, token, voz).rodar()
457
317
  return 0
458
318
 
459
319
 
@@ -119,10 +119,40 @@ def transcrever(wav: Path, idioma: str = "pt") -> str:
119
119
  return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
120
120
 
121
121
 
122
+ def palavras(arquivo: str, idioma: str = "pt") -> list:
123
+ """Cada palavra com o tempo dela, no formato do /api/transcrever
124
+ ({de, ate, texto}). É o que o editor de vídeo usa para legenda e para
125
+ apagar palavra (lib/video/transcript.js)."""
126
+ modelo = carregar()
127
+ segmentos, _ = modelo.transcribe(str(arquivo), language=idioma, beam_size=5, word_timestamps=True,
128
+ vad_filter=True, condition_on_previous_text=False)
129
+ out = []
130
+ for s in segmentos:
131
+ if not aproveitavel(s):
132
+ continue
133
+ for w in (s.words or []):
134
+ texto = (w.word or "").strip()
135
+ if texto:
136
+ out.append({"de": round(float(w.start), 3), "ate": round(float(w.end), 3), "texto": texto})
137
+ return out
138
+
139
+
122
140
  def main() -> int:
123
141
  """`python -m primo_voz.transcrever baixar` — usado pelo /voice para deixar
124
- o modelo pronto antes da primeira frase. Responde uma linha de JSON."""
142
+ o modelo pronto antes da primeira frase. Responde uma linha de JSON.
143
+ `... palavras <arquivo> [idioma]` devolve as palavras com tempo."""
125
144
  acao = sys.argv[1] if len(sys.argv) > 1 else "baixar"
145
+ if acao == "palavras":
146
+ if not disponivel():
147
+ print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}))
148
+ return 1
149
+ try:
150
+ ws = palavras(sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else "pt")
151
+ except Exception as e:
152
+ print(json.dumps({"ok": False, "error": str(e)}))
153
+ return 1
154
+ print(json.dumps({"ok": True, "palavras": ws}, ensure_ascii=False))
155
+ return 0
126
156
  if acao != "baixar":
127
157
  print(json.dumps({"ok": False, "error": f"ação desconhecida: {acao}"}))
128
158
  return 2
package/app/ansi.js DELETED
@@ -1,55 +0,0 @@
1
- /* ansi.js — a saída do terminal virando HTML com as cores do PrimoCode.
2
- *
3
- * A mesma tradução do app.js (a janela do /rc), agora num arquivo que a IDE
4
- * também carrega. Só o que o PrimoCode realmente emite: cor 256 (38;5;N),
5
- * negrito e reset. O resto dos códigos (mover cursor, apagar linha) é desenho
6
- * de terminal e é descartado — na página quem rola é o navegador.
7
- *
8
- * O `estado` atravessa os pedaços: o Node corta a saída onde der, e um
9
- * "\x1b[38;5;45m" pode chegar num pedaço e o texto colorido no seguinte.
10
- */
11
- (function (raiz) {
12
- "use strict";
13
- var ANSI = /\x1b\[([0-9;?]*)([a-zA-Z])/g;
14
-
15
- function fugir(s) {
16
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17
- }
18
-
19
- function paraHtml(texto, estado) {
20
- var fora = "";
21
- var ultimo = 0;
22
- var m;
23
- ANSI.lastIndex = 0;
24
- function abrir() {
25
- var classes = [];
26
- if (estado.cor) classes.push("c" + estado.cor);
27
- if (estado.forte) classes.push("forte");
28
- return classes.length ? '<span class="' + classes.join(" ") + '">' : "";
29
- }
30
- function fechar() { return (estado.cor || estado.forte) ? "</span>" : ""; }
31
-
32
- while ((m = ANSI.exec(texto)) !== null) {
33
- var pedaco = texto.slice(ultimo, m.index);
34
- if (pedaco) fora += abrir() + fugir(pedaco) + fechar();
35
- ultimo = m.index + m[0].length;
36
- if (m[2] !== "m") continue;
37
- var partes = m[1].split(";");
38
- for (var i = 0; i < partes.length; i++) {
39
- var n = partes[i];
40
- if (n === "" || n === "0") { estado.cor = null; estado.forte = false; }
41
- else if (n === "1") estado.forte = true;
42
- else if (n === "38" && partes[i + 1] === "5") { estado.cor = partes[i + 2]; i += 2; }
43
- else if (n === "39") estado.cor = null;
44
- else if (n === "22") estado.forte = false;
45
- }
46
- }
47
- var resto = texto.slice(ultimo);
48
- if (resto) fora += abrir() + fugir(resto) + fechar();
49
- // \r sozinho é o terminal voltando ao começo da linha para redesenhar;
50
- // na página ele só deixaria um caractere invisível no meio do texto.
51
- return fora.replace(/\r(?!\n)/g, "");
52
- }
53
-
54
- raiz.PrimoAnsi = { paraHtml: paraHtml, fugir: fugir };
55
- })(window);