primocode 8.11.2 → 8.13.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/lib/studio.js +26 -2
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/ferramentas/corte.md +45 -2
- package/studio/server.py +126 -19
- package/studio/voz.py +307 -0
- package/studio/web/core.css +133 -2
- package/studio/web/js/editor.js +614 -99
- package/studio/web/js/ferramentas.js +4 -1
- package/studio/web/js/narracao.js +18 -2
- package/studio/web/js/render.js +114 -4
- package/studio/web/js/voz-neural.js +88 -178
package/lib/studio.js
CHANGED
|
@@ -39,7 +39,10 @@ const BASE = `http://127.0.0.1:${PORTA}`;
|
|
|
39
39
|
|
|
40
40
|
// Só o código do Studio é atualizado; `arquivos/` e `midia/` (o que o usuário
|
|
41
41
|
// criou) nunca são tocados.
|
|
42
|
-
|
|
42
|
+
// voz.py é módulo do servidor, não enfeite: sem ele o server.py não importa
|
|
43
|
+
// e o Studio inteiro deixa de subir. Todo arquivo .py do Studio precisa
|
|
44
|
+
// entrar aqui.
|
|
45
|
+
const COPIAVEIS = new Set(['server.py', 'voz.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* Onde está o Python 3 desta máquina.
|
|
@@ -300,11 +303,32 @@ async function comIdReal(id, fn) {
|
|
|
300
303
|
|
|
301
304
|
const ler = (id) => comStudio(() => comIdReal(id, async () => ({ ok: true, arquivo: await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`) })));
|
|
302
305
|
|
|
306
|
+
// Quando o documento tem narração, o servidor começa a baixar a voz neural
|
|
307
|
+
// sozinho. São ~90 MB, e o usuário merece saber por que o primeiro vídeo sai
|
|
308
|
+
// com uma voz e o próximo com outra — melhor ele ouvir isso do agente do que
|
|
309
|
+
// achar que o programa está inconstante.
|
|
310
|
+
async function avisoDaVoz(doc) {
|
|
311
|
+
const temNarracao = !!(doc && (doc.narracao
|
|
312
|
+
|| (doc.cenas || []).some((c) => c && typeof c.narracao === 'string' && c.narracao.trim())));
|
|
313
|
+
if (!temNarracao) return null;
|
|
314
|
+
try {
|
|
315
|
+
const s = await pedir('GET', '/api/voz/status', null, 3000);
|
|
316
|
+
if (s && s.piper && !s.piper.pronta && s.piper.suportado) {
|
|
317
|
+
return 'A voz neural (offline, ilimitada) está sendo baixada agora — ~90 MB, uma vez só. '
|
|
318
|
+
+ 'Até terminar a narração sai por uma voz de reserva, mais simples. Diga isso ao usuário.';
|
|
319
|
+
}
|
|
320
|
+
} catch { /* status é enfeite; não pode atrapalhar a criação */ }
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
303
324
|
const criar = ({ ferramenta, titulo, doc }) => comStudio(async () => {
|
|
304
325
|
if (!ferramenta) return { ok: false, error: 'Diga a ferramenta: prisma, tela, prosa, grade, corte ou traco.' };
|
|
305
326
|
if (!doc) return { ok: false, error: 'Falta o doc. Leia a spec com studio_spec antes de criar.' };
|
|
306
327
|
const r = await pedir('POST', '/api/arquivos', { ferramenta, titulo: titulo || 'Sem título', doc });
|
|
307
|
-
|
|
328
|
+
const saida = { ok: true, ...r };
|
|
329
|
+
const voz = await avisoDaVoz(doc);
|
|
330
|
+
if (voz) saida.voz = voz;
|
|
331
|
+
return saida;
|
|
308
332
|
});
|
|
309
333
|
|
|
310
334
|
// O que o agente recebe de volta depois de mexer num arquivo. Devolver o
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.13.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
Binary file
|
|
@@ -138,12 +138,28 @@ na hora de tocar — sem gravar nada, sem arquivo:
|
|
|
138
138
|
"elementos": [ ... ] }
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
Escolha da voz no **documento** (opcional):
|
|
142
142
|
|
|
143
143
|
```json
|
|
144
|
-
"voz": { "
|
|
144
|
+
"voz": { "voz": "jeff", "grave": true, "velocidade": 1 }
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
A síntese é neural e roda na máquina do usuário — offline, sem limite. As
|
|
148
|
+
vozes pt-BR, com a frequência de cada uma medida (menor = mais grave):
|
|
149
|
+
|
|
150
|
+
| id | timbre | Hz |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `jeff` | grave, de locução — **é a padrão** | 139 |
|
|
153
|
+
| `cadu` | grave, mais solta | 140 |
|
|
154
|
+
| `edresson` | média, mais leve de baixar | 151 |
|
|
155
|
+
| `faber` | clara, de leitura | 167 |
|
|
156
|
+
| `tugao` | português de Portugal | — |
|
|
157
|
+
|
|
158
|
+
`"grave": true` abaixa mais uns 10% — é o registro de abertura de vídeo. Não
|
|
159
|
+
há voz feminina em pt-BR neste motor; se pedirem uma, diga isso em vez de
|
|
160
|
+
prometer. Na primeira vez o motor é baixado sozinho (~90 MB); enquanto isso
|
|
161
|
+
a narração sai por uma voz de reserva, e o vídeo nunca fica mudo.
|
|
162
|
+
|
|
147
163
|
Regras que fazem diferença:
|
|
148
164
|
|
|
149
165
|
- **Escreva para o ouvido, não para o olho.** A narração NÃO repete o texto da
|
|
@@ -169,6 +185,33 @@ Na cena, `transicao` define como ela **entra**:
|
|
|
169
185
|
|
|
170
186
|
Vale o mesmo conselho das animações: varie. Tudo em `fade` parece slide, não vídeo.
|
|
171
187
|
|
|
188
|
+
## Chaves de animação
|
|
189
|
+
|
|
190
|
+
`anim` faz o elemento **entrar** e parar por ali. Para ele se mexer ao longo da
|
|
191
|
+
cena — atravessar, crescer devagar, sumir no fim — use `chaves`: uma lista de
|
|
192
|
+
instantes, com `t` de 0 (começo da cena) a 1 (fim).
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{ "tipo": "texto", "texto": "Atravessa a tela",
|
|
196
|
+
"chaves": [
|
|
197
|
+
{ "t": 0, "x": -400, "opacidade": 0 },
|
|
198
|
+
{ "t": 0.25, "x": 0, "opacidade": 1 },
|
|
199
|
+
{ "t": 0.75, "x": 0, "opacidade": 1 },
|
|
200
|
+
{ "t": 1, "x": 400, "opacidade": 0 }
|
|
201
|
+
] }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Campos de uma chave, todos opcionais: `x` e `y` (**deslocamento** em pixels a
|
|
205
|
+
partir de onde o elemento está — não é posição absoluta), `escala` (1 = tamanho
|
|
206
|
+
normal), `giro` (graus), `opacidade` (0 a 1) e `desfoque` (pixels).
|
|
207
|
+
|
|
208
|
+
No elemento: `"repetir": true` roda sem parar, e `"suavizar": false` dá
|
|
209
|
+
movimento constante em vez de acelerar e frear.
|
|
210
|
+
|
|
211
|
+
Duas chaves já bastam. Quem tem `chaves` ignora `anim` — as duas disputariam o
|
|
212
|
+
mesmo movimento, e a chave é mais específica. Use em **um ou dois** elementos
|
|
213
|
+
por vídeo: tudo se mexendo ao mesmo tempo cansa e esconde a mensagem.
|
|
214
|
+
|
|
172
215
|
## Acabamento
|
|
173
216
|
|
|
174
217
|
Textura por cima da cena inteira — é o que tira a cara de template:
|
package/studio/server.py
CHANGED
|
@@ -25,6 +25,37 @@ from urllib.parse import urlparse, unquote, urlencode
|
|
|
25
25
|
import urllib.request
|
|
26
26
|
|
|
27
27
|
RAIZ = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
sys.path.insert(0, RAIZ)
|
|
29
|
+
try:
|
|
30
|
+
import voz as voz_lib # noqa: E402 (precisa da RAIZ no path antes)
|
|
31
|
+
except Exception as _e: # instalação parcial, ou voz.py de uma versão velha
|
|
32
|
+
# Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
|
|
33
|
+
# Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
|
|
34
|
+
sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)
|
|
35
|
+
|
|
36
|
+
class _SemVoz:
|
|
37
|
+
@staticmethod
|
|
38
|
+
def pronta(*a, **k):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def sintetizar(*a, **k):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def instalar(*a, **k):
|
|
47
|
+
return {"pronta": False, "erro": "módulo de voz indisponível"}
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def estado():
|
|
51
|
+
return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def apagar():
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
voz_lib = _SemVoz()
|
|
58
|
+
|
|
28
59
|
WEB = os.path.join(RAIZ, "web")
|
|
29
60
|
ARQUIVOS = os.path.join(RAIZ, "arquivos")
|
|
30
61
|
FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
|
|
@@ -32,13 +63,21 @@ MIDIA = os.path.join(RAIZ, "midia")
|
|
|
32
63
|
PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
|
|
33
64
|
|
|
34
65
|
def _assinatura():
|
|
35
|
-
"""SHA-1 do
|
|
66
|
+
"""SHA-1 do código do Studio, lido NA SUBIDA. Se o npm trocar o código no
|
|
36
67
|
disco depois, o processo no ar continua respondendo a assinatura antiga —
|
|
37
68
|
e é exatamente essa diferença que o CLI usa para saber que precisa
|
|
38
|
-
reiniciar o Studio em vez de servir a versão velha para sempre.
|
|
69
|
+
reiniciar o Studio em vez de servir a versão velha para sempre.
|
|
70
|
+
|
|
71
|
+
Entra todo módulo do Studio, não só este arquivo: uma correção que mexa
|
|
72
|
+
apenas na voz também precisa derrubar o processo velho."""
|
|
73
|
+
h = hashlib.sha1()
|
|
39
74
|
try:
|
|
40
|
-
|
|
41
|
-
|
|
75
|
+
for nome in ("server.py", "voz.py"):
|
|
76
|
+
caminho = os.path.join(RAIZ, nome)
|
|
77
|
+
if os.path.exists(caminho):
|
|
78
|
+
with open(caminho, "rb") as fh:
|
|
79
|
+
h.update(fh.read())
|
|
80
|
+
return h.hexdigest()[:12]
|
|
42
81
|
except Exception:
|
|
43
82
|
return "?"
|
|
44
83
|
|
|
@@ -234,6 +273,32 @@ def ler_versao(arq_id, versao):
|
|
|
234
273
|
return json.load(fh)
|
|
235
274
|
|
|
236
275
|
|
|
276
|
+
def _tem_narracao(doc):
|
|
277
|
+
if not isinstance(doc, dict):
|
|
278
|
+
return False
|
|
279
|
+
if isinstance(doc.get("narracao"), str) and doc["narracao"].strip():
|
|
280
|
+
return True
|
|
281
|
+
for c in doc.get("cenas") or []:
|
|
282
|
+
if isinstance(c, dict) and isinstance(c.get("narracao"), str) and c["narracao"].strip():
|
|
283
|
+
return True
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def preparar_voz(doc):
|
|
288
|
+
"""Documento com narração chegou: começa a baixar a voz agora.
|
|
289
|
+
|
|
290
|
+
O download é de ~90 MB e leva algum tempo. Fazê-lo quando o usuário
|
|
291
|
+
aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
|
|
292
|
+
aqui, no instante em que o roteiro é gravado, dá ao download a mesma
|
|
293
|
+
janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
|
|
294
|
+
a voz boa já está na máquina. Se não der tempo, a corrente cobre."""
|
|
295
|
+
if _tem_narracao(doc) and not voz_lib.pronta():
|
|
296
|
+
try:
|
|
297
|
+
voz_lib.instalar()
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
237
302
|
def mesclar(antigo, novo):
|
|
238
303
|
"""O documento novo por cima do antigo, chave a chave.
|
|
239
304
|
|
|
@@ -369,6 +434,7 @@ def criar(dados):
|
|
|
369
434
|
if problema:
|
|
370
435
|
raise ValueError(problema)
|
|
371
436
|
|
|
437
|
+
preparar_voz(doc)
|
|
372
438
|
arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
|
|
373
439
|
return gravar({
|
|
374
440
|
"id": arq_id,
|
|
@@ -742,17 +808,25 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
742
808
|
return super().do_GET()
|
|
743
809
|
|
|
744
810
|
# ── Voz ────────────────────────────────────────────────────────────
|
|
745
|
-
#
|
|
746
|
-
#
|
|
811
|
+
# A narração inteira é decidida AQUI, no Python, e não no navegador.
|
|
812
|
+
#
|
|
813
|
+
# Antes o navegador tentava a voz neural do Edge por WebSocket e, quando
|
|
814
|
+
# ela não subia, caía no speechSynthesis — o TTS da própria máquina, que
|
|
815
|
+
# soa a computador. A voz do Edge parou de subir (a Microsoft responde
|
|
816
|
+
# 403 na síntese), então na prática TODO vídeo saía com voz de robô. Era
|
|
817
|
+
# o que o usuário estava ouvindo.
|
|
747
818
|
#
|
|
748
|
-
#
|
|
749
|
-
#
|
|
750
|
-
#
|
|
751
|
-
#
|
|
819
|
+
# A corrente agora é esta, e o navegador só consome o resultado:
|
|
820
|
+
# 1. PIPER neural, roda na máquina, offline, ilimitado, com escolha
|
|
821
|
+
# de voz — inclusive graves de verdade (voz.py mede e diz
|
|
822
|
+
# a frequência de cada uma). É o bom.
|
|
823
|
+
# 2. VOICEBOX se o usuário tiver o app: voz clonável.
|
|
824
|
+
# 3. GOOGLE uma voz só, robótica, mas responde na hora sem instalar
|
|
825
|
+
# nada — é o que cobre enquanto o Piper baixa.
|
|
752
826
|
#
|
|
753
|
-
# O endpoint recusa texto comprido, então a frase é cortada em
|
|
754
|
-
# até 190 caracteres, sempre numa fronteira de palavra, e os
|
|
755
|
-
# emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
|
|
827
|
+
# O endpoint do Google recusa texto comprido, então a frase é cortada em
|
|
828
|
+
# pedaços de até 190 caracteres, sempre numa fronteira de palavra, e os
|
|
829
|
+
# MP3 são emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
|
|
756
830
|
def voz(self, query):
|
|
757
831
|
from urllib.parse import parse_qs
|
|
758
832
|
q = parse_qs(query or "")
|
|
@@ -763,22 +837,45 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
763
837
|
if len(texto) > 4000:
|
|
764
838
|
texto = texto[:4000]
|
|
765
839
|
|
|
766
|
-
|
|
840
|
+
voz_pedida = (q.get("voz", [""])[0] or "").strip()
|
|
767
841
|
instrucao = (q.get("instrucao", [""])[0] or "").strip()
|
|
768
842
|
motor = (q.get("motor", [""])[0] or "").strip()
|
|
843
|
+
grave = (q.get("grave", [""])[0] or "").lower() in ("1", "true", "sim")
|
|
844
|
+
try:
|
|
845
|
+
velocidade = float(q.get("velocidade", ["1"])[0])
|
|
846
|
+
except ValueError:
|
|
847
|
+
velocidade = 1.0
|
|
769
848
|
|
|
770
849
|
tipo = "audio/mpeg"
|
|
771
850
|
audio = b""
|
|
772
|
-
|
|
851
|
+
|
|
852
|
+
# 1) Piper: o motor bom. Se ainda não baixou, começa a baixar agora e
|
|
853
|
+
# segue para o próximo — quem pediu narração hoje ouve o Google, e
|
|
854
|
+
# a partir da próxima já é voz de verdade.
|
|
855
|
+
if motor not in ("google", "voicebox"):
|
|
856
|
+
try:
|
|
857
|
+
if voz_lib.pronta(voz_pedida or None):
|
|
858
|
+
audio = voz_lib.sintetizar(texto, voz_pedida or None,
|
|
859
|
+
grave=grave, velocidade=velocidade) or b""
|
|
860
|
+
if audio:
|
|
861
|
+
tipo = "audio/wav"
|
|
862
|
+
else:
|
|
863
|
+
voz_lib.instalar(voz_pedida or None)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
sys.stderr.write("[voz] Piper falhou (%s); seguindo a corrente\n" % e)
|
|
866
|
+
audio = b""
|
|
867
|
+
|
|
868
|
+
# 2) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
|
|
773
869
|
# sem depender de nada na internet.
|
|
774
|
-
if motor != "google" and self._voicebox_vivo():
|
|
870
|
+
if not audio and motor != "google" and self._voicebox_vivo():
|
|
775
871
|
try:
|
|
776
|
-
audio, tipo = self._voz_voicebox(texto,
|
|
872
|
+
audio, tipo = self._voz_voicebox(texto, voz_pedida, instrucao)
|
|
777
873
|
except Exception as e:
|
|
778
874
|
sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
|
|
779
875
|
audio = b""
|
|
780
|
-
#
|
|
876
|
+
# 3) Google: uma voz só, mas responde sempre.
|
|
781
877
|
if not audio:
|
|
878
|
+
tipo = "audio/mpeg"
|
|
782
879
|
try:
|
|
783
880
|
audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
|
|
784
881
|
except Exception as e: # rede, bloqueio, mudança do serviço
|
|
@@ -807,7 +904,8 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
807
904
|
|
|
808
905
|
def voz_status(self):
|
|
809
906
|
"""O que está disponível para narrar, para a interface não oferecer o
|
|
810
|
-
que não existe
|
|
907
|
+
que não existe — e para ela poder mostrar o download em vez de fingir
|
|
908
|
+
que já está tudo pronto."""
|
|
811
909
|
vivo = self._voicebox_vivo()
|
|
812
910
|
vozes = []
|
|
813
911
|
if vivo:
|
|
@@ -818,6 +916,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
818
916
|
except Exception:
|
|
819
917
|
vozes = []
|
|
820
918
|
return self.js({
|
|
919
|
+
"piper": voz_lib.estado(),
|
|
821
920
|
"voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
|
|
822
921
|
"google": {"ativo": True},
|
|
823
922
|
})
|
|
@@ -960,6 +1059,13 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
960
1059
|
corpo, status = guardar_midia(self.corpo())
|
|
961
1060
|
return self.js(corpo, status)
|
|
962
1061
|
|
|
1062
|
+
if rota == "/api/voz/instalar":
|
|
1063
|
+
pedido = self.corpo()
|
|
1064
|
+
if pedido.get("apagar"):
|
|
1065
|
+
return self.js(voz_lib.apagar())
|
|
1066
|
+
return self.js(voz_lib.instalar(pedido.get("voz"),
|
|
1067
|
+
esperar=min(60, int(pedido.get("esperar") or 0))))
|
|
1068
|
+
|
|
963
1069
|
if rota == "/api/sair":
|
|
964
1070
|
# Só o CLI chama, e só quando o código no disco mudou (update do
|
|
965
1071
|
# npm). Sem isto o processo antigo ficava no ar servindo o bug que
|
|
@@ -1050,6 +1156,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
1050
1156
|
if problema:
|
|
1051
1157
|
return self.js({"erro": problema}, 400)
|
|
1052
1158
|
|
|
1159
|
+
preparar_voz(novo)
|
|
1053
1160
|
versao = guardar_versao(a)
|
|
1054
1161
|
if "titulo" in dados:
|
|
1055
1162
|
a["titulo"] = dados["titulo"]
|
package/studio/voz.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Narração de verdade, gerada aqui em Python.
|
|
2
|
+
|
|
3
|
+
── O PROBLEMA ────────────────────────────────────────────────────────────
|
|
4
|
+
A narração era feita no NAVEGADOR. Havia três caminhos lá: a voz neural do
|
|
5
|
+
Edge por WebSocket, o TTS do Google por este servidor, e o speechSynthesis
|
|
6
|
+
da própria máquina. O primeiro morreu — a Microsoft passou a responder 403
|
|
7
|
+
na síntese (a lista de vozes ainda responde; a síntese, não), e isso é uma
|
|
8
|
+
trava que eles impõem de propósito, não um bug para contornar. O terceiro é
|
|
9
|
+
o TTS do sistema: existe em todo computador e soa a computador. Resultado
|
|
10
|
+
prático: o usuário pedia vídeo institucional e ouvia voz de robô.
|
|
11
|
+
|
|
12
|
+
── A SAÍDA ───────────────────────────────────────────────────────────────
|
|
13
|
+
Trazer a síntese para cá, para o Python, onde ela não depende de navegador
|
|
14
|
+
nem de serviço de ninguém. O motor é o Piper (MIT, do projeto Rhasspy):
|
|
15
|
+
rede neural que roda na máquina, offline, sem chave, sem cota e sem limite.
|
|
16
|
+
Ele não vem no pacote — são ~90 MB entre programa e modelo — então é
|
|
17
|
+
baixado uma vez, sob demanda, e daí em diante a narração é instantânea e
|
|
18
|
+
funciona sem internet.
|
|
19
|
+
|
|
20
|
+
── AS VOZES ──────────────────────────────────────────────────────────────
|
|
21
|
+
As quatro vozes pt-BR do Piper foram sintetizadas e MEDIDAS: a frequência
|
|
22
|
+
fundamental de cada uma está na tabela abaixo, apurada por autocorrelação
|
|
23
|
+
sobre os quadros com tom. Não é adjetivo de catálogo, é medida. Por isso a
|
|
24
|
+
padrão é a Jeff, de 139 Hz — a mais grave, que é o que sustenta abertura de
|
|
25
|
+
vídeo. E por isso não há voz feminina listada: o Piper não tem nenhuma em
|
|
26
|
+
pt-BR, e inventar rótulo seria pior que admitir a falta.
|
|
27
|
+
|
|
28
|
+
── A CORRENTE ────────────────────────────────────────────────────────────
|
|
29
|
+
1. PIPER neural, offline, ilimitado, com escolha de voz. O bom.
|
|
30
|
+
2. VOICEBOX se o usuário tiver o app instalado — voz clonável.
|
|
31
|
+
3. GOOGLE uma voz só, robótica, mas responde na hora e sem instalar nada.
|
|
32
|
+
|
|
33
|
+
Cada um cai no seguinte quando falha. A narração nunca fica muda por falta
|
|
34
|
+
de motor: enquanto o Piper baixa, o Google cobre.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import platform
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import tarfile
|
|
45
|
+
import tempfile
|
|
46
|
+
import threading
|
|
47
|
+
import urllib.parse
|
|
48
|
+
import urllib.request
|
|
49
|
+
import wave
|
|
50
|
+
import zipfile
|
|
51
|
+
|
|
52
|
+
CASA = os.path.join(os.path.expanduser("~"), ".primocode", "vozes")
|
|
53
|
+
|
|
54
|
+
RELEASE = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/"
|
|
55
|
+
VOZES_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
|
|
56
|
+
|
|
57
|
+
# O pacote do Piper para cada sistema. Os cinco nomes foram conferidos no ar.
|
|
58
|
+
PACOTES = {
|
|
59
|
+
("Linux", "x86_64"): "piper_linux_x86_64.tar.gz",
|
|
60
|
+
("Linux", "aarch64"): "piper_linux_aarch64.tar.gz",
|
|
61
|
+
("Linux", "arm64"): "piper_linux_aarch64.tar.gz",
|
|
62
|
+
("Darwin", "x86_64"): "piper_macos_x64.tar.gz",
|
|
63
|
+
("Darwin", "arm64"): "piper_macos_aarch64.tar.gz",
|
|
64
|
+
("Windows", "AMD64"): "piper_windows_amd64.zip",
|
|
65
|
+
("Windows", "x86_64"): "piper_windows_amd64.zip",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# hz: fundamental medida, em Hz. Quanto menor, mais grave.
|
|
69
|
+
CATALOGO = [
|
|
70
|
+
{"id": "jeff", "nome": "Jeff", "hz": 139, "grave": True, "padrao": True,
|
|
71
|
+
"tom": "grave, de locução", "caminho": "pt/pt_BR/jeff/medium/pt_BR-jeff-medium", "mb": 60},
|
|
72
|
+
{"id": "cadu", "nome": "Cadu", "hz": 140, "grave": True,
|
|
73
|
+
"tom": "grave, mais solta", "caminho": "pt/pt_BR/cadu/medium/pt_BR-cadu-medium", "mb": 60},
|
|
74
|
+
{"id": "faber", "nome": "Faber", "hz": 167,
|
|
75
|
+
"tom": "clara, de leitura", "caminho": "pt/pt_BR/faber/medium/pt_BR-faber-medium", "mb": 60},
|
|
76
|
+
{"id": "edresson", "nome": "Edresson", "hz": 151,
|
|
77
|
+
"tom": "média, mais leve de baixar", "caminho": "pt/pt_BR/edresson/low/pt_BR-edresson-low", "mb": 21},
|
|
78
|
+
{"id": "tugao", "nome": "Tugão", "hz": 0,
|
|
79
|
+
"tom": "português de Portugal", "caminho": "pt/pt_PT/tugão/medium/pt_PT-tugão-medium", "mb": 60},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
POR_ID = {v["id"]: v for v in CATALOGO}
|
|
83
|
+
PADRAO = next(v["id"] for v in CATALOGO if v.get("padrao"))
|
|
84
|
+
|
|
85
|
+
# Estado do download, para a interface poder mostrar em vez de fingir que
|
|
86
|
+
# está tudo pronto. Um dicionário simples, protegido por tranca porque quem
|
|
87
|
+
# escreve é a thread do download e quem lê são as requisições.
|
|
88
|
+
_tranca = threading.Lock()
|
|
89
|
+
_baixando = {"ativo": False, "voz": None, "pct": 0, "etapa": "", "erro": None}
|
|
90
|
+
_thread = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _versao_pacote():
|
|
94
|
+
return PACOTES.get((platform.system(), platform.machine()))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pasta_piper():
|
|
98
|
+
return os.path.join(CASA, "piper")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def binario():
|
|
102
|
+
"""O executável do Piper, se já foi baixado."""
|
|
103
|
+
nome = "piper.exe" if platform.system() == "Windows" else "piper"
|
|
104
|
+
p = os.path.join(pasta_piper(), nome)
|
|
105
|
+
return p if os.path.exists(p) else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def modelo(voz_id):
|
|
109
|
+
v = POR_ID.get(voz_id or PADRAO)
|
|
110
|
+
if not v:
|
|
111
|
+
return None
|
|
112
|
+
base = os.path.join(CASA, os.path.basename(v["caminho"]))
|
|
113
|
+
return base if os.path.exists(base + ".onnx") and os.path.exists(base + ".onnx.json") else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def instaladas():
|
|
117
|
+
return [v["id"] for v in CATALOGO if modelo(v["id"])]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pronta(voz_id=None):
|
|
121
|
+
return bool(binario() and modelo(voz_id or PADRAO))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ baixar --
|
|
125
|
+
|
|
126
|
+
def _baixar(url, destino, fatia=(0, 100)):
|
|
127
|
+
"""Baixa mostrando progresso dentro da fatia que lhe cabe do total."""
|
|
128
|
+
ini, fim = fatia
|
|
129
|
+
with urllib.request.urlopen(url, timeout=60) as r:
|
|
130
|
+
total = int(r.headers.get("Content-Length") or 0)
|
|
131
|
+
feito = 0
|
|
132
|
+
tmp = destino + ".parcial"
|
|
133
|
+
with open(tmp, "wb") as fh:
|
|
134
|
+
while True:
|
|
135
|
+
p = r.read(262144)
|
|
136
|
+
if not p:
|
|
137
|
+
break
|
|
138
|
+
fh.write(p)
|
|
139
|
+
feito += len(p)
|
|
140
|
+
if total:
|
|
141
|
+
with _tranca:
|
|
142
|
+
_baixando["pct"] = int(ini + (fim - ini) * feito / total)
|
|
143
|
+
# Só vira o arquivo de verdade quando terminou inteiro: assim uma
|
|
144
|
+
# queda de rede não deixa um modelo pela metade que "existe".
|
|
145
|
+
os.replace(tmp, destino)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _instalar_piper():
|
|
149
|
+
pacote = _versao_pacote()
|
|
150
|
+
if not pacote:
|
|
151
|
+
raise RuntimeError("não há Piper para %s/%s" % (platform.system(), platform.machine()))
|
|
152
|
+
os.makedirs(CASA, exist_ok=True)
|
|
153
|
+
with _tranca:
|
|
154
|
+
_baixando["etapa"] = "baixando o motor de voz"
|
|
155
|
+
alvo = os.path.join(CASA, pacote)
|
|
156
|
+
_baixar(RELEASE + pacote, alvo, (0, 35))
|
|
157
|
+
|
|
158
|
+
with _tranca:
|
|
159
|
+
_baixando["etapa"] = "instalando o motor"
|
|
160
|
+
if pacote.endswith(".zip"):
|
|
161
|
+
with zipfile.ZipFile(alvo) as z:
|
|
162
|
+
z.extractall(CASA)
|
|
163
|
+
else:
|
|
164
|
+
with tarfile.open(alvo) as t:
|
|
165
|
+
t.extractall(CASA)
|
|
166
|
+
os.remove(alvo)
|
|
167
|
+
|
|
168
|
+
exe = binario()
|
|
169
|
+
if not exe:
|
|
170
|
+
raise RuntimeError("o pacote do Piper não trouxe o executável")
|
|
171
|
+
if platform.system() != "Windows":
|
|
172
|
+
os.chmod(exe, 0o755)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _instalar_voz(voz_id):
|
|
176
|
+
v = POR_ID[voz_id]
|
|
177
|
+
os.makedirs(CASA, exist_ok=True)
|
|
178
|
+
base = os.path.join(CASA, os.path.basename(v["caminho"]))
|
|
179
|
+
with _tranca:
|
|
180
|
+
_baixando["etapa"] = "baixando a voz %s" % v["nome"]
|
|
181
|
+
# O nome tem "ã" no caso do tugão: a URL precisa disso escapado.
|
|
182
|
+
url = VOZES_URL + urllib.parse.quote(v["caminho"])
|
|
183
|
+
_baixar(url + ".onnx.json", base + ".onnx.json", (35, 40))
|
|
184
|
+
_baixar(url + ".onnx", base + ".onnx", (40, 100))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _rotina(voz_id):
|
|
188
|
+
global _thread
|
|
189
|
+
try:
|
|
190
|
+
if not binario():
|
|
191
|
+
_instalar_piper()
|
|
192
|
+
if not modelo(voz_id):
|
|
193
|
+
_instalar_voz(voz_id)
|
|
194
|
+
with _tranca:
|
|
195
|
+
_baixando.update(ativo=False, pct=100, etapa="pronta", erro=None)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
sys.stderr.write("[voz] instalação falhou: %s\n" % e)
|
|
198
|
+
with _tranca:
|
|
199
|
+
_baixando.update(ativo=False, etapa="", erro=str(e))
|
|
200
|
+
finally:
|
|
201
|
+
_thread = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def instalar(voz_id=None, esperar=0):
|
|
205
|
+
"""Começa (ou acompanha) a instalação. Não bloqueia, por padrão.
|
|
206
|
+
|
|
207
|
+
Quem chama é a interface e o próprio servidor quando vê um documento com
|
|
208
|
+
narração: preparar a voz enquanto o agente ainda escreve o roteiro é o
|
|
209
|
+
que faz ela estar pronta na hora de tocar."""
|
|
210
|
+
global _thread
|
|
211
|
+
voz_id = voz_id if voz_id in POR_ID else PADRAO
|
|
212
|
+
with _tranca:
|
|
213
|
+
if _thread is None and not pronta(voz_id):
|
|
214
|
+
_baixando.update(ativo=True, voz=voz_id, pct=0, etapa="começando", erro=None)
|
|
215
|
+
_thread = threading.Thread(target=_rotina, args=(voz_id,), daemon=True)
|
|
216
|
+
_thread.start()
|
|
217
|
+
t = _thread
|
|
218
|
+
if t and esperar:
|
|
219
|
+
t.join(esperar)
|
|
220
|
+
return estado()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def estado():
|
|
224
|
+
with _tranca:
|
|
225
|
+
b = dict(_baixando)
|
|
226
|
+
return {
|
|
227
|
+
"motor": "piper",
|
|
228
|
+
"pronta": pronta(),
|
|
229
|
+
"instaladas": instaladas(),
|
|
230
|
+
"suportado": bool(_versao_pacote()),
|
|
231
|
+
"baixando": b,
|
|
232
|
+
"vozes": [{
|
|
233
|
+
"id": v["id"], "nome": v["nome"], "tom": v["tom"], "hz": v["hz"], "mb": v["mb"],
|
|
234
|
+
"instalada": bool(modelo(v["id"])),
|
|
235
|
+
"grave": bool(v.get("grave")),
|
|
236
|
+
"padrao": bool(v.get("padrao")),
|
|
237
|
+
} for v in CATALOGO],
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --------------------------------------------------------------- sintetizar --
|
|
242
|
+
|
|
243
|
+
def _mais_grave(bruto, razao):
|
|
244
|
+
"""Abaixa o tom reescrevendo a taxa declarada do WAV.
|
|
245
|
+
|
|
246
|
+
Não há reamostragem: os mesmos quadros, lidos mais devagar, soam mais
|
|
247
|
+
graves na mesma proporção. Como isso também alonga o áudio, a síntese já
|
|
248
|
+
foi feita acelerada pelo mesmo fator, e a duração volta ao lugar.
|
|
249
|
+
Medido: a voz Jeff sai de 139 Hz para 125 Hz com razão 0.9."""
|
|
250
|
+
with wave.open(bruto, "rb") as w:
|
|
251
|
+
p = w.getparams()
|
|
252
|
+
quadros = w.readframes(p.nframes)
|
|
253
|
+
saida = bruto + ".grave.wav"
|
|
254
|
+
with wave.open(saida, "wb") as s:
|
|
255
|
+
s.setnchannels(p.nchannels)
|
|
256
|
+
s.setsampwidth(p.sampwidth)
|
|
257
|
+
s.setframerate(max(8000, int(p.framerate * razao)))
|
|
258
|
+
s.writeframes(quadros)
|
|
259
|
+
return saida
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def sintetizar(texto, voz_id=None, grave=False, velocidade=1.0, timeout=120):
|
|
263
|
+
"""O texto vira WAV. None quando o Piper não está pronto — aí quem chamou
|
|
264
|
+
cai para o próximo motor da corrente."""
|
|
265
|
+
exe = binario()
|
|
266
|
+
voz_id = voz_id if voz_id in POR_ID else PADRAO
|
|
267
|
+
mod = modelo(voz_id)
|
|
268
|
+
if not exe or not mod:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Sem isto, um texto com quebra de linha vira várias falas e o Piper
|
|
272
|
+
# devolve só a última.
|
|
273
|
+
limpo = re.sub(r"\s+", " ", str(texto or "")).strip()
|
|
274
|
+
if not limpo:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
razao = 0.9 if grave else 1.0
|
|
278
|
+
# length_scale > 1 é mais devagar. A razão do grave entra aqui para a
|
|
279
|
+
# duração não crescer quando a taxa for reescrita lá embaixo.
|
|
280
|
+
escala = (1.0 / max(0.5, min(2.0, velocidade))) * razao
|
|
281
|
+
|
|
282
|
+
pasta = tempfile.mkdtemp(prefix="primo-voz-")
|
|
283
|
+
try:
|
|
284
|
+
saida = os.path.join(pasta, "voz.wav")
|
|
285
|
+
r = subprocess.run(
|
|
286
|
+
[exe, "--model", mod + ".onnx", "--config", mod + ".onnx.json",
|
|
287
|
+
"--length_scale", "%.3f" % escala, "--sentence_silence", "0.25",
|
|
288
|
+
"--output_file", saida],
|
|
289
|
+
input=limpo.encode("utf-8"),
|
|
290
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout,
|
|
291
|
+
)
|
|
292
|
+
if r.returncode != 0 or not os.path.exists(saida):
|
|
293
|
+
raise RuntimeError((r.stderr or b"").decode("utf-8", "replace")[-300:] or "piper falhou")
|
|
294
|
+
if grave:
|
|
295
|
+
saida = _mais_grave(saida, razao)
|
|
296
|
+
with open(saida, "rb") as fh:
|
|
297
|
+
return fh.read()
|
|
298
|
+
finally:
|
|
299
|
+
shutil.rmtree(pasta, ignore_errors=True)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def apagar():
|
|
303
|
+
"""Some com tudo que foi baixado. Existe para o usuário poder desfazer:
|
|
304
|
+
são 90 MB na máquina dele, e ele tem direito de recuperá-los."""
|
|
305
|
+
if os.path.isdir(CASA):
|
|
306
|
+
shutil.rmtree(CASA, ignore_errors=True)
|
|
307
|
+
return {"ok": True}
|