primocode 10.15.0 → 10.15.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 +2 -2
- package/bin/primocode.js +9 -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 +139 -20
- package/voz/primo_voz/app.py +350 -0
- package/voz/primo_voz/appmac.py +124 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/escuta.py +25 -3
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/hud.html +58 -24
- package/voz/primo_voz/hud.py +61 -10
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/tela.py +36 -54
- package/voz/primo_voz/transcrever.py +13 -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.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 v10.15.
|
|
12
|
+
▐▛███▜▌ Primo Code v10.15.1
|
|
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])) {
|
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.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": {
|
|
@@ -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, 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,10 @@ 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
|
+
threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
|
|
192
232
|
self.gravador = escuta.Gravador()
|
|
193
233
|
self.gravador.vad = escuta.criar_vad()
|
|
194
234
|
self.corte = escuta.Corte()
|
|
@@ -209,6 +249,10 @@ class Voz:
|
|
|
209
249
|
self._ultimo_ok = ""
|
|
210
250
|
self.bolinha.enviar({"nome": self.nome})
|
|
211
251
|
|
|
252
|
+
def lat(self, texto: str) -> None:
|
|
253
|
+
if os.environ.get("PRIMOCODE_LAT") == "1":
|
|
254
|
+
avisar("lat: " + texto)
|
|
255
|
+
|
|
212
256
|
# ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
|
|
213
257
|
|
|
214
258
|
def _estado(self, estado: str, nivel: float = None) -> None:
|
|
@@ -270,7 +314,7 @@ class Voz:
|
|
|
270
314
|
|
|
271
315
|
def _parcial(self) -> None:
|
|
272
316
|
"""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):
|
|
317
|
+
if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
|
|
274
318
|
return
|
|
275
319
|
copia = list(self.gravador._blocos)
|
|
276
320
|
if not copia:
|
|
@@ -332,7 +376,9 @@ class Voz:
|
|
|
332
376
|
wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
|
|
333
377
|
if not wav:
|
|
334
378
|
continue
|
|
379
|
+
self._t_acabou = time.time()
|
|
335
380
|
texto = transcrever.limpar(self._transcrever(wav))
|
|
381
|
+
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}")
|
|
336
382
|
self._tratar(texto)
|
|
337
383
|
mic.fechar()
|
|
338
384
|
|
|
@@ -365,6 +411,12 @@ class Voz:
|
|
|
365
411
|
else:
|
|
366
412
|
self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
|
|
367
413
|
return
|
|
414
|
+
nova = fala.voz_pedida(texto, self.voz_id)
|
|
415
|
+
if nova:
|
|
416
|
+
self.voz_id = nova
|
|
417
|
+
self.cfg = identidade.salvar(voz=nova)
|
|
418
|
+
self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
|
|
419
|
+
return
|
|
368
420
|
cmd = conversa.comando(texto)
|
|
369
421
|
if cmd == "esconder":
|
|
370
422
|
self.bolinha.enviar({"painel": "esconder"})
|
|
@@ -450,28 +502,86 @@ class Voz:
|
|
|
450
502
|
except queue.Full:
|
|
451
503
|
continue
|
|
452
504
|
|
|
505
|
+
def _cache_de(self, frase: str) -> "Path | None":
|
|
506
|
+
"""Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
|
|
507
|
+
if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
|
|
508
|
+
return None
|
|
509
|
+
import hashlib
|
|
510
|
+
pasta = Path.home() / ".primocode" / "voz-cache"
|
|
511
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
if re.match(r"^São \d+ (?:e \d\d|horas em ponto)\.$", frase):
|
|
513
|
+
return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
|
|
514
|
+
return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
|
|
515
|
+
|
|
516
|
+
def _cache_horas(self) -> None:
|
|
517
|
+
agora = time.time()
|
|
518
|
+
for dm in (0, 1, 2):
|
|
519
|
+
f = fala.hora_falada(time.localtime(agora + 60 * dm))
|
|
520
|
+
c = self._cache_de(f)
|
|
521
|
+
if c is not None and not c.exists():
|
|
522
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
523
|
+
|
|
524
|
+
def _pre_aquecer_frases(self) -> None:
|
|
525
|
+
"""As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
|
|
526
|
+
self.edge.aquecer()
|
|
527
|
+
self._cache_horas()
|
|
528
|
+
for f in ("Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar.", "Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
|
|
529
|
+
"Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
|
|
530
|
+
c = self._cache_de(f)
|
|
531
|
+
if c is not None and not c.exists():
|
|
532
|
+
self.edge.sintetizar(f, self.voz_id, c)
|
|
533
|
+
while self._vivo: # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
|
|
534
|
+
self._cache_horas()
|
|
535
|
+
for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"): # só os minutos que já passaram
|
|
536
|
+
try:
|
|
537
|
+
if time.time() - velho.stat().st_mtime > 600:
|
|
538
|
+
velho.unlink()
|
|
539
|
+
except OSError:
|
|
540
|
+
pass
|
|
541
|
+
time.sleep(20)
|
|
542
|
+
|
|
453
543
|
def _sintetizar(self, frase: str) -> "Path | None":
|
|
454
544
|
limpo = " ".join(str(frase).split())[:600]
|
|
455
545
|
if not limpo:
|
|
456
546
|
return None
|
|
547
|
+
t0 = time.time()
|
|
457
548
|
mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
|
|
549
|
+
# 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
|
|
550
|
+
guardada = self._cache_de(limpo)
|
|
551
|
+
if guardada is not None and guardada.exists():
|
|
552
|
+
shutil.copyfile(guardada, mp3)
|
|
553
|
+
self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
|
|
554
|
+
return mp3
|
|
555
|
+
# 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
|
|
556
|
+
if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
|
|
557
|
+
self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
|
|
558
|
+
if guardada is not None:
|
|
559
|
+
try:
|
|
560
|
+
shutil.copyfile(mp3, guardada)
|
|
561
|
+
except OSError:
|
|
562
|
+
pass
|
|
563
|
+
return mp3
|
|
564
|
+
# 2. ElevenLabs, só se a pessoa pediu
|
|
458
565
|
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
|
-
|
|
566
|
+
r = None
|
|
567
|
+
if self.cfg.get("tts") == "elevenlabs":
|
|
568
|
+
try:
|
|
569
|
+
r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
|
|
570
|
+
capture_output=True, timeout=60)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
avisar(f"não deu para gerar a voz: {e}")
|
|
573
|
+
if r is not None and r.returncode == 0 and mp3.exists():
|
|
574
|
+
return mp3
|
|
575
|
+
# 3. offline: a voz do sistema
|
|
576
|
+
if sys.platform == "darwin" and shutil.which("say"):
|
|
577
|
+
aiff = mp3.with_suffix(".aiff")
|
|
578
|
+
if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
|
|
579
|
+
subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
|
|
580
|
+
self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
|
|
581
|
+
return aiff
|
|
582
|
+
avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
|
|
583
|
+
self._erro("Não consegui gerar a voz. O texto está no painel.")
|
|
584
|
+
return None
|
|
475
585
|
|
|
476
586
|
def _tocar_em_fila(self) -> None:
|
|
477
587
|
while self._vivo:
|
|
@@ -528,6 +638,11 @@ class Voz:
|
|
|
528
638
|
except (FileNotFoundError, OSError):
|
|
529
639
|
continue
|
|
530
640
|
t0 = time.time()
|
|
641
|
+
if self._t_acabou:
|
|
642
|
+
self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
|
|
643
|
+
self._t_acabou = None
|
|
644
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
645
|
+
avisar(f"falando ({cmd[0]}): {arquivo.name}")
|
|
531
646
|
while p.poll() is None:
|
|
532
647
|
if env:
|
|
533
648
|
i = int((time.time() - t0) / 0.05)
|
|
@@ -541,6 +656,8 @@ class Voz:
|
|
|
541
656
|
pass
|
|
542
657
|
return
|
|
543
658
|
time.sleep(0.05)
|
|
659
|
+
if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
|
|
660
|
+
avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
|
|
544
661
|
if p.returncode == 0:
|
|
545
662
|
return
|
|
546
663
|
# INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
|
|
@@ -592,6 +709,8 @@ class Voz:
|
|
|
592
709
|
("dormindo" if est == "esperando" else est))
|
|
593
710
|
if est == "esperando":
|
|
594
711
|
self.sons.tocar("pronto", falando=self._falando)
|
|
712
|
+
elif tipo == "acao":
|
|
713
|
+
self._acao_do_painel(str(msg.get("acao") or ""))
|
|
595
714
|
elif tipo == "painel":
|
|
596
715
|
self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
|
|
597
716
|
elif tipo == "fim":
|