primocode 9.1.0 → 9.2.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/README.md +11 -17
- package/bin/primocode.js +88 -15
- package/lib/act.js +2 -2
- package/lib/{acervo.js → estudio/acervo.js} +1 -1
- package/lib/estudio/assets.js +1 -1
- package/lib/estudio/identidade.js +2 -2
- package/lib/estudio/index.js +1 -1
- package/lib/{marca.js → estudio/marca.js} +1 -1
- package/lib/repeticao.js +6 -1
- package/lib/tools.js +35 -10
- package/lib/ui.js +10 -4
- package/lib/voz.js +104 -4
- package/package.json +2 -2
- package/voz/primo_voz/__main__.py +67 -3
- package/voz/primo_voz/desperta.py +195 -0
- package/voz/primo_voz/escuta.py +5 -4
- package/voz/primo_voz/orbe.py +42 -0
- /package/lib/{diretor.js → estudio/diretor.js} +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.
|
|
1
|
+
# PrimoCode v9.2.1
|
|
2
2
|
|
|
3
3
|
Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
|
|
4
4
|
Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
|
|
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
|
|
|
9
9
|
está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
▐▛███▜▌ Primo Code v9.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.2.1
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
|
@@ -33,25 +33,19 @@ Em uma frase: **é o poder de um agente de código no terminal, em português, c
|
|
|
33
33
|
|
|
34
34
|
## Instalação
|
|
35
35
|
|
|
36
|
-
**macOS / Linux / WSL** — um comando, e ele resolve tudo (inclusive instalar o Node, se faltar):
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
curl -fsSL https://raw.githubusercontent.com/ConectaPrimoAI/primocode/main/install.sh | bash
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
**Windows** — baixe o repositório e rode:
|
|
43
|
-
|
|
44
|
-
```cmd
|
|
45
|
-
install.cmd
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Já tem Node 18.17+? Então basta:
|
|
49
|
-
|
|
50
36
|
```bash
|
|
51
37
|
npm install -g primocode
|
|
52
38
|
```
|
|
53
39
|
|
|
54
|
-
Requer **Node.js 18.17+**. O CLI não tem
|
|
40
|
+
Requer **Node.js 18.17+**. O CLI não tem dependência nenhuma — só a stdlib do
|
|
41
|
+
Node — e por isso instala em segundos. Depois é só `primocode`: ele abre o
|
|
42
|
+
login no navegador e, voltando, está pronto.
|
|
43
|
+
|
|
44
|
+
Não tem Node? Instale em [nodejs.org](https://nodejs.org) e repita o comando
|
|
45
|
+
acima. Quem já clonou este repositório pode usar os instaladores que moram
|
|
46
|
+
aqui (`install.sh` no macOS/Linux/WSL, `install.cmd` no Windows): eles detectam
|
|
47
|
+
o Node, oferecem instalar, e apontam o npm para `~/.npm-global` em vez da pasta
|
|
48
|
+
do sistema — que é o que evita o `EACCES` da tabela abaixo.
|
|
55
49
|
|
|
56
50
|
### Se der erro
|
|
57
51
|
|
package/bin/primocode.js
CHANGED
|
@@ -151,7 +151,8 @@ const SLASH_COMANDOS = [
|
|
|
151
151
|
['/account', 'sua conta, plano e computadores conectados'],
|
|
152
152
|
['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
|
|
153
153
|
['/logout', 'desconecta este computador da conta'],
|
|
154
|
-
['/voice', '
|
|
154
|
+
['/voice', 'fica ouvindo em segundo plano — diga "ei, primo" e a orb aparece'],
|
|
155
|
+
['/voice parar', 'desliga o modo voz e fecha o microfone'],
|
|
155
156
|
['/skills', 'o que ele sabe fazer bem — ligue e desligue'],
|
|
156
157
|
['/marketplace', 'a tela das skills: navegue, ligue, crie'],
|
|
157
158
|
['/install-skill', 'instala uma skill de um arquivo'],
|
|
@@ -966,6 +967,11 @@ async function comprimirConversa(forcado = false) {
|
|
|
966
967
|
// na tela depois de o usuário ter mandado parar.
|
|
967
968
|
let chatEmVoo = null;
|
|
968
969
|
|
|
970
|
+
/* O ouvinte de voz em segundo plano.
|
|
971
|
+
Um por sessão: dois processos disputando o mesmo microfone dão um vazio
|
|
972
|
+
intermitente que parece defeito de hardware e não é. */
|
|
973
|
+
let vozEmEspera = null;
|
|
974
|
+
|
|
969
975
|
async function runChat(promptForModel, userTextForMemory) {
|
|
970
976
|
chatEmVoo = new AbortController();
|
|
971
977
|
const sp = new Spinner(ui.PENSANDO).start();
|
|
@@ -1405,6 +1411,24 @@ async function handleSlash(text) {
|
|
|
1405
1411
|
* garantia de que um deles está errado.
|
|
1406
1412
|
*/
|
|
1407
1413
|
case 'voice': case 'voz': {
|
|
1414
|
+
/* ── /voice PARA ─────────────────────────────────────────────
|
|
1415
|
+
Encerrar é o primeiro caso tratado de propósito: quem quer
|
|
1416
|
+
desligar o microfone quer desligar AGORA, e não deve esbarrar
|
|
1417
|
+
em checagem de dependência nem em download de modelo. */
|
|
1418
|
+
if (/^(parar|stop|off|sair)$/i.test((rest || '').trim())) {
|
|
1419
|
+
if (!vozEmEspera) { console.log(c.muted(' O modo voz não está ligado.')); break; }
|
|
1420
|
+
try { vozEmEspera.kill(); } catch {}
|
|
1421
|
+
vozEmEspera = null;
|
|
1422
|
+
console.log(c.muted(' Modo voz desligado. O microfone fechou.'));
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (vozEmEspera) {
|
|
1427
|
+
console.log(c.muted(' O modo voz já está ouvindo. Diga "ei, primo".'));
|
|
1428
|
+
console.log(c.dim(' /voice parar desliga.'));
|
|
1429
|
+
break;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1408
1432
|
const pronto = vozModo.conferir();
|
|
1409
1433
|
if (!pronto.ok && !pronto.instalavel) {
|
|
1410
1434
|
console.log(c.err(` ${pronto.error}`));
|
|
@@ -1430,29 +1454,59 @@ async function handleSlash(text) {
|
|
|
1430
1454
|
const conferido = vozModo.conferir();
|
|
1431
1455
|
if (!conferido.ok) {
|
|
1432
1456
|
console.log(c.err(` ainda falta: ${conferido.error}`));
|
|
1433
|
-
if (conferido.comoResolver) console.log(c.muted(` ${conferido.comoResolver}`));
|
|
1434
1457
|
break;
|
|
1435
1458
|
}
|
|
1436
1459
|
}
|
|
1437
1460
|
|
|
1438
1461
|
const py = (pronto.ok ? pronto : vozModo.conferir()).python;
|
|
1462
|
+
|
|
1463
|
+
/* ── O RECONHECEDOR LOCAL ────────────────────────────────────
|
|
1464
|
+
Ele é o que permite o microfone ficar aberto sem o áudio sair
|
|
1465
|
+
da máquina: o nome é reconhecido AQUI, e só depois disso a
|
|
1466
|
+
frase viaja. São ~31 MB, uma vez, e ficam em ~/.primocode.
|
|
1467
|
+
|
|
1468
|
+
Se a pessoa recusar, o modo voz ainda abre — mas já ouvindo,
|
|
1469
|
+
sem espera. O que NÃO acontece em hipótese nenhuma é cair para
|
|
1470
|
+
"manda tudo para o servidor o dia inteiro": isso seria trocar
|
|
1471
|
+
a privacidade dela por conveniência sem perguntar. */
|
|
1472
|
+
const espera = await vozModo.conferirEspera(py);
|
|
1473
|
+
let esperando = espera.ok;
|
|
1474
|
+
if (!espera.ok && espera.baixavel) {
|
|
1475
|
+
console.log('');
|
|
1476
|
+
console.log(' ' + c.muted('Para ficar ouvindo só o nome, sem mandar áudio para lugar nenhum,'));
|
|
1477
|
+
console.log(' ' + c.muted('ele precisa do reconhecedor local — ') + c.dim('~31 MB, uma vez.'));
|
|
1478
|
+
if (await confirm('Baixar agora?')) {
|
|
1479
|
+
const sp = new Spinner('baixando o reconhecedor').start();
|
|
1480
|
+
const r = await vozModo.baixarEspera(py);
|
|
1481
|
+
sp.stop();
|
|
1482
|
+
if (r.ok) esperando = true;
|
|
1483
|
+
else {
|
|
1484
|
+
console.log(c.err(` não deu: ${r.error}`));
|
|
1485
|
+
if (r.comoResolver) console.log(c.dim(` ${r.comoResolver}`));
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
} else if (!espera.ok && espera.error) {
|
|
1489
|
+
console.log(c.dim(` ${espera.error}`));
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1439
1492
|
console.log('');
|
|
1440
1493
|
console.log(' ' + c.bold(c.brand('Primo Code Voice')));
|
|
1441
|
-
|
|
1442
|
-
+ c.
|
|
1443
|
-
|
|
1494
|
+
if (esperando) {
|
|
1495
|
+
console.log(' ' + c.muted('Ouvindo em segundo plano. Diga ') + c.white('"ei, primo"') + c.muted(' e a orb aparece.'));
|
|
1496
|
+
console.log(' ' + c.dim('Nada sai da máquina até você chamar · /voice parar desliga'));
|
|
1497
|
+
} else {
|
|
1498
|
+
console.log(' ' + c.muted('É só falar. A orb responde: ')
|
|
1499
|
+
+ c.dim('parada espera · pulsa ouvindo · respira pensando · brilha falando'));
|
|
1500
|
+
console.log(' ' + c.dim('Falar por cima corta a resposta · /voice parar desliga'));
|
|
1501
|
+
}
|
|
1444
1502
|
console.log('');
|
|
1445
1503
|
|
|
1446
|
-
|
|
1447
|
-
// está digitando, e uma caixa piscando embaixo é ruído.
|
|
1448
|
-
if (rl && rl.suspender) rl.suspender();
|
|
1449
|
-
sliderAtivo = true;
|
|
1450
|
-
|
|
1451
|
-
const r = await vozModo.abrir({
|
|
1504
|
+
const sessao = vozModo.abrir({
|
|
1452
1505
|
python: py,
|
|
1453
1506
|
servidor: state.config.server,
|
|
1454
1507
|
token: state.config.token,
|
|
1455
1508
|
voz: state.config.voz || 'antonio',
|
|
1509
|
+
esperando,
|
|
1456
1510
|
mostrar: (texto, deErro) => {
|
|
1457
1511
|
if (deErro) console.log(' ' + c.dim(texto));
|
|
1458
1512
|
else console.log(' ' + c.brand('❯ ') + c.white(texto));
|
|
@@ -1485,10 +1539,17 @@ async function handleSlash(text) {
|
|
|
1485
1539
|
},
|
|
1486
1540
|
});
|
|
1487
1541
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1542
|
+
/* O TERMINAL CONTINUA SEU.
|
|
1543
|
+
Aqui NÃO se espera o fim. Era isso que obrigava a digitar
|
|
1544
|
+
/voice antes de cada pedido: o comando bloqueava o prompt, e a
|
|
1545
|
+
única forma de voltar a digitar era encerrar o modo voz. Agora
|
|
1546
|
+
ele vive ao lado — a pessoa fala quando quiser e digita quando
|
|
1547
|
+
quiser, sem desligar um para usar o outro. */
|
|
1548
|
+
vozEmEspera = sessao.processo;
|
|
1549
|
+
sessao.pronto.then((r) => {
|
|
1550
|
+
vozEmEspera = null;
|
|
1551
|
+
if (r && !r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
|
|
1552
|
+
});
|
|
1492
1553
|
break;
|
|
1493
1554
|
}
|
|
1494
1555
|
|
|
@@ -1971,6 +2032,18 @@ async function main() {
|
|
|
1971
2032
|
function sayBye() {
|
|
1972
2033
|
if (saidBye) return;
|
|
1973
2034
|
saidBye = true;
|
|
2035
|
+
|
|
2036
|
+
/* O MICROFONE FECHA JUNTO.
|
|
2037
|
+
O ouvinte de voz é um processo filho, e filho no POSIX não morre
|
|
2038
|
+
porque o pai saiu: ficaria um Python segurando o microfone depois
|
|
2039
|
+
de o terminal fechar — sem janela, sem jeito de desligar a não ser
|
|
2040
|
+
pelo gerenciador de tarefas, e com a luzinha da câmera do lado
|
|
2041
|
+
acesa. Matar aqui é o que faz "fechei o terminal" significar
|
|
2042
|
+
"fechei tudo". */
|
|
2043
|
+
if (vozEmEspera) {
|
|
2044
|
+
try { vozEmEspera.kill(); } catch { /* já morreu */ }
|
|
2045
|
+
vozEmEspera = null;
|
|
2046
|
+
}
|
|
1974
2047
|
// O último turno pode ainda estar subindo — gravar é de propósito
|
|
1975
2048
|
// solto, para não segurar o prompt. Fechar o terminal em cima dele
|
|
1976
2049
|
// perderia justamente a resposta que a pessoa acabou de ler, e ela
|
package/lib/act.js
CHANGED
|
@@ -539,7 +539,7 @@ async function runAct(userPrompt, opts) {
|
|
|
539
539
|
* Num subagente não entra: ele já está fazendo uma parte de um plano, e
|
|
540
540
|
* um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
|
|
541
541
|
if (!subagent && profundidade === 0) {
|
|
542
|
-
const producao = require('./diretor.js').precisaDeProducao(pedido);
|
|
542
|
+
const producao = require('./estudio/diretor.js').precisaDeProducao(pedido);
|
|
543
543
|
if (producao.producao) {
|
|
544
544
|
messages.push({ role: 'system', content:
|
|
545
545
|
'ESTE PEDIDO É DE PRODUÇÃO AUDIOVISUAL. Antes de criar qualquer peça, chame '
|
|
@@ -1108,7 +1108,7 @@ async function rodarDiretor(args, ctx) {
|
|
|
1108
1108
|
return { ok: false, error: 'Recusado: o diretor não roda dentro de um subagente. '
|
|
1109
1109
|
+ 'Produza com o plano que você já recebeu.' };
|
|
1110
1110
|
}
|
|
1111
|
-
const diretor = require('./diretor.js');
|
|
1111
|
+
const diretor = require('./estudio/diretor.js');
|
|
1112
1112
|
const correr = async (prompt, rotulo) => {
|
|
1113
1113
|
if (PARAR) return '';
|
|
1114
1114
|
fala.agente('Diretor', rotulo, profundidade);
|
|
@@ -73,7 +73,7 @@ function buscarJSON(url, { timeout = 15000, cabecalhos = {} } = {}) {
|
|
|
73
73
|
política deles pede. */
|
|
74
74
|
function agente() {
|
|
75
75
|
let v = '?';
|
|
76
|
-
try { v = require('
|
|
76
|
+
try { v = require('../../package.json').version; } catch { /* sem package: tanto faz */ }
|
|
77
77
|
return `PrimoCode/${v} (https://github.com/parisgroup-ai/primocode)`;
|
|
78
78
|
}
|
|
79
79
|
|
package/lib/estudio/assets.js
CHANGED
|
@@ -58,7 +58,7 @@ function pegar(url, { json = false, binario = false, cabecalhos = {} } = {}) {
|
|
|
58
58
|
path: u.pathname + u.search,
|
|
59
59
|
headers: {
|
|
60
60
|
// O Openverse recusa quem não se identifica, e com razão.
|
|
61
|
-
'User-Agent': 'PrimoCode-Studio/1.0 (+https://
|
|
61
|
+
'User-Agent': 'PrimoCode-Studio/1.0 (+https://www.npmjs.com/package/primocode)',
|
|
62
62
|
Accept: json ? 'application/json' : '*/*',
|
|
63
63
|
...cabecalhos,
|
|
64
64
|
},
|
|
@@ -36,8 +36,8 @@ const path = require('path');
|
|
|
36
36
|
const https = require('https');
|
|
37
37
|
const referencia = require('../referencia.js');
|
|
38
38
|
const assets = require('./assets.js');
|
|
39
|
-
const marca = require('
|
|
40
|
-
const acervo = require('
|
|
39
|
+
const marca = require('./marca.js');
|
|
40
|
+
const acervo = require('./acervo.js');
|
|
41
41
|
const http = require('http');
|
|
42
42
|
const { URL } = require('url');
|
|
43
43
|
|
package/lib/estudio/index.js
CHANGED
|
@@ -33,7 +33,7 @@ const path = require('path');
|
|
|
33
33
|
const { spawn } = require('child_process');
|
|
34
34
|
|
|
35
35
|
const projeto = require('./projeto.js');
|
|
36
|
-
const acervo = require('
|
|
36
|
+
const acervo = require('./acervo.js');
|
|
37
37
|
const plano = require('./plano.js');
|
|
38
38
|
const remotion = require('./remotion.js');
|
|
39
39
|
const assets = require('./assets.js');
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
const fs = require('fs');
|
|
35
35
|
const path = require('path');
|
|
36
|
-
const referencia = require('
|
|
36
|
+
const referencia = require('../referencia.js');
|
|
37
37
|
|
|
38
38
|
const GENERICAS_FONTE = /^(sans-serif|serif|monospace|system-ui|-apple-system|blinkmacsystemfont|segoe ui|arial|helvetica( neue)?|roboto|inherit|initial|ui-sans-serif|ui-serif|ui-monospace|apple color emoji|segoe ui emoji|noto color emoji|times new roman|courier new|verdana|tahoma|georgia)$/i;
|
|
39
39
|
|
package/lib/repeticao.js
CHANGED
|
@@ -33,7 +33,12 @@
|
|
|
33
33
|
'use strict';
|
|
34
34
|
|
|
35
35
|
/** Tools cuja resposta é documento fixo — repetir é sempre desperdício. */
|
|
36
|
-
|
|
36
|
+
/* O documento fixo que NÃO pode encolher na conversa. Eram o manual e a spec
|
|
37
|
+
do Estúdio antigo; o Estúdio novo não tem manual porque não tem formato a
|
|
38
|
+
decorar. Quem ocupa esse lugar é a PAUTA do diretor, escrita uma vez, que o
|
|
39
|
+
produtor relê enquanto monta as cenas — encolhê-la faz o modelo chamar o
|
|
40
|
+
diretor de novo, que é o laço que isto existe para evitar. */
|
|
41
|
+
const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
|
|
37
42
|
|
|
38
43
|
/** Acima disto, duas buscas são a mesma busca. */
|
|
39
44
|
const LIMITE_SEMELHANCA = 0.6;
|
package/lib/tools.js
CHANGED
|
@@ -429,8 +429,9 @@ async function despachar(name, args, ctx) {
|
|
|
429
429
|
return {
|
|
430
430
|
ok: false,
|
|
431
431
|
error: `"${args.path}" é arquivo de vídeo/áudio, e write_file só escreve TEXTO — isso criaria um arquivo falso que não abre. `
|
|
432
|
-
+ 'Vídeo se faz no
|
|
433
|
-
+ 'O
|
|
432
|
+
+ 'Vídeo se faz no Estúdio: estudio_projeto (criar) → planejar_producao → estudio_plano → '
|
|
433
|
+
+ 'estudio_render. O render devolve o CAMINHO do arquivo — é esse o vídeo. '
|
|
434
|
+
+ 'Não monte vídeo com ffmpeg à mão.',
|
|
434
435
|
};
|
|
435
436
|
}
|
|
436
437
|
const filePath = resolveInProject(args.path, projectDir);
|
|
@@ -610,14 +611,38 @@ async function despachar(name, args, ctx) {
|
|
|
610
611
|
divergir do catálogo daqui, e ela se conserta lá. Mas a rede
|
|
611
612
|
é o que separa uma tarefa que se recupera de uma que morre
|
|
612
613
|
com o usuário esperando. */
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
614
|
+
{
|
|
615
|
+
/* O CASO QUE ACONTECE DE VERDADE tem nome: o servidor
|
|
616
|
+
está desatualizado e continua oferecendo o Estúdio
|
|
617
|
+
antigo (studio_criar, studio_spec…). Visto ao vivo, e a
|
|
618
|
+
reação do modelo foi a pior possível: chutou nomes de
|
|
619
|
+
outro produto (`repo_browser.read_file`), tentou
|
|
620
|
+
escrever um .mp4 com write_file, e desistiu com "parei
|
|
621
|
+
sem concluir" — sem nunca dizer ao usuário qual era o
|
|
622
|
+
problema.
|
|
623
|
+
|
|
624
|
+
Então a resposta separa os dois casos. Para o Estúdio
|
|
625
|
+
velho ela EXPLICA o que houve e manda diagnosticar em
|
|
626
|
+
voz alta se as novas não estiverem na lista dele: um
|
|
627
|
+
"o servidor está desatualizado" é acionável; um "parei
|
|
628
|
+
sem concluir" não é. */
|
|
629
|
+
const velhoEstudio = /^studio_/.test(String(name));
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
error: `A ferramenta "${name}" não existe.`,
|
|
633
|
+
parecidas: catalogo.parecidasCom(name),
|
|
634
|
+
comoResolver: velhoEstudio
|
|
635
|
+
? 'O Estúdio foi TROCADO: studio_* não existe mais. As ferramentas de hoje são '
|
|
636
|
+
+ 'planejar_producao, estudio_projeto, estudio_plano, estudio_midia, estudio_narrar, '
|
|
637
|
+
+ 'estudio_video, estudio_render e estudio_site. Use-as. '
|
|
638
|
+
+ 'Se ELAS não estiverem na sua lista de ferramentas, o servidor está desatualizado: '
|
|
639
|
+
+ 'PARE de tentar outros nomes e diga isso ao usuário em uma frase — é o que ele '
|
|
640
|
+
+ 'precisa saber para resolver.'
|
|
641
|
+
: 'Chame uma das ferramentas que existem de verdade; as "parecidas" acima são as de '
|
|
642
|
+
+ 'nome mais próximo. NÃO invente nome de ferramenta e não tente variações — se '
|
|
643
|
+
+ 'nenhuma servir, diga ao usuário o que faltou, em vez de desistir calado.',
|
|
644
|
+
};
|
|
645
|
+
}
|
|
621
646
|
}
|
|
622
647
|
}
|
|
623
648
|
}
|
package/lib/ui.js
CHANGED
|
@@ -295,12 +295,18 @@ const ROTULOS = {
|
|
|
295
295
|
desktop_drag:'Desktop·drag', desktop_type:'Desktop·type', desktop_key:'Desktop·key',
|
|
296
296
|
desktop_scroll:'Desktop·scroll', desktop_size:'Desktop·size',
|
|
297
297
|
desktop_double_click:'Desktop·2click', desktop_open_app:'Desktop·app',
|
|
298
|
-
studio_manual
|
|
299
|
-
|
|
300
|
-
|
|
298
|
+
/* Os nomes do Estúdio ATUAL. Os antigos (studio_manual, studio_spec,
|
|
299
|
+
studio_criar…) saíram daqui de propósito: enquanto estavam, uma
|
|
300
|
+
chamada a ferramenta que não existe mais aparecia na tela com
|
|
301
|
+
rótulo bonito ("Estúdio·spec corte"), como se fosse trabalho
|
|
302
|
+
legítimo — e só a linha de baixo revelava que tinha falhado. */
|
|
303
|
+
planejar_producao:'Diretor', estudio_projeto:'Estúdio·projeto',
|
|
304
|
+
estudio_plano:'Estúdio·plano', estudio_narrar:'Estúdio·narração',
|
|
305
|
+
estudio_video:'Estúdio·vídeo', estudio_render:'Estúdio·render',
|
|
306
|
+
estudio_site:'Estúdio·marca',
|
|
301
307
|
desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
|
|
302
308
|
desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
|
|
303
|
-
|
|
309
|
+
estudio_midia:'Estúdio·mídia', construir_app:'Construir',
|
|
304
310
|
remember_general:'Lembrar',
|
|
305
311
|
};
|
|
306
312
|
|
package/lib/voz.js
CHANGED
|
@@ -169,10 +169,13 @@ function instalar(py, pacotes, aoFalar = () => {}) {
|
|
|
169
169
|
* @param {string} op.voz
|
|
170
170
|
* @param {(texto:string)=>Promise<string>} op.pensar o agente de verdade
|
|
171
171
|
* @param {(texto:string)=>void} op.mostrar o que aparece no terminal
|
|
172
|
+
* @param {boolean} op.esperando fica dormindo até ouvir o nome
|
|
173
|
+
* @returns {{processo, pronto: Promise}} o processo, e a promessa do fim
|
|
172
174
|
*/
|
|
173
|
-
function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} }) {
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {}, esperando = false }) {
|
|
176
|
+
let p;
|
|
177
|
+
const pronto = new Promise((resolve) => {
|
|
178
|
+
p = spawn(py, ['-m', 'primo_voz'], {
|
|
176
179
|
cwd: PASTA,
|
|
177
180
|
env: {
|
|
178
181
|
...process.env,
|
|
@@ -180,6 +183,8 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} })
|
|
|
180
183
|
PRIMOCODE_SERVER: servidor,
|
|
181
184
|
PRIMOCODE_TOKEN: token || '',
|
|
182
185
|
PRIMOCODE_VOZ: voz || 'antonio',
|
|
186
|
+
// Dormir até ouvir o nome, em vez de já entrar ouvindo.
|
|
187
|
+
PRIMOCODE_VOZ_ESPERA: esperando ? '1' : '',
|
|
183
188
|
PYTHONUNBUFFERED: '1',
|
|
184
189
|
},
|
|
185
190
|
});
|
|
@@ -234,6 +239,101 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} })
|
|
|
234
239
|
p.on('close', () => resolve({ ok: true }));
|
|
235
240
|
p.on('error', (e) => resolve({ ok: false, error: e.message }));
|
|
236
241
|
});
|
|
242
|
+
|
|
243
|
+
/* O PROCESSO VOLTA JUNTO COM A PROMESSA, e não é preciosismo de API.
|
|
244
|
+
No modo de espera o /voice não bloqueia o terminal: ele sobe o ouvinte
|
|
245
|
+
e devolve o prompt. Quem chamou precisa de um jeito de ENCERRAR aquilo
|
|
246
|
+
depois (/voice parar, ou a saída do programa) — e, sem o handle, o
|
|
247
|
+
único jeito seria matar pelo nome do processo, que atropela outra
|
|
248
|
+
sessão do PrimoCode aberta na mesma máquina. */
|
|
249
|
+
return { processo: p, pronto };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
/* ── O RECONHECEDOR LOCAL DO NOME ────────────────────────────────────────
|
|
254
|
+
*
|
|
255
|
+
* Duas funções, e a razão de existirem é a mesma: no modo de espera o
|
|
256
|
+
* microfone fica ABERTO o tempo todo, e há dois jeitos de saber se alguém
|
|
257
|
+
* disse "ei, primo" — mandar tudo o que ele ouve para um servidor, ou
|
|
258
|
+
* reconhecer a palavra aqui dentro.
|
|
259
|
+
*
|
|
260
|
+
* O primeiro é mais fácil de escrever e significa a conversa da casa inteira
|
|
261
|
+
* subindo para a rede o dia inteiro. O segundo custa ~31 MB de modelo, uma
|
|
262
|
+
* vez. Não é escolha difícil, e não é escolha que o programa faça sozinho:
|
|
263
|
+
* `conferirEspera` só RESPONDE o que falta; quem pergunta à pessoa é o CLI.
|
|
264
|
+
*/
|
|
265
|
+
function conferirEspera(py) {
|
|
266
|
+
const perguntar = `
|
|
267
|
+
import json, sys
|
|
268
|
+
sys.path.insert(0, ${JSON.stringify(PASTA)})
|
|
269
|
+
try:
|
|
270
|
+
from primo_voz import desperta
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(json.dumps({"ok": False, "error": "não consegui ler o módulo: %s" % e})); raise SystemExit
|
|
273
|
+
try:
|
|
274
|
+
import vosk # noqa: F401
|
|
275
|
+
temVosk = True
|
|
276
|
+
except ImportError:
|
|
277
|
+
temVosk = False
|
|
278
|
+
print(json.dumps({
|
|
279
|
+
"ok": bool(temVosk and desperta.tem_modelo()),
|
|
280
|
+
"temVosk": temVosk,
|
|
281
|
+
"temModelo": desperta.tem_modelo(),
|
|
282
|
+
"onde": str(desperta.caminho_do_modelo()),
|
|
283
|
+
}))
|
|
284
|
+
`;
|
|
285
|
+
try {
|
|
286
|
+
const saida = execFileSync(py, ['-c', perguntar],
|
|
287
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }).toString();
|
|
288
|
+
const r = JSON.parse(saida.trim().split('\n').pop());
|
|
289
|
+
if (r.ok) return { ok: true };
|
|
290
|
+
// Falta só o modelo (ou o vosk, que o pip resolve): dá para oferecer.
|
|
291
|
+
return { ok: false, baixavel: true, temVosk: r.temVosk, onde: r.onde,
|
|
292
|
+
error: r.error || 'o reconhecedor local do nome não está instalado' };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return { ok: false, baixavel: false,
|
|
295
|
+
error: `não consegui conferir o reconhecedor: ${String(e.message || e).slice(-160)}` };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Instala o vosk (se faltar) e baixa o modelo. Uma vez, ~31 MB. */
|
|
300
|
+
function baixarEspera(py) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
const script = `
|
|
303
|
+
import json, subprocess, sys
|
|
304
|
+
sys.path.insert(0, ${JSON.stringify(PASTA)})
|
|
305
|
+
try:
|
|
306
|
+
import vosk # noqa: F401
|
|
307
|
+
except ImportError:
|
|
308
|
+
# As mesmas estratégias do narrar.py: o pip do sistema recusa instalar
|
|
309
|
+
# em Debian/Ubuntu e no Homebrew (PEP 668), e uma tentativa só, calada,
|
|
310
|
+
# é a falha que ninguém vê.
|
|
311
|
+
for args in (["-m","pip","install","--user","--quiet","vosk"],
|
|
312
|
+
["-m","pip","install","--user","--quiet","--break-system-packages","vosk"],
|
|
313
|
+
["-m","pip","install","--quiet","vosk"],
|
|
314
|
+
["-m","pip","install","--quiet","--break-system-packages","vosk"]):
|
|
315
|
+
r = subprocess.run([sys.executable] + args, capture_output=True)
|
|
316
|
+
if r.returncode == 0:
|
|
317
|
+
break
|
|
318
|
+
try:
|
|
319
|
+
import vosk # noqa: F401
|
|
320
|
+
except ImportError as e:
|
|
321
|
+
print(json.dumps({"ok": False, "error": "não consegui instalar o vosk: %s" % e}))
|
|
322
|
+
raise SystemExit
|
|
323
|
+
from primo_voz import desperta
|
|
324
|
+
print(json.dumps(desperta.baixar_modelo()))
|
|
325
|
+
`;
|
|
326
|
+
const p = spawn(py, ['-c', script]);
|
|
327
|
+
let fora = '';
|
|
328
|
+
let erro = '';
|
|
329
|
+
p.stdout.on('data', (d) => { fora += d.toString(); });
|
|
330
|
+
p.stderr.on('data', (d) => { erro += d.toString(); });
|
|
331
|
+
p.on('close', () => {
|
|
332
|
+
try { resolve(JSON.parse(fora.trim().split('\n').pop())); }
|
|
333
|
+
catch { resolve({ ok: false, error: erro.slice(-300).trim() || 'o download não devolveu resposta' }); }
|
|
334
|
+
});
|
|
335
|
+
p.on('error', (e) => resolve({ ok: false, error: e.message }));
|
|
336
|
+
});
|
|
237
337
|
}
|
|
238
338
|
|
|
239
|
-
module.exports = { conferir, instalar, abrir, python, COMO_FALAR, PASTA };
|
|
339
|
+
module.exports = { conferir, instalar, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "9.1
|
|
3
|
+
"version": "9.2.1",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -50,7 +50,7 @@ from pathlib import Path
|
|
|
50
50
|
AQUI = Path(__file__).resolve().parent
|
|
51
51
|
sys.path.insert(0, str(AQUI.parent))
|
|
52
52
|
|
|
53
|
-
from primo_voz import escuta, orbe # noqa: E402
|
|
53
|
+
from primo_voz import desperta, escuta, orbe # noqa: E402
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def falar_para_o_node(obj: dict) -> None:
|
|
@@ -106,11 +106,34 @@ PS_TOCAR_MP3 = (
|
|
|
106
106
|
class Voz:
|
|
107
107
|
"""A costura: microfone → transcrição → Node → voz → microfone."""
|
|
108
108
|
|
|
109
|
-
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"
|
|
109
|
+
def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio",
|
|
110
|
+
esperando: bool = False):
|
|
110
111
|
self.servidor = servidor
|
|
111
112
|
self.token = token
|
|
112
113
|
self.voz = voz_escolhida
|
|
113
114
|
|
|
115
|
+
# ── MODO DE ESPERA ───────────────────────────────────────────
|
|
116
|
+
#
|
|
117
|
+
# Ligado, ele fica dormindo com a orbe escondida até ouvir o nome, e
|
|
118
|
+
# volta a dormir depois de atender. Desligado (o padrão antigo), ele
|
|
119
|
+
# já entra ouvindo — que é o certo para quem digitou /voice e vai
|
|
120
|
+
# falar agora.
|
|
121
|
+
#
|
|
122
|
+
# A espera exige o reconhecedor local. Sem ele instalado, não cai
|
|
123
|
+
# para "mandar tudo para o servidor": cai para o modo de sempre, e
|
|
124
|
+
# DIZ por quê. Silenciosamente transformar uma coisa na outra seria
|
|
125
|
+
# abrir o microfone da pessoa para a internet sem ela saber.
|
|
126
|
+
#
|
|
127
|
+
self.esperando = bool(esperando)
|
|
128
|
+
self.desperta = None
|
|
129
|
+
if self.esperando:
|
|
130
|
+
self.desperta = desperta.Desperta(ao_despertar=lambda: None)
|
|
131
|
+
if not self.desperta.pronto:
|
|
132
|
+
avisar(f"modo de espera indisponível: {self.desperta.motivo} — "
|
|
133
|
+
"abrindo no modo normal, já ouvindo.")
|
|
134
|
+
self.esperando = False
|
|
135
|
+
self.desperta = None
|
|
136
|
+
|
|
114
137
|
self.orb = orbe.Orbe(ao_fechar=self.parar)
|
|
115
138
|
self.gravador = escuta.Gravador()
|
|
116
139
|
self.corte = escuta.Corte()
|
|
@@ -122,6 +145,19 @@ class Voz:
|
|
|
122
145
|
self._parar_fala = threading.Event()
|
|
123
146
|
self._do_node = queue.Queue()
|
|
124
147
|
|
|
148
|
+
def _esvaziar_blocos(self) -> None:
|
|
149
|
+
"""Joga fora o áudio que está na fila.
|
|
150
|
+
|
|
151
|
+
Depois de despertar, os blocos do próprio "ei primo" ainda estão
|
|
152
|
+
enfileirados — e sem isto eles entram na gravação do PEDIDO, que chega
|
|
153
|
+
ao agente com o nome colado na frente.
|
|
154
|
+
"""
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
self._blocos.get_nowait()
|
|
158
|
+
except queue.Empty:
|
|
159
|
+
return
|
|
160
|
+
|
|
125
161
|
# ── microfone ────────────────────────────────────────────────────────
|
|
126
162
|
def _ao_receber(self, bloco) -> None:
|
|
127
163
|
if not self._vivo:
|
|
@@ -153,6 +189,31 @@ class Voz:
|
|
|
153
189
|
return
|
|
154
190
|
|
|
155
191
|
while self._vivo:
|
|
192
|
+
# ── O SONO ───────────────────────────────────────────────
|
|
193
|
+
# No modo de espera a orbe some e o microfone fica ouvindo SÓ o
|
|
194
|
+
# nome, aqui dentro, sem nada sair da máquina (ver desperta.py).
|
|
195
|
+
# Sem isso, o modo de espera seria o microfone da pessoa aberto
|
|
196
|
+
# para a internet o dia inteiro.
|
|
197
|
+
if self.esperando and self.desperta is not None:
|
|
198
|
+
self.orb.esconder()
|
|
199
|
+
self.desperta.zerar()
|
|
200
|
+
acordou = False
|
|
201
|
+
while self._vivo and not acordou:
|
|
202
|
+
try:
|
|
203
|
+
bloco = self._blocos.get(timeout=0.3)
|
|
204
|
+
except queue.Empty:
|
|
205
|
+
continue
|
|
206
|
+
acordou = self.desperta.alimentar(bloco)
|
|
207
|
+
if not self._vivo:
|
|
208
|
+
break
|
|
209
|
+
# Chamou: a orbe aparece e ele passa a ouvir o PEDIDO.
|
|
210
|
+
self.orb.mostrar()
|
|
211
|
+
self.orb.mudar(estado="ouvindo", nivel=0)
|
|
212
|
+
# Sem esvaziar a fila, os blocos do próprio "ei primo" entram
|
|
213
|
+
# na gravação do pedido — e o pedido chega com o nome colado
|
|
214
|
+
# na frente.
|
|
215
|
+
self._esvaziar_blocos()
|
|
216
|
+
|
|
156
217
|
self.orb.mudar(estado="esperando", nivel=0)
|
|
157
218
|
self.gravador.comecar()
|
|
158
219
|
|
|
@@ -362,6 +423,9 @@ def main() -> int:
|
|
|
362
423
|
servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
|
|
363
424
|
token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
|
|
364
425
|
voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
|
|
426
|
+
# O modo de espera vem por variável, como o resto: quem abre este programa
|
|
427
|
+
# é o /voice do Node, não uma pessoa numa linha de comando.
|
|
428
|
+
esperando = os.environ.get("PRIMOCODE_VOZ_ESPERA", "") == "1"
|
|
365
429
|
|
|
366
430
|
if not servidor:
|
|
367
431
|
avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
|
|
@@ -375,7 +439,7 @@ def main() -> int:
|
|
|
375
439
|
f"{sys.executable} -m pip install sounddevice numpy")
|
|
376
440
|
return 3
|
|
377
441
|
|
|
378
|
-
Voz(servidor, token, voz).rodar()
|
|
442
|
+
Voz(servidor, token, voz, esperando=esperando).rodar()
|
|
379
443
|
return 0
|
|
380
444
|
|
|
381
445
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""desperta.py — ouvir o nome, e só o nome, sem mandar áudio para lugar nenhum.
|
|
2
|
+
|
|
3
|
+
── POR QUE OFFLINE, E POR QUE ISSO NÃO É DETALHE ───────────────────────────
|
|
4
|
+
No modo de espera o microfone fica ABERTO o tempo todo. Existem dois jeitos de
|
|
5
|
+
saber se alguém disse "ei, primo":
|
|
6
|
+
|
|
7
|
+
1. Mandar tudo o que o microfone ouve para um servidor transcrever.
|
|
8
|
+
2. Reconhecer a palavra aqui dentro, sem nada sair da máquina.
|
|
9
|
+
|
|
10
|
+
O primeiro funciona e é mais simples de escrever. Também significa que TODA
|
|
11
|
+
conversa perto do computador — a ligação, a reunião, a briga em casa — sobe
|
|
12
|
+
para um servidor, o dia inteiro, para que uma vez por hora alguém diga o nome
|
|
13
|
+
do programa. Isso não é uma troca aceitável, e não é decisão que um programa
|
|
14
|
+
toma pelo dono da máquina.
|
|
15
|
+
|
|
16
|
+
Então é o segundo. O Vosk roda local, o modelo mora no disco da pessoa, e
|
|
17
|
+
NADA sai daqui enquanto o nome não for dito. Depois do nome, sim: a frase vai
|
|
18
|
+
para a transcrição, como no modo normal — mas aí foi a pessoa que chamou.
|
|
19
|
+
|
|
20
|
+
── A GRAMÁTICA RESTRITA ────────────────────────────────────────────────────
|
|
21
|
+
O reconhecedor é criado sabendo APENAS as palavras de despertar. Não é
|
|
22
|
+
otimização: é o que impede ele de "ouvir" o nome no meio de uma frase
|
|
23
|
+
qualquer. Um reconhecedor livre transcreve tudo e depois se procura a palavra
|
|
24
|
+
no texto — e aí "prima", "primeiro" e "exprimo" acordam o assistente. Com a
|
|
25
|
+
gramática restrita ele só pode devolver uma das palavras da lista ou "[unk]".
|
|
26
|
+
|
|
27
|
+
Herdado do Jarvis (src/jarvis/core/wake.py), que resolveu isto primeiro; o
|
|
28
|
+
modelo aqui é o de PORTUGUÊS, porque "primo" é palavra portuguesa — ao
|
|
29
|
+
contrário de "Jarvis", que é nome inglês e por isso lá usava o modelo de
|
|
30
|
+
inglês.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import threading
|
|
37
|
+
import urllib.request
|
|
38
|
+
import zipfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Callable, Optional
|
|
41
|
+
|
|
42
|
+
TAXA = 16000
|
|
43
|
+
|
|
44
|
+
# Onde o modelo mora. Fora do pacote npm de propósito: são 31 MB, e um pacote
|
|
45
|
+
# que engorda 31 MB para todo mundo por causa de um modo que nem todos usam é
|
|
46
|
+
# um pacote que demora a instalar para todo mundo.
|
|
47
|
+
PASTA_MODELO = Path.home() / ".primocode" / "modelos"
|
|
48
|
+
NOME_MODELO = "vosk-model-small-pt-0.3"
|
|
49
|
+
URL_MODELO = f"https://alphacephei.com/vosk/models/{NOME_MODELO}.zip"
|
|
50
|
+
|
|
51
|
+
# O que ACORDA. Variantes porque o reconhecedor pequeno erra a vogal átona, e
|
|
52
|
+
# porque as pessoas falam de jeitos diferentes ("ei primo", "oi primo", "ô
|
|
53
|
+
# primo"). O "primo" sozinho entra: dentro da gramática restrita ele não
|
|
54
|
+
# dispara em conversa comum, porque o reconhecedor não transcreve conversa
|
|
55
|
+
# comum — só consegue devolver estas palavras.
|
|
56
|
+
VOCABULARIO = ("primo", "ei", "oi", "olá", "ô")
|
|
57
|
+
DESPERTA = ("primo",)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def caminho_do_modelo() -> Path:
|
|
61
|
+
return PASTA_MODELO / NOME_MODELO
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tem_modelo() -> bool:
|
|
65
|
+
caminho = caminho_do_modelo()
|
|
66
|
+
return caminho.is_dir() and any(caminho.iterdir())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def baixar_modelo(aoFalar: Optional[Callable[[str], None]] = None) -> dict:
|
|
70
|
+
"""Traz o modelo, uma vez. ~31 MB.
|
|
71
|
+
|
|
72
|
+
Baixa para um arquivo temporário e só então move para o lugar: interromper
|
|
73
|
+
no meio deixaria uma pasta pela metade que `tem_modelo()` daria por boa, e
|
|
74
|
+
o erro apareceria depois, como "falha ao carregar o Vosk".
|
|
75
|
+
"""
|
|
76
|
+
fala = aoFalar or (lambda _t: None)
|
|
77
|
+
if tem_modelo():
|
|
78
|
+
return {"ok": True, "jaTinha": True}
|
|
79
|
+
try:
|
|
80
|
+
PASTA_MODELO.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
temporario = PASTA_MODELO / (NOME_MODELO + ".zip.parcial")
|
|
82
|
+
fala("baixando o reconhecedor de voz (uma vez, ~31 MB)")
|
|
83
|
+
with urllib.request.urlopen(URL_MODELO, timeout=120) as resposta, open(temporario, "wb") as saida:
|
|
84
|
+
while True:
|
|
85
|
+
pedaco = resposta.read(1 << 16)
|
|
86
|
+
if not pedaco:
|
|
87
|
+
break
|
|
88
|
+
saida.write(pedaco)
|
|
89
|
+
fala("descompactando")
|
|
90
|
+
with zipfile.ZipFile(temporario) as z:
|
|
91
|
+
z.extractall(PASTA_MODELO)
|
|
92
|
+
temporario.unlink(missing_ok=True)
|
|
93
|
+
if not tem_modelo():
|
|
94
|
+
return {"ok": False, "error": "o modelo baixou mas não apareceu onde devia"}
|
|
95
|
+
return {"ok": True}
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return {"ok": False, "error": str(e),
|
|
98
|
+
"comoResolver": f"baixe {URL_MODELO} à mão e descompacte em {PASTA_MODELO}"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def contem_nome(texto: str) -> bool:
|
|
102
|
+
"""O texto que o reconhecedor devolveu contém o nome?
|
|
103
|
+
|
|
104
|
+
Comparação por PALAVRA INTEIRA. Com `in` de substring, "prima" e
|
|
105
|
+
"primeiro" acordariam o assistente — e a gramática restrita não protege
|
|
106
|
+
disso sozinha, porque ela devolve as palavras da lista e o resto vira
|
|
107
|
+
"[unk]", que pode vir colado.
|
|
108
|
+
"""
|
|
109
|
+
palavras = set(str(texto or "").lower().replace("[unk]", " ").split())
|
|
110
|
+
return any(n in palavras for n in DESPERTA)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Desperta:
|
|
114
|
+
"""Escuta o nome. Nada sai da máquina até ele ser dito."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, ao_despertar: Callable[[], None]) -> None:
|
|
117
|
+
self.ao_despertar = ao_despertar
|
|
118
|
+
self._rec = None
|
|
119
|
+
self._modelo = None
|
|
120
|
+
self.pronto = False
|
|
121
|
+
self.ligado = True
|
|
122
|
+
self.motivo = ""
|
|
123
|
+
# O reconhecedor do Vosk NÃO é seguro entre threads: a do áudio chama
|
|
124
|
+
# process() dez vezes por segundo enquanto o laço principal pode
|
|
125
|
+
# chamar reset() ao despertar. Sem trava, o estado interno corrompe e
|
|
126
|
+
# ele passa a levantar "Failed to process waveform" — sintoma que não
|
|
127
|
+
# fala de thread nenhuma, e por isso custa caro de achar.
|
|
128
|
+
self._trava = threading.Lock()
|
|
129
|
+
self._carregar()
|
|
130
|
+
|
|
131
|
+
def _carregar(self) -> None:
|
|
132
|
+
if not tem_modelo():
|
|
133
|
+
self.motivo = "o reconhecedor de voz não está baixado"
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
import vosk
|
|
137
|
+
vosk.SetLogLevel(-1)
|
|
138
|
+
self._modelo = vosk.Model(str(caminho_do_modelo()))
|
|
139
|
+
self._rec = self._novo()
|
|
140
|
+
self.pronto = True
|
|
141
|
+
except ImportError:
|
|
142
|
+
self.motivo = "falta o vosk (pip install vosk)"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.motivo = f"não consegui carregar o reconhecedor: {e}"
|
|
145
|
+
|
|
146
|
+
def _novo(self):
|
|
147
|
+
import vosk
|
|
148
|
+
gramatica = json.dumps(sorted({*VOCABULARIO, "[unk]"}))
|
|
149
|
+
return vosk.KaldiRecognizer(self._modelo, TAXA, gramatica)
|
|
150
|
+
|
|
151
|
+
def alimentar(self, bloco) -> bool:
|
|
152
|
+
"""Um bloco de áudio. Devolve True se o nome foi dito."""
|
|
153
|
+
if not self.pronto or self._rec is None or not self.ligado:
|
|
154
|
+
return False
|
|
155
|
+
with self._trava:
|
|
156
|
+
try:
|
|
157
|
+
if self._rec.AcceptWaveform(bloco.tobytes()):
|
|
158
|
+
texto = json.loads(self._rec.Result()).get("text", "")
|
|
159
|
+
else:
|
|
160
|
+
texto = json.loads(self._rec.PartialResult()).get("partial", "")
|
|
161
|
+
except Exception:
|
|
162
|
+
# Reconhecedor travado: recria e segue. Desligar o modo de
|
|
163
|
+
# espera por causa de um bloco ruim seria perder o recurso
|
|
164
|
+
# inteiro por um soluço de áudio.
|
|
165
|
+
try:
|
|
166
|
+
self._rec = self._novo()
|
|
167
|
+
except Exception:
|
|
168
|
+
self.pronto = False
|
|
169
|
+
self.motivo = "o reconhecedor parou e não voltou"
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
achou = bool(texto and contem_nome(texto))
|
|
173
|
+
if achou:
|
|
174
|
+
self._rec.Reset()
|
|
175
|
+
|
|
176
|
+
if achou:
|
|
177
|
+
try:
|
|
178
|
+
self.ao_despertar()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
return achou
|
|
182
|
+
|
|
183
|
+
def zerar(self) -> None:
|
|
184
|
+
"""Esquece o que ouviu até agora.
|
|
185
|
+
|
|
186
|
+
Chamado ao voltar do atendimento: sem isso, o resto da frase que a
|
|
187
|
+
pessoa falou DEPOIS do nome ainda está no reconhecedor, e ele desperta
|
|
188
|
+
de novo no instante seguinte.
|
|
189
|
+
"""
|
|
190
|
+
with self._trava:
|
|
191
|
+
if self._rec is not None:
|
|
192
|
+
try:
|
|
193
|
+
self._rec.Reset()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -20,10 +20,11 @@ mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pr
|
|
|
20
20
|
mim") cai abaixo do limiar e a frase é cortada antes de acabar.
|
|
21
21
|
|
|
22
22
|
── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
|
|
23
|
-
A wake word
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
A wake word saiu DAQUI, não do produto. Este módulo cuida de uma coisa só:
|
|
24
|
+
achar o começo e o fim de uma frase. Quem reconhece o nome é o desperta.py, e
|
|
25
|
+
ele só entra no modo de espera — quando a pessoa pediu para ele ficar ouvindo
|
|
26
|
+
em segundo plano. Quem digita /voice e vai falar agora não diz nome nenhum: a
|
|
27
|
+
orbe já está ouvindo.
|
|
27
28
|
"""
|
|
28
29
|
from __future__ import annotations
|
|
29
30
|
|
package/voz/primo_voz/orbe.py
CHANGED
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import math
|
|
41
41
|
import queue
|
|
42
|
+
import sys
|
|
42
43
|
import threading
|
|
43
44
|
import tkinter as tk
|
|
44
45
|
from typing import Callable, Optional
|
|
@@ -63,6 +64,7 @@ class Orbe:
|
|
|
63
64
|
self.raiz = tk.Tk()
|
|
64
65
|
self.raiz.title("Primo Code Voice")
|
|
65
66
|
self.estado = "esperando"
|
|
67
|
+
self.visivel = True
|
|
66
68
|
self.nivel = 0.0 # 0..1, o quanto a pessoa está falando
|
|
67
69
|
self._fase = 0.0
|
|
68
70
|
self._fila = queue.Queue()
|
|
@@ -130,6 +132,12 @@ class Orbe:
|
|
|
130
132
|
while True:
|
|
131
133
|
try:
|
|
132
134
|
estado, nivel = self._fila.get_nowait()
|
|
135
|
+
if estado == '__esconder__':
|
|
136
|
+
self._aplicar_visibilidade(False)
|
|
137
|
+
continue
|
|
138
|
+
if estado == '__mostrar__':
|
|
139
|
+
self._aplicar_visibilidade(True)
|
|
140
|
+
continue
|
|
133
141
|
except queue.Empty:
|
|
134
142
|
break
|
|
135
143
|
if estado:
|
|
@@ -197,6 +205,40 @@ class Orbe:
|
|
|
197
205
|
principal, então o que chega vai para uma fila e é lido no desenho."""
|
|
198
206
|
self._fila.put((estado, nivel))
|
|
199
207
|
|
|
208
|
+
def esconder(self) -> None:
|
|
209
|
+
"""Some da tela sem morrer.
|
|
210
|
+
|
|
211
|
+
O modo de espera fica MINUTOS ou horas sem nada acontecer, e uma orbe
|
|
212
|
+
parada no meio do monitor todo esse tempo é um estorvo — ela cobre o
|
|
213
|
+
que a pessoa está fazendo. Então ela some e volta quando chamada.
|
|
214
|
+
|
|
215
|
+
Destruir e recriar a janela a cada despertar seria o caminho óbvio, e é
|
|
216
|
+
o errado: recriar uma Tk leva centenas de milissegundos, pisca, e em
|
|
217
|
+
alguns sistemas rouba o foco da janela onde a pessoa estava digitando.
|
|
218
|
+
`withdraw` tira da tela e guarda tudo de pé.
|
|
219
|
+
"""
|
|
220
|
+
self._fila.put(("__esconder__", None))
|
|
221
|
+
|
|
222
|
+
def mostrar(self) -> None:
|
|
223
|
+
"""Volta para a tela, no centro, por cima de tudo."""
|
|
224
|
+
self._fila.put(("__mostrar__", None))
|
|
225
|
+
|
|
226
|
+
def _aplicar_visibilidade(self, visivel: bool) -> None:
|
|
227
|
+
# Só na thread da interface: tkinter não perdoa chamada de fora.
|
|
228
|
+
try:
|
|
229
|
+
if visivel:
|
|
230
|
+
self.veu.deiconify()
|
|
231
|
+
self.raiz.deiconify()
|
|
232
|
+
self.raiz.lift()
|
|
233
|
+
if sys.platform != "linux":
|
|
234
|
+
self.raiz.attributes("-topmost", True)
|
|
235
|
+
else:
|
|
236
|
+
self.raiz.withdraw()
|
|
237
|
+
self.veu.withdraw()
|
|
238
|
+
except Exception:
|
|
239
|
+
pass # janela já fechando: nada a fazer
|
|
240
|
+
self.visivel = visivel
|
|
241
|
+
|
|
200
242
|
def fechar(self) -> None:
|
|
201
243
|
if not self._vivo:
|
|
202
244
|
return
|
|
File without changes
|