primocode 9.10.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.
- package/README.md +30 -6
- package/bin/primocode.js +100 -341
- package/lib/act.js +245 -23
- package/lib/browser.js +17 -0
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +4 -2
- package/lib/estudio/diretor.js +9 -1
- package/lib/estudio/estilo.js +51 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/index.js +41 -18
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/index.jsx +11 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +72 -0
- package/lib/estudio/lancamento.js +413 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/remotion.js +87 -43
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/vox.js +385 -0
- package/lib/fala.js +1 -94
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +8 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +79 -20
- package/lib/ui.js +13 -6
- package/lib/video/analise.js +89 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +247 -0
- package/lib/video/ops.js +322 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/render.js +221 -0
- package/lib/video/texto.js +142 -0
- package/lib/video/timeline.js +310 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +3 -4
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
|
@@ -1,40 +1,31 @@
|
|
|
1
|
-
"""primo_voz — o
|
|
1
|
+
"""primo_voz — o ouvido e a boca do PrimoCode. Não tem cérebro.
|
|
2
2
|
|
|
3
|
-
"
|
|
4
|
-
|
|
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
|
-
──
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
18
|
+
Uma linha de JSON por mensagem.
|
|
26
19
|
|
|
27
|
-
para o Node: {"tipo":"
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
173
|
-
#
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
|
|
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
|
|
262
|
-
|
|
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
|
-
# ──
|
|
295
|
-
|
|
190
|
+
# ── a boca ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def _falar_em_fila(self) -> None:
|
|
296
193
|
while self._vivo:
|
|
297
194
|
try:
|
|
298
|
-
|
|
195
|
+
texto = self._falas.get(timeout=0.3)
|
|
299
196
|
except queue.Empty:
|
|
300
197
|
continue
|
|
301
|
-
|
|
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
|
|
253
|
+
return
|
|
407
254
|
time.sleep(0.05)
|
|
408
255
|
if p.returncode == 0:
|
|
409
256
|
return
|
|
410
|
-
# INICIAR não é TOCAR
|
|
411
|
-
#
|
|
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
|
-
+ ("
|
|
417
|
-
else "
|
|
418
|
-
else "
|
|
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.
|
|
430
|
-
threading.Thread(target=self.
|
|
431
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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);
|