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.
- package/bin/primocode.js +5 -2
- package/lib/act.js +25 -3
- package/package.json +1 -1
- package/studio/ferramentas/corte.md +46 -5
- 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 +98 -11
- 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/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
|
-
|
|
526
|
-
|
|
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 =
|
|
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": {
|
|
@@ -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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|