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/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
- return falar(texto, c);
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 PROBLEMA ──────────────────────────────────────────────────────────
5
- A narração usava speechSynthesis, o TTS da própria máquina. Ele existe em
6
- todo computador, e é justamente esse o problema: soa a computador. Para
7
- vídeo institucional não serve — e o dono pediu voz densa, cinematográfica,
8
- com possibilidade de ESCOLHER.
9
-
10
- ── OS TRÊS CAMINHOS, DO MELHOR PARA O PIOR ─────────────────────────────
11
- 1. NEURAL (Edge) vozes neurais da Microsoft. Grátis, sem chave, sem
12
- limite prático, e as pt-BR são boas de verdade:
13
- Antonio (masculina, grave — a de vídeo), Francisca
14
- (feminina) e Thalita (multilíngue).
15
- Fala por WebSocket, que não passa por CORS.
16
- 2. GOOGLE (proxy) uma voz só, sem escolha, mas responde sempre. Vai pelo
17
- servidor do Studio porque o Google não manda cabeçalho
18
- de CORS — do navegador direto seria bloqueado.
19
- 3. SISTEMA o speechSynthesis de antes. Rascunho, e nada mais.
20
-
21
- Cada caminho cai no seguinte quando falha. Narração é acabamento: se a voz
22
- não vier, o vídeo continua existindo, mudo.
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
- /* Constantes do serviço de leitura em voz alta do Edge. */
36
- var TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
37
- var HOST = "speech.platform.bing.com/consumer/speech/synthesize/readaloud";
38
- var VERSAO_GEC = "1-130.0.2849.68";
39
- /* 1601-01-01 → 1970-01-01 em segundos: o relógio do Windows começa antes. */
40
- var EPOCA_WIN = 11644473600;
41
-
42
- /* As vozes que valem para narrar em português. `grave` é o que o dono
43
- chamou de densa: a que sustenta abertura de vídeo. */
44
- var VOZES = [
45
- { id: "pt-BR-AntonioNeural", nome: "Antônio", tom: "masculina, grave", grave: true, padrao: true },
46
- { id: "pt-BR-FranciscaNeural", nome: "Francisca", tom: "feminina, calorosa" },
47
- { id: "pt-BR-ThalitaMultilingualNeural", nome: "Thalita", tom: "feminina, moderna" },
48
- { id: "pt-PT-DuarteNeural", nome: "Duarte", tom: "masculina de Portugal", grave: true },
49
- { id: "pt-PT-RaquelNeural", nome: "Raquel", tom: "feminina de Portugal" }
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
- /* ---------------------------------------------------------------- DRM --- */
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
- function escapar(t) {
80
- return String(t || "")
81
- .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
82
- .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
- /* -------------------------------------------------------------- neural -- */
62
+ /* --------------------------------------------------------------- fala --- */
86
63
 
87
- /* Uma frase → um Blob de MP3. Abre a conexão, manda a configuração e o
88
- SSML, junta os pedaços binários até o "turn.end". */
89
- function pelaNeural(texto, cfg) {
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 voz = cfg.voz || vozPadrao();
92
- /* Voz de narração pede um passo mais lento e um tom um pouco abaixo — é
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
- /* -------------------------------------------------------------- google -- */
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
- /* Pelo servidor do Studio: o Google não manda cabeçalho de CORS, então do
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
- /* --------------------------------------------------------------- fala --- */
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
- if (cfg.motor === "google") return pelaGoogle(t);
199
- return pelaNeural(t, cfg).catch(function (e) {
200
- if (window.console) console.warn("[voz] neural falhou, indo de Google:", e.message);
201
- return pelaGoogle(t);
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