primocode 8.11.1 → 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/catalogo.js +3 -1
- package/lib/studio.js +115 -7
- package/lib/tools.js +2 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/ferramentas/00-protocolo.md +15 -2
- package/studio/ferramentas/01-modelos.md +2 -0
- package/studio/ferramentas/corte.md +18 -2
- package/studio/server.py +314 -23
- package/studio/voz.py +307 -0
- package/studio/web/js/narracao.js +18 -2
- package/studio/web/js/voz-neural.js +88 -178
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
|
|