primocode 9.4.0 → 9.5.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/README.md +2 -2
- package/bin/primocode.js +21 -64
- package/lib/voz.js +170 -1
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +17 -5
- package/voz/primo_voz/orbe.py +234 -122
- package/voz/primo_voz/transcrever.py +106 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.
|
|
1
|
+
# PrimoCode v9.5.0
|
|
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.5.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/bin/primocode.js
CHANGED
|
@@ -1523,72 +1523,29 @@ async function handleSlash(text) {
|
|
|
1523
1523
|
break;
|
|
1524
1524
|
}
|
|
1525
1525
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1526
|
+
/* ── PREPARAR TUDO, SEM PERGUNTAR ───────────────────────────
|
|
1527
|
+
"Instale tudo que precisa, rode tudo que precisa." O que falta
|
|
1528
|
+
(bibliotecas do sistema, pacotes do Python, o Whisper local e o
|
|
1529
|
+
reconhecedor do "ei, primo") é instalado aqui, com uma linha de
|
|
1530
|
+
progresso por etapa. Da segunda vez em diante, tudo já está lá
|
|
1531
|
+
e isto leva um segundo. Ver `preparar` em lib/voz.js.
|
|
1532
|
+
|
|
1533
|
+
O reconhecedor do nome é o que deixa o microfone aberto sem o
|
|
1534
|
+
áudio sair da máquina: o nome é reconhecido AQUI. Se ele não
|
|
1535
|
+
ficar pronto, o modo voz abre já ouvindo — nunca manda o dia
|
|
1536
|
+
inteiro de áudio para o servidor. */
|
|
1537
|
+
const sp = new Spinner('preparando o modo voz').start();
|
|
1538
|
+
const prep = await vozModo.preparar((msg) => { sp.text = msg; });
|
|
1539
|
+
sp.stop();
|
|
1540
|
+
if (!prep.ok) {
|
|
1541
|
+
console.log(c.err(` ${prep.error}`));
|
|
1542
|
+
if (prep.comoResolver) console.log(c.muted(` ${prep.comoResolver}`));
|
|
1543
|
+
for (const a of prep.avisos) console.log(c.dim(` ${a}`));
|
|
1537
1544
|
break;
|
|
1538
1545
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
if (!(await confirm('Instalar agora?'))) {
|
|
1543
|
-
console.log(c.muted(' Sem eles o modo voz não abre.'));
|
|
1544
|
-
if (pronto.comoResolver) console.log(c.dim(` ${pronto.comoResolver}`));
|
|
1545
|
-
break;
|
|
1546
|
-
}
|
|
1547
|
-
const sp = new Spinner('instalando').start();
|
|
1548
|
-
const r = await vozModo.instalar(pronto.python, pronto.instalavel, () => {});
|
|
1549
|
-
sp.stop();
|
|
1550
|
-
if (!r.ok) {
|
|
1551
|
-
console.log(c.err(` não deu: ${r.error}`));
|
|
1552
|
-
console.log(c.muted(` ${pronto.comoResolver}`));
|
|
1553
|
-
break;
|
|
1554
|
-
}
|
|
1555
|
-
const conferido = vozModo.conferir();
|
|
1556
|
-
if (!conferido.ok) {
|
|
1557
|
-
console.log(c.err(` ainda falta: ${conferido.error}`));
|
|
1558
|
-
break;
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
const py = (pronto.ok ? pronto : vozModo.conferir()).python;
|
|
1563
|
-
|
|
1564
|
-
/* ── O RECONHECEDOR LOCAL ────────────────────────────────────
|
|
1565
|
-
Ele é o que permite o microfone ficar aberto sem o áudio sair
|
|
1566
|
-
da máquina: o nome é reconhecido AQUI, e só depois disso a
|
|
1567
|
-
frase viaja. São ~31 MB, uma vez, e ficam em ~/.primocode.
|
|
1568
|
-
|
|
1569
|
-
Se a pessoa recusar, o modo voz ainda abre — mas já ouvindo,
|
|
1570
|
-
sem espera. O que NÃO acontece em hipótese nenhuma é cair para
|
|
1571
|
-
"manda tudo para o servidor o dia inteiro": isso seria trocar
|
|
1572
|
-
a privacidade dela por conveniência sem perguntar. */
|
|
1573
|
-
const espera = await vozModo.conferirEspera(py);
|
|
1574
|
-
let esperando = espera.ok;
|
|
1575
|
-
if (!espera.ok && espera.baixavel) {
|
|
1576
|
-
console.log('');
|
|
1577
|
-
console.log(' ' + c.muted('Para ficar ouvindo só o nome, sem mandar áudio para lugar nenhum,'));
|
|
1578
|
-
console.log(' ' + c.muted('ele precisa do reconhecedor local — ') + c.dim('~31 MB, uma vez.'));
|
|
1579
|
-
if (await confirm('Baixar agora?')) {
|
|
1580
|
-
const sp = new Spinner('baixando o reconhecedor').start();
|
|
1581
|
-
const r = await vozModo.baixarEspera(py);
|
|
1582
|
-
sp.stop();
|
|
1583
|
-
if (r.ok) esperando = true;
|
|
1584
|
-
else {
|
|
1585
|
-
console.log(c.err(` não deu: ${r.error}`));
|
|
1586
|
-
if (r.comoResolver) console.log(c.dim(` ${r.comoResolver}`));
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
} else if (!espera.ok && espera.error) {
|
|
1590
|
-
console.log(c.dim(` ${espera.error}`));
|
|
1591
|
-
}
|
|
1546
|
+
for (const a of prep.avisos) console.log(c.dim(` ${a}`));
|
|
1547
|
+
const py = prep.python;
|
|
1548
|
+
const esperando = prep.esperando;
|
|
1592
1549
|
|
|
1593
1550
|
console.log('');
|
|
1594
1551
|
console.log(' ' + c.bold(c.brand('Primo Code Voice')));
|
package/lib/voz.js
CHANGED
|
@@ -237,6 +237,10 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {}, e
|
|
|
237
237
|
// Dormir até ouvir o nome, em vez de já entrar ouvindo.
|
|
238
238
|
PRIMOCODE_VOZ_ESPERA: esperando ? '1' : '',
|
|
239
239
|
PYTHONUNBUFFERED: '1',
|
|
240
|
+
// O Whisper deixa um semáforo para o sistema recolher na saída,
|
|
241
|
+
// e o Python avisa disso no stderr — que aqui vira texto no
|
|
242
|
+
// terminal da pessoa. É inofensivo; o aviso é que não é.
|
|
243
|
+
PYTHONWARNINGS: 'ignore::UserWarning:multiprocessing.resource_tracker',
|
|
240
244
|
},
|
|
241
245
|
});
|
|
242
246
|
|
|
@@ -387,4 +391,169 @@ print(json.dumps(desperta.baixar_modelo()))
|
|
|
387
391
|
});
|
|
388
392
|
}
|
|
389
393
|
|
|
390
|
-
|
|
394
|
+
/* ── PREPARAR TUDO, SEM PERGUNTAR ────────────────────────────────────────
|
|
395
|
+
*
|
|
396
|
+
* "Instale tudo que precisa, rode tudo que precisa, então fica redondinho,
|
|
397
|
+
* já funcionando." (23/09/2026)
|
|
398
|
+
*
|
|
399
|
+
* O /voice perguntava duas vezes ("Instalar agora?", "Baixar agora?") e
|
|
400
|
+
* parava em cada falta com um comando para a pessoa copiar. Agora ele mesmo
|
|
401
|
+
* resolve o que dá para resolver, nesta ordem:
|
|
402
|
+
*
|
|
403
|
+
* 1. bibliotecas do SISTEMA (PortAudio e o Tk do Python) — pelo Homebrew no
|
|
404
|
+
* macOS; no Linux, `sudo -n` (só funciona sem senha; senão, o comando);
|
|
405
|
+
* 2. pacotes do Python — os obrigatórios (numpy, sounddevice) e os que
|
|
406
|
+
* deixam tudo bonito e local (faster-whisper, vosk, PyObjC no macOS);
|
|
407
|
+
* 3. os modelos — o Whisper (transcrição local) e o reconhecedor do nome.
|
|
408
|
+
*
|
|
409
|
+
* Só o que é OBRIGATÓRIO faltar impede o modo voz de abrir. Faltar um opcional
|
|
410
|
+
* vira um aviso de uma linha, e o modo voz abre mesmo assim.
|
|
411
|
+
*/
|
|
412
|
+
const OPCIONAIS = () => [
|
|
413
|
+
['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
|
|
414
|
+
['vosk', 'vosk', 'o reconhecedor local do "ei, primo"'],
|
|
415
|
+
...(process.platform === 'darwin' ? [['AppKit', 'pyobjc-framework-Cocoa', 'a orb transparente do macOS']] : []),
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
function temModulo(py, modulo) {
|
|
419
|
+
try {
|
|
420
|
+
execFileSync(py, ['-c', `import ${modulo}`], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 });
|
|
421
|
+
return true;
|
|
422
|
+
} catch { return false; }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function rodar(cmd, args, timeout = 15 * 60 * 1000) {
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
let saida = '';
|
|
428
|
+
let p;
|
|
429
|
+
try { p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
430
|
+
catch (e) { return resolve({ ok: false, saida: e.message }); }
|
|
431
|
+
const t = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
|
|
432
|
+
p.stdout.on('data', (d) => { saida += d.toString(); });
|
|
433
|
+
p.stderr.on('data', (d) => { saida += d.toString(); });
|
|
434
|
+
p.on('close', (code) => { clearTimeout(t); resolve({ ok: code === 0, saida }); });
|
|
435
|
+
p.on('error', (e) => { clearTimeout(t); resolve({ ok: false, saida: e.message }); });
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function brew() {
|
|
440
|
+
for (const b of ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
|
|
441
|
+
try { execFileSync(b, ['--version'], { stdio: 'ignore', timeout: 10000 }); return b; } catch { /* próximo */ }
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** As bibliotecas do sistema que faltam, instaladas por quem der. */
|
|
447
|
+
async function instalarSistema(py, falta, aoFalar) {
|
|
448
|
+
const avisos = [];
|
|
449
|
+
const precisa = [];
|
|
450
|
+
if (falta.some((f) => f.modulo === 'sounddevice' && f.sistema)) precisa.push('portaudio');
|
|
451
|
+
if (falta.some((f) => f.modulo === 'tkinter')) precisa.push('tkinter');
|
|
452
|
+
if (!precisa.length) return avisos;
|
|
453
|
+
|
|
454
|
+
if (process.platform === 'darwin') {
|
|
455
|
+
const b = brew();
|
|
456
|
+
if (!b) {
|
|
457
|
+
avisos.push('o Homebrew não está instalado, então não deu para instalar '
|
|
458
|
+
+ precisa.join(' e ') + ' sozinho — instale em brew.sh e rode /voice de novo.');
|
|
459
|
+
return avisos;
|
|
460
|
+
}
|
|
461
|
+
// O Tk do Homebrew tem versão casada com a do Python: python-tk@3.14
|
|
462
|
+
// serve ao python3.14, e só a ele.
|
|
463
|
+
let versao = '';
|
|
464
|
+
try { versao = execFileSync(py, ['-c', 'import sys;print("%d.%d"%sys.version_info[:2])']).toString().trim(); } catch {}
|
|
465
|
+
const formulas = precisa.map((x) => (x === 'tkinter' ? (versao ? `python-tk@${versao}` : 'python-tk') : x));
|
|
466
|
+
aoFalar(`instalando ${formulas.join(', ')} pelo Homebrew`);
|
|
467
|
+
const r = await rodar(b, ['install', ...formulas]);
|
|
468
|
+
if (!r.ok) avisos.push(`o Homebrew não instalou ${formulas.join(', ')}: ${porqueOPipFalhou(r.saida)}`);
|
|
469
|
+
return avisos;
|
|
470
|
+
}
|
|
471
|
+
if (process.platform === 'linux') {
|
|
472
|
+
const pacotes = precisa.map((x) => (x === 'tkinter' ? 'python3-tk' : 'libportaudio2'));
|
|
473
|
+
aoFalar(`instalando ${pacotes.join(', ')}`);
|
|
474
|
+
const r = await rodar('sudo', ['-n', 'apt-get', 'install', '-y', ...pacotes]);
|
|
475
|
+
if (!r.ok) avisos.push(`precisa de senha para instalar: sudo apt install ${pacotes.join(' ')}`);
|
|
476
|
+
return avisos;
|
|
477
|
+
}
|
|
478
|
+
avisos.push(precisa.map((x) => COMO_SISTEMA[x === 'tkinter' ? 'tkinter' : 'portaudio'].win32).join(' · '));
|
|
479
|
+
return avisos;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** O Whisper local, baixado antes da primeira frase. ~480 MB, uma vez. */
|
|
483
|
+
async function baixarWhisper(py) {
|
|
484
|
+
const r = await new Promise((resolve) => {
|
|
485
|
+
const p = spawn(py, ['-m', 'primo_voz.transcrever', 'baixar'], {
|
|
486
|
+
cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA },
|
|
487
|
+
});
|
|
488
|
+
let fora = '';
|
|
489
|
+
let erro = '';
|
|
490
|
+
p.stdout.on('data', (d) => { fora += d.toString(); });
|
|
491
|
+
p.stderr.on('data', (d) => { erro += d.toString(); });
|
|
492
|
+
p.on('close', () => {
|
|
493
|
+
try { resolve(JSON.parse(fora.trim().split('\n').pop())); }
|
|
494
|
+
catch { resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o download não respondeu' }); }
|
|
495
|
+
});
|
|
496
|
+
p.on('error', (e) => resolve({ ok: false, error: e.message }));
|
|
497
|
+
});
|
|
498
|
+
return r;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Deixa o modo voz pronto: instala e baixa tudo o que falta, sem perguntar.
|
|
503
|
+
*
|
|
504
|
+
* @param {(msg:string)=>void} aoFalar uma linha de progresso por etapa
|
|
505
|
+
* @returns {Promise<{ok:boolean, python?:string, esperando?:boolean,
|
|
506
|
+
* avisos:string[], error?:string, comoResolver?:string}>}
|
|
507
|
+
*/
|
|
508
|
+
async function preparar(aoFalar = () => {}) {
|
|
509
|
+
const py = python();
|
|
510
|
+
if (!py) {
|
|
511
|
+
return { ok: false, avisos: [], error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.',
|
|
512
|
+
comoResolver: process.platform === 'darwin' ? 'brew install python' : 'instale em python.org e abra o terminal de novo' };
|
|
513
|
+
}
|
|
514
|
+
const avisos = [];
|
|
515
|
+
|
|
516
|
+
// 1. sistema, depois 2. os obrigatórios pelo pip.
|
|
517
|
+
let falta = faltando(py);
|
|
518
|
+
avisos.push(...await instalarSistema(py, falta, aoFalar));
|
|
519
|
+
falta = faltando(py);
|
|
520
|
+
const pipObrigatorio = falta.filter((f) => f.pacote).map((f) => f.pacote);
|
|
521
|
+
if (pipObrigatorio.length) {
|
|
522
|
+
const r = await instalar(py, pipObrigatorio, aoFalar);
|
|
523
|
+
if (!r.ok) avisos.push(`o pip não instalou ${pipObrigatorio.join(', ')}: ${r.error}`);
|
|
524
|
+
falta = faltando(py);
|
|
525
|
+
}
|
|
526
|
+
if (falta.length) {
|
|
527
|
+
const c = conferir();
|
|
528
|
+
return { ok: false, python: py, avisos, error: c.error, comoResolver: c.comoResolver };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 2b. os opcionais: faltar um deles não impede o modo voz de abrir.
|
|
532
|
+
const opcionais = OPCIONAIS().filter(([m]) => !temModulo(py, m));
|
|
533
|
+
if (opcionais.length) {
|
|
534
|
+
const r = await instalar(py, opcionais.map(([, pacote]) => pacote), aoFalar);
|
|
535
|
+
for (const [m, , para] of opcionais) {
|
|
536
|
+
if (!temModulo(py, m)) avisos.push(`sem ${para}: ${r.ok ? 'o pacote não carregou' : r.error}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 3. os modelos.
|
|
541
|
+
if (temModulo(py, 'faster_whisper')) {
|
|
542
|
+
aoFalar('preparando a transcrição local (Whisper, ~480 MB na primeira vez)');
|
|
543
|
+
const w = await baixarWhisper(py);
|
|
544
|
+
if (!w.ok) avisos.push(`a transcrição local não ficou pronta (${w.error}) — vai pelo servidor.`);
|
|
545
|
+
}
|
|
546
|
+
let esperando = false;
|
|
547
|
+
const espera = conferirEspera(py);
|
|
548
|
+
if (espera.ok) esperando = true;
|
|
549
|
+
else if (espera.baixavel) {
|
|
550
|
+
aoFalar('baixando o reconhecedor do "ei, primo" (~31 MB)');
|
|
551
|
+
const r = await baixarEspera(py);
|
|
552
|
+
if (r.ok) esperando = true;
|
|
553
|
+
else avisos.push(`sem o "ei, primo": ${r.error} — o modo voz abre já ouvindo.`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { ok: true, python: py, esperando, avisos };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
module.exports = { conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.5.0",
|
|
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": {
|
|
@@ -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 desperta, escuta, orbe # noqa: E402
|
|
53
|
+
from primo_voz import desperta, escuta, orbe, transcrever # noqa: E402
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def falar_para_o_node(obj: dict) -> None:
|
|
@@ -135,6 +135,11 @@ class Voz:
|
|
|
135
135
|
self.desperta = None
|
|
136
136
|
|
|
137
137
|
self.orb = orbe.Orbe(ao_fechar=self.parar)
|
|
138
|
+
# O Whisper local carrega em segundo plano desde já: senão a primeira
|
|
139
|
+
# frase esperaria o modelo inteiro subir.
|
|
140
|
+
self.local = transcrever.disponivel()
|
|
141
|
+
if self.local:
|
|
142
|
+
transcrever.aquecer()
|
|
138
143
|
self.gravador = escuta.Gravador()
|
|
139
144
|
self.corte = escuta.Corte()
|
|
140
145
|
|
|
@@ -251,11 +256,18 @@ class Voz:
|
|
|
251
256
|
|
|
252
257
|
# ── transcrição ──────────────────────────────────────────────────────
|
|
253
258
|
def _transcrever(self, wav: Path) -> str:
|
|
254
|
-
"""
|
|
255
|
-
|
|
256
|
-
É a mesma rota que a legenda de vídeo usa. Duas formas de transcrever
|
|
257
|
-
seriam duas contas para pagar e duas para consertar.
|
|
259
|
+
"""Primeiro aqui, com o Whisper local (ver transcrever.py). O servidor
|
|
260
|
+
só entra se o local não estiver instalado ou falhar — e diz que caiu.
|
|
258
261
|
"""
|
|
262
|
+
if self.local:
|
|
263
|
+
try:
|
|
264
|
+
return transcrever.transcrever(wav)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
avisar(f"a transcrição local falhou ({e}); tentando pelo servidor.")
|
|
267
|
+
return self._transcrever_no_servidor(wav)
|
|
268
|
+
|
|
269
|
+
def _transcrever_no_servidor(self, wav: Path) -> str:
|
|
270
|
+
"""A rota do servidor, a mesma que a legenda de vídeo usa."""
|
|
259
271
|
import base64
|
|
260
272
|
import urllib.request
|
|
261
273
|
|
package/voz/primo_voz/orbe.py
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
"""orbe.py — a única coisa que aparece na tela no modo voz.
|
|
2
2
|
|
|
3
|
-
"
|
|
4
|
-
|
|
3
|
+
"Quero que ele fique na parte inferior da tela, bem centralizado, um pouco
|
|
4
|
+
maior [...] mas ele tá rodando em uma caixa, deve ser totalmente
|
|
5
|
+
transparente." (23/09/2026)
|
|
5
6
|
|
|
6
|
-
──
|
|
7
|
+
── O QUE ERA A "CAIXA" ─────────────────────────────────────────────────────
|
|
8
|
+
Medido com uma captura da própria janela, no Tk 9 do macOS:
|
|
7
9
|
|
|
8
|
-
1.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
1. `overrideredirect(True)` NÃO tira a moldura: a janela nascia com barra de
|
|
11
|
+
título, os três botões e o nome "Primo Code Voice".
|
|
12
|
+
2. O fundo `systemTransparent` saía PRETO.
|
|
13
|
+
3. O Canvas não apaga o quadro anterior sobre fundo transparente: cada
|
|
14
|
+
partícula deixava um rastro, e em segundos a janela virava um borrão.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
Nenhum dos três se conserta de dentro do Tk. Então, no macOS, a orb é
|
|
17
|
+
desenhada pelo próprio sistema (PyObjC): uma NSWindow sem borda, sem fundo e
|
|
18
|
+
sem sombra, e uma NSView que limpa e pinta cada quadro com transparência de
|
|
19
|
+
verdade — o brilho é um gradiente com alfa, não discos misturados contra uma
|
|
20
|
+
cor de fundo adivinhada. Só a orb aparece; o resto da janela não existe.
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
O Tk continua sendo quem roda o laço de eventos (é ele que o resto do modo
|
|
23
|
+
voz já usa), e segue como desenho de reserva fora do macOS ou sem PyObjC.
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
── ONDE ELA MORA ───────────────────────────────────────────────────────────
|
|
26
|
+
Embaixo, centralizada na ÁREA ÚTIL da tela — acima do Dock, não atrás dele.
|
|
27
|
+
A posição é aplicada depois de a janela existir: dada cedo demais, a janela
|
|
28
|
+
sem moldura ignora.
|
|
21
29
|
|
|
22
30
|
── O QUE ELA DESENHA ───────────────────────────────────────────────────────
|
|
23
|
-
Um núcleo com
|
|
31
|
+
Um núcleo com brilho suave e um enxame de partículas em órbita. A cor diz o
|
|
24
32
|
estado; o MOVIMENTO diz a intensidade — as partículas se afastam e aceleram
|
|
25
33
|
com a voz, e respiram devagar quando ele está pensando. Nenhuma palavra: quem
|
|
26
34
|
está falando não lê.
|
|
27
|
-
|
|
28
|
-
── O QUE NÃO DÁ PARA FAZER EM TKINTER, E COMO SE CONTORNA ─────────────────
|
|
29
|
-
Não há desfoque nem gradiente: o Canvas do Tk desenha forma chapada. O halo é
|
|
30
|
-
feito de círculos concêntricos com a cor interpolada contra o fundo — o
|
|
31
|
-
mesmo truque de sempre, e o motivo de o fundo precisar ser conhecido. Onde a
|
|
32
|
-
janela é transparente de verdade, o "fundo" para a mistura é o preto, que é o
|
|
33
|
-
que mais se aproxima de qualquer tela.
|
|
34
35
|
"""
|
|
35
36
|
from __future__ import annotations
|
|
36
37
|
|
|
@@ -39,23 +40,27 @@ import queue
|
|
|
39
40
|
import random
|
|
40
41
|
import sys
|
|
41
42
|
import tkinter as tk
|
|
42
|
-
from typing import Callable, Optional
|
|
43
|
+
from typing import Callable, List, Optional, Tuple
|
|
43
44
|
|
|
44
45
|
# Os quatro estados, e a cor de cada um. A paleta é a do PrimoCode (ciano de
|
|
45
46
|
# marca, âmbar de aviso) para o modo voz não parecer outro produto.
|
|
46
47
|
CORES = {
|
|
47
|
-
"esperando": ("#
|
|
48
|
+
"esperando": ("#22b8d8", "#0e3a4a"), # ciano calmo: pronto, sem pressa
|
|
48
49
|
"ouvindo": ("#22d3ee", "#0e7490"), # ciano vivo: a vez é sua
|
|
49
50
|
"pensando": ("#fbbf24", "#78350f"), # âmbar: ele está trabalhando
|
|
50
51
|
"falando": ("#67e8f9", "#0891b2"), # ciano claro: a voz é dele
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
LARGURA =
|
|
54
|
-
ALTURA =
|
|
55
|
-
RAIO_BASE =
|
|
56
|
-
MARGEM_INFERIOR =
|
|
57
|
-
PARTICULAS =
|
|
58
|
-
FUNDO_FALSO = "#05070a" # só
|
|
54
|
+
LARGURA = 360 # só a área da orb e do brilho — o resto é transparente
|
|
55
|
+
ALTURA = 220
|
|
56
|
+
RAIO_BASE = 34 # o núcleo
|
|
57
|
+
MARGEM_INFERIOR = 24 # acima da área útil (o Dock já fica de fora)
|
|
58
|
+
PARTICULAS = 44
|
|
59
|
+
FUNDO_FALSO = "#05070a" # só no desenho de reserva, onde não há alfa
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rgb(cor: str) -> Tuple[float, float, float]:
|
|
63
|
+
return int(cor[1:3], 16) / 255, int(cor[3:5], 16) / 255, int(cor[5:7], 16) / 255
|
|
59
64
|
|
|
60
65
|
|
|
61
66
|
def _mistura(a: str, b: str, quanto: float) -> str:
|
|
@@ -80,11 +85,111 @@ class Particula:
|
|
|
80
85
|
|
|
81
86
|
def __init__(self, rnd: random.Random) -> None:
|
|
82
87
|
self.ang = rnd.uniform(0, math.tau)
|
|
83
|
-
self.raio = rnd.uniform(1.
|
|
88
|
+
self.raio = rnd.uniform(1.25, 2.3) # múltiplo do raio do núcleo
|
|
84
89
|
self.giro = rnd.uniform(-0.9, 0.9) or 0.4
|
|
85
|
-
self.tam = rnd.uniform(1.
|
|
90
|
+
self.tam = rnd.uniform(1.3, 3.0)
|
|
86
91
|
self.fase = rnd.uniform(0, math.tau)
|
|
87
|
-
self.brilho = rnd.uniform(0.
|
|
92
|
+
self.brilho = rnd.uniform(0.45, 1.0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Quadro:
|
|
96
|
+
"""Um quadro da animação, sem saber quem vai pintá-lo."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, vivo: str, fundo: str, raio: float, pulso: float,
|
|
99
|
+
particulas: List[Tuple[float, float, float, float]]):
|
|
100
|
+
self.vivo, self.fundo = vivo, fundo
|
|
101
|
+
self.raio, self.pulso = raio, pulso
|
|
102
|
+
self.particulas = particulas # (x, y, tamanho, intensidade 0..1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── O desenho nativo do macOS ────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def _criar_nativa(orbe: "Orbe"):
|
|
108
|
+
"""A janela sem borda e sem fundo, desenhada pelo macOS. None se não der."""
|
|
109
|
+
if sys.platform != "darwin":
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
import objc
|
|
113
|
+
from AppKit import (NSBackingStoreBuffered, NSBezierPath, NSColor,
|
|
114
|
+
NSCompositingOperationClear, NSGradient, NSMakeRect,
|
|
115
|
+
NSRectFillUsingOperation, NSScreen, NSView, NSWindow)
|
|
116
|
+
except Exception:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def cor(hexa: str, alfa: float):
|
|
120
|
+
r, g, b = _rgb(hexa)
|
|
121
|
+
return NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, max(0.0, min(1.0, alfa)))
|
|
122
|
+
|
|
123
|
+
# A classe Objective-C só pode ser registrada uma vez por processo.
|
|
124
|
+
try:
|
|
125
|
+
Vista = objc.lookUpClass("PrimoOrbeVista")
|
|
126
|
+
except objc.nosuchclass_error:
|
|
127
|
+
class PrimoOrbeVista(NSView):
|
|
128
|
+
def isOpaque(self):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def acceptsFirstMouse_(self, evento):
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def drawRect_(self, rect):
|
|
135
|
+
NSColor.clearColor().set()
|
|
136
|
+
NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
|
|
137
|
+
q = getattr(self, "quadro", None)
|
|
138
|
+
if q is None:
|
|
139
|
+
return
|
|
140
|
+
cx, cy = LARGURA / 2, ALTURA / 2
|
|
141
|
+
centro = (cx, cy)
|
|
142
|
+
|
|
143
|
+
# BRILHO: um gradiente radial com alfa, que some no nada. É o
|
|
144
|
+
# que o Tk não sabia fazer — e o que tira o "disco escuro".
|
|
145
|
+
alcance = q.raio * (2.6 + q.pulso * 0.9)
|
|
146
|
+
brilho = NSGradient.alloc().initWithColors_(
|
|
147
|
+
[cor(q.vivo, 0.34 + q.pulso * 0.20), cor(q.vivo, 0.10), cor(q.vivo, 0.0)])
|
|
148
|
+
brilho.drawFromCenter_radius_toCenter_radius_options_(centro, q.raio * 0.6, centro, alcance, 0)
|
|
149
|
+
|
|
150
|
+
# PARTÍCULAS
|
|
151
|
+
for (px, py, t, forca) in q.particulas:
|
|
152
|
+
cor(q.vivo, 0.25 + forca * 0.75).set()
|
|
153
|
+
NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(px - t, py - t, 2 * t, 2 * t)).fill()
|
|
154
|
+
|
|
155
|
+
# NÚCLEO: gradiente do claro ao vivo, com um anel fino por fora.
|
|
156
|
+
r = q.raio
|
|
157
|
+
nucleo = NSGradient.alloc().initWithColors_(
|
|
158
|
+
[cor(_mistura(q.vivo, "#ffffff", 0.55), 1.0), cor(q.vivo, 1.0),
|
|
159
|
+
cor(_mistura(q.vivo, q.fundo, 0.45), 1.0)])
|
|
160
|
+
nucleo.drawFromCenter_radius_toCenter_radius_options_(
|
|
161
|
+
(cx - r * 0.25, cy + r * 0.25), 0, centro, r, 0)
|
|
162
|
+
cor(q.vivo, 0.55).set()
|
|
163
|
+
anel = NSBezierPath.bezierPathWithOvalInRect_(
|
|
164
|
+
NSMakeRect(cx - r * 1.18, cy - r * 1.18, r * 2.36, r * 2.36))
|
|
165
|
+
anel.setLineWidth_(1.5)
|
|
166
|
+
anel.stroke()
|
|
167
|
+
|
|
168
|
+
Vista = PrimoOrbeVista
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
172
|
+
NSMakeRect(0, 0, LARGURA, ALTURA), 0, NSBackingStoreBuffered, False) # 0 = sem borda
|
|
173
|
+
janela.setOpaque_(False)
|
|
174
|
+
janela.setBackgroundColor_(NSColor.clearColor())
|
|
175
|
+
janela.setHasShadow_(False)
|
|
176
|
+
janela.setLevel_(25) # NSStatusWindowLevel: por cima de tudo
|
|
177
|
+
janela.setMovableByWindowBackground_(True) # arrasta pela orb
|
|
178
|
+
janela.setCollectionBehavior_(1 | 16) # todos os espaços; fora do Exposé
|
|
179
|
+
janela.setReleasedWhenClosed_(False)
|
|
180
|
+
vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))
|
|
181
|
+
janela.setContentView_(vista)
|
|
182
|
+
|
|
183
|
+
# Embaixo, centralizada na área útil (acima do Dock). O Cocoa conta
|
|
184
|
+
# de baixo para cima, então a margem é somada ao pé da área útil.
|
|
185
|
+
util = NSScreen.mainScreen().visibleFrame()
|
|
186
|
+
x = util.origin.x + (util.size.width - LARGURA) / 2
|
|
187
|
+
y = util.origin.y + MARGEM_INFERIOR
|
|
188
|
+
janela.setFrameOrigin_((x, y))
|
|
189
|
+
janela.orderFrontRegardless()
|
|
190
|
+
return janela, vista
|
|
191
|
+
except Exception:
|
|
192
|
+
return None
|
|
88
193
|
|
|
89
194
|
|
|
90
195
|
class Orbe:
|
|
@@ -103,97 +208,65 @@ class Orbe:
|
|
|
103
208
|
self._rnd = random.Random(7) # semente fixa: o enxame é sempre o mesmo
|
|
104
209
|
self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]
|
|
105
210
|
|
|
106
|
-
self
|
|
211
|
+
nativa = _criar_nativa(self)
|
|
212
|
+
if nativa:
|
|
213
|
+
# O macOS desenha; a janela do Tk fica escondida e só roda o laço.
|
|
214
|
+
self.janela, self.vista = nativa
|
|
215
|
+
self.nativa = True
|
|
216
|
+
self.raiz.withdraw()
|
|
217
|
+
else:
|
|
218
|
+
self.janela = self.vista = None
|
|
219
|
+
self.nativa = False
|
|
220
|
+
self._montar_tk()
|
|
221
|
+
|
|
107
222
|
self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
|
|
108
223
|
self.raiz.bind("<Escape>", lambda e: self.fechar())
|
|
109
224
|
self._pintar()
|
|
110
225
|
|
|
111
|
-
# ── a
|
|
112
|
-
def
|
|
113
|
-
"""Sem moldura, embaixo, centralizada — e transparente onde dá.
|
|
114
|
-
|
|
115
|
-
A transparência se pede de um jeito em cada sistema, e pedir errado
|
|
116
|
-
não avisa: devolve TclError, que engolido deixa a janela OPACA. Era
|
|
117
|
-
exatamente o "quadrado feio". Por isso aqui cada caminho é explícito,
|
|
118
|
-
e o fundo falso só entra quando nenhum funcionou.
|
|
119
|
-
"""
|
|
226
|
+
# ── a reserva em Tk (fora do macOS, ou sem PyObjC) ───────────────────
|
|
227
|
+
def _montar_tk(self) -> None:
|
|
120
228
|
self.raiz.overrideredirect(True)
|
|
121
|
-
larguraTela = self.raiz.winfo_screenwidth()
|
|
122
|
-
alturaTela = self.raiz.winfo_screenheight()
|
|
123
|
-
x = (larguraTela - LARGURA) // 2
|
|
124
|
-
y = alturaTela - ALTURA - MARGEM_INFERIOR
|
|
125
|
-
self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
|
|
126
|
-
|
|
127
229
|
self.fundo = FUNDO_FALSO
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if sys.platform == "darwin":
|
|
131
|
-
# macOS: `-transparent` mais o fundo mágico. Os dois juntos, ou
|
|
132
|
-
# nenhum dos dois funciona.
|
|
133
|
-
try:
|
|
134
|
-
self.raiz.attributes("-transparent", True)
|
|
135
|
-
self.fundo = "systemTransparent"
|
|
136
|
-
transparente = True
|
|
137
|
-
except tk.TclError:
|
|
138
|
-
pass
|
|
139
|
-
elif sys.platform == "win32":
|
|
230
|
+
if sys.platform == "win32":
|
|
231
|
+
# No Windows o Tk dá transparência por cor-chave, e ela funciona.
|
|
140
232
|
try:
|
|
141
233
|
self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
|
|
142
|
-
transparente = True
|
|
143
234
|
except tk.TclError:
|
|
144
235
|
pass
|
|
145
|
-
|
|
146
236
|
try:
|
|
147
237
|
self.raiz.attributes("-topmost", True)
|
|
148
238
|
except tk.TclError:
|
|
149
239
|
pass
|
|
150
|
-
|
|
151
240
|
self.raiz.configure(bg=self.fundo)
|
|
152
241
|
self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
|
|
153
242
|
bg=self.fundo, highlightthickness=0, bd=0)
|
|
154
243
|
self.tela.pack()
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# some melhor em qualquer tela.
|
|
159
|
-
self.fundoDaMistura = FUNDO_FALSO if transparente else self.fundo
|
|
160
|
-
|
|
161
|
-
# Sem moldura não há barra de título para arrastar: o arrasto é na
|
|
162
|
-
# janela toda. Quem quiser a orb noutro canto, leva.
|
|
244
|
+
self.raiz.update_idletasks()
|
|
245
|
+
self._posicionar_tk()
|
|
246
|
+
self.raiz.after(120, self._posicionar_tk)
|
|
163
247
|
self.raiz.bind("<Button-1>", self._pegar)
|
|
164
248
|
self.raiz.bind("<B1-Motion>", self._arrastar)
|
|
165
249
|
self._pego = (0, 0)
|
|
166
250
|
|
|
251
|
+
def _posicionar_tk(self) -> None:
|
|
252
|
+
x = (self.raiz.winfo_screenwidth() - LARGURA) // 2
|
|
253
|
+
y = self.raiz.winfo_screenheight() - ALTURA - MARGEM_INFERIOR - 48
|
|
254
|
+
try:
|
|
255
|
+
self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
|
|
256
|
+
except tk.TclError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
167
259
|
def _pegar(self, e) -> None:
|
|
168
260
|
self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())
|
|
169
261
|
|
|
170
262
|
def _arrastar(self, e) -> None:
|
|
171
263
|
self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")
|
|
172
264
|
|
|
173
|
-
# ──
|
|
174
|
-
def
|
|
175
|
-
if not self._vivo:
|
|
176
|
-
return
|
|
177
|
-
while True:
|
|
178
|
-
try:
|
|
179
|
-
estado, nivel = self._fila.get_nowait()
|
|
180
|
-
if estado == "__esconder__":
|
|
181
|
-
self._aplicarVisibilidade(False)
|
|
182
|
-
continue
|
|
183
|
-
if estado == "__mostrar__":
|
|
184
|
-
self._aplicarVisibilidade(True)
|
|
185
|
-
continue
|
|
186
|
-
if estado:
|
|
187
|
-
self.estado = estado
|
|
188
|
-
if nivel is not None:
|
|
189
|
-
self.nivel = max(0.0, min(1.0, nivel))
|
|
190
|
-
except queue.Empty:
|
|
191
|
-
break
|
|
192
|
-
|
|
265
|
+
# ── a animação ───────────────────────────────────────────────────────
|
|
266
|
+
def _quadro(self) -> Quadro:
|
|
193
267
|
self._fase += 0.045
|
|
194
268
|
vivo, fundo = CORES.get(self.estado, CORES["esperando"])
|
|
195
269
|
cx, cy = LARGURA / 2, ALTURA / 2
|
|
196
|
-
self.tela.delete("all")
|
|
197
270
|
|
|
198
271
|
# A respiração de cada estado. Quem espera respira devagar; quem
|
|
199
272
|
# pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
|
|
@@ -206,49 +279,77 @@ class Orbe:
|
|
|
206
279
|
pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
|
|
207
280
|
else:
|
|
208
281
|
pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
|
|
282
|
+
raio = RAIO_BASE * (1 + pulso * 0.30)
|
|
209
283
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
# HALO: círculos concêntricos, do mais largo e apagado ao núcleo. É o
|
|
213
|
-
# desfoque que o Canvas não tem — e por isso a cor de cada anel é
|
|
214
|
-
# interpolada contra o fundo, em vez de ter opacidade.
|
|
215
|
-
for i in range(11, 0, -1):
|
|
216
|
-
f = i / 11
|
|
217
|
-
r = raio * (1 + f * 2.6)
|
|
218
|
-
cor = _mistura(self.fundoDaMistura, fundo, (1 - f) ** 2 * 0.85)
|
|
219
|
-
self.tela.create_oval(cx - r, cy - r, cx + r, cy + r, fill=cor, outline="")
|
|
220
|
-
|
|
221
|
-
# PARTÍCULAS: entre o halo e o núcleo, para o núcleo ficar por cima.
|
|
284
|
+
pontos = []
|
|
222
285
|
for p in self._particulas:
|
|
223
286
|
p.ang += p.giro * 0.012 * (0.6 + pulso)
|
|
224
287
|
# O raio respira junto com a voz: quanto mais alto se fala, mais
|
|
225
288
|
# longe o enxame se abre.
|
|
226
289
|
osc = math.sin(self._fase * 1.7 + p.fase)
|
|
227
|
-
r = raio * (p.raio + 0.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
self.tela.create_oval(cx - raio, cy - raio, cx + raio, cy + raio,
|
|
236
|
-
fill=_mistura(fundo, vivo, 0.72), outline="")
|
|
237
|
-
rn = raio * 0.55
|
|
238
|
-
self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
|
|
239
|
-
fill=_mistura(vivo, "#ffffff", 0.35), outline="")
|
|
290
|
+
r = raio * (p.raio + 0.25 * osc + pulso * 0.40)
|
|
291
|
+
# Elipse leve, mais larga que alta — e dentro da janela: o que
|
|
292
|
+
# passa da borda é cortado seco, e partícula cortada é defeito.
|
|
293
|
+
px = cx + math.cos(p.ang) * r * 1.2
|
|
294
|
+
py = cy + math.sin(p.ang) * r * 0.74
|
|
295
|
+
t = p.tam * (0.75 + pulso * 0.6)
|
|
296
|
+
pontos.append((px, py, t, p.brilho * (0.5 + pulso * 0.5)))
|
|
297
|
+
return Quadro(vivo, fundo, raio, pulso, pontos)
|
|
240
298
|
|
|
299
|
+
def _pintar(self) -> None:
|
|
300
|
+
if not self._vivo:
|
|
301
|
+
return
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
estado, nivel = self._fila.get_nowait()
|
|
305
|
+
if estado == "__esconder__":
|
|
306
|
+
self._aplicarVisibilidade(False)
|
|
307
|
+
continue
|
|
308
|
+
if estado == "__mostrar__":
|
|
309
|
+
self._aplicarVisibilidade(True)
|
|
310
|
+
continue
|
|
311
|
+
if estado:
|
|
312
|
+
self.estado = estado
|
|
313
|
+
if nivel is not None:
|
|
314
|
+
self.nivel = max(0.0, min(1.0, nivel))
|
|
315
|
+
except queue.Empty:
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
q = self._quadro()
|
|
319
|
+
if self.nativa:
|
|
320
|
+
self.vista.quadro = q
|
|
321
|
+
self.vista.setNeedsDisplay_(True)
|
|
322
|
+
else:
|
|
323
|
+
self._pintar_tk(q)
|
|
241
324
|
self.raiz.after(33, self._pintar)
|
|
242
325
|
|
|
326
|
+
def _pintar_tk(self, q: Quadro) -> None:
|
|
327
|
+
"""A reserva: forma chapada, sem alfa. Misturas contra o fundo."""
|
|
328
|
+
cx, cy = LARGURA / 2, ALTURA / 2
|
|
329
|
+
self.tela.delete("all")
|
|
330
|
+
for i in range(6, 0, -1):
|
|
331
|
+
f = i / 6
|
|
332
|
+
r = q.raio * (1 + f * 1.3)
|
|
333
|
+
self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
|
|
334
|
+
outline=_mistura(self.fundo, q.vivo, (1 - f) ** 1.6 * 0.7), width=2)
|
|
335
|
+
for (px, py, t, forca) in q.particulas:
|
|
336
|
+
self.tela.create_oval(px - t, py - t, px + t, py + t,
|
|
337
|
+
fill=_mistura(self.fundo, q.vivo, 0.3 + forca * 0.7), outline="")
|
|
338
|
+
r = q.raio
|
|
339
|
+
self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
|
|
340
|
+
fill=_mistura(q.fundo, q.vivo, 0.8), outline="")
|
|
341
|
+
rn = r * 0.55
|
|
342
|
+
self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
|
|
343
|
+
fill=_mistura(q.vivo, "#ffffff", 0.4), outline="")
|
|
344
|
+
|
|
243
345
|
# ── visibilidade ─────────────────────────────────────────────────────
|
|
244
346
|
def esconder(self) -> None:
|
|
245
347
|
"""Some da tela sem morrer.
|
|
246
348
|
|
|
247
349
|
No modo de espera ele fica horas sem nada acontecer, e uma orb parada
|
|
248
350
|
na tela todo esse tempo é estorvo. Destruir e recriar a janela a cada
|
|
249
|
-
despertar
|
|
250
|
-
|
|
251
|
-
janela onde a pessoa estava digitando. `withdraw` guarda tudo de pé.
|
|
351
|
+
despertar pisca e, em alguns sistemas, rouba o foco de onde a pessoa
|
|
352
|
+
estava digitando. Esconder guarda tudo de pé.
|
|
252
353
|
"""
|
|
253
354
|
self._fila.put(("__esconder__", None))
|
|
254
355
|
|
|
@@ -256,9 +357,14 @@ class Orbe:
|
|
|
256
357
|
self._fila.put(("__mostrar__", None))
|
|
257
358
|
|
|
258
359
|
def _aplicarVisibilidade(self, visivel: bool) -> None:
|
|
259
|
-
# Só na thread da interface:
|
|
360
|
+
# Só na thread da interface: nem o Tk nem o AppKit perdoam chamada de fora.
|
|
260
361
|
try:
|
|
261
|
-
if
|
|
362
|
+
if self.nativa:
|
|
363
|
+
if visivel:
|
|
364
|
+
self.janela.orderFrontRegardless()
|
|
365
|
+
else:
|
|
366
|
+
self.janela.orderOut_(None)
|
|
367
|
+
elif visivel:
|
|
262
368
|
self.raiz.deiconify()
|
|
263
369
|
self.raiz.lift()
|
|
264
370
|
if sys.platform != "linux":
|
|
@@ -282,6 +388,12 @@ class Orbe:
|
|
|
282
388
|
self._ao_fechar()
|
|
283
389
|
except Exception:
|
|
284
390
|
pass
|
|
391
|
+
try:
|
|
392
|
+
if self.janela is not None:
|
|
393
|
+
self.janela.orderOut_(None)
|
|
394
|
+
self.janela.close()
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
285
397
|
try:
|
|
286
398
|
self.raiz.destroy()
|
|
287
399
|
except Exception:
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""transcrever.py — a fala vira texto AQUI, na máquina.
|
|
2
|
+
|
|
3
|
+
"Pode ser local, daí pra transcrever ele instala Whisper." (23/09/2026)
|
|
4
|
+
|
|
5
|
+
── POR QUE LOCAL ───────────────────────────────────────────────────────────
|
|
6
|
+
A transcrição ia para o servidor do PrimoCode (`/api/transcrever`). Com o
|
|
7
|
+
servidor fora do ar, o modo voz ouvia, pensava — e não entendia nada. Local,
|
|
8
|
+
ela não depende de rede, não custa chave e o áudio não sai do computador.
|
|
9
|
+
|
|
10
|
+
── POR QUE faster-whisper, E POR QUE O `small` ─────────────────────────────
|
|
11
|
+
É o Whisper da OpenAI reimplementado em CTranslate2: roda no processador de
|
|
12
|
+
qualquer máquina (Mac Intel ou Apple Silicon, Linux, Windows), instala por pip
|
|
13
|
+
sem compilar nada e lê o wav direto, sem ffmpeg. O modelo `small` (~480 MB,
|
|
14
|
+
uma vez) é o menor que entende português falado sem tropeçar em nome próprio;
|
|
15
|
+
o `base` erra demais, o `medium` dobra o tempo de resposta. Quem quiser outro
|
|
16
|
+
troca por PRIMOCODE_WHISPER_MODELO.
|
|
17
|
+
|
|
18
|
+
O modelo carrega UMA vez por sessão, em segundo plano, logo que o modo voz
|
|
19
|
+
abre — senão a primeira frase esperaria o carregamento inteiro.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "small").strip() or "small"
|
|
31
|
+
PASTA = Path.home() / ".primocode" / "modelos" / "whisper"
|
|
32
|
+
|
|
33
|
+
_modelo = None
|
|
34
|
+
_trava = threading.Lock()
|
|
35
|
+
_erro: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def disponivel() -> bool:
|
|
39
|
+
try:
|
|
40
|
+
import faster_whisper # noqa: F401
|
|
41
|
+
return True
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def carregar():
|
|
47
|
+
"""O modelo, carregado uma vez (e baixado, se for a primeira)."""
|
|
48
|
+
global _modelo, _erro
|
|
49
|
+
with _trava:
|
|
50
|
+
if _modelo is not None:
|
|
51
|
+
return _modelo
|
|
52
|
+
from faster_whisper import WhisperModel
|
|
53
|
+
PASTA.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
try:
|
|
55
|
+
# int8 no processador: é o que cabe em qualquer máquina e ainda
|
|
56
|
+
# responde uma frase de dez segundos em um ou dois.
|
|
57
|
+
_modelo = WhisperModel(MODELO, device="cpu", compute_type="int8",
|
|
58
|
+
download_root=str(PASTA))
|
|
59
|
+
_erro = None
|
|
60
|
+
except Exception as e:
|
|
61
|
+
_erro = str(e)
|
|
62
|
+
raise
|
|
63
|
+
return _modelo
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def aquecer() -> None:
|
|
67
|
+
"""Carrega em segundo plano. Erro aqui não derruba nada: a transcrição
|
|
68
|
+
tenta de novo na hora, e cai para o servidor se não der."""
|
|
69
|
+
def _ir():
|
|
70
|
+
try:
|
|
71
|
+
carregar()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
threading.Thread(target=_ir, daemon=True).start()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def transcrever(wav: Path, idioma: str = "pt") -> str:
|
|
78
|
+
modelo = carregar()
|
|
79
|
+
# vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
|
|
80
|
+
# "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
|
|
81
|
+
segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
|
|
82
|
+
vad_filter=True, condition_on_previous_text=False)
|
|
83
|
+
return " ".join(s.text.strip() for s in segmentos).strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
"""`python -m primo_voz.transcrever baixar` — usado pelo /voice para deixar
|
|
88
|
+
o modelo pronto antes da primeira frase. Responde uma linha de JSON."""
|
|
89
|
+
acao = sys.argv[1] if len(sys.argv) > 1 else "baixar"
|
|
90
|
+
if acao != "baixar":
|
|
91
|
+
print(json.dumps({"ok": False, "error": f"ação desconhecida: {acao}"}))
|
|
92
|
+
return 2
|
|
93
|
+
if not disponivel():
|
|
94
|
+
print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}))
|
|
95
|
+
return 1
|
|
96
|
+
try:
|
|
97
|
+
carregar()
|
|
98
|
+
except Exception as e:
|
|
99
|
+
print(json.dumps({"ok": False, "error": f"não consegui baixar o modelo {MODELO}: {e}"}))
|
|
100
|
+
return 1
|
|
101
|
+
print(json.dumps({"ok": True, "modelo": MODELO, "pasta": str(PASTA)}))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main())
|