primocode 8.11.2 → 8.12.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 +18 -2
- package/studio/server.py +126 -19
- package/studio/voz.py +307 -0
- package/studio/web/js/narracao.js +18 -2
- 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.12.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"
|
|
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
|
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}
|
|
@@ -191,12 +191,28 @@ var Narracao = (function () {
|
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/* Mede quanto tempo a fala leva, sem ninguém ouvir.
|
|
194
|
+
/* Mede quanto tempo a fala leva, sem ninguém ouvir.
|
|
195
|
+
|
|
196
|
+
Com a voz neural o áudio vem pronto, em arquivo, e a duração está nos
|
|
197
|
+
metadados dele: perguntar é exato e é silencioso. Só o caminho do
|
|
198
|
+
speechSynthesis ainda precisa falar para cronometrar — e lá o volume
|
|
199
|
+
zero é obedecido de verdade. */
|
|
195
200
|
function medir(texto, cfg) {
|
|
201
|
+
cfg = cfg || {};
|
|
202
|
+
if (cfg.motor !== "sistema" && typeof VozNeural !== "undefined") {
|
|
203
|
+
return VozNeural.duracaoDe(texto, cfg).then(function (s) {
|
|
204
|
+
return s || medirPelaSintese(texto, cfg);
|
|
205
|
+
}).catch(function () { return medirPelaSintese(texto, cfg); });
|
|
206
|
+
}
|
|
207
|
+
return medirPelaSintese(texto, cfg);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function medirPelaSintese(texto, cfg) {
|
|
196
211
|
var c = {};
|
|
197
212
|
for (var k in (cfg || {})) c[k] = cfg[k];
|
|
198
213
|
c.volume = 0;
|
|
199
|
-
|
|
214
|
+
c.motor = "sistema";
|
|
215
|
+
return pelaSintese(texto, c);
|
|
200
216
|
}
|
|
201
217
|
|
|
202
218
|
function parar() {
|
|
@@ -1,204 +1,112 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
2
|
voz-neural.js — locução com voz de verdade.
|
|
3
3
|
|
|
4
|
-
── O
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
── NOTA HONESTA ────────────────────────────────────────────────────────
|
|
25
|
-
O caminho 1 foi escrito a partir do protocolo do edge-tts e a lista de
|
|
26
|
-
vozes foi confirmada no ar, mas a SÍNTESE não pôde ser testada de onde
|
|
27
|
-
este código foi escrito (o proxy de lá não passa WebSocket). O caminho 2
|
|
28
|
-
foi testado de ponta a ponta e devolveu MP3 real. Por isso a corrente
|
|
29
|
-
existe: se o 1 não subir na sua máquina, o 2 assume sem você notar.
|
|
4
|
+
── O QUE MUDOU, E POR QUÊ ──────────────────────────────────────────────
|
|
5
|
+
Este arquivo já tentou sintetizar no próprio navegador, falando por
|
|
6
|
+
WebSocket com o serviço de leitura em voz alta do Edge. Não funciona
|
|
7
|
+
mais: a Microsoft passou a responder 403 na síntese. A lista de vozes
|
|
8
|
+
ainda responde, o que enganava — parecia vivo e não estava.
|
|
9
|
+
|
|
10
|
+
Com o caminho neural morto, toda narração caía no speechSynthesis, o TTS
|
|
11
|
+
da própria máquina. Ele existe em todo computador, e é justamente por
|
|
12
|
+
isso que soa a computador. Era o que se ouvia em TODO vídeo.
|
|
13
|
+
|
|
14
|
+
Agora a síntese acontece no servidor do Studio, em Python (studio/voz.py),
|
|
15
|
+
com o Piper: rede neural que roda na máquina do usuário, offline, sem
|
|
16
|
+
chave, sem cota e sem limite — e com vozes graves de verdade, escolhidas
|
|
17
|
+
por frequência MEDIDA, não por adjetivo. Este arquivo virou o que devia
|
|
18
|
+
ter sido desde o começo: um cliente magro de /api/voz.
|
|
19
|
+
|
|
20
|
+
A vantagem não é só timbre. O servidor devolve um Blob, e Blob serve
|
|
21
|
+
tanto para tocar quanto para ir junto no vídeo exportado — coisa que o
|
|
22
|
+
speechSynthesis nunca permitiu, porque ele fala e some.
|
|
30
23
|
========================================================================== */
|
|
31
24
|
|
|
32
25
|
var VozNeural = (function () {
|
|
33
26
|
"use strict";
|
|
34
27
|
|
|
35
|
-
/*
|
|
36
|
-
|
|
37
|
-
var
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
28
|
+
/* Preenchido por /api/voz/status na primeira consulta. Começa com a voz
|
|
29
|
+
padrão do servidor para a interface ter o que mostrar antes da resposta. */
|
|
30
|
+
var VOZES = [];
|
|
31
|
+
var estado = null;
|
|
32
|
+
|
|
33
|
+
function status(forcar) {
|
|
34
|
+
if (estado && !forcar) return Promise.resolve(estado);
|
|
35
|
+
return fetch("/api/voz/status")
|
|
36
|
+
.then(function (r) { return r.json(); })
|
|
37
|
+
.then(function (s) {
|
|
38
|
+
estado = s;
|
|
39
|
+
VOZES = (s.piper && s.piper.vozes) || [];
|
|
40
|
+
return s;
|
|
41
|
+
})
|
|
42
|
+
.catch(function () { return { piper: { pronta: false, vozes: [] } }; });
|
|
43
|
+
}
|
|
51
44
|
|
|
52
45
|
function vozes() { return VOZES.slice(); }
|
|
53
|
-
function vozPadrao() { return VOZES[0].id; }
|
|
54
46
|
function graves() { return VOZES.filter(function (v) { return v.grave; }); }
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/* O serviço exige um token derivado do relógio, arredondado para baixo de
|
|
59
|
-
5 em 5 minutos, concatenado com a chave pública do cliente e passado por
|
|
60
|
-
SHA-256. Sem ele o servidor recusa a conexão. */
|
|
61
|
-
function selo() {
|
|
62
|
-
var ticks = Math.floor(Date.now() / 1000) + EPOCA_WIN;
|
|
63
|
-
ticks -= ticks % 300;
|
|
64
|
-
/* de segundos para intervalos de 100 nanossegundos */
|
|
65
|
-
var texto = String(ticks * 1e7) + TOKEN;
|
|
66
|
-
var bytes = new TextEncoder().encode(texto);
|
|
67
|
-
return crypto.subtle.digest("SHA-256", bytes).then(function (buf) {
|
|
68
|
-
return Array.prototype.map
|
|
69
|
-
.call(new Uint8Array(buf), function (b) { return ("0" + b.toString(16)).slice(-2); })
|
|
70
|
-
.join("")
|
|
71
|
-
.toUpperCase();
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function agoraISO() {
|
|
76
|
-
return new Date().toUTCString().replace("GMT", "GMT+0000 (Coordinated Universal Time)");
|
|
47
|
+
function vozPadrao() {
|
|
48
|
+
var p = VOZES.filter(function (v) { return v.padrao; })[0];
|
|
49
|
+
return p ? p.id : "";
|
|
77
50
|
}
|
|
78
51
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
52
|
+
/* Manda baixar o motor e a voz. A interface chama isto quando o usuário
|
|
53
|
+
escolhe uma voz que ainda não está na máquina. */
|
|
54
|
+
function instalar(voz, esperar) {
|
|
55
|
+
return fetch("/api/voz/instalar", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ voz: voz || "", esperar: esperar || 0 }),
|
|
59
|
+
}).then(function (r) { return r.json(); });
|
|
83
60
|
}
|
|
84
61
|
|
|
85
|
-
/*
|
|
62
|
+
/* --------------------------------------------------------------- fala --- */
|
|
86
63
|
|
|
87
|
-
/*
|
|
88
|
-
|
|
89
|
-
function
|
|
64
|
+
/* O texto vira áudio pelo melhor motor que o servidor tiver pronto. O
|
|
65
|
+
navegador não decide nada: só pede e toca. */
|
|
66
|
+
function audioDe(texto, cfg) {
|
|
90
67
|
cfg = cfg || {};
|
|
91
|
-
var
|
|
92
|
-
|
|
93
|
-
o que separa locução de leitura de robô. */
|
|
94
|
-
var vel = cfg.velocidade != null ? cfg.velocidade : -8;
|
|
95
|
-
var tom = cfg.tom != null ? cfg.tom : -4;
|
|
96
|
-
|
|
97
|
-
return selo().then(function (gec) {
|
|
98
|
-
return new Promise(function (ok, erro) {
|
|
99
|
-
var url = "wss://" + HOST + "/edge/v1?TrustedClientToken=" + TOKEN
|
|
100
|
-
+ "&Sec-MS-GEC=" + gec + "&Sec-MS-GEC-Version=" + VERSAO_GEC;
|
|
101
|
-
var ws;
|
|
102
|
-
try { ws = new WebSocket(url); } catch (e) { return erro(e); }
|
|
103
|
-
ws.binaryType = "arraybuffer";
|
|
104
|
-
|
|
105
|
-
var pedacos = [];
|
|
106
|
-
var fechou = false;
|
|
107
|
-
var relogio = setTimeout(function () {
|
|
108
|
-
if (!fechou) { fechou = true; try { ws.close(); } catch (e) {} erro(new Error("voz neural nao respondeu")); }
|
|
109
|
-
}, 6000);
|
|
110
|
-
|
|
111
|
-
function desistir(e) {
|
|
112
|
-
if (fechou) return;
|
|
113
|
-
fechou = true;
|
|
114
|
-
clearTimeout(relogio);
|
|
115
|
-
try { ws.close(); } catch (x) {}
|
|
116
|
-
erro(e instanceof Error ? e : new Error("voz neural falhou"));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
ws.onerror = desistir;
|
|
120
|
-
ws.onclose = function () {
|
|
121
|
-
if (fechou) return;
|
|
122
|
-
fechou = true;
|
|
123
|
-
clearTimeout(relogio);
|
|
124
|
-
if (pedacos.length) ok(new Blob(pedacos, { type: "audio/mpeg" }));
|
|
125
|
-
else erro(new Error("voz neural fechou sem audio"));
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
ws.onopen = function () {
|
|
129
|
-
ws.send(
|
|
130
|
-
"X-Timestamp:" + agoraISO() + "\r\n"
|
|
131
|
-
+ "Content-Type:application/json; charset=utf-8\r\n"
|
|
132
|
-
+ "Path:speech.config\r\n\r\n"
|
|
133
|
-
+ '{"context":{"synthesis":{"audio":{"metadataoptions":'
|
|
134
|
-
+ '{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},'
|
|
135
|
-
+ '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}'
|
|
136
|
-
);
|
|
137
|
-
var ssml = '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="pt-BR">'
|
|
138
|
-
+ '<voice name="' + voz + '">'
|
|
139
|
-
+ '<prosody pitch="' + tom + 'Hz" rate="' + vel + '%" volume="+0%">'
|
|
140
|
-
+ escapar(texto)
|
|
141
|
-
+ "</prosody></voice></speak>";
|
|
142
|
-
ws.send(
|
|
143
|
-
"X-RequestId:" + pedido() + "\r\n"
|
|
144
|
-
+ "Content-Type:application/ssml+xml\r\n"
|
|
145
|
-
+ "X-Timestamp:" + agoraISO() + "Z\r\n"
|
|
146
|
-
+ "Path:ssml\r\n\r\n" + ssml
|
|
147
|
-
);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
ws.onmessage = function (ev) {
|
|
151
|
-
if (typeof ev.data === "string") {
|
|
152
|
-
if (ev.data.indexOf("Path:turn.end") !== -1) {
|
|
153
|
-
fechou = true;
|
|
154
|
-
clearTimeout(relogio);
|
|
155
|
-
try { ws.close(); } catch (x) {}
|
|
156
|
-
if (pedacos.length) ok(new Blob(pedacos, { type: "audio/mpeg" }));
|
|
157
|
-
else erro(new Error("voz neural nao mandou audio"));
|
|
158
|
-
}
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
/* Binário: 2 bytes de tamanho do cabeçalho, o cabeçalho, e o áudio. */
|
|
162
|
-
var b = new Uint8Array(ev.data);
|
|
163
|
-
if (b.length < 2) return;
|
|
164
|
-
var tamCabecalho = (b[0] << 8) | b[1];
|
|
165
|
-
if (b.length > 2 + tamCabecalho) pedacos.push(b.slice(2 + tamCabecalho));
|
|
166
|
-
};
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function pedido() {
|
|
172
|
-
var s = "";
|
|
173
|
-
for (var i = 0; i < 32; i++) s += "0123456789abcdef"[Math.floor(Math.random() * 16)];
|
|
174
|
-
return s;
|
|
175
|
-
}
|
|
68
|
+
var t = String(texto || "").trim();
|
|
69
|
+
if (!t) return Promise.reject(new Error("sem texto"));
|
|
176
70
|
|
|
177
|
-
|
|
71
|
+
var q = "texto=" + encodeURIComponent(t);
|
|
72
|
+
if (cfg.voz) q += "&voz=" + encodeURIComponent(cfg.voz);
|
|
73
|
+
if (cfg.motor) q += "&motor=" + encodeURIComponent(cfg.motor);
|
|
74
|
+
if (cfg.grave) q += "&grave=1";
|
|
75
|
+
if (cfg.velocidade) q += "&velocidade=" + encodeURIComponent(cfg.velocidade);
|
|
178
76
|
|
|
179
|
-
|
|
180
|
-
navegador direto o pedido morre. Uma voz só, sem escolha — mas responde. */
|
|
181
|
-
function pelaGoogle(texto) {
|
|
182
|
-
return fetch("/api/voz?texto=" + encodeURIComponent(texto)).then(function (r) {
|
|
77
|
+
return fetch("/api/voz?" + q).then(function (r) {
|
|
183
78
|
if (!r.ok) throw new Error("voz do servidor falhou (" + r.status + ")");
|
|
184
79
|
return r.blob();
|
|
185
80
|
});
|
|
186
81
|
}
|
|
187
82
|
|
|
188
|
-
/*
|
|
189
|
-
|
|
190
|
-
/* O texto vira áudio pelo melhor caminho que responder. Devolve um Blob de
|
|
191
|
-
MP3, que serve tanto para tocar quanto para ir junto no vídeo exportado —
|
|
192
|
-
coisa que o speechSynthesis nunca permitiu, porque ele fala e some. */
|
|
193
|
-
function audioDe(texto, cfg) {
|
|
194
|
-
cfg = cfg || {};
|
|
195
|
-
var t = String(texto || "").trim();
|
|
196
|
-
if (!t) return Promise.reject(new Error("sem texto"));
|
|
83
|
+
/* Quanto tempo a narração vai levar, SEM tocar nada.
|
|
197
84
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
85
|
+
O editor precisa disso para ajustar a duração da cena ao texto. Antes
|
|
86
|
+
ele media falando com volume zero — funcionava porque o speechSynthesis
|
|
87
|
+
obedece volume. Agora o áudio vem em Blob, e "tocar mudo" seria tocar
|
|
88
|
+
alto. Os metadados do arquivo já trazem a duração exata: é só perguntar,
|
|
89
|
+
e é mais preciso do que cronometrar a fala. */
|
|
90
|
+
function duracaoDe(texto, cfg) {
|
|
91
|
+
return audioDe(texto, cfg).then(function (blob) {
|
|
92
|
+
return new Promise(function (ok) {
|
|
93
|
+
var a = new Audio();
|
|
94
|
+
var url = URL.createObjectURL(blob);
|
|
95
|
+
var respondeu = false;
|
|
96
|
+
function fim(s) {
|
|
97
|
+
if (respondeu) return;
|
|
98
|
+
respondeu = true;
|
|
99
|
+
URL.revokeObjectURL(url);
|
|
100
|
+
ok(s);
|
|
101
|
+
}
|
|
102
|
+
a.onloadedmetadata = function () {
|
|
103
|
+
fim(isFinite(a.duration) && a.duration > 0 ? a.duration : 0);
|
|
104
|
+
};
|
|
105
|
+
a.onerror = function () { fim(0); };
|
|
106
|
+
setTimeout(function () { fim(0); }, 8000);
|
|
107
|
+
a.preload = "metadata";
|
|
108
|
+
a.src = url;
|
|
109
|
+
});
|
|
202
110
|
});
|
|
203
111
|
}
|
|
204
112
|
|
|
@@ -232,10 +140,12 @@ var VozNeural = (function () {
|
|
|
232
140
|
.catch(function (e) { return { ok: false, error: e.message }; });
|
|
233
141
|
}
|
|
234
142
|
|
|
143
|
+
status();
|
|
144
|
+
|
|
235
145
|
return {
|
|
236
|
-
vozes: vozes, vozPadrao: vozPadrao, graves: graves,
|
|
146
|
+
vozes: vozes, vozPadrao: vozPadrao, graves: graves, status: status,
|
|
147
|
+
instalar: instalar, duracaoDe: duracaoDe,
|
|
237
148
|
audioDe: audioDe, falar: falar, parar: parar, falando: falando, testar: testar,
|
|
238
|
-
_selo: selo
|
|
239
149
|
};
|
|
240
150
|
})();
|
|
241
151
|
|