primocode 8.19.0 → 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.
- package/lib/act.js +25 -3
- package/package.json +1 -1
- package/studio/ferramentas/corte.md +27 -0
- package/studio/server.py +89 -2
- package/studio/voz.py +92 -11
- package/studio/web/js/audio.js +35 -2
- package/studio/web/js/player.js +56 -5
- package/studio/web/js/sfx.js +107 -0
- package/studio/web/tool.html +1 -0
- package/studio/web/view.html +1 -0
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
- package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
- package/studio/arquivos/corte-legado-teste.json +0 -1
- package/studio/arquivos/corte-legado2.json +0 -1
- package/studio/arquivos/corte-minimo-2a829.json +0 -24
- package/studio/arquivos/corte-ok-00a3b.json +0 -18
- package/studio/arquivos/corte-ok-1270e.json +0 -22
- package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
- package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
- package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
- package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
- package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
- package/studio/arquivos/prisma-y-b0785.json +0 -52
- package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
- package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
- package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
- package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
- package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
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 =
|
|
556
|
-
|
|
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.
|
|
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": {
|
|
@@ -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
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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}
|
package/studio/web/js/audio.js
CHANGED
|
@@ -65,7 +65,8 @@ function criarTrilha(cfg, aoErro) {
|
|
|
65
65
|
|
|
66
66
|
var ctx = new Ctx();
|
|
67
67
|
var mestre = ctx.createGain();
|
|
68
|
-
|
|
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) {
|
package/studio/web/js/player.js
CHANGED
|
@@ -33,8 +33,17 @@
|
|
|
33
33
|
function duracaoDe(i) {
|
|
34
34
|
/* "duracao": "3s" (texto) fazia acc virar concatenação e o relógio dava
|
|
35
35
|
vídeo de 00:00. Número são ou o padrão — nunca corrente de texto. */
|
|
36
|
-
var
|
|
37
|
-
|
|
36
|
+
var c = cenas[i] || {};
|
|
37
|
+
var d = Number(c.duracao);
|
|
38
|
+
d = (isFinite(d) && d > 0) ? Math.min(60, d) : 4;
|
|
39
|
+
/* A narração pré-renderizada tem duração conhecida (o servidor mediu o
|
|
40
|
+
WAV e gravou `narracaoDur` na cena). A cena dura o que for MAIOR:
|
|
41
|
+
o que o autor pediu ou a fala inteira + um respiro. É isto que torna
|
|
42
|
+
a linha do tempo determinística — o "cena espera a voz" do laço vira
|
|
43
|
+
só a rede de segurança para narração sintetizada na hora. */
|
|
44
|
+
var n = Number(c.narracaoDur);
|
|
45
|
+
if (isFinite(n) && n > 0 && c.narracaoAudio) d = Math.max(d, Math.min(90, n + 0.4));
|
|
46
|
+
return d;
|
|
38
47
|
}
|
|
39
48
|
function total() { return cenas.reduce(function (s, _, i) { return s + duracaoDe(i); }, 0); }
|
|
40
49
|
function inicioDe(i) { var s = 0; for (var k = 0; k < i; k++) s += duracaoDe(k); return s; }
|
|
@@ -129,6 +138,7 @@
|
|
|
129
138
|
|
|
130
139
|
function ir(n, semRelogio) {
|
|
131
140
|
if (!cenas.length) return;
|
|
141
|
+
var anterior = atual;
|
|
132
142
|
atual = Math.max(0, Math.min(cenas.length - 1, n));
|
|
133
143
|
Array.prototype.forEach.call(palco.children, function (no, i) {
|
|
134
144
|
no.style.display = i === atual ? "block" : "none";
|
|
@@ -139,6 +149,17 @@
|
|
|
139
149
|
contar(no);
|
|
140
150
|
}
|
|
141
151
|
});
|
|
152
|
+
/* SFX na entrada da cena — só durante a reprodução (navegar com as setas
|
|
153
|
+
não faz barulho) e nunca na primeira montagem. `doc.sfx === false`
|
|
154
|
+
desliga tudo; `cena.sfx` escolhe o efeito ("whoosh" | "impacto" |
|
|
155
|
+
"click" | "nenhum") e ganha do automático por transição. */
|
|
156
|
+
if (sfx && tocando && ehVideo && atual !== anterior && doc.sfx !== false) {
|
|
157
|
+
var cenaNova = cenas[atual] || {};
|
|
158
|
+
if (cenaNova.sfx !== "nenhum") {
|
|
159
|
+
if (cenaNova.sfx && sfx[cenaNova.sfx]) sfx[cenaNova.sfx]();
|
|
160
|
+
else sfx.transicao(cenaNova.transicao, atual);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
142
163
|
narrar();
|
|
143
164
|
pintarApresentador();
|
|
144
165
|
if (!semRelogio) decorrido = inicioDe(atual);
|
|
@@ -255,6 +276,7 @@
|
|
|
255
276
|
function narrar() {
|
|
256
277
|
if (typeof Narracao !== "undefined") Narracao.parar();
|
|
257
278
|
if (vozGravada) { vozGravada.pause(); vozGravada = null; }
|
|
279
|
+
if (trilha && trilha.duck) trilha.duck(false);
|
|
258
280
|
if (mudo || doc.narracao === false) return;
|
|
259
281
|
|
|
260
282
|
var cena = cenas[atual] || {};
|
|
@@ -263,13 +285,35 @@
|
|
|
263
285
|
if (cena.narracaoAudio) {
|
|
264
286
|
vozGravada = new Audio(cena.narracaoAudio);
|
|
265
287
|
vozGravada.volume = (doc.voz && doc.voz.volume != null) ? doc.voz.volume : 1;
|
|
288
|
+
/* Pelo grafo da trilha, quando ele existe: a voz entra na gravação MP4
|
|
289
|
+
sem "compartilhar áudio da aba", e a música cede espaço (duck)
|
|
290
|
+
enquanto a fala dura. Sem grafo, toca direto como antes. */
|
|
291
|
+
if (trilha && trilha.ctx && trilha.entradaVoz) {
|
|
292
|
+
try {
|
|
293
|
+
var fonteVoz = trilha.ctx.createMediaElementSource(vozGravada);
|
|
294
|
+
fonteVoz.connect(trilha.entradaVoz);
|
|
295
|
+
} catch (_) { /* elemento já conectado ou grafo indisponível */ }
|
|
296
|
+
if (trilha.duck) {
|
|
297
|
+
trilha.duck(true);
|
|
298
|
+
vozGravada.onended = function () { if (trilha && trilha.duck) trilha.duck(false); };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
266
301
|
var p = vozGravada.play();
|
|
267
302
|
if (p && p.catch) p.catch(function () {});
|
|
268
303
|
return;
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
if (typeof Narracao === "undefined" || !Narracao.disponivel()) return;
|
|
272
|
-
if (cena.narracao)
|
|
307
|
+
if (cena.narracao) {
|
|
308
|
+
if (trilha && trilha.duck) {
|
|
309
|
+
trilha.duck(true);
|
|
310
|
+
Narracao.falar(cena.narracao, doc.voz || {}).then(function () {
|
|
311
|
+
if (trilha && trilha.duck) trilha.duck(false);
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
Narracao.falar(cena.narracao, doc.voz || {});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
273
317
|
}
|
|
274
318
|
|
|
275
319
|
/* ----------------------------------------------------- apresentador --- */
|
|
@@ -317,11 +361,18 @@
|
|
|
317
361
|
|
|
318
362
|
/* ------------------------------------------------------------ trilha -- */
|
|
319
363
|
|
|
364
|
+
var sfx = null;
|
|
365
|
+
|
|
320
366
|
function prepararTrilha() {
|
|
321
|
-
var cfg = doc.trilha;
|
|
322
|
-
if (!cfg || (!cfg.src && cfg.tipo !== "gerada")) return;
|
|
323
367
|
if (typeof criarTrilha !== "function") return;
|
|
368
|
+
var cfg = doc.trilha;
|
|
369
|
+
/* Sem trilha o grafo ainda nasce (tipo "nenhuma"): é por ele que a
|
|
370
|
+
narração e os SFX entram na gravação MP4. */
|
|
371
|
+
if (!cfg || (!cfg.src && cfg.tipo !== "gerada")) cfg = { tipo: "nenhuma" };
|
|
324
372
|
trilha = criarTrilha(cfg, function (msg) { aviso(msg, 3000); });
|
|
373
|
+
if (trilha && trilha.ctx && trilha.no && typeof criarSfx === "function" && doc.sfx !== false) {
|
|
374
|
+
sfx = criarSfx(trilha.ctx, trilha.no);
|
|
375
|
+
}
|
|
325
376
|
}
|
|
326
377
|
|
|
327
378
|
/* --------------------------------------------------------- transporte -- */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
sfx.js — efeitos sonoros do Corte, sintetizados na hora.
|
|
3
|
+
|
|
4
|
+
Nenhum arquivo, nenhuma licença: whoosh é ruído filtrado com varredura,
|
|
5
|
+
impacto é um seno grave despencando, click é um blip curto. Tudo entra no
|
|
6
|
+
mesmo grafo da trilha (via `destino`), então a gravação MP4 captura os
|
|
7
|
+
efeitos junto — sem depender de "compartilhar áudio da aba".
|
|
8
|
+
|
|
9
|
+
Uso:
|
|
10
|
+
var sfx = criarSfx(ctx, destino); // destino: nó do grafo (compressor)
|
|
11
|
+
sfx.whoosh(); // transição de cena
|
|
12
|
+
sfx.impacto(); // ênfase (flash, número grande)
|
|
13
|
+
sfx.click(); // entrada de elemento, feedback discreto
|
|
14
|
+
|
|
15
|
+
O volume de cada efeito já vem calibrado para sentar SOB a narração.
|
|
16
|
+
========================================================================== */
|
|
17
|
+
|
|
18
|
+
function criarSfx(ctx, destino) {
|
|
19
|
+
"use strict";
|
|
20
|
+
if (!ctx || !destino) return null;
|
|
21
|
+
|
|
22
|
+
var saida = ctx.createGain();
|
|
23
|
+
saida.gain.value = 1;
|
|
24
|
+
saida.connect(destino);
|
|
25
|
+
|
|
26
|
+
var ruido = null;
|
|
27
|
+
function bufferRuido() {
|
|
28
|
+
if (ruido) return ruido;
|
|
29
|
+
ruido = ctx.createBuffer(1, ctx.sampleRate * 1.2, ctx.sampleRate);
|
|
30
|
+
var d = ruido.getChannelData(0);
|
|
31
|
+
for (var i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
|
32
|
+
return ruido;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Varredura de ruído — o "passar de página" do vídeo. `variacao` muda o
|
|
36
|
+
miolo da varredura para os whooshes não saírem todos idênticos. */
|
|
37
|
+
function whoosh(variacao) {
|
|
38
|
+
var agora = ctx.currentTime;
|
|
39
|
+
var v = (variacao == null ? Math.random() : variacao) % 1;
|
|
40
|
+
var s = ctx.createBufferSource(), g = ctx.createGain(), f = ctx.createBiquadFilter();
|
|
41
|
+
s.buffer = bufferRuido();
|
|
42
|
+
f.type = "bandpass";
|
|
43
|
+
f.Q.value = 1.1;
|
|
44
|
+
f.frequency.setValueAtTime(300 + v * 300, agora);
|
|
45
|
+
f.frequency.exponentialRampToValueAtTime(2200 + v * 1400, agora + 0.16);
|
|
46
|
+
f.frequency.exponentialRampToValueAtTime(500, agora + 0.42);
|
|
47
|
+
g.gain.setValueAtTime(0.0001, agora);
|
|
48
|
+
g.gain.exponentialRampToValueAtTime(0.11, agora + 0.10);
|
|
49
|
+
g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.45);
|
|
50
|
+
s.connect(f); f.connect(g); g.connect(saida);
|
|
51
|
+
s.start(agora); s.stop(agora + 0.5);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Seno grave despencando + um sopro de ruído no ataque. */
|
|
55
|
+
function impacto() {
|
|
56
|
+
var agora = ctx.currentTime;
|
|
57
|
+
var o = ctx.createOscillator(), g = ctx.createGain();
|
|
58
|
+
o.type = "sine";
|
|
59
|
+
o.frequency.setValueAtTime(120, agora);
|
|
60
|
+
o.frequency.exponentialRampToValueAtTime(36, agora + 0.5);
|
|
61
|
+
g.gain.setValueAtTime(0.30, agora);
|
|
62
|
+
g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.9);
|
|
63
|
+
o.connect(g); g.connect(saida);
|
|
64
|
+
o.start(agora); o.stop(agora + 1.0);
|
|
65
|
+
|
|
66
|
+
var s = ctx.createBufferSource(), gr = ctx.createGain(), f = ctx.createBiquadFilter();
|
|
67
|
+
s.buffer = bufferRuido();
|
|
68
|
+
f.type = "lowpass";
|
|
69
|
+
f.frequency.value = 900;
|
|
70
|
+
gr.gain.setValueAtTime(0.09, agora);
|
|
71
|
+
gr.gain.exponentialRampToValueAtTime(0.0001, agora + 0.22);
|
|
72
|
+
s.connect(f); f.connect(gr); gr.connect(saida);
|
|
73
|
+
s.start(agora); s.stop(agora + 0.3);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Blip de interface: curto, agudo, discreto. */
|
|
77
|
+
function click() {
|
|
78
|
+
var agora = ctx.currentTime;
|
|
79
|
+
var o = ctx.createOscillator(), g = ctx.createGain();
|
|
80
|
+
o.type = "sine";
|
|
81
|
+
o.frequency.setValueAtTime(2100, agora);
|
|
82
|
+
g.gain.setValueAtTime(0.06, agora);
|
|
83
|
+
g.gain.exponentialRampToValueAtTime(0.0001, agora + 0.05);
|
|
84
|
+
o.connect(g); g.connect(saida);
|
|
85
|
+
o.start(agora); o.stop(agora + 0.07);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* O efeito certo para a ENTRADA de uma cena, pela transição dela.
|
|
89
|
+
flash e zoom pedem peso; corte seco não pede nada. */
|
|
90
|
+
function transicao(tipo, indice) {
|
|
91
|
+
if (tipo === "corte") return;
|
|
92
|
+
if (tipo === "flash" || tipo === "zoom") return impacto();
|
|
93
|
+
whoosh(((indice || 0) * 0.37) % 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
whoosh: whoosh,
|
|
98
|
+
impacto: impacto,
|
|
99
|
+
click: click,
|
|
100
|
+
transicao: transicao,
|
|
101
|
+
volume: function (v) { saida.gain.value = v; },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
106
|
+
module.exports = { criarSfx: criarSfx };
|
|
107
|
+
}
|
package/studio/web/tool.html
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
document.getElementById("btn-baixar").innerHTML = svgIcone("baixar");
|
|
68
68
|
</script>
|
|
69
69
|
<script src="/js/audio.js"></script>
|
|
70
|
+
<script src="/js/sfx.js"></script>
|
|
70
71
|
<script src="/js/voz-neural.js"></script>
|
|
71
72
|
<script src="/js/narracao.js"></script>
|
|
72
73
|
<script src="/js/midia.js"></script>
|
package/studio/web/view.html
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<script src="/js/render-prosa.js"></script>
|
|
18
18
|
<script src="/js/render-grade.js"></script>
|
|
19
19
|
<script src="/js/audio.js"></script>
|
|
20
|
+
<script src="/js/sfx.js"></script>
|
|
20
21
|
<script src="/js/voz-neural.js"></script>
|
|
21
22
|
<script src="/js/narracao.js"></script>
|
|
22
23
|
<script src="/js/player.js"></script>
|
|
Binary file
|