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 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
- const COPIAVEIS = new Set(['server.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
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
- return { ok: true, ...r };
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.11.2",
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"
@@ -138,12 +138,28 @@ na hora de tocar — sem gravar nada, sem arquivo:
138
138
  "elementos": [ ... ] }
139
139
  ```
140
140
 
141
- Ajuste da voz no **documento** (opcional):
141
+ Escolha da voz no **documento** (opcional):
142
142
 
143
143
  ```json
144
- "voz": { "velocidade": 1, "tom": 1, "volume": 1, "lang": "pt-BR" }
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 próprio arquivo, lido NA SUBIDA. Se o npm trocar o código no
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
- with open(os.path.abspath(__file__), "rb") as fh:
41
- return hashlib.sha1(fh.read()).hexdigest()[:12]
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
- # Ponte para o TTS do Google. Existe porque o Google não manda cabeçalho
746
- # de CORS: do navegador direto o pedido morre, daqui funciona.
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
- # É a rede de segurança da narração. O caminho bom é a voz neural do Edge,
749
- # que fala direto do navegador por WebSocket (sem CORS) e tem voz grave de
750
- # verdade para vídeo; quando ela não sobe, cai aqui — uma voz só, sem
751
- # escolha, mas que responde sempre.
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 pedaços de
754
- # até 190 caracteres, sempre numa fronteira de palavra, e os MP3 são
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
- voz = (q.get("voz", [""])[0] or "").strip()
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
- # 1) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
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, voz, instrucao)
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
- # 2) Google: uma voz só, mas responde sempre.
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
- 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