primocode 8.21.0 → 8.22.1
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 +41 -1
- package/lib/referencia.js +145 -18
- package/lib/studio.js +1 -1
- package/package.json +1 -1
- package/studio/edge.py +221 -0
- package/studio/ferramentas/corte.md +16 -2
- package/studio/server.py +100 -17
- package/studio/web/js/render.js +27 -9
package/lib/act.js
CHANGED
|
@@ -349,6 +349,12 @@ async function runAct(userPrompt, opts) {
|
|
|
349
349
|
// Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
|
|
350
350
|
const jaChamado = repeticao.novo();
|
|
351
351
|
let voltasEmFalso = 0;
|
|
352
|
+
// Pesquisas que voltaram de mãos vazias. A partir da terceira, a tool
|
|
353
|
+
// para de rodar e devolve ORDEM DE SEGUIR: sem isto o agente entrava em
|
|
354
|
+
// ciclo — buscava, não achava, lia "tente outra consulta", buscava de
|
|
355
|
+
// novo — até a cota do minuto acabar e o modelo responder vazio. Foi
|
|
356
|
+
// assim que "crie um vídeo para a tostudy" morreu sem entregar nada.
|
|
357
|
+
let pesquisasSecas = 0;
|
|
352
358
|
let rodadasParadas = 0;
|
|
353
359
|
|
|
354
360
|
const messages = [];
|
|
@@ -515,9 +521,22 @@ async function runAct(userPrompt, opts) {
|
|
|
515
521
|
// o modelo grátis engasgando. Cair direto no "parei sem concluir"
|
|
516
522
|
// jogava a tarefa fora quando repetir a MESMA rodada quase sempre
|
|
517
523
|
// resolve — visto em produção no primeiro pedido de vídeo.
|
|
518
|
-
if (!finalText && respostasVazias <
|
|
524
|
+
if (!finalText && respostasVazias < 3 && !PARAR) {
|
|
519
525
|
respostasVazias++;
|
|
520
526
|
if (!subagent) console.log(ident(profundidade) + c.dim(' ⎿ o modelo respondeu vazio — tentando de novo'));
|
|
527
|
+
// Repetir o MESMO contexto costuma produzir o mesmo vazio: se
|
|
528
|
+
// o turno anterior já engasgou, o que falta é um empurrão
|
|
529
|
+
// curto e concreto. A partir da segunda vez, entra um pedido
|
|
530
|
+
// explícito de fechamento — barato em tokens (é justamente
|
|
531
|
+
// quando a cota está no fim) e suficiente para o modelo
|
|
532
|
+
// escrever a resposta ou chamar finish.
|
|
533
|
+
if (respostasVazias >= 2) {
|
|
534
|
+
messages.push({
|
|
535
|
+
role: 'user',
|
|
536
|
+
content: 'Sua última resposta veio vazia. Não pesquise mais nem explique o processo: '
|
|
537
|
+
+ 'faça agora a entrega pedida com o que você já tem e finalize.',
|
|
538
|
+
});
|
|
539
|
+
}
|
|
521
540
|
iteration--;
|
|
522
541
|
continue;
|
|
523
542
|
}
|
|
@@ -616,6 +635,22 @@ async function runAct(userPrompt, opts) {
|
|
|
616
635
|
temPasta = true;
|
|
617
636
|
}
|
|
618
637
|
|
|
638
|
+
if (tc.name === 'pesquisar_referencia' && pesquisasSecas >= 3) {
|
|
639
|
+
voltasEmFalso++;
|
|
640
|
+
console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
|
|
641
|
+
console.log(ident(profundidade) + c.muted(' ⎿ ') + c.warn('3 pesquisas sem resultado — siga com o que já tem'));
|
|
642
|
+
messages.push({
|
|
643
|
+
role: 'tool',
|
|
644
|
+
tool_call_id: tc.id,
|
|
645
|
+
content: 'PARE DE PESQUISAR. Três buscas seguidas não trouxeram fonte nenhuma; '
|
|
646
|
+
+ 'mais uma vai gastar a cota e devolver o mesmo nada. FAÇA AGORA a entrega '
|
|
647
|
+
+ 'pedida com o que você já sabe. Se faltar um fato específico (número, data, '
|
|
648
|
+
+ 'preço, nome de pessoa), NÃO invente: escreva a peça sem ele e avise o '
|
|
649
|
+
+ 'usuário no final, em uma linha, o que não deu para confirmar.',
|
|
650
|
+
});
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
619
654
|
algoRodou = true;
|
|
620
655
|
console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
|
|
621
656
|
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
|
|
@@ -626,6 +661,11 @@ async function runAct(userPrompt, opts) {
|
|
|
626
661
|
// não corrige um teste quebrado noutro lugar; antes isto zerava
|
|
627
662
|
// a lista inteira e o agente conseguia dizer "concluído" com uma
|
|
628
663
|
// falha real ainda pendente.
|
|
664
|
+
if (tc.name === 'pesquisar_referencia') {
|
|
665
|
+
if (result && result.ok === false) pesquisasSecas++;
|
|
666
|
+
else pesquisasSecas = 0; // achou fonte: o ciclo se rompeu
|
|
667
|
+
}
|
|
668
|
+
|
|
629
669
|
if (result && result.ok === false) {
|
|
630
670
|
falhasPendentes.push({
|
|
631
671
|
tool: tc.name,
|
package/lib/referencia.js
CHANGED
|
@@ -244,21 +244,116 @@ function relevante(cand, palavras) {
|
|
|
244
244
|
* Foi o que salvou este caso — parisgroup.ai abre e tem o texto todo, enquanto
|
|
245
245
|
* DuckDuckGo devolvia 202 e o Bing devolvia Paris, a cidade.
|
|
246
246
|
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
247
|
+
* Reconhece nome de DUAS palavras com maiúscula ("Paris Group") e também de
|
|
248
|
+
* UMA palavra, mesmo em minúscula — que é como as pessoas digitam no meio de
|
|
249
|
+
* uma frase. Exigir duas maiúsculas custou caro: para "crie um vídeo para a
|
|
250
|
+
* tostudy, plataforma de aprendizagem com IA" a função devolvia lista vazia,
|
|
251
|
+
* o buscador não conhecia o nome, a pesquisa falhava — e o agente repetia
|
|
252
|
+
* buscas até a tarefa morrer, com tostudy.ai no ar o tempo todo.
|
|
250
253
|
*/
|
|
254
|
+
|
|
255
|
+
// Palavras que aparecem em qualquer pedido e NUNCA são o nome da marca. Sem
|
|
256
|
+
// esta peneira o chute viraria plataforma.com, aprendizagem.com, video.com.
|
|
257
|
+
const NAO_E_MARCA = new Set([
|
|
258
|
+
'plataforma', 'plataformas', 'empresa', 'startup', 'site', 'oficial', 'aplicativo', 'app',
|
|
259
|
+
'aprendizagem', 'aprendizado', 'ensino', 'educacao', 'educacional', 'curso', 'cursos', 'escola',
|
|
260
|
+
'video', 'videos', 'reels', 'anuncio', 'campanha', 'marketing', 'institucional', 'apresentacao',
|
|
261
|
+
'inteligencia', 'artificial', 'brasil', 'brasileira', 'sobre', 'para', 'como', 'uma', 'que',
|
|
262
|
+
'crie', 'criar', 'faca', 'fazer', 'quero', 'preciso', 'estudo', 'estudos', 'tecnologia',
|
|
263
|
+
'software', 'sistema', 'servico', 'servicos', 'produto', 'produtos', 'negocio', 'digital',
|
|
264
|
+
'documentacao', 'tutorial', 'exemplo', 'exemplos', 'guia', 'referencia',
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
function _slug(txt) {
|
|
268
|
+
return String(txt || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
269
|
+
.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
251
272
|
function sitesProvaveis(consulta) {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
273
|
+
const texto = String(consulta || '');
|
|
274
|
+
const marcas = [];
|
|
275
|
+
|
|
276
|
+
// Entre aspas é a forma de a pessoa dizer "o nome é ESTE". Vem primeiro.
|
|
277
|
+
const citado = /["“'”]([^"“'”]{3,40})["“'”]/.exec(texto);
|
|
278
|
+
if (citado) marcas.push({ rotulo: citado[1].trim(), slug: _slug(citado[1]) });
|
|
279
|
+
|
|
280
|
+
const duplo = /\b([A-ZÀ-Ý][\wÀ-ɏ]{1,})\s+([A-ZÀ-Ý][\wÀ-ɏ]{1,})\b/.exec(texto);
|
|
281
|
+
if (duplo) marcas.push({ rotulo: `${duplo[1]} ${duplo[2]}`, slug: _slug(duplo[1] + duplo[2]) });
|
|
282
|
+
|
|
283
|
+
// Marca de UMA palavra só é chutada quando a consulta dá SINAL de que
|
|
284
|
+
// fala de um negócio ("plataforma", "empresa", "app", "site"...) ou põe o
|
|
285
|
+
// nome entre aspas. Sem essa exigência, "react hooks tutorial" viraria
|
|
286
|
+
// react.com e "como fazer um bolo" viraria fazer.com — dez buscas
|
|
287
|
+
// inúteis para cada pesquisa técnica.
|
|
288
|
+
const SINAL_DE_MARCA = /\b(plataforma|empresa|startup|site|marca|neg[óo]cio|produto|servi[çc]o|app|aplicativo|institucional|cliente|fabricante|loja|ag[êe]ncia|ecommerce|e-commerce|saas)\b/i;
|
|
289
|
+
const temSinal = SINAL_DE_MARCA.test(texto) || !!citado;
|
|
290
|
+
if (temSinal) {
|
|
291
|
+
const util = (bruto) => {
|
|
292
|
+
const s = _slug(bruto);
|
|
293
|
+
return (s.length >= 4 && s.length <= 24 && !NAO_E_MARCA.has(s) && !/^\d+$/.test(s)) ? s : null;
|
|
294
|
+
};
|
|
295
|
+
// O nome vem quase sempre depois de artigo ou preposição: "um mouse
|
|
296
|
+
// DA multilaser", "vídeo para A tostudy". Pegar simplesmente a
|
|
297
|
+
// primeira palavra não-genérica escolhia o verbo do pedido
|
|
298
|
+
// ("anunciar") e chutava anunciar.ai.
|
|
299
|
+
const depoisDeArtigo = [...texto.matchAll(/\b(?:d[aeo]s?|[ao]|n[ao]s?|à)\s+([A-Za-zÀ-ÿ][\wÀ-ÿ-]{3,23})\b/gi)];
|
|
300
|
+
for (const m of depoisDeArtigo) {
|
|
301
|
+
const s = util(m[1]);
|
|
302
|
+
if (s) { marcas.push({ rotulo: m[1], slug: s }); break; }
|
|
303
|
+
}
|
|
304
|
+
// Só quando o artigo não entregou nada: senão o verbo do pedido
|
|
305
|
+
// ("anunciar") entraria junto e gastaria dez sondas à toa.
|
|
306
|
+
if (!marcas.length) {
|
|
307
|
+
for (const bruto of texto.split(/[\s,.;:!?()]+/)) {
|
|
308
|
+
const s = util(bruto);
|
|
309
|
+
if (s) { marcas.push({ rotulo: bruto, slug: s }); break; }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const vistos = new Set();
|
|
315
|
+
const saida = [];
|
|
316
|
+
for (const m of marcas.slice(0, 2)) {
|
|
317
|
+
if (!m.slug || m.slug.length < 4 || vistos.has(m.slug)) continue;
|
|
318
|
+
vistos.add(m.slug);
|
|
319
|
+
for (const tld of ['ai', 'com', 'com.br', 'io', 'co']) {
|
|
320
|
+
// Com e sem "www": um resolve onde o outro não, e quem decide é a
|
|
321
|
+
// hospedagem. Como estes candidatos passam pela sonda antes de
|
|
322
|
+
// entrar na fila de leitura, tentar os dois não custa vaga.
|
|
323
|
+
saida.push({ titulo: `${m.rotulo} — site oficial`, url: `https://${m.slug}.${tld}`, nota: 'site provável da empresa' });
|
|
324
|
+
saida.push({ titulo: `${m.rotulo} — site oficial`, url: `https://www.${m.slug}.${tld}`, nota: 'site provável da empresa' });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return saida;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Sonda os domínios chutados e devolve só os que responderam, com o HTML já
|
|
332
|
+
* na mão. Sem isto, dez chutes entrariam na fila de leitura e expulsariam os
|
|
333
|
+
* resultados de verdade do buscador — e a maioria deles nem existe.
|
|
334
|
+
*/
|
|
335
|
+
async function chutesVivos(chutes, limite = 2) {
|
|
336
|
+
if (!chutes.length) return { vivos: [], html: new Map() };
|
|
337
|
+
// 12 s, não 6: a sonda roda em paralelo e a home de uma empresa hoje pesa
|
|
338
|
+
// centenas de KB. Com 6 s o site CERTO chegava atrasado, era descartado
|
|
339
|
+
// como morto, e a pesquisa falhava com o site no ar — medido.
|
|
340
|
+
const respostas = await Promise.all(chutes.slice(0, 12).map(async (c) => {
|
|
341
|
+
const html = await pegar(c.url, { timeout: 12000 });
|
|
342
|
+
return html && html.length > 500 ? { cand: c, html } : null;
|
|
261
343
|
}));
|
|
344
|
+
const vivos = [];
|
|
345
|
+
const html = new Map();
|
|
346
|
+
const dominios = new Set();
|
|
347
|
+
for (const r of respostas.filter(Boolean)) {
|
|
348
|
+
// www.x.ai e x.ai são o mesmo site: vale o primeiro que respondeu.
|
|
349
|
+
const raiz = r.cand.url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
|
|
350
|
+
if (dominios.has(raiz)) continue;
|
|
351
|
+
dominios.add(raiz);
|
|
352
|
+
vivos.push(r.cand);
|
|
353
|
+
html.set(r.cand.url, r.html);
|
|
354
|
+
if (vivos.length >= limite) break;
|
|
355
|
+
}
|
|
356
|
+
return { vivos, html };
|
|
262
357
|
}
|
|
263
358
|
|
|
264
359
|
// Links que não são referência de nada — resultado de busca sujo.
|
|
@@ -460,7 +555,17 @@ async function pesquisar({ consulta, paginas = 3 }) {
|
|
|
460
555
|
// O site provável da empresa vai NA FRENTE: quando a consulta nomeia um
|
|
461
556
|
// negócio, o site dele é a melhor fonte que existe, e não depende de
|
|
462
557
|
// buscador nenhum estar de bom humor.
|
|
463
|
-
|
|
558
|
+
// Os chutes de domínio são SONDADOS antes de entrar na fila: só os que
|
|
559
|
+
// respondem ocupam vaga. São dez palpites (5 TLDs x com/sem www) e a
|
|
560
|
+
// maioria não existe — enfileirar todos expulsaria os resultados reais
|
|
561
|
+
// do buscador. A sonda roda em paralelo e o HTML já volta aproveitado.
|
|
562
|
+
const palpites = sitesProvaveis(termo);
|
|
563
|
+
const { vivos, html: htmlChutes } = await chutesVivos(palpites);
|
|
564
|
+
// A sonda ORDENA, não faz porteiro. Ela é uma corrida contra o relógio
|
|
565
|
+
// contra home pesada e rede lenta, e perdê-la não pode significar perder
|
|
566
|
+
// o site certo: quando nada responde a tempo, os primeiros palpites vão
|
|
567
|
+
// para a fila mesmo assim e o laço de leitura tenta de novo, sem pressa.
|
|
568
|
+
const chutes = vivos.length ? vivos : palpites.slice(0, 3);
|
|
464
569
|
const palavras = palavrasDaConsulta(termo);
|
|
465
570
|
const brutos = [...chutes, ...web, ...so, ...docsConhecidas(termo)];
|
|
466
571
|
|
|
@@ -481,7 +586,15 @@ async function pesquisar({ consulta, paginas = 3 }) {
|
|
|
481
586
|
provavelCausa: descartados
|
|
482
587
|
? `Vieram ${descartados} link(s), mas nenhum era sobre "${termo}" — o buscador não achou o assunto e devolveu página aleatória. Fonte errada é pior que fonte nenhuma, então foram descartadas.`
|
|
483
588
|
: 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
|
|
484
|
-
|
|
589
|
+
// "Tente outra consulta" era um convite ao ciclo: o agente
|
|
590
|
+
// reformulava a busca, falhava igual, e repetia até a cota acabar.
|
|
591
|
+
// A ordem agora é explícita — no máximo UMA nova tentativa, e por
|
|
592
|
+
// um caminho DIFERENTE (o site direto), senão segue sem a fonte.
|
|
593
|
+
comoCorrigir: 'NÃO repita esta busca com outras palavras — o buscador não conhece o assunto e '
|
|
594
|
+
+ 'vai devolver o mesmo nada. Se souber (ou conseguir deduzir) o site, tente UMA vez '
|
|
595
|
+
+ 'browser_open + browser_read nele. Se também não abrir, SIGA COM A TAREFA usando o que '
|
|
596
|
+
+ 'você já sabe e avise ao usuário, no final, qual fato não deu para confirmar. Nunca invente '
|
|
597
|
+
+ 'dado sobre empresa, número, data, preço ou pessoa.',
|
|
485
598
|
};
|
|
486
599
|
}
|
|
487
600
|
|
|
@@ -491,7 +604,9 @@ async function pesquisar({ consulta, paginas = 3 }) {
|
|
|
491
604
|
const chutesLidos = [];
|
|
492
605
|
for (const cand of candidatos) {
|
|
493
606
|
if (lidas.length >= paginas) break;
|
|
494
|
-
|
|
607
|
+
// A sonda já baixou o HTML dos chutes: reaproveita em vez de pedir
|
|
608
|
+
// a mesma página duas vezes.
|
|
609
|
+
const html = htmlChutes.get(cand.url) || await pegar(cand.url);
|
|
495
610
|
if (!html) continue;
|
|
496
611
|
const texto = textoDe(html);
|
|
497
612
|
if (texto.length < 200) continue;
|
|
@@ -502,7 +617,17 @@ async function pesquisar({ consulta, paginas = 3 }) {
|
|
|
502
617
|
// da página, sim. Sem esta prova o agente escreveria sobre a errada.
|
|
503
618
|
const ehChute = cand.nota === 'site provável da empresa';
|
|
504
619
|
if (ehChute) {
|
|
505
|
-
|
|
620
|
+
let nota = quantoBate({ titulo: texto.slice(0, 4000), url: cand.url }, palavras);
|
|
621
|
+
// O NOME DA MARCA no domínio, confirmado no texto da página, já é
|
|
622
|
+
// prova — e é a prova mais forte que existe. Exigir duas palavras
|
|
623
|
+
// genéricas da consulta ("plataforma", "aprendizagem") reprovava o
|
|
624
|
+
// site certo: tostudy.ai abria, dizia "ToStudy" na primeira linha,
|
|
625
|
+
// e mesmo assim era descartado por não repetir o vocabulário do
|
|
626
|
+
// pedido. O agente então repetia buscas até a tarefa morrer.
|
|
627
|
+
const marca = (cand.url.match(/^https?:\/\/(?:www\.)?([a-z0-9-]+)\./i) || [])[1] || '';
|
|
628
|
+
const textoLimpo = texto.slice(0, 8000).normalize('NFD')
|
|
629
|
+
.replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
630
|
+
if (marca.length >= 4 && textoLimpo.includes(marca.toLowerCase())) nota = Math.max(nota, 2);
|
|
506
631
|
if (nota < Math.min(2, palavras.length)) continue;
|
|
507
632
|
// Passar não basta: entre dois domínios do MESMO nome, fica o que
|
|
508
633
|
// fala mais do assunto. parisgroup.ai e parisgroup.co os dois
|
|
@@ -532,8 +657,10 @@ async function pesquisar({ consulta, paginas = 3 }) {
|
|
|
532
657
|
error: 'achei os links mas não consegui abrir nenhum',
|
|
533
658
|
consulta: termo,
|
|
534
659
|
links: candidatos.map((c) => c.url),
|
|
535
|
-
comoCorrigir: 'Tente browser_open + browser_read num desses links
|
|
536
|
-
+ '
|
|
660
|
+
comoCorrigir: 'Tente browser_open + browser_read num desses links — UMA vez. Não refaça a busca '
|
|
661
|
+
+ 'com outras palavras: os links são estes mesmos. Se nada abrir, SIGA COM A TAREFA e diga ao '
|
|
662
|
+
+ 'usuário, no final, que não conseguiu confirmar os fatos — NUNCA invente dado sobre empresa, '
|
|
663
|
+
+ 'data, preço ou pessoa.',
|
|
537
664
|
};
|
|
538
665
|
}
|
|
539
666
|
|
package/lib/studio.js
CHANGED
|
@@ -42,7 +42,7 @@ const BASE = `http://127.0.0.1:${PORTA}`;
|
|
|
42
42
|
// voz.py é módulo do servidor, não enfeite: sem ele o server.py não importa
|
|
43
43
|
// e o Studio inteiro deixa de subir. Todo arquivo .py do Studio precisa
|
|
44
44
|
// entrar aqui.
|
|
45
|
-
const COPIAVEIS = new Set(['server.py', 'voz.py', 'elevenlabs.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
|
|
45
|
+
const COPIAVEIS = new Set(['server.py', 'voz.py', 'elevenlabs.py', 'edge.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Onde está o Python 3 desta máquina.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.22.1",
|
|
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": {
|
package/studio/edge.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Narração neural grátis, via Edge TTS (vozes online da Microsoft).
|
|
2
|
+
|
|
3
|
+
── POR QUE ESTE MOTOR EXISTE ─────────────────────────────────────────────
|
|
4
|
+
A corrente de voz tinha um buraco entre o ElevenLabs (premium, com chave e
|
|
5
|
+
cota) e o Google (uma voz só, robótica). No meio ficava o Piper — neural e
|
|
6
|
+
offline, mas que baixa 90 MB e, em Mac ARM, vinha com binário x86 e travava.
|
|
7
|
+
Quando o Piper não sobe, a narração caía direto no Google e o vídeo saía com
|
|
8
|
+
voz de robô.
|
|
9
|
+
|
|
10
|
+
O Edge TTS preenche esse buraco: são as MESMAS vozes neurais do serviço da
|
|
11
|
+
Microsoft (Antonio, Francisca, Thalita em pt-BR), de graça, SEM chave e SEM
|
|
12
|
+
conta. Precisa de internet — mas quem está gerando vídeo já está online. A
|
|
13
|
+
qualidade fica logo abaixo do ElevenLabs e muito acima do Google.
|
|
14
|
+
|
|
15
|
+
── COMO ENTRA NA CORRENTE ────────────────────────────────────────────────
|
|
16
|
+
1. ELEVENLABS premium, se houver chave com crédito.
|
|
17
|
+
2. EDGE TTS neural, grátis, sem chave. ← ESTE
|
|
18
|
+
3. PIPER neural, offline, ilimitado.
|
|
19
|
+
4. VOICEBOX se o app estiver instalado.
|
|
20
|
+
5. GOOGLE última reserva; nunca fica mudo.
|
|
21
|
+
|
|
22
|
+
── SÍNTESE ───────────────────────────────────────────────────────────────
|
|
23
|
+
Rodamos o edge-tts por SUBPROCESSO (`python3 -m edge_tts`), não pela API
|
|
24
|
+
async: o servidor do Studio é stdlib síncrono, e enfiar um event loop dentro
|
|
25
|
+
de uma thread dele é fonte de travamento. O subprocesso é isolado, tem
|
|
26
|
+
timeout de verdade, e devolve o MP3 pronto.
|
|
27
|
+
|
|
28
|
+
A DURAÇÃO sai das legendas que o próprio edge-tts escreve (`--write-subtitles`):
|
|
29
|
+
o último timestamp do VTT é o fim da fala, medido — não estimado. Com isso a
|
|
30
|
+
cena sabe quanto dura de fato e a linha do tempo fica determinística.
|
|
31
|
+
|
|
32
|
+
O pacote `edge-tts` não vem no PrimoCode (é pip, não stdlib). Se faltar, este
|
|
33
|
+
motor se declara indisponível e a corrente segue para o Piper — nada trava.
|
|
34
|
+
`instalar()` tenta trazê-lo em segundo plano, uma vez, sem bloquear ninguém.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
import threading
|
|
43
|
+
|
|
44
|
+
# Catálogo pt-BR do Edge. Só três vozes existem no serviço da Microsoft para
|
|
45
|
+
# português do Brasil; não há por que inventar mais. A padrão é a Antonio,
|
|
46
|
+
# masculina e de locução, que é o registro de abertura de vídeo — a mesma
|
|
47
|
+
# lógica da voz grave padrão do Piper.
|
|
48
|
+
CATALOGO = [
|
|
49
|
+
{"id": "antonio", "voz": "pt-BR-AntonioNeural", "nome": "Antonio", "genero": "m", "padrao": True},
|
|
50
|
+
{"id": "francisca", "voz": "pt-BR-FranciscaNeural", "nome": "Francisca", "genero": "f"},
|
|
51
|
+
{"id": "thalita", "voz": "pt-BR-ThalitaMultilingualNeural","nome": "Thalita", "genero": "f"},
|
|
52
|
+
]
|
|
53
|
+
POR_ID = {v["id"]: v for v in CATALOGO}
|
|
54
|
+
PADRAO = next(v["voz"] for v in CATALOGO if v.get("padrao"))
|
|
55
|
+
|
|
56
|
+
# Apelidos: as vozes do ElevenLabs que o usuário talvez peça no documento
|
|
57
|
+
# mapeiam para o timbre mais próximo aqui, para o mesmo documento render
|
|
58
|
+
# igual com qualquer motor ativo. Masculinas → Antonio; femininas → Francisca.
|
|
59
|
+
APELIDOS = {
|
|
60
|
+
"brian": "pt-BR-AntonioNeural", "daniel": "pt-BR-AntonioNeural",
|
|
61
|
+
"adam": "pt-BR-AntonioNeural", "bill": "pt-BR-AntonioNeural",
|
|
62
|
+
"liam": "pt-BR-AntonioNeural", "jeff": "pt-BR-AntonioNeural",
|
|
63
|
+
"cadu": "pt-BR-AntonioNeural", "edresson": "pt-BR-AntonioNeural",
|
|
64
|
+
"faber": "pt-BR-AntonioNeural",
|
|
65
|
+
"sarah": "pt-BR-FranciscaNeural", "alice": "pt-BR-FranciscaNeural",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_verificado = None # cache do disponivel(): None = ainda não checou
|
|
69
|
+
_instalando = False
|
|
70
|
+
_tranca = threading.Lock()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _voz_de(voz_cfg):
|
|
74
|
+
"""Resolve o id/nome pedido no documento para um nome de voz do Edge."""
|
|
75
|
+
pedido = ""
|
|
76
|
+
if isinstance(voz_cfg, dict):
|
|
77
|
+
pedido = str(voz_cfg.get("voz") or "").strip()
|
|
78
|
+
elif isinstance(voz_cfg, str):
|
|
79
|
+
pedido = voz_cfg.strip()
|
|
80
|
+
if not pedido:
|
|
81
|
+
return PADRAO
|
|
82
|
+
baixo = pedido.lower()
|
|
83
|
+
if baixo in POR_ID:
|
|
84
|
+
return POR_ID[baixo]["voz"]
|
|
85
|
+
if baixo in APELIDOS:
|
|
86
|
+
return APELIDOS[baixo]
|
|
87
|
+
# Nome cru do Edge ("pt-BR-...Neural") passa direto.
|
|
88
|
+
if re.match(r"^[a-z]{2}-[A-Z]{2}-\w+$", pedido):
|
|
89
|
+
return pedido
|
|
90
|
+
return PADRAO
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _taxa(velocidade):
|
|
94
|
+
"""velocidade do documento (1.0 = normal) vira o --rate do edge (+N%)."""
|
|
95
|
+
try:
|
|
96
|
+
v = float(velocidade)
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
v = 1.0
|
|
99
|
+
v = max(0.5, min(2.0, v))
|
|
100
|
+
pct = int(round((v - 1.0) * 100))
|
|
101
|
+
return ("+%d%%" % pct) if pct >= 0 else ("%d%%" % pct)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def disponivel():
|
|
105
|
+
"""O edge-tts está utilizável nesta máquina? Resultado é cacheado: a
|
|
106
|
+
checagem roda o CLI e não vale repetir a cada narração."""
|
|
107
|
+
global _verificado
|
|
108
|
+
if _verificado is not None:
|
|
109
|
+
return _verificado
|
|
110
|
+
try:
|
|
111
|
+
r = subprocess.run(
|
|
112
|
+
[sys.executable, "-m", "edge_tts", "--help"],
|
|
113
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
|
|
114
|
+
_verificado = (r.returncode == 0)
|
|
115
|
+
except Exception:
|
|
116
|
+
_verificado = False
|
|
117
|
+
return _verificado
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rotina_instalar():
|
|
121
|
+
global _instalando, _verificado
|
|
122
|
+
try:
|
|
123
|
+
subprocess.run(
|
|
124
|
+
[sys.executable, "-m", "pip", "install", "--user", "--quiet", "edge-tts"],
|
|
125
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=180)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
sys.stderr.write("[edge] pip install falhou: %s\n" % e)
|
|
128
|
+
finally:
|
|
129
|
+
_verificado = None # força nova checagem
|
|
130
|
+
with _tranca:
|
|
131
|
+
_instalando = False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def instalar():
|
|
135
|
+
"""Tenta trazer o pacote em segundo plano, uma vez. Não bloqueia: quem
|
|
136
|
+
pediu narração agora ouve o próximo motor da corrente, e a partir da
|
|
137
|
+
próxima vez o Edge já responde."""
|
|
138
|
+
global _instalando
|
|
139
|
+
if disponivel():
|
|
140
|
+
return
|
|
141
|
+
with _tranca:
|
|
142
|
+
if _instalando:
|
|
143
|
+
return
|
|
144
|
+
_instalando = True
|
|
145
|
+
threading.Thread(target=_rotina_instalar, daemon=True).start()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _dur_do_vtt(caminho):
|
|
149
|
+
"""Último timestamp do VTT = fim da fala, em segundos. Medido, não
|
|
150
|
+
estimado. Formato do edge-tts: `HH:MM:SS,mmm --> HH:MM:SS,mmm`."""
|
|
151
|
+
try:
|
|
152
|
+
with open(caminho, "r", encoding="utf-8") as fh:
|
|
153
|
+
texto = fh.read()
|
|
154
|
+
except OSError:
|
|
155
|
+
return None
|
|
156
|
+
fins = re.findall(r"-->\s*(\d\d):(\d\d):(\d\d)[,.](\d\d\d)", texto)
|
|
157
|
+
if not fins:
|
|
158
|
+
return None
|
|
159
|
+
h, m, s, ms = fins[-1]
|
|
160
|
+
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sintetizar(texto, voz_cfg=None, timeout=90):
|
|
164
|
+
"""O texto vira (mp3_bytes, duracao_em_s). Devolve (None, None) quando o
|
|
165
|
+
Edge não está disponível ou falha — e NUNCA levanta exceção, para a
|
|
166
|
+
corrente de voz cair no motor seguinte sem tratamento especial."""
|
|
167
|
+
if not disponivel():
|
|
168
|
+
instalar()
|
|
169
|
+
return None, None
|
|
170
|
+
|
|
171
|
+
# Uma fala por vez: quebras viram uma frase só, com respiro de pontuação.
|
|
172
|
+
limpo = re.sub(r"\s+", " ", str(texto or "")).strip()
|
|
173
|
+
if not limpo:
|
|
174
|
+
return None, None
|
|
175
|
+
|
|
176
|
+
grave = bool(isinstance(voz_cfg, dict) and voz_cfg.get("grave"))
|
|
177
|
+
velocidade = (voz_cfg.get("velocidade") if isinstance(voz_cfg, dict) else None) or 1.0
|
|
178
|
+
voz = _voz_de(voz_cfg)
|
|
179
|
+
taxa = _taxa(velocidade)
|
|
180
|
+
|
|
181
|
+
pasta = tempfile.mkdtemp(prefix="primo-edge-")
|
|
182
|
+
try:
|
|
183
|
+
mp3 = os.path.join(pasta, "voz.mp3")
|
|
184
|
+
vtt = os.path.join(pasta, "voz.vtt")
|
|
185
|
+
try:
|
|
186
|
+
r = subprocess.run(
|
|
187
|
+
[sys.executable, "-m", "edge_tts",
|
|
188
|
+
"--voice", voz, "--rate", taxa, "--text", limpo,
|
|
189
|
+
"--write-media", mp3, "--write-subtitles", vtt],
|
|
190
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout)
|
|
191
|
+
except subprocess.TimeoutExpired:
|
|
192
|
+
sys.stderr.write("[edge] tempo esgotado (%ss)\n" % timeout)
|
|
193
|
+
return None, None
|
|
194
|
+
if r.returncode != 0 or not os.path.exists(mp3) or os.path.getsize(mp3) < 800:
|
|
195
|
+
msg = (r.stderr or b"").decode("utf-8", "replace")[-200:]
|
|
196
|
+
sys.stderr.write("[edge] síntese falhou: %s\n" % msg)
|
|
197
|
+
return None, None
|
|
198
|
+
with open(mp3, "rb") as fh:
|
|
199
|
+
dados = fh.read()
|
|
200
|
+
dur = _dur_do_vtt(vtt)
|
|
201
|
+
return dados, (round(dur, 2) if dur else None)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
sys.stderr.write("[edge] erro inesperado: %s\n" % e)
|
|
204
|
+
return None, None
|
|
205
|
+
finally:
|
|
206
|
+
import shutil
|
|
207
|
+
shutil.rmtree(pasta, ignore_errors=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def estado():
|
|
211
|
+
"""O que a interface mostra sobre este motor."""
|
|
212
|
+
with _tranca:
|
|
213
|
+
inst = _instalando
|
|
214
|
+
return {
|
|
215
|
+
"motor": "edge",
|
|
216
|
+
"disponivel": disponivel(),
|
|
217
|
+
"instalando": inst,
|
|
218
|
+
"precisa_internet": True,
|
|
219
|
+
"vozes": [{"id": v["id"], "nome": v["nome"], "genero": v["genero"],
|
|
220
|
+
"padrao": bool(v.get("padrao"))} for v in CATALOGO],
|
|
221
|
+
}
|
|
@@ -152,8 +152,22 @@ sociais) · `sarah` e `alice` (femininas). Também aceita um voice_id cru do
|
|
|
152
152
|
ElevenLabs — é assim que a voz clonada do próprio usuário entra. Consulte
|
|
153
153
|
`GET /api/voz/status` para saber o que está ativo nesta máquina.
|
|
154
154
|
|
|
155
|
-
A
|
|
156
|
-
|
|
155
|
+
**A corrente de voz, em ordem de qualidade** (cada motor cai no seguinte
|
|
156
|
+
quando não responde — a narração nunca fica muda):
|
|
157
|
+
|
|
158
|
+
1. **ElevenLabs** — premium, se houver chave com crédito.
|
|
159
|
+
2. **Edge TTS** — vozes neurais da Microsoft, **grátis e sem chave**, só
|
|
160
|
+
pede internet. É a melhor voz gratuita: `antonio` (masculina, de locução
|
|
161
|
+
— **a padrão gratuita**), `francisca` e `thalita` (femininas). Instala-se
|
|
162
|
+
sozinho na primeira vez (`pip install edge-tts`, ~1 min) e, enquanto isso,
|
|
163
|
+
a narração sai por outro motor. Não há por que citar "voz de robô": com
|
|
164
|
+
internet, o padrão já é voz neural de gente.
|
|
165
|
+
3. **Piper** — neural e **offline**, sem limite (detalhado abaixo).
|
|
166
|
+
4. **Voicebox** — se o app estiver instalado (voz clonável).
|
|
167
|
+
5. **Google** — última reserva, robótica, mas responde sempre.
|
|
168
|
+
|
|
169
|
+
O **Piper** roda na máquina do usuário — offline, sem limite. As vozes
|
|
170
|
+
pt-BR, com a frequência de cada uma medida (menor = mais grave):
|
|
157
171
|
|
|
158
172
|
| id | timbre | Hz |
|
|
159
173
|
|---|---|---|
|
package/studio/server.py
CHANGED
|
@@ -86,6 +86,31 @@ except Exception as _e:
|
|
|
86
86
|
|
|
87
87
|
elevenlabs_lib = _SemEleven()
|
|
88
88
|
|
|
89
|
+
try:
|
|
90
|
+
import edge as edge_lib # noqa: E402
|
|
91
|
+
except Exception as _e:
|
|
92
|
+
sys.stderr.write("[voz] edge indisponível (%s)\n" % _e)
|
|
93
|
+
|
|
94
|
+
class _SemEdge:
|
|
95
|
+
@staticmethod
|
|
96
|
+
def disponivel():
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def sintetizar(*a, **k):
|
|
101
|
+
return None, None
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def instalar():
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def estado():
|
|
109
|
+
return {"motor": "edge", "disponivel": False, "vozes": [],
|
|
110
|
+
"erro": "módulo indisponível"}
|
|
111
|
+
|
|
112
|
+
edge_lib = _SemEdge()
|
|
113
|
+
|
|
89
114
|
WEB = os.path.join(RAIZ, "web")
|
|
90
115
|
ARQUIVOS = os.path.join(RAIZ, "arquivos")
|
|
91
116
|
FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
|
|
@@ -378,27 +403,50 @@ def _assinatura_voz(texto, voz):
|
|
|
378
403
|
return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]
|
|
379
404
|
|
|
380
405
|
|
|
406
|
+
def _google_tts(texto, idioma="pt-BR"):
|
|
407
|
+
"""Google Translate TTS em MP3, no nível do módulo, para o pré-render
|
|
408
|
+
também ter o mesmo piso do endpoint: uma voz que responde sempre. Robótica,
|
|
409
|
+
mas garante que NENHUMA cena fique sem áudio. Devolve b"" se falhar."""
|
|
410
|
+
try:
|
|
411
|
+
pedacos = []
|
|
412
|
+
for t in _picar(texto, 190):
|
|
413
|
+
url = "https://translate.google.com/translate_tts?" + urlencode({
|
|
414
|
+
"ie": "UTF-8", "q": t, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
|
|
415
|
+
})
|
|
416
|
+
req = urllib.request.Request(url, headers={
|
|
417
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
418
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
|
|
419
|
+
"Referer": "https://translate.google.com/",
|
|
420
|
+
})
|
|
421
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
422
|
+
pedacos.append(r.read())
|
|
423
|
+
return b"".join(pedacos)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
sys.stderr.write("[voz] Google TTS falhou: %s\n" % e)
|
|
426
|
+
return b""
|
|
427
|
+
|
|
428
|
+
|
|
381
429
|
def _gravar_narracao(arq_id):
|
|
382
430
|
"""Sintetiza a narração de cada cena e guarda em midia/."""
|
|
383
431
|
try:
|
|
384
|
-
# Com
|
|
385
|
-
#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
#
|
|
432
|
+
# Com ElevenLabs (chave) ou Edge (grátis, neural, sem download) já há
|
|
433
|
+
# voz de gente na hora — não se espera o Piper. Só quando NENHUM dos
|
|
434
|
+
# dois responde vale aguardar o Piper baixar, porque a alternativa é
|
|
435
|
+
# o Google, que soa a robô no vídeo que a pessoa vai apresentar.
|
|
436
|
+
if not elevenlabs_lib.configurado() and not edge_lib.disponivel():
|
|
437
|
+
edge_lib.instalar() # tenta trazer o Edge em paralelo
|
|
390
438
|
for _ in range(120):
|
|
391
|
-
if voz_lib.pronta():
|
|
439
|
+
if voz_lib.pronta() or edge_lib.disponivel():
|
|
392
440
|
break
|
|
393
441
|
# Máquina onde o Piper nunca vai rodar (binário de outra
|
|
394
442
|
# arquitetura): esperar 10 minutos aqui era esperar por nada.
|
|
395
443
|
if voz_lib.bloqueado():
|
|
396
|
-
sys.stderr.write("[voz]
|
|
397
|
-
|
|
444
|
+
sys.stderr.write("[voz] Piper bloqueado: %s\n" % voz_lib.bloqueado())
|
|
445
|
+
break
|
|
398
446
|
voz_lib.instalar()
|
|
399
447
|
time.sleep(5)
|
|
400
|
-
|
|
401
|
-
|
|
448
|
+
# Nem Piper nem Edge subiram: ainda assim seguimos — o Google, lá
|
|
449
|
+
# embaixo na síntese, garante que nenhuma cena fique sem áudio.
|
|
402
450
|
|
|
403
451
|
a = ler(arq_id)
|
|
404
452
|
if not a:
|
|
@@ -422,12 +470,25 @@ def _gravar_narracao(arq_id):
|
|
|
422
470
|
if c.get("narracaoAudio") and not c.get("narracaoVoz"):
|
|
423
471
|
continue
|
|
424
472
|
audio, ext, dur = None, ".wav", None
|
|
425
|
-
#
|
|
426
|
-
#
|
|
473
|
+
# A CORRENTE, na ordem de qualidade. Cada motor cai no seguinte
|
|
474
|
+
# quando não devolve áudio — e o Google, no fim, garante que a
|
|
475
|
+
# cena nunca fique muda. É o que faz o pré-render nunca parar.
|
|
476
|
+
|
|
477
|
+
# 1) ElevenLabs: a voz que o usuário aprovou. MP3 CBR — a duração
|
|
478
|
+
# do módulo já vem medida.
|
|
427
479
|
if elevenlabs_lib.configurado():
|
|
428
480
|
audio, dur = elevenlabs_lib.sintetizar(texto, voz)
|
|
429
481
|
if audio:
|
|
430
482
|
ext = ".mp3"
|
|
483
|
+
|
|
484
|
+
# 2) Edge TTS: neural, grátis, sem chave. MP3, duração medida pelo
|
|
485
|
+
# VTT que o próprio edge escreve.
|
|
486
|
+
if not audio and edge_lib.disponivel():
|
|
487
|
+
dados, edur = edge_lib.sintetizar(texto, voz)
|
|
488
|
+
if dados:
|
|
489
|
+
audio, dur, ext = dados, edur, ".mp3"
|
|
490
|
+
|
|
491
|
+
# 3) Piper: neural e offline, quando baixado e compatível. WAV.
|
|
431
492
|
if not audio:
|
|
432
493
|
try:
|
|
433
494
|
audio = voz_lib.sintetizar(
|
|
@@ -435,10 +496,19 @@ def _gravar_narracao(arq_id):
|
|
|
435
496
|
grave=bool(voz.get("grave")),
|
|
436
497
|
velocidade=float(voz.get("velocidade") or 1))
|
|
437
498
|
except Exception as e:
|
|
438
|
-
sys.stderr.write("[voz] cena %d falhou: %s\n" % (i + 1, e))
|
|
439
|
-
|
|
499
|
+
sys.stderr.write("[voz] Piper cena %d falhou: %s\n" % (i + 1, e))
|
|
500
|
+
audio = None
|
|
440
501
|
if audio:
|
|
441
|
-
dur = _duracao_wav(audio)
|
|
502
|
+
dur, ext = _duracao_wav(audio), ".wav"
|
|
503
|
+
|
|
504
|
+
# 4) Google: última reserva. Robótica, mas responde. MP3 — a
|
|
505
|
+
# duração sai do tamanho (CBR de 24 kbps ≈ 3000 bytes/s).
|
|
506
|
+
if not audio:
|
|
507
|
+
dados = _google_tts(texto)
|
|
508
|
+
if dados:
|
|
509
|
+
audio, ext = dados, ".mp3"
|
|
510
|
+
dur = round(len(dados) / 3000.0, 2)
|
|
511
|
+
|
|
442
512
|
if not audio:
|
|
443
513
|
continue
|
|
444
514
|
nome = "narr-%s-%d-%s%s" % (arq_id[:28], i + 1, assinatura, ext)
|
|
@@ -1057,10 +1127,22 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
1057
1127
|
elif motor == "elevenlabs":
|
|
1058
1128
|
return self.js({"erro": "ElevenLabs não devolveu áudio — chave válida? cota?"}, 502)
|
|
1059
1129
|
|
|
1130
|
+
# 0.5) Edge TTS: neural, grátis, sem chave. Entra logo depois do
|
|
1131
|
+
# ElevenLabs porque é a melhor voz gratuita da corrente — vozes
|
|
1132
|
+
# da Microsoft, muito acima do Google. Precisa de internet.
|
|
1133
|
+
if not audio and motor in ("", "edge"):
|
|
1134
|
+
dados, _ = edge_lib.sintetizar(texto, {"voz": voz_pedida,
|
|
1135
|
+
"grave": grave, "velocidade": velocidade})
|
|
1136
|
+
if dados:
|
|
1137
|
+
audio = dados
|
|
1138
|
+
tipo = "audio/mpeg"
|
|
1139
|
+
elif motor == "edge":
|
|
1140
|
+
return self.js({"erro": "Edge TTS não devolveu áudio — sem internet ou pacote ausente?"}, 502)
|
|
1141
|
+
|
|
1060
1142
|
# 1) Piper: o motor local. Se ainda não baixou, começa a baixar agora
|
|
1061
1143
|
# e segue para o próximo — quem pediu narração hoje ouve o Google,
|
|
1062
1144
|
# e a partir da próxima já é voz de verdade.
|
|
1063
|
-
if not audio and motor not in ("google", "voicebox", "elevenlabs"):
|
|
1145
|
+
if not audio and motor not in ("google", "voicebox", "elevenlabs", "edge"):
|
|
1064
1146
|
try:
|
|
1065
1147
|
if voz_lib.pronta(voz_pedida or None):
|
|
1066
1148
|
audio = voz_lib.sintetizar(texto, voz_pedida or None,
|
|
@@ -1125,6 +1207,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|
|
1125
1207
|
vozes = []
|
|
1126
1208
|
return self.js({
|
|
1127
1209
|
"elevenlabs": elevenlabs_lib.estado(),
|
|
1210
|
+
"edge": edge_lib.estado(),
|
|
1128
1211
|
"piper": voz_lib.estado(),
|
|
1129
1212
|
"voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
|
|
1130
1213
|
"google": {"ativo": True},
|
package/studio/web/js/render.js
CHANGED
|
@@ -178,25 +178,43 @@ function aplicarChaves(no, e, ctx) {
|
|
|
178
178
|
return _numero(a.t, 0) - _numero(b.t, 0);
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
// Um campo OMITIDO numa chave herda o valor da chave anterior — não pula
|
|
182
|
+
// para um padrão fixo. Sem isto, uma chave que só mexe em `opacidade`
|
|
183
|
+
// zerava a `escala` implícita: o elemento nascia com scale(0) e sumia da
|
|
184
|
+
// tela, e a animação parecia "bugada e atrasada". Herdar é o que a pessoa
|
|
185
|
+
// espera: "o que eu não toquei, fica como estava". O primeiro valor de
|
|
186
|
+
// cada campo, quando nem a primeira chave o declara, é o neutro (escala e
|
|
187
|
+
// opacidade em 1, deslocamento e giro em 0) — nunca zero para escala.
|
|
188
|
+
var NEUTRO = { x: 0, y: 0, escala: 1, giro: 0, opacidade: 1, desfoque: 0 };
|
|
189
|
+
var CAMPOS = ["x", "y", "escala", "giro", "opacidade", "desfoque"];
|
|
190
|
+
var herdado = {};
|
|
191
|
+
CAMPOS.forEach(function (c) {
|
|
192
|
+
// semente: primeiro valor declarado em qualquer chave, senão o neutro.
|
|
193
|
+
for (var i = 0; i < chaves.length; i++) {
|
|
194
|
+
if (typeof chaves[i][c] === "number" && isFinite(chaves[i][c])) { herdado[c] = chaves[i][c]; return; }
|
|
195
|
+
}
|
|
196
|
+
herdado[c] = NEUTRO[c];
|
|
197
|
+
});
|
|
198
|
+
|
|
181
199
|
var nome = "ch" + (++_nChave);
|
|
182
200
|
var passos = chaves.map(function (k) {
|
|
201
|
+
CAMPOS.forEach(function (c) {
|
|
202
|
+
if (typeof k[c] === "number" && isFinite(k[c])) herdado[c] = k[c];
|
|
203
|
+
});
|
|
183
204
|
var pct = Math.max(0, Math.min(1, _numero(k.t, 0))) * 100;
|
|
184
205
|
var mov = [];
|
|
185
206
|
// x e y de uma chave são DESLOCAMENTO a partir de onde o elemento está.
|
|
186
207
|
// Assim a chave continua valendo se a pessoa arrastar o elemento no
|
|
187
208
|
// palco depois — o contrário obrigaria a refazer todas as chaves.
|
|
188
|
-
var dx =
|
|
209
|
+
var dx = herdado.x, dy = herdado.y;
|
|
189
210
|
if (dx || dy) mov.push("translate(" + dx + "px," + dy + "px)");
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
var giro = _numero(k.giro, 0);
|
|
193
|
-
if (giro) mov.push("rotate(" + giro + "deg)");
|
|
211
|
+
if (herdado.escala !== 1) mov.push("scale(" + herdado.escala + ")");
|
|
212
|
+
if (herdado.giro) mov.push("rotate(" + herdado.giro + "deg)");
|
|
194
213
|
|
|
195
214
|
var linhas = ["transform:" + (mov.length ? mov.join(" ") : "none")];
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
linhas.push("filter:blur(" + k.desfoque + "px)");
|
|
215
|
+
linhas.push("opacity:" + herdado.opacidade);
|
|
216
|
+
if (herdado.desfoque > 0) {
|
|
217
|
+
linhas.push("filter:blur(" + herdado.desfoque + "px)");
|
|
200
218
|
}
|
|
201
219
|
return pct.toFixed(3) + "%{" + linhas.join(";") + "}";
|
|
202
220
|
}).join("");
|