primocode 10.15.0 → 10.15.2
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 +18 -3
- package/lib/config.js +1 -1
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/tools.js +1 -0
- package/lib/voz.js +27 -3
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +201 -24
- package/voz/primo_voz/app.py +451 -0
- package/voz/primo_voz/appmac.py +125 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +17 -6
- package/voz/primo_voz/escuta.py +30 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +60 -26
- package/voz/primo_voz/hud.py +84 -12
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/tela.py +36 -54
- package/voz/primo_voz/transcrever.py +19 -2
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.15.
|
|
1
|
+
# PrimoCode v10.15.2
|
|
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 v10.15.
|
|
12
|
+
▐▛███▜▌ Primo Code v10.15.2
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/bin/primocode.js
CHANGED
|
@@ -2519,6 +2519,7 @@ async function handleSlash(text) {
|
|
|
2519
2519
|
break;
|
|
2520
2520
|
}
|
|
2521
2521
|
for (const a of prep.avisos) console.log(c.dim(` ${a}`));
|
|
2522
|
+
if (process.platform === 'darwin') { try { vozModo.instalarApp({ python: prep.python }); } catch { /* o app é bônus */ } }
|
|
2522
2523
|
|
|
2523
2524
|
const sessao = vozModo.abrir({
|
|
2524
2525
|
python: prep.python,
|
|
@@ -2922,7 +2923,14 @@ async function main() {
|
|
|
2922
2923
|
r.processo.on('close', () => process.exit(0));
|
|
2923
2924
|
return;
|
|
2924
2925
|
}
|
|
2925
|
-
|
|
2926
|
+
if (sub === 'instalar-app') {
|
|
2927
|
+
const py = vz.python();
|
|
2928
|
+
if (!py) { console.log('O app precisa do Python 3 (rode /voice uma vez para preparar o ambiente).'); process.exit(1); }
|
|
2929
|
+
const r = vz.instalarApp({ python: py, dock: resto.includes('--dock') });
|
|
2930
|
+
console.log(r.ok ? `Primo.app instalado em ${r.app}. Abra pelo Launchpad, Spotlight ou Finder.` : `Não deu: ${r.erro || 'veja o log'}`);
|
|
2931
|
+
process.exit(r.ok ? 0 : 1);
|
|
2932
|
+
}
|
|
2933
|
+
console.log('Uso: primocode voz nome <Nome> | primocode voz painel | primocode voz instalar-app [--dock] (o resto é /voice dentro da sessão)');
|
|
2926
2934
|
process.exit(0);
|
|
2927
2935
|
}
|
|
2928
2936
|
if (['doutor', 'doctor', 'preparar'].includes(process.argv[2])) {
|
|
@@ -2953,7 +2961,11 @@ async function main() {
|
|
|
2953
2961
|
state.workDirOrigem = 'padrao';
|
|
2954
2962
|
// O projeto em que se estava na última sessão (até uma semana atrás): o
|
|
2955
2963
|
// "continua o jogo" de amanhã cai nele, e não num projeto novo.
|
|
2956
|
-
|
|
2964
|
+
// O app do Primo (voz) manda a pasta de cada pedido: PRIMOCODE_PASTA (+ ORIGEM) e nunca herda a última do terminal.
|
|
2965
|
+
const lembrado = process.env.PRIMOCODE_APP === '1'
|
|
2966
|
+
? (process.env.PRIMOCODE_PASTA && fs.existsSync(process.env.PRIMOCODE_PASTA)
|
|
2967
|
+
? { dir: process.env.PRIMOCODE_PASTA, origem: process.env.PRIMOCODE_PASTA_ORIGEM === '/dir' ? '/dir' : 'projeto' } : null)
|
|
2968
|
+
: cfg.projetoLembrado();
|
|
2957
2969
|
if (lembrado && pastaSegura(lembrado.dir)) {
|
|
2958
2970
|
state.workDir = lembrado.dir;
|
|
2959
2971
|
state.currentProject = path.basename(lembrado.dir);
|
|
@@ -2962,7 +2974,7 @@ async function main() {
|
|
|
2962
2974
|
|
|
2963
2975
|
// Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
|
|
2964
2976
|
// mesmo que uma refatoração.
|
|
2965
|
-
state.mode = ['primocode', 'chat', 'auto'].includes(state.config.mode) ? state.config.mode : 'auto';
|
|
2977
|
+
state.mode = ['primocode', 'chat', 'auto'].includes(process.env.PRIMOCODE_MODO || state.config.mode) ? (process.env.PRIMOCODE_MODO || state.config.mode) : 'auto';
|
|
2966
2978
|
|
|
2967
2979
|
// `-c` / `--continue` pode vir em qualquer posição e sai da lista: o
|
|
2968
2980
|
// resto (one-shot, -f) segue sendo lido do jeito de sempre.
|
|
@@ -3007,6 +3019,9 @@ async function main() {
|
|
|
3007
3019
|
if (typeof fl.resume === 'string') await retomarConversa(fl.resume);
|
|
3008
3020
|
else if (continuar) await continuarUltima();
|
|
3009
3021
|
state.semTerminal = true;
|
|
3022
|
+
if (process.env.PRIMOCODE_LOG_FERRAMENTAS === '1') { // o app do Primo registra cada ferramenta no log dele
|
|
3023
|
+
require('../lib/voz-ponte').escutar((nome, a) => process.stderr.write(`ferramenta: ${nome} ${JSON.stringify(a || {}).slice(0, 220)}\n`));
|
|
3024
|
+
}
|
|
3010
3025
|
let erro = null;
|
|
3011
3026
|
const texto = await H.capturar(async () => {
|
|
3012
3027
|
try { await sendPrompt(pedido); } catch (e) { erro = e.message || String(e); }
|
package/lib/config.js
CHANGED
|
@@ -16,7 +16,7 @@ const os = require('os');
|
|
|
16
16
|
const { nomeCurto } = require('./nome-curto.js');
|
|
17
17
|
|
|
18
18
|
const HOME = os.homedir();
|
|
19
|
-
const ROOT_DIR = path.join(HOME, 'primocode');
|
|
19
|
+
const ROOT_DIR = process.env.PRIMOCODE_RAIZ || path.join(HOME, 'primocode'); // o app do Primo usa ~/Primo
|
|
20
20
|
const CONFIG_DIR = path.join(HOME, '.primocode');
|
|
21
21
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
22
22
|
const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
|
package/lib/conta.js
CHANGED
|
@@ -213,6 +213,7 @@ async function entrar({ email, senha } = {}, op = {}) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
function abrirNoNavegador(url) {
|
|
216
|
+
if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
|
|
216
217
|
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
217
218
|
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
218
219
|
: ['xdg-open', [url]];
|
package/lib/estudio/exportar.js
CHANGED
|
@@ -156,6 +156,7 @@ function registrar({ id, assunto, caminho, formato, duracao, assinatura, entrega
|
|
|
156
156
|
}
|
|
157
157
|
const listar = (arq) => ler(arq).filter((v) => v.caminho && fs.existsSync(v.caminho)).sort((a, b) => String(b.data).localeCompare(String(a.data)));
|
|
158
158
|
function abrirNoSistema(alvo) {
|
|
159
|
+
if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
|
|
159
160
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
160
161
|
const args = process.platform === 'win32' ? ['/c', 'start', '""', alvo] : [alvo];
|
|
161
162
|
return spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
|
package/lib/mcp.js
CHANGED
|
@@ -204,6 +204,7 @@ function conectarHttp(def, { tetoMs = 60000, nome, buscar = globalThis.fetch } =
|
|
|
204
204
|
const b64url = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
205
205
|
|
|
206
206
|
function abrirUrl(url) {
|
|
207
|
+
if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
|
|
207
208
|
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
208
209
|
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
209
210
|
: ['xdg-open', [url]];
|
package/lib/tools.js
CHANGED
|
@@ -304,6 +304,7 @@ async function runShell(command, timeout = 60000, cwd, env) {
|
|
|
304
304
|
// UM argumento, não texto que um shell reinterpreta — sem isso, um valor tipo
|
|
305
305
|
// `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
|
|
306
306
|
function openInBrowser(target) {
|
|
307
|
+
if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return Promise.resolve({ ok: false, error: 'o navegador está bloqueado no modo voz: só abre quando a pessoa clica' }); // app do Primo (voz): navegador só com clique explícito
|
|
307
308
|
const p = os.platform();
|
|
308
309
|
const [cmd, args, opener] = p === 'darwin' ? ['open', [target], 'open']
|
|
309
310
|
: p === 'win32' ? ['cmd.exe', ['/c', 'start', '', target], 'start']
|
package/lib/voz.js
CHANGED
|
@@ -322,6 +322,7 @@ function abrir({ python: py, servidor, token, voz, aoParar = () => {}, aoOuvir,
|
|
|
322
322
|
const OPCIONAIS = () => [
|
|
323
323
|
['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
|
|
324
324
|
['pysilero_vad', 'pysilero-vad', 'o fim de fala preciso (Silero VAD)'],
|
|
325
|
+
['edge_tts', 'edge-tts', 'a voz neural pt-BR (Edge TTS)'],
|
|
325
326
|
...(process.platform === 'darwin' ? [
|
|
326
327
|
['WebKit', 'pyobjc-framework-WebKit', 'o painel em tela cheia do Primo'],
|
|
327
328
|
['EventKit', 'pyobjc-framework-EventKit', 'os compromissos de hoje do app Calendário'],
|
|
@@ -466,11 +467,34 @@ async function preparar(aoFalar = () => {}) {
|
|
|
466
467
|
return { ok: true, python: py, avisos };
|
|
467
468
|
}
|
|
468
469
|
|
|
470
|
+
/** Primo.app em ~/Applications (Launchpad/Spotlight) + o primo-app.json com os caminhos ABSOLUTOS de node e primocode:
|
|
471
|
+
* o app aberto pelo Finder não tem o PATH do terminal. Chamado no primeiro /voice e por `primocode voz instalar-app`. */
|
|
472
|
+
function instalarApp({ python: py, dock = false } = {}) {
|
|
473
|
+
if (process.platform !== 'darwin') return { ok: false, erro: 'o app do Primo é só para macOS' };
|
|
474
|
+
try {
|
|
475
|
+
let cfg = {};
|
|
476
|
+
try { cfg = require('./config').loadConfig(); } catch { /* usa o padrão do app */ }
|
|
477
|
+
const arq = path.join(os.homedir(), '.primocode', 'primo-app.json');
|
|
478
|
+
fs.mkdirSync(path.dirname(arq), { recursive: true });
|
|
479
|
+
fs.writeFileSync(arq, JSON.stringify({
|
|
480
|
+
node: process.execPath, primocode: path.resolve(__dirname, '..', 'bin', 'primocode.js'),
|
|
481
|
+
servidor: cfg.server || '', path: process.env.PATH || '', voz: cfg.voz || 'antonio', versao: require('../package.json').version,
|
|
482
|
+
}, null, 2));
|
|
483
|
+
const env = { ...process.env, PYTHONPATH: PASTA, ...(dock ? { PRIMOCODE_APP_DOCK: '1' } : {}) };
|
|
484
|
+
const linhas = execFileSync(py, ['-m', 'primo_voz.appmac'], { cwd: PASTA, env, encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n');
|
|
485
|
+
return { ok: !!linhas[0], exe: linhas[0] || null, pythonpath: linhas[1] || PASTA, app: path.join(os.homedir(), 'Applications', 'Primo.app') };
|
|
486
|
+
} catch (e) { return { ok: false, erro: e.message }; }
|
|
487
|
+
}
|
|
488
|
+
|
|
469
489
|
/** `/voice painel`: só o HUD (sem microfone). Mac com PyObjC = tela cheia; senão, o quadro no terminal. */
|
|
470
490
|
function abrirPainel({ python: py }) {
|
|
471
491
|
const mac = process.platform === 'darwin' && temModulo(py, 'WebKit');
|
|
472
|
-
|
|
473
|
-
|
|
492
|
+
// No Mac o painel roda pelo Primo.app (Dock e Cmd+Tab mostram "Primo" com o ícone); sem ele, o python direto.
|
|
493
|
+
let exe = null, pp = PASTA;
|
|
494
|
+
if (mac) { const r = instalarApp({ python: py }); if (r.exe) { exe = r.exe; pp = r.pythonpath; } }
|
|
495
|
+
const [cmd, args] = exe ? [exe, ['-m', 'primo_voz.hud', '--livre']] : [py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])]];
|
|
496
|
+
const p = spawn(cmd, args, {
|
|
497
|
+
cwd: PASTA, env: { ...process.env, PYTHONPATH: exe ? pp : PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
|
|
474
498
|
});
|
|
475
499
|
return { processo: p, tela: mac };
|
|
476
500
|
}
|
|
@@ -505,4 +529,4 @@ function linhasDoutor() {
|
|
|
505
529
|
];
|
|
506
530
|
}
|
|
507
531
|
|
|
508
|
-
module.exports = { definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
|
|
532
|
+
module.exports = { instalarApp, definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "10.15.
|
|
3
|
+
"version": "10.15.2",
|
|
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": {
|
|
@@ -31,6 +31,7 @@ from __future__ import annotations
|
|
|
31
31
|
import json
|
|
32
32
|
import os
|
|
33
33
|
import queue
|
|
34
|
+
import re
|
|
34
35
|
import shutil
|
|
35
36
|
import subprocess
|
|
36
37
|
import sys
|
|
@@ -42,12 +43,36 @@ from pathlib import Path
|
|
|
42
43
|
AQUI = Path(__file__).resolve().parent
|
|
43
44
|
sys.path.insert(0, str(AQUI.parent))
|
|
44
45
|
|
|
45
|
-
from primo_voz import conversa, escuta, identidade, painel, transcrever # noqa: E402
|
|
46
|
+
from primo_voz import conversa, escuta, fala, fala_pt, identidade, painel, transcrever # noqa: E402
|
|
46
47
|
from primo_voz import tela as dados_tela # noqa: E402
|
|
47
48
|
from primo_voz.luz import Luz # noqa: E402
|
|
48
49
|
from primo_voz.sons import Sons # noqa: E402
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
class _PonteHud:
|
|
53
|
+
"""No modo app o painel mora no processo-pai (app.py): as mensagens dele vão pelo stdout do protocolo."""
|
|
54
|
+
|
|
55
|
+
class _Entrada:
|
|
56
|
+
def write(self, linha: str) -> None:
|
|
57
|
+
try:
|
|
58
|
+
falar_para_o_node({"tipo": "hud", "msg": json.loads(linha)})
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def flush(self) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.stdin = self._Entrada()
|
|
70
|
+
self.stdout = iter(())
|
|
71
|
+
|
|
72
|
+
def poll(self):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
51
76
|
class Bolinha:
|
|
52
77
|
"""A orb (orbe.py) num processo à parte: este só diz o estado a ela.
|
|
53
78
|
|
|
@@ -62,6 +87,9 @@ class Bolinha:
|
|
|
62
87
|
if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
|
|
63
88
|
return
|
|
64
89
|
self.ao_acao = None
|
|
90
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
91
|
+
self.modulo, self.p = "primo_voz.hud", _PonteHud()
|
|
92
|
+
return
|
|
65
93
|
# No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
|
|
66
94
|
modulo = "primo_voz.orbe"
|
|
67
95
|
if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
|
|
@@ -72,11 +100,19 @@ class Bolinha:
|
|
|
72
100
|
pass
|
|
73
101
|
self.modulo = modulo
|
|
74
102
|
try:
|
|
75
|
-
|
|
103
|
+
cmd = [sys.executable, "-m", modulo]
|
|
104
|
+
env_extra = {"PYTHONPATH": str(AQUI.parent)}
|
|
105
|
+
if modulo.endswith("hud"):
|
|
106
|
+
from primo_voz import appmac # Primo.app: nome e ícone no Dock; cai no python se não der
|
|
107
|
+
exe = appmac.preparar()
|
|
108
|
+
if exe:
|
|
109
|
+
cmd = [exe, "-m", "primo_voz.hud"]
|
|
110
|
+
env_extra = {"PYTHONPATH": appmac.pythonpath()}
|
|
111
|
+
self.p = subprocess.Popen(cmd, cwd=str(AQUI.parent),
|
|
76
112
|
stdin=subprocess.PIPE,
|
|
77
113
|
stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
|
|
78
114
|
stderr=subprocess.DEVNULL,
|
|
79
|
-
env={**os.environ,
|
|
115
|
+
env={**os.environ, **env_extra}, text=True)
|
|
80
116
|
if modulo.endswith("hud"):
|
|
81
117
|
threading.Thread(target=self._ler_acoes, daemon=True).start()
|
|
82
118
|
except Exception:
|
|
@@ -189,6 +225,12 @@ class Voz:
|
|
|
189
225
|
self.local = transcrever.disponivel()
|
|
190
226
|
if self.local:
|
|
191
227
|
transcrever.aquecer()
|
|
228
|
+
self.edge = fala.Edge()
|
|
229
|
+
self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
|
|
230
|
+
self._t_acabou = None
|
|
231
|
+
self._pend = ""
|
|
232
|
+
self._pend_ate = 0.0
|
|
233
|
+
threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
|
|
192
234
|
self.gravador = escuta.Gravador()
|
|
193
235
|
self.gravador.vad = escuta.criar_vad()
|
|
194
236
|
self.corte = escuta.Corte()
|
|
@@ -209,6 +251,10 @@ class Voz:
|
|
|
209
251
|
self._ultimo_ok = ""
|
|
210
252
|
self.bolinha.enviar({"nome": self.nome})
|
|
211
253
|
|
|
254
|
+
def lat(self, texto: str) -> None:
|
|
255
|
+
if os.environ.get("PRIMOCODE_LAT") == "1":
|
|
256
|
+
avisar("lat: " + texto)
|
|
257
|
+
|
|
212
258
|
# ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
|
|
213
259
|
|
|
214
260
|
def _estado(self, estado: str, nivel: float = None) -> None:
|
|
@@ -270,7 +316,7 @@ class Voz:
|
|
|
270
316
|
|
|
271
317
|
def _parcial(self) -> None:
|
|
272
318
|
"""Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
|
|
273
|
-
if not self.local or getattr(self, "_parcial_rodando", False):
|
|
319
|
+
if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
|
|
274
320
|
return
|
|
275
321
|
copia = list(self.gravador._blocos)
|
|
276
322
|
if not copia:
|
|
@@ -328,14 +374,64 @@ class Voz:
|
|
|
328
374
|
if not self._vivo:
|
|
329
375
|
break
|
|
330
376
|
if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
|
|
377
|
+
if self._pend and time.time() >= self._pend_ate: # ninguém continuou: agora sim vira pedido
|
|
378
|
+
pend, self._pend = self._pend, ""
|
|
379
|
+
self.gravador.espera = escuta.ESPERA_COMECAR
|
|
380
|
+
self.lat(f"frase completa após a pausa extra: {pend!r}")
|
|
381
|
+
self._tratar(pend)
|
|
331
382
|
continue
|
|
332
383
|
wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
|
|
333
384
|
if not wav:
|
|
334
385
|
continue
|
|
386
|
+
self._t_acabou = time.time()
|
|
335
387
|
texto = transcrever.limpar(self._transcrever(wav))
|
|
336
|
-
self.
|
|
388
|
+
self.lat(f"transcrição {time.time() - self._t_acabou:.2f}s (a frase fechou após {escuta.RABO_VAD if self.gravador.vad else escuta.RABO_SILENCIO:.1f}s de silêncio) -> {texto!r}")
|
|
389
|
+
self._juntar_e_tratar(texto)
|
|
337
390
|
mic.fechar()
|
|
338
391
|
|
|
392
|
+
# "espera": a pessoa segura a vez. Palavras que, sozinhas no fim, prometem continuação.
|
|
393
|
+
_SEGURA = re.compile(r"(?:^|[\s,.])(?:espera|espere|pera ai|perai|aguarda|aguarde|calma|deixa eu pensar)[\s.!?,]*$", re.I)
|
|
394
|
+
_CONTINUA = re.compile(r"\b(?:e|ou|mas|ent[aã]o|que|tipo|porque|pois|a[ií]|pra|para|de|do|da|no|na|com|como|se|quando|ent[aã]o|tamb[eé]m|a|o|um|uma|meu|minha)\W*$", re.I)
|
|
395
|
+
|
|
396
|
+
def _incompleta(self, texto: str) -> bool:
|
|
397
|
+
t = texto.strip()
|
|
398
|
+
if not t:
|
|
399
|
+
return False
|
|
400
|
+
if t[-1] in ".?!":
|
|
401
|
+
return False
|
|
402
|
+
if t[-1] in ",;:…-" or self._CONTINUA.search(t):
|
|
403
|
+
return True
|
|
404
|
+
return len(t.split()) >= 5 # sem pontuação de fim numa frase já longa: provavelmente vem mais; comando curto não espera
|
|
405
|
+
|
|
406
|
+
def _juntar_e_tratar(self, texto: str) -> None:
|
|
407
|
+
"""Fim de fala ADAPTATIVO: 1,1 s de silêncio já fechou; se a frase soa incompleta ("e…", "então…", sem ponto)
|
|
408
|
+
ou a pessoa disse "espera", ele segura mais um pouco e junta com o que vier. Nunca corta no meio."""
|
|
409
|
+
if self._pend:
|
|
410
|
+
texto = (self._pend + " " + texto).strip()
|
|
411
|
+
self._pend = ""
|
|
412
|
+
if not texto:
|
|
413
|
+
self.gravador.espera = escuta.ESPERA_COMECAR
|
|
414
|
+
self._tratar(texto)
|
|
415
|
+
return
|
|
416
|
+
if self._SEGURA.search(texto):
|
|
417
|
+
resto = self._SEGURA.sub("", texto).strip(" ,")
|
|
418
|
+
self._pend, self._pend_ate = resto or " ", time.time() + 10
|
|
419
|
+
self.gravador.espera = 10.0
|
|
420
|
+
self.lat("a pessoa pediu para esperar: segurando até 10 s")
|
|
421
|
+
self.bolinha.enviar({"transcricao": (resto + " …") if resto else "…", "parcial": True})
|
|
422
|
+
self._estado("ouvindo")
|
|
423
|
+
return
|
|
424
|
+
if self._incompleta(texto):
|
|
425
|
+
self._pend, self._pend_ate = texto, time.time() + 1.6
|
|
426
|
+
self._acordado_ate = max(self._acordado_ate, time.time() + 30)
|
|
427
|
+
self.gravador.espera = 1.6
|
|
428
|
+
self.lat(f"frase incompleta, esperando mais 1,6 s: {texto!r}")
|
|
429
|
+
self.bolinha.enviar({"transcricao": texto + " …", "parcial": True})
|
|
430
|
+
self._estado("ouvindo")
|
|
431
|
+
return
|
|
432
|
+
self.gravador.espera = escuta.ESPERA_COMECAR
|
|
433
|
+
self._tratar(texto)
|
|
434
|
+
|
|
339
435
|
def _tratar(self, texto: str) -> None:
|
|
340
436
|
"""O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
|
|
341
437
|
if not texto:
|
|
@@ -345,6 +441,8 @@ class Voz:
|
|
|
345
441
|
acordado = (self.modo == "sempre" or agora < self._acordado_ate or agora < self._ptt_ate)
|
|
346
442
|
chamou, resto = conversa.ativacao(texto, self.nome)
|
|
347
443
|
if not acordado and not chamou:
|
|
444
|
+
self.lat(f"ignorado (sem o nome \"{self.nome}\" e fora da conversa): {texto!r}")
|
|
445
|
+
self.bolinha.enviar({"transcricao": f"(diga \"{self.nome}\" antes do pedido) {texto}", "parcial": False})
|
|
348
446
|
self._estado("dormindo") # não era comigo: não vira pedido
|
|
349
447
|
return
|
|
350
448
|
texto = resto if chamou else texto
|
|
@@ -365,6 +463,12 @@ class Voz:
|
|
|
365
463
|
else:
|
|
366
464
|
self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
|
|
367
465
|
return
|
|
466
|
+
nova = fala.voz_pedida(texto, self.voz_id)
|
|
467
|
+
if nova:
|
|
468
|
+
self.voz_id = nova
|
|
469
|
+
self.cfg = identidade.salvar(voz=nova)
|
|
470
|
+
self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
|
|
471
|
+
return
|
|
368
472
|
cmd = conversa.comando(texto)
|
|
369
473
|
if cmd == "esconder":
|
|
370
474
|
self.bolinha.enviar({"painel": "esconder"})
|
|
@@ -450,28 +554,89 @@ class Voz:
|
|
|
450
554
|
except queue.Full:
|
|
451
555
|
continue
|
|
452
556
|
|
|
557
|
+
def _cache_de(self, frase: str, hora: bool = False) -> "Path | None":
|
|
558
|
+
"""Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
|
|
559
|
+
if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
|
|
560
|
+
return None
|
|
561
|
+
import hashlib
|
|
562
|
+
pasta = Path.home() / ".primocode" / "voz-cache"
|
|
563
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
if hora:
|
|
565
|
+
return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
|
|
566
|
+
return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
|
|
567
|
+
|
|
568
|
+
def _cache_horas(self) -> None:
|
|
569
|
+
agora = time.time()
|
|
570
|
+
for dm in (0, 1, 2):
|
|
571
|
+
f = " ".join(fala_pt.normalizar(fala.hora_falada(time.localtime(agora + 60 * dm))).split())
|
|
572
|
+
c = self._cache_de(f, hora=True)
|
|
573
|
+
if c is not None and not c.exists():
|
|
574
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
575
|
+
|
|
576
|
+
def _pre_aquecer_frases(self) -> None:
|
|
577
|
+
"""As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
|
|
578
|
+
self.edge.aquecer()
|
|
579
|
+
self._cache_horas()
|
|
580
|
+
for f in ("Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
|
|
581
|
+
"Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
|
|
582
|
+
c = self._cache_de(f)
|
|
583
|
+
if c is not None and not c.exists():
|
|
584
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
585
|
+
while self._vivo: # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
|
|
586
|
+
self._cache_horas()
|
|
587
|
+
for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"): # só os minutos que já passaram
|
|
588
|
+
try:
|
|
589
|
+
if time.time() - velho.stat().st_mtime > 600:
|
|
590
|
+
velho.unlink()
|
|
591
|
+
except OSError:
|
|
592
|
+
pass
|
|
593
|
+
time.sleep(20)
|
|
594
|
+
|
|
453
595
|
def _sintetizar(self, frase: str) -> "Path | None":
|
|
454
|
-
limpo = " ".join(
|
|
596
|
+
limpo = " ".join(fala_pt.normalizar(frase).split())[:900] # números, unidades, siglas: por extenso, como se fala
|
|
455
597
|
if not limpo:
|
|
456
598
|
return None
|
|
599
|
+
t0 = time.time()
|
|
600
|
+
self.lat(f"vai falar: {limpo[:140]!r}")
|
|
457
601
|
mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
|
|
602
|
+
# 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
|
|
603
|
+
guardada = self._cache_de(limpo)
|
|
604
|
+
if guardada is not None and not guardada.exists():
|
|
605
|
+
guardada = self._cache_de(limpo, hora=True) if self._cache_de(limpo, hora=True).exists() else guardada
|
|
606
|
+
if guardada is not None and guardada.exists():
|
|
607
|
+
shutil.copyfile(guardada, mp3)
|
|
608
|
+
self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
|
|
609
|
+
return mp3
|
|
610
|
+
# 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
|
|
611
|
+
if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
|
|
612
|
+
self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
|
|
613
|
+
if guardada is not None:
|
|
614
|
+
try:
|
|
615
|
+
shutil.copyfile(mp3, guardada)
|
|
616
|
+
except OSError:
|
|
617
|
+
pass
|
|
618
|
+
return mp3
|
|
619
|
+
# 2. ElevenLabs, só se a pessoa pediu
|
|
458
620
|
narrar = AQUI.parent.parent / "studio" / "narrar.py"
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if r.returncode
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
621
|
+
r = None
|
|
622
|
+
if self.cfg.get("tts") == "elevenlabs":
|
|
623
|
+
try:
|
|
624
|
+
r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
|
|
625
|
+
capture_output=True, timeout=60)
|
|
626
|
+
except Exception as e:
|
|
627
|
+
avisar(f"não deu para gerar a voz: {e}")
|
|
628
|
+
if r is not None and r.returncode == 0 and mp3.exists():
|
|
629
|
+
return mp3
|
|
630
|
+
# 3. offline: a voz do sistema
|
|
631
|
+
if sys.platform == "darwin" and shutil.which("say"):
|
|
632
|
+
aiff = mp3.with_suffix(".aiff")
|
|
633
|
+
if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
|
|
634
|
+
subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
|
|
635
|
+
self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
|
|
636
|
+
return aiff
|
|
637
|
+
avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
|
|
638
|
+
self._erro("Não consegui gerar a voz. O texto está no painel.")
|
|
639
|
+
return None
|
|
475
640
|
|
|
476
641
|
def _tocar_em_fila(self) -> None:
|
|
477
642
|
while self._vivo:
|
|
@@ -502,7 +667,7 @@ class Voz:
|
|
|
502
667
|
self._falando = False
|
|
503
668
|
self.maquina.mandar("fim_fala")
|
|
504
669
|
self._estado("ouvindo" if self.modo == "sempre" or time.time() < self._acordado_ate else "dormindo")
|
|
505
|
-
self._acordado_ate = max(self._acordado_ate, time.time() +
|
|
670
|
+
self._acordado_ate = max(self._acordado_ate, time.time() + 90) # conversa em andamento: 90 s sem repetir o nome
|
|
506
671
|
self.sons.tocar("pronto")
|
|
507
672
|
self.corte.nivel_dele = 0.0
|
|
508
673
|
self._esvaziar_blocos() # o que o microfone pegou era a voz dele
|
|
@@ -528,6 +693,11 @@ class Voz:
|
|
|
528
693
|
except (FileNotFoundError, OSError):
|
|
529
694
|
continue
|
|
530
695
|
t0 = time.time()
|
|
696
|
+
if self._t_acabou:
|
|
697
|
+
self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
|
|
698
|
+
self._t_acabou = None
|
|
699
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
700
|
+
avisar(f"falando ({cmd[0]}): {arquivo.name}")
|
|
531
701
|
while p.poll() is None:
|
|
532
702
|
if env:
|
|
533
703
|
i = int((time.time() - t0) / 0.05)
|
|
@@ -541,6 +711,8 @@ class Voz:
|
|
|
541
711
|
pass
|
|
542
712
|
return
|
|
543
713
|
time.sleep(0.05)
|
|
714
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
715
|
+
avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
|
|
544
716
|
if p.returncode == 0:
|
|
545
717
|
return
|
|
546
718
|
# INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
|
|
@@ -565,7 +737,10 @@ class Voz:
|
|
|
565
737
|
tipo = msg.get("tipo")
|
|
566
738
|
if tipo == "falar":
|
|
567
739
|
texto = str(msg.get("texto") or "")
|
|
568
|
-
if msg.get("
|
|
740
|
+
if msg.get("painel"): # o cérebro já mandou o texto FALADO e o ESCRITO separados
|
|
741
|
+
self.bolinha.enviar({"resposta": str(msg["painel"])[:1500]})
|
|
742
|
+
self._acordado_ate = time.time() + 90
|
|
743
|
+
elif msg.get("bruto"):
|
|
569
744
|
# a voz resume; o texto inteiro (código, listas) vai ao painel
|
|
570
745
|
fala, tela = conversa.resumo(texto)
|
|
571
746
|
self.bolinha.enviar({"resposta": tela[:700]})
|
|
@@ -592,6 +767,8 @@ class Voz:
|
|
|
592
767
|
("dormindo" if est == "esperando" else est))
|
|
593
768
|
if est == "esperando":
|
|
594
769
|
self.sons.tocar("pronto", falando=self._falando)
|
|
770
|
+
elif tipo == "acao":
|
|
771
|
+
self._acao_do_painel(str(msg.get("acao") or ""))
|
|
595
772
|
elif tipo == "painel":
|
|
596
773
|
self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
|
|
597
774
|
elif tipo == "fim":
|