primocode 8.18.1 → 8.20.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.
Files changed (30) hide show
  1. package/bin/primocode.js +5 -2
  2. package/lib/act.js +25 -3
  3. package/package.json +1 -1
  4. package/studio/ferramentas/corte.md +46 -5
  5. package/studio/server.py +89 -2
  6. package/studio/voz.py +92 -11
  7. package/studio/web/js/audio.js +35 -2
  8. package/studio/web/js/player.js +98 -11
  9. package/studio/web/js/sfx.js +107 -0
  10. package/studio/web/tool.html +1 -0
  11. package/studio/web/view.html +1 -0
  12. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  13. package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
  14. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
  15. package/studio/arquivos/corte-legado-teste.json +0 -1
  16. package/studio/arquivos/corte-legado2.json +0 -1
  17. package/studio/arquivos/corte-minimo-2a829.json +0 -24
  18. package/studio/arquivos/corte-ok-00a3b.json +0 -18
  19. package/studio/arquivos/corte-ok-1270e.json +0 -22
  20. package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
  21. package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
  22. package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
  23. package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
  24. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
  25. package/studio/arquivos/prisma-y-b0785.json +0 -52
  26. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
  27. package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
  28. package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
  29. package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
  30. package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
package/bin/primocode.js CHANGED
@@ -522,8 +522,11 @@ const RE_DESCOBRIR = /\b(pesquis|busca|procur|descubr|investig|analis|leia|estud
522
522
  const RE_CRIAR_DEPOIS = /\b(cri[ae]|faz|faça|mont[ae]|ger[ae]|escrev[ae]|produz|desenvolv)\w*/i;
523
523
 
524
524
  /** "baixa/exporta o vídeo" resolvido na hora, sem gastar modelo. */
525
- const RE_BAIXAR = /\b(baixa\w*|exporta\w*|download|salva\w*)\b/i;
526
- const RE_PECA_STUDIO = /\b(v[ií]deo|mp4|mp3|apresenta[çc][ãa]o|slides?|reels?)\b/i;
525
+ // "me manda o vídeo", "quero pra mandar no zap", "gera o arquivo" — a lista
526
+ // era curta e o pedido sai de muitos jeitos. Fora dela, o pedido virava
527
+ // tarefa de agente: gasto de API para abrir uma URL que o CLI já sabe.
528
+ const RE_BAIXAR = /\b(baixa\w*|exporta\w*|download|salva\w*|grava\w*|me manda|manda(r)? (o|a|esse|essa)|gera(r)? o arquivo|em mp4|pra mandar|arquivo do)\b/i;
529
+ const RE_PECA_STUDIO = /\b(v[ií]deo|mp4|mp3|apresenta[çc][ãa]o|slides?|reels?|anima[çc][ãa]o|filme)\b/i;
527
530
 
528
531
  async function baixarCriacaoDireto(texto) {
529
532
  if (!(RE_BAIXAR.test(texto) && RE_PECA_STUDIO.test(texto))) return false;
package/lib/act.js CHANGED
@@ -529,6 +529,26 @@ async function runAct(userPrompt, opts) {
529
529
 
530
530
  let finished = null;
531
531
  let algoRodou = false;
532
+
533
+ // ORQUESTRAÇÃO: subagentes da MESMA rodada rodam juntos. O modelo que
534
+ // pede "3 agentes, um por arquivo" esperava paralelismo e recebia uma
535
+ // fila — cada subagente só começava quando o anterior terminava, e a
536
+ // rodada custava a soma dos três. Agora as chamadas spawn_agent da
537
+ // rodada disparam de uma vez (Promise.all) e cada resultado volta na
538
+ // posição certa da conversa. Limite de 4 por rodada: acima disso é
539
+ // sinal de decomposição ruim, e o excedente roda em série mesmo.
540
+ const spawns = toolCalls.filter((tc) => tc.name === 'spawn_agent');
541
+ const resultadosSpawn = new Map();
542
+ if (spawns.length > 1 && !PARAR) {
543
+ const juntos = spawns.slice(0, 4);
544
+ console.log(ident(profundidade) + ` ${c.magenta('◆')} ${c.bold(c.magenta('Agentes'))} ${c.muted(`${juntos.length} em paralelo`)}`);
545
+ const rs = await Promise.all(juntos.map((tc) =>
546
+ runSubagent(tc.arguments || {}, {
547
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
548
+ }).catch((e) => `Subagente falhou: ${e && e.message}`)));
549
+ juntos.forEach((tc, i) => resultadosSpawn.set(tc.id, rs[i]));
550
+ }
551
+
532
552
  for (const tc of toolCalls) {
533
553
  if (PARAR) break;
534
554
  if (tc.name === 'finish') {
@@ -552,9 +572,11 @@ async function runAct(userPrompt, opts) {
552
572
 
553
573
  if (tc.name === 'spawn_agent') {
554
574
  algoRodou = true;
555
- const r = await runSubagent(tc.arguments || {}, {
556
- server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
557
- });
575
+ const r = resultadosSpawn.has(tc.id)
576
+ ? resultadosSpawn.get(tc.id)
577
+ : await runSubagent(tc.arguments || {}, {
578
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
579
+ });
558
580
  messages.push({ role: 'tool', tool_call_id: tc.id, content: r });
559
581
  continue;
560
582
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.18.1",
3
+ "version": "8.20.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": {
@@ -1,7 +1,7 @@
1
1
  # CORTE — vídeo
2
2
 
3
3
  No lugar de CapCut, Premiere e After Effects. Modelo `cena`, com `duracao` em cada
4
- cena, trilha sonora, transições e gravação em `.webm`.
4
+ cena, trilha sonora, transições e download em `.mp4`.
5
5
 
6
6
  <!-- O EXEMPLO FICA NO COMEÇO DE PROPÓSITO: quando o plano de tokens é
7
7
  apertado, o agente recebe só os primeiros ~3500 caracteres desta spec. Um
@@ -174,6 +174,33 @@ Regras que fazem diferença:
174
174
  O usuário também pode gravar a própria voz pelo editor; aí a cena ganha
175
175
  `narracaoAudio` com o arquivo, e ela tem prioridade sobre a voz sintetizada.
176
176
 
177
+ **Sincronia é automática.** Quando o estúdio gera a narração em arquivo, ele
178
+ mede o áudio e grava `narracaoDur` na cena; a cena então dura o que for maior —
179
+ a `duracao` pedida ou a fala inteira mais um respiro. Não calcule isso à mão,
180
+ e não apague `narracaoDur` ao ajustar um documento.
181
+
182
+ **Mixagem é automática.** A música abaixa sozinha enquanto a voz fala (ducking)
183
+ e volta no intervalo entre as falas. Não compense baixando o `volume` da trilha:
184
+ deixe em 0.6–0.8 e o duck cuida do resto.
185
+
186
+ ---
187
+
188
+ ## Efeitos sonoros
189
+
190
+ Cada troca de cena ganha um efeito sintetizado na hora, sem arquivo e sem
191
+ licença: transição `flash` e `zoom` soam um **impacto** grave; as demais, um
192
+ **whoosh** (variado, para não repetir); `corte` fica em silêncio.
193
+
194
+ Para controlar:
195
+
196
+ ```json
197
+ "sfx": false // no documento: desliga todos os efeitos
198
+ { "transicao": "deslizar", "sfx": "impacto" } // na cena: força um efeito
199
+ ```
200
+
201
+ Valores por cena: `"whoosh"` · `"impacto"` · `"click"` · `"nenhum"`.
202
+ O padrão (sem declarar nada) já é o certo para a maioria dos vídeos.
203
+
177
204
  ---
178
205
 
179
206
  ## Transições
@@ -269,11 +296,25 @@ play/pausa, linha do tempo clicável com marca por cena, tempo, volume e **Grava
269
296
 
270
297
  `K` toca e pausa · `M` corta o som · `→` `←` pulam cena · `F` tela cheia · `Escape` para.
271
298
 
272
- ## Virar arquivo de vídeo
299
+ ## Baixar o vídeo como arquivo
300
+
301
+ O botão **Baixar MP4** no player faz tudo sozinho: pede a permissão de captura, toca
302
+ do começo, para no fim e baixa. Sai `.mp4` (H.264/AAC, que toca em qualquer lugar) nos
303
+ navegadores que sabem gravar MP4; onde não sabem, sai `.webm`.
304
+
305
+ **Se o usuário pedir o arquivo do vídeo** — "baixa", "exporta", "me manda em mp4",
306
+ "quero mandar no WhatsApp" — abra o player já em modo de gravação:
307
+
308
+ ```
309
+ open_in_browser → http://localhost:7777/v/<id>#gravar
310
+ ```
311
+
312
+ A âncora `#gravar` dispara o download assim que a página abre; o usuário só confirma a
313
+ permissão de captura. **Não** use `write_file` para criar `.mp4`, e **nunca** chame
314
+ ffmpeg: o vídeo já nasce pronto aqui, e escrever um `.mp4` com write_file produz um
315
+ arquivo de texto com nome de vídeo — já aconteceu.
273
316
 
274
- O botão **Gravar** usa a captura de tela do navegador: escolha a aba, o vídeo toca do
275
- começo, para sozinho no fim e baixa um `.webm` com a trilha junto. Para o melhor
276
- resultado, entre em tela cheia (`F`) antes de gravar.
317
+ Para o melhor resultado, entre em tela cheia (`F`) antes de gravar.
277
318
 
278
319
  O export normal (`/api/arquivos/<id>/export`) devolve um `.html` autônomo com a
279
320
  mídia embutida — abre offline em qualquer navegador e toca igual.
package/studio/server.py CHANGED
@@ -20,6 +20,7 @@ import threading
20
20
  import time
21
21
  import unicodedata
22
22
  import uuid
23
+ import wave
23
24
  from datetime import datetime, timezone
24
25
  from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
25
26
  from urllib.parse import urlparse, unquote, urlencode
@@ -184,11 +185,30 @@ def ler(arq_id):
184
185
 
185
186
  def gravar(arq):
186
187
  arq["atualizado"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
187
- with open(caminho(arq["id"]), "w", encoding="utf-8") as fh:
188
+ destino = caminho(arq["id"])
189
+ # Escrita atômica: o servidor é multi-thread (autosave do editor a cada
190
+ # 600 ms + a thread de narração gravando o mesmo doc). Escrever direto
191
+ # podia deixar um JSON pela metade se duas gravações se cruzassem ou o
192
+ # processo caísse no meio. Com tmp + replace, ou o arquivo antigo está
193
+ # inteiro, ou o novo está inteiro — nunca metade.
194
+ tmp = destino + ".tmp"
195
+ with open(tmp, "w", encoding="utf-8") as fh:
188
196
  json.dump(arq, fh, ensure_ascii=False, indent=2)
197
+ os.replace(tmp, destino)
189
198
  return arq
190
199
 
191
200
 
201
+ def _duracao_wav(dados):
202
+ """Segundos de um WAV em bytes; None se não der para medir."""
203
+ try:
204
+ import io
205
+ with wave.open(io.BytesIO(dados), "rb") as w:
206
+ fr = w.getframerate()
207
+ return (w.getnframes() / fr) if fr else None
208
+ except Exception:
209
+ return None
210
+
211
+
192
212
  # ------------------------------------------------------------------ versões --
193
213
  #
194
214
  # Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
@@ -335,6 +355,11 @@ def _gravar_narracao(arq_id):
335
355
  for _ in range(120):
336
356
  if voz_lib.pronta():
337
357
  break
358
+ # Máquina onde o Piper nunca vai rodar (binário de outra
359
+ # arquitetura): esperar 10 minutos aqui era esperar por nada.
360
+ if voz_lib.bloqueado():
361
+ sys.stderr.write("[voz] motor neural bloqueado: %s\n" % voz_lib.bloqueado())
362
+ return
338
363
  voz_lib.instalar()
339
364
  time.sleep(5)
340
365
  if not voz_lib.pronta():
@@ -376,6 +401,14 @@ def _gravar_narracao(arq_id):
376
401
  fh.write(audio)
377
402
  c["narracaoAudio"] = "/midia/" + nome
378
403
  c["narracaoVoz"] = assinatura
404
+ # A duração medida do WAV vai junto: com ela o player sabe quanto
405
+ # a cena REALMENTE dura (a fala inteira + respiro) e a linha do
406
+ # tempo vira determinística — sem ela, o relógio congela na
407
+ # beirada da cena esperando a voz, e a duração do vídeo muda a
408
+ # cada reprodução.
409
+ dur = _duracao_wav(audio)
410
+ if dur:
411
+ c["narracaoDur"] = round(dur, 2)
379
412
  mudou = True
380
413
 
381
414
  if mudou:
@@ -389,6 +422,8 @@ def _gravar_narracao(arq_id):
389
422
  and cenas[i].get("narracao") == c.get("narracao"):
390
423
  cenas[i]["narracaoAudio"] = c["narracaoAudio"]
391
424
  cenas[i]["narracaoVoz"] = c["narracaoVoz"]
425
+ if c.get("narracaoDur"):
426
+ cenas[i]["narracaoDur"] = c["narracaoDur"]
392
427
  gravar(atual)
393
428
  except Exception as e:
394
429
  sys.stderr.write("[voz] narração em arquivo falhou: %s\n" % e)
@@ -1577,7 +1612,7 @@ def html_autonomo(a):
1577
1612
  return fh.read()
1578
1613
 
1579
1614
  js = "\n".join(leia("js", n) for n in
1580
- ("icones.js", "audio.js", "render.js", "render-prosa.js", "render-grade.js"))
1615
+ ("icones.js", "audio.js", "sfx.js", "render.js", "render-prosa.js", "render-grade.js"))
1581
1616
 
1582
1617
  dados = json.dumps(a, ensure_ascii=False).replace("</script", "<\\/script")
1583
1618
 
@@ -1603,7 +1638,50 @@ def html_autonomo(a):
1603
1638
 
1604
1639
  # -------------------------------------------------------------------- main --
1605
1640
 
1641
+ def _abrir_log():
1642
+ """Diagnóstico que sobrevive ao `stdio: 'ignore'` do CLI.
1643
+
1644
+ O CLI sobe este servidor descartando stdout/stderr — e era exatamente
1645
+ para lá que iam os erros de voz e o traceback de porta ocupada. Ninguém
1646
+ via nada: o Studio "não subia" em silêncio. Espelhar stdout/stderr num
1647
+ arquivo ao lado do código dá onde olhar. Rotação simples: passou de
1648
+ 2 MB, o log atual vira `.old` e começa outro.
1649
+ """
1650
+ try:
1651
+ destino = os.path.join(RAIZ, "studio.log")
1652
+ if os.path.exists(destino) and os.path.getsize(destino) > 2 * 1024 * 1024:
1653
+ os.replace(destino, destino + ".old")
1654
+ fh = open(destino, "a", encoding="utf-8", buffering=1)
1655
+
1656
+ class _Tee:
1657
+ def __init__(self, original):
1658
+ self.original = original
1659
+ def write(self, s):
1660
+ try:
1661
+ self.original.write(s)
1662
+ except Exception:
1663
+ pass
1664
+ try:
1665
+ fh.write(s)
1666
+ except Exception:
1667
+ pass
1668
+ def flush(self):
1669
+ for alvo in (self.original, fh):
1670
+ try:
1671
+ alvo.flush()
1672
+ except Exception:
1673
+ pass
1674
+
1675
+ sys.stdout = _Tee(sys.stdout)
1676
+ sys.stderr = _Tee(sys.stderr)
1677
+ print("[studio] %s — iniciando na porta %d" %
1678
+ (datetime.now(timezone.utc).isoformat(timespec="seconds"), PORTA))
1679
+ except Exception:
1680
+ pass # sem log ainda é melhor que sem servidor
1681
+
1682
+
1606
1683
  if __name__ == "__main__":
1684
+ _abrir_log()
1607
1685
  url = "http://localhost:%d" % PORTA
1608
1686
  print("""
1609
1687
  ██ PrimoCode Studio
@@ -1619,3 +1697,12 @@ if __name__ == "__main__":
1619
1697
  ThreadingHTTPServer(("127.0.0.1", PORTA), Handler).serve_forever()
1620
1698
  except KeyboardInterrupt:
1621
1699
  print("\n Encerrado.\n")
1700
+ except OSError as e:
1701
+ # Porta ocupada saía como traceback — e com stdio descartado pelo
1702
+ # CLI, saía para lugar nenhum. Mensagem clara + código de saída
1703
+ # próprio: quem supervisiona sabe o que houve sem ler stack.
1704
+ if getattr(e, "errno", None) in (48, 98, 10048): # mac, linux, windows
1705
+ print("\n A porta %d já está em uso — outro Studio no ar?\n"
1706
+ " Feche-o (POST /api/sair) ou use PRIMOCODE_STUDIO_PORT.\n" % PORTA)
1707
+ sys.exit(2)
1708
+ raise
package/studio/voz.py CHANGED
@@ -117,8 +117,66 @@ def instaladas():
117
117
  return [v["id"] for v in CATALOGO if modelo(v["id"])]
118
118
 
119
119
 
120
+ # O binário EXISTIR não prova que ele RODA. O pacote macos_aarch64 do Piper
121
+ # 2023.11.14-2 veio com executável x86_64 dentro — num Mac Apple Silicon sem
122
+ # Rosetta, spawná-lo morre com "Bad CPU type" (errno 86). Sem esta checagem,
123
+ # `pronta()` dizia sim, o servidor esperava a voz que nunca viria, e cada
124
+ # pedido de narração travava a corrente inteira de motores por minutos.
125
+ # A prova roda UMA vez e o veredito fica em arquivo: reiniciar não re-testa,
126
+ # e reinstalar (apagar()) limpa o veredito junto.
127
+ _MARCA_INCOMPATIVEL = os.path.join(CASA, "binario-incompativel")
128
+ _roda_cache = None
129
+
130
+
131
+ def marcar_incompativel(motivo):
132
+ global _roda_cache
133
+ _roda_cache = False
134
+ try:
135
+ os.makedirs(CASA, exist_ok=True)
136
+ with open(_MARCA_INCOMPATIVEL, "w") as fh:
137
+ fh.write(str(motivo))
138
+ except Exception:
139
+ pass
140
+
141
+
142
+ def bloqueado():
143
+ """Por que o Piper nunca vai funcionar nesta máquina — ou None."""
144
+ try:
145
+ if os.path.exists(_MARCA_INCOMPATIVEL):
146
+ with open(_MARCA_INCOMPATIVEL) as fh:
147
+ return fh.read().strip() or "binário incompatível com esta máquina"
148
+ except Exception:
149
+ pass
150
+ return None
151
+
152
+
153
+ def _binario_roda():
154
+ global _roda_cache
155
+ if _roda_cache is not None:
156
+ return _roda_cache
157
+ if bloqueado():
158
+ _roda_cache = False
159
+ return False
160
+ exe = binario()
161
+ if not exe:
162
+ return None # ainda não baixou: sem veredito
163
+ try:
164
+ subprocess.run([exe, "--version"], stdout=subprocess.DEVNULL,
165
+ stderr=subprocess.DEVNULL, timeout=10)
166
+ _roda_cache = True
167
+ except OSError as e:
168
+ if getattr(e, "errno", None) == 86: # Bad CPU type (macOS)
169
+ marcar_incompativel("o pacote do Piper veio com binário de outra "
170
+ "arquitetura (x86_64 num Mac ARM sem Rosetta)")
171
+ else:
172
+ marcar_incompativel("o binário do Piper não executa: %s" % e)
173
+ except Exception:
174
+ _roda_cache = True # demorou mas executa: não é errno 86
175
+ return _roda_cache
176
+
177
+
120
178
  def pronta(voz_id=None):
121
- return bool(binario() and modelo(voz_id or PADRAO))
179
+ return bool(binario() and modelo(voz_id or PADRAO) and _binario_roda())
122
180
 
123
181
 
124
182
  # ------------------------------------------------------------------ baixar --
@@ -209,6 +267,11 @@ def instalar(voz_id=None, esperar=0):
209
267
  que faz ela estar pronta na hora de tocar."""
210
268
  global _thread
211
269
  voz_id = voz_id if voz_id in POR_ID else PADRAO
270
+ # Baixar de novo não conserta binário de arquitetura errada: viria o
271
+ # mesmo pacote. Sem esta guarda, cada pedido de narração re-disparava
272
+ # os ~90 MB de download para nada.
273
+ if bloqueado():
274
+ return estado()
212
275
  with _tranca:
213
276
  if _thread is None and not pronta(voz_id):
214
277
  _baixando.update(ativo=True, voz=voz_id, pct=0, etapa="começando", erro=None)
@@ -227,7 +290,11 @@ def estado():
227
290
  "motor": "piper",
228
291
  "pronta": pronta(),
229
292
  "instaladas": instaladas(),
230
- "suportado": bool(_versao_pacote()),
293
+ # "suportado" responde à pergunta de verdade: o Piper VAI funcionar
294
+ # aqui? Pacote inexistente para a plataforma e binário que não roda
295
+ # são o mesmo "não" — e `bloqueado` explica o porquê.
296
+ "suportado": bool(_versao_pacote()) and not bloqueado(),
297
+ "bloqueado": bloqueado(),
231
298
  "baixando": b,
232
299
  "vozes": [{
233
300
  "id": v["id"], "nome": v["nome"], "tom": v["tom"], "hz": v["hz"], "mb": v["mb"],
@@ -265,7 +332,7 @@ def sintetizar(texto, voz_id=None, grave=False, velocidade=1.0, timeout=120):
265
332
  exe = binario()
266
333
  voz_id = voz_id if voz_id in POR_ID else PADRAO
267
334
  mod = modelo(voz_id)
268
- if not exe or not mod:
335
+ if not exe or not mod or not _binario_roda():
269
336
  return None
270
337
 
271
338
  # Sem isto, um texto com quebra de linha vira várias falas e o Piper
@@ -282,13 +349,22 @@ def sintetizar(texto, voz_id=None, grave=False, velocidade=1.0, timeout=120):
282
349
  pasta = tempfile.mkdtemp(prefix="primo-voz-")
283
350
  try:
284
351
  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
- )
352
+ try:
353
+ r = subprocess.run(
354
+ [exe, "--model", mod + ".onnx", "--config", mod + ".onnx.json",
355
+ "--length_scale", "%.3f" % escala, "--sentence_silence", "0.25",
356
+ "--output_file", saida],
357
+ input=limpo.encode("utf-8"),
358
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout,
359
+ )
360
+ except OSError as e:
361
+ # errno 86 = binário de outra arquitetura (macOS). Marca de vez:
362
+ # a corrente cai para o próximo motor e ninguém trava de novo.
363
+ if getattr(e, "errno", None) == 86:
364
+ marcar_incompativel("o pacote do Piper veio com binário de "
365
+ "outra arquitetura (x86_64 num Mac ARM sem Rosetta)")
366
+ return None
367
+ raise
292
368
  if r.returncode != 0 or not os.path.exists(saida):
293
369
  raise RuntimeError((r.stderr or b"").decode("utf-8", "replace")[-300:] or "piper falhou")
294
370
  if grave:
@@ -301,7 +377,12 @@ def sintetizar(texto, voz_id=None, grave=False, velocidade=1.0, timeout=120):
301
377
 
302
378
  def apagar():
303
379
  """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."""
380
+ são 90 MB na máquina dele, e ele tem direito de recuperá-los.
381
+ O veredito de incompatibilidade vai junto (mora dentro de CASA): quem
382
+ instalar de novo — num Mac com Rosetta, num pacote corrigido — merece
383
+ uma prova nova, não a sentença antiga."""
384
+ global _roda_cache
385
+ _roda_cache = None
305
386
  if os.path.isdir(CASA):
306
387
  shutil.rmtree(CASA, ignore_errors=True)
307
388
  return {"ok": True}
@@ -65,7 +65,8 @@ function criarTrilha(cfg, aoErro) {
65
65
 
66
66
  var ctx = new Ctx();
67
67
  var mestre = ctx.createGain();
68
- mestre.gain.value = cfg.volume != null ? cfg.volume : 0.7;
68
+ var volumeBase = cfg.volume != null ? cfg.volume : 0.7;
69
+ mestre.gain.value = volumeBase;
69
70
 
70
71
  var compressor = ctx.createDynamicsCompressor();
71
72
  compressor.threshold.value = -14;
@@ -77,17 +78,49 @@ function criarTrilha(cfg, aoErro) {
77
78
  var paraGravar = ctx.createMediaStreamDestination();
78
79
  compressor.connect(paraGravar);
79
80
 
81
+ /* Entrada da NARRAÇÃO: depois do `mestre`, antes do compressor. A voz não
82
+ passa pelo volume da trilha nem pelo duck — quem abaixa é a música, nunca
83
+ a fala. E como desemboca no mesmo compressor, a voz entra na gravação
84
+ MP4 pelo grafo, sem depender de "compartilhar áudio da aba". */
85
+ var entradaVoz = ctx.createGain();
86
+ entradaVoz.gain.value = 1;
87
+ entradaVoz.connect(compressor);
88
+
80
89
  var api = {
81
90
  ctx: ctx,
91
+ no: compressor, // onde SFX e outras fontes se conectam
92
+ entradaVoz: entradaVoz,
82
93
  fluxo: paraGravar.stream,
83
94
  tocando: false,
84
95
  tocar: function () {},
85
96
  parar: function () {},
86
- volume: function (v) { mestre.gain.value = v; },
97
+ volume: function (v) { volumeBase = v; mestre.gain.value = v; },
87
98
  esmaecer: function () {},
88
99
  duracao: function () { return null; },
100
+ /* Ducking: a trilha cede espaço enquanto a narração fala.
101
+ Rampa curta para descer (a voz não espera) e longa para voltar. */
102
+ duck: function (ligado) {
103
+ var agora = ctx.currentTime;
104
+ mestre.gain.cancelScheduledValues(agora);
105
+ mestre.gain.setValueAtTime(mestre.gain.value, agora);
106
+ mestre.gain.linearRampToValueAtTime(
107
+ ligado ? volumeBase * 0.35 : volumeBase,
108
+ agora + (ligado ? 0.15 : 0.5));
109
+ },
89
110
  };
90
111
 
112
+ /* -------------------------------------------------------- sem trilha ---- */
113
+ /* tipo "nenhuma": o documento não tem música, mas o grafo ainda serve —
114
+ é por ele que narração e SFX entram na gravação. */
115
+ if (cfg.tipo === "nenhuma") {
116
+ api.tocar = function () {
117
+ if (ctx.state === "suspended") ctx.resume();
118
+ api.tocando = true;
119
+ };
120
+ api.parar = function () { api.tocando = false; };
121
+ return api;
122
+ }
123
+
91
124
  /* --------------------------------------------------------- arquivo ---- */
92
125
 
93
126
  if (cfg.tipo !== "gerada" && cfg.src) {