primocode 8.11.2 → 8.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/studio.js 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.13.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -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
@@ -169,6 +185,33 @@ Na cena, `transicao` define como ela **entra**:
169
185
 
170
186
  Vale o mesmo conselho das animações: varie. Tudo em `fade` parece slide, não vídeo.
171
187
 
188
+ ## Chaves de animação
189
+
190
+ `anim` faz o elemento **entrar** e parar por ali. Para ele se mexer ao longo da
191
+ cena — atravessar, crescer devagar, sumir no fim — use `chaves`: uma lista de
192
+ instantes, com `t` de 0 (começo da cena) a 1 (fim).
193
+
194
+ ```json
195
+ { "tipo": "texto", "texto": "Atravessa a tela",
196
+ "chaves": [
197
+ { "t": 0, "x": -400, "opacidade": 0 },
198
+ { "t": 0.25, "x": 0, "opacidade": 1 },
199
+ { "t": 0.75, "x": 0, "opacidade": 1 },
200
+ { "t": 1, "x": 400, "opacidade": 0 }
201
+ ] }
202
+ ```
203
+
204
+ Campos de uma chave, todos opcionais: `x` e `y` (**deslocamento** em pixels a
205
+ partir de onde o elemento está — não é posição absoluta), `escala` (1 = tamanho
206
+ normal), `giro` (graus), `opacidade` (0 a 1) e `desfoque` (pixels).
207
+
208
+ No elemento: `"repetir": true` roda sem parar, e `"suavizar": false` dá
209
+ movimento constante em vez de acelerar e frear.
210
+
211
+ Duas chaves já bastam. Quem tem `chaves` ignora `anim` — as duas disputariam o
212
+ mesmo movimento, e a chave é mais específica. Use em **um ou dois** elementos
213
+ por vídeo: tudo se mexendo ao mesmo tempo cansa e esconde a mensagem.
214
+
172
215
  ## Acabamento
173
216
 
174
217
  Textura por cima da cena inteira — é o que tira a cara de template:
package/studio/server.py CHANGED
@@ -25,6 +25,37 @@ from urllib.parse import urlparse, unquote, urlencode
25
25
  import urllib.request
26
26
 
27
27
  RAIZ = os.path.dirname(os.path.abspath(__file__))
28
+ sys.path.insert(0, RAIZ)
29
+ try:
30
+ import voz as voz_lib # noqa: E402 (precisa da RAIZ no path antes)
31
+ except Exception as _e: # instalação parcial, ou voz.py de uma versão velha
32
+ # Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
33
+ # Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
34
+ sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)
35
+
36
+ class _SemVoz:
37
+ @staticmethod
38
+ def pronta(*a, **k):
39
+ return False
40
+
41
+ @staticmethod
42
+ def sintetizar(*a, **k):
43
+ return None
44
+
45
+ @staticmethod
46
+ def instalar(*a, **k):
47
+ return {"pronta": False, "erro": "módulo de voz indisponível"}
48
+
49
+ @staticmethod
50
+ def estado():
51
+ return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}
52
+
53
+ @staticmethod
54
+ def apagar():
55
+ return {"ok": True}
56
+
57
+ voz_lib = _SemVoz()
58
+
28
59
  WEB = os.path.join(RAIZ, "web")
29
60
  ARQUIVOS = os.path.join(RAIZ, "arquivos")
30
61
  FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
@@ -32,13 +63,21 @@ MIDIA = os.path.join(RAIZ, "midia")
32
63
  PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
33
64
 
34
65
  def _assinatura():
35
- """SHA-1 do 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}