primocode 11.3.0 → 11.3.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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v11.3.0
1
+ # PrimoCode v11.3.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 v11.3.0
12
+ ▐▛███▜▌ Primo Code v11.3.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -41,7 +41,10 @@ async function main(args) {
41
41
  const contaFalsa = process.env.PRIMOCODE_CONTA_FALSA;
42
42
  // PRIMOCODE_TRANSCRITOR_FALSO=<arquivo.js>: async (wav) => ({ ok, texto }) no lugar do Whisper local. Só para teste de tela.
43
43
  const transcritorFalso = process.env.PRIMOCODE_TRANSCRITOR_FALSO;
44
+ // PRIMOCODE_FALA_FALSA=<arquivo.js>: { falar(texto) -> {ok,arquivo,mime}, aquecer(), disponivel() } no lugar da voz local. Só para teste.
45
+ const falaFalsa = process.env.PRIMOCODE_FALA_FALSA;
44
46
  const srv = criarServidor({
47
+ ...(falaFalsa ? { falaViva: require(require('path').resolve(falaFalsa)) } : {}),
45
48
  ...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
46
49
  ...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
47
50
  ...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
@@ -37,6 +37,7 @@ const PAREADO_PODE = new Set([
37
37
  'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
38
38
  'project/create', 'project/update', 'project/delete', 'agent/create', 'agent/update', 'agent/delete', 'agent/toggle',
39
39
  'brand/read', 'brand/write', 'config/write', 'voice/start', 'voice/stop', 'voice/transcribe',
40
+ 'voice/warm', 'voice/frases', 'voice/speak', // modo voz ao vivo do celular: só gera áudio e devolve ao celular, nunca toca no computador
40
41
  ]);
41
42
  const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
42
43
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -62,7 +63,7 @@ function githubConectado() {
62
63
  } catch { return null; }
63
64
  }
64
65
 
65
- function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor } = {}) {
66
+ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor, falaViva } = {}) {
66
67
  const cfg = require('../config/config');
67
68
  const m = motor || require('../motor');
68
69
  const base = casa || cfg.CONFIG_DIR;
@@ -82,6 +83,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
82
83
  const Marca = require('../plugin/marca-kit');
83
84
  const Piloto = require('../piloto');
84
85
  const Voz = voz || require('../motor/voz').criarVoz({ pasta: path.join(base, 'voz') });
86
+ let Fala = falaViva || null; // a boca do modo voz ao vivo (lib/motor/fala-viva.js), criada só quando alguém fala
87
+ const falaDoModoVoz = () => (Fala = Fala || require('../motor/fala-viva').criarFalaViva({ pasta: path.join(base, 'voz') }));
85
88
  let loginNavegador = null;
86
89
  const statusConta = () => {
87
90
  const a = Conta.atual();
@@ -522,6 +525,24 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
522
525
  /* ── Voz (6.9): provedor trocável em lib/motor/voz.js; só gera arquivo, nunca toca ── */
523
526
  async 'voice/start'(p, conn) { return Voz.iniciar({ texto: conn && conn.pareado ? undefined : p && p.texto }); }, // o celular não manda o computador falar
524
527
  'voice/stop'() { return Voz.parar(); },
528
+ /* Modo voz ao vivo: abrir a tela sobe o Whisper e o Kokoro em segundo plano (modelo carregado antes da primeira fala). */
529
+ 'voice/warm'() {
530
+ if (!transcritor) require('../motor/transcricao').aquecer().catch(() => {});
531
+ falaDoModoVoz().aquecer().catch(() => {});
532
+ return { ok: true, fala: falaDoModoVoz().disponivel() };
533
+ },
534
+ /* A resposta do Primo em frases curtas para falar (sem markdown, código, caminho nem link). */
535
+ 'voice/frases'(p) { return { frases: require('../motor/fala-viva').frasesParaFalar(p && p.texto, { max: 6 }) }; },
536
+ /* Uma frase vira áudio (arquivo gerado e devolvido em base64). Nunca toca: quem toca é a tela. */
537
+ async 'voice/speak'(p) {
538
+ const f = falaDoModoVoz();
539
+ const r = await f.falar(p && p.texto);
540
+ if (!r.ok) return { ok: false, error: r.error };
541
+ let audio;
542
+ try { audio = fs.readFileSync(r.arquivo).toString('base64'); } catch (e) { return { ok: false, error: `não li o áudio: ${e.message}` }; }
543
+ try { fs.rmSync(r.arquivo, { force: true }); } catch { /* sem arquivo */ }
544
+ return { ok: true, audio, mime: r.mime || 'audio/wav', provedor: r.provedor, ms: r.ms };
545
+ },
525
546
  /* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
526
547
  async 'voice/transcribe'(p) {
527
548
  const T = require('../motor/transcricao');
@@ -794,6 +815,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
794
815
  if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
795
816
  for (const c of [...conexoes]) if (c.fechar) c.fechar();
796
817
  conexoes.clear();
818
+ if (!transcritor) { try { require('../motor/transcricao').parar(); } catch { /* sem whisper vivo */ } }
819
+ if (Fala && Fala.parar) { try { Fala.parar(); } catch { /* sem voz viva */ } }
797
820
  try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
798
821
  if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
799
822
  return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
@@ -0,0 +1,77 @@
1
+ // A boca do modo voz ao vivo: o texto de uma frase vira um arquivo de áudio (WAV/AIFF). NUNCA toca som: devolve o arquivo,
2
+ // e quem toca é a tela (que, no teste, desvia o player). Cadeia: Kokoro local em processo vivo (modelo carregado uma vez, sem
3
+ // provedor pago) -> `say` do macOS -> espeak-ng. Só entra o que a máquina tem.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { execFileSync, spawn } = require('child_process');
10
+ const { criarProcessoVivo } = require('./processo-vivo');
11
+ const K = require('../plugin/estudio/kokoro');
12
+
13
+ const SCRIPT_VIVO = path.join(__dirname, '..', '..', 'studio', 'voz_kokoro_vivo.py');
14
+ const TETO_FRASE = 500;
15
+
16
+ const achar = (nome) => { try { return execFileSync('which', [nome], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
17
+
18
+ /** Frases que vão pro ouvido: sem markdown, código, caminho nem link; no máximo `max` frases curtas. */
19
+ function frasesParaFalar(texto, { max = 6 } = {}) {
20
+ const { paraFalar } = require('../plugin/voz');
21
+ const limpo = paraFalar(texto, { frases: max, teto: 900 });
22
+ const partes = limpo.match(/[^.!?…]+[.!?…]*/g) || [];
23
+ const out = [];
24
+ for (const p of partes) { const t = p.replace(/\s+/g, ' ').trim(); if (t.length > 1) out.push(t.slice(0, TETO_FRASE)); }
25
+ return out;
26
+ }
27
+
28
+ function criarFalaViva({ pasta, uv, voz = 'pf_dora', provedores } = {}) {
29
+ const dir = pasta || path.join(os.tmpdir(), 'primocode-voz');
30
+ let n = 0;
31
+ const arq = (ext) => { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return path.join(dir, `frase-${Date.now()}-${++n}.${ext}`); };
32
+
33
+ let kokoro = null;
34
+ const kokoroVivo = () => {
35
+ if (kokoro) return kokoro;
36
+ const u = uv || achar('uv') || (fs.existsSync(path.join(os.homedir(), '.local', 'bin', 'uv')) ? path.join(os.homedir(), '.local', 'bin', 'uv') : null);
37
+ const esp = K.acharEspeak();
38
+ if (!u || !esp || !fs.existsSync(SCRIPT_VIVO)) return null;
39
+ const args = ['run', '--quiet', '--python', '3.11'];
40
+ for (const d of K.DEPS) args.push('--with', d);
41
+ args.push('python', SCRIPT_VIVO);
42
+ kokoro = criarProcessoVivo({ cmd: u, args, env: { ...process.env, ESPEAK_DATA_PATH: esp.dados, PHONEMIZER_ESPEAK_LIBRARY: esp.lib }, tempoPronto: 15 * 60000, tempoPedido: 60000 });
43
+ return kokoro;
44
+ };
45
+
46
+ const cadeia = provedores || [
47
+ { nome: 'kokoro', disponivel: () => !!kokoroVivo() && process.env.PRIMOCODE_VOZ !== 'say',
48
+ async falar(texto) { const o = arq('wav'); const r = await kokoroVivo().pedir({ texto, saida: o, voz }); return r.ok ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: r.error }; } },
49
+ { nome: 'say', disponivel: () => process.platform === 'darwin' && !!achar('say'),
50
+ falar: (texto) => new Promise((res) => { const o = arq('aiff'); const p = spawn('say', ['-v', 'Luciana', '-o', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/aiff' } : { ok: false, error: 'o say não gerou o áudio' })); }) },
51
+ { nome: 'espeak-ng', disponivel: () => !!achar('espeak-ng'),
52
+ falar: (texto) => new Promise((res) => { const o = arq('wav'); const p = spawn('espeak-ng', ['-v', 'pt-br', '-w', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: 'o espeak-ng não gerou o áudio' })); }) },
53
+ ];
54
+
55
+ return {
56
+ /** Sobe o Kokoro em segundo plano (modelo carregado antes da primeira frase). */
57
+ aquecer() { const k = cadeia[0].nome === 'kokoro' && cadeia[0].disponivel() ? kokoroVivo() : null; return k ? k.aquecer() : Promise.resolve({ ok: false }); },
58
+ disponivel() { return cadeia.find((p) => p.disponivel()) ? { ok: true, provedor: cadeia.find((p) => p.disponivel()).nome } : { ok: false, motivo: 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`' }; },
59
+ /** Uma frase vira áudio. Devolve {ok, arquivo, mime, provedor, ms} (nunca rejeita). */
60
+ async falar(texto) {
61
+ const t0 = Date.now();
62
+ const frase = String(texto || '').trim().slice(0, TETO_FRASE);
63
+ if (!frase) return { ok: false, error: 'falta texto' };
64
+ let ultimo = 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`';
65
+ for (const p of cadeia) {
66
+ if (!p.disponivel()) continue;
67
+ const r = await p.falar(K.pronuncia(frase));
68
+ if (r.ok) return { ...r, provedor: p.nome, ms: Date.now() - t0 };
69
+ ultimo = r.error || ultimo;
70
+ }
71
+ return { ok: false, error: ultimo };
72
+ },
73
+ parar() { if (kokoro) { kokoro.parar(); kokoro = null; } },
74
+ };
75
+ }
76
+
77
+ module.exports = { criarFalaViva, frasesParaFalar };
@@ -0,0 +1,66 @@
1
+ // Um processo filho que fica VIVO e conversa por linhas de JSON (uma linha entra, uma linha sai, casadas pelo `id`).
2
+ // Serve ao modo voz ao vivo: o Whisper e o Kokoro carregam o modelo uma vez e respondem de pé, sem subir um processo por trecho.
3
+ // Nunca toca som. Sobe na primeira chamada; `pronto` espera a linha {"pronto":true}; se o filho cai, o próximo pedido sobe outro.
4
+ 'use strict';
5
+
6
+ const { spawn } = require('child_process');
7
+
8
+ function criarProcessoVivo({ cmd, args = [], env, cwd, tempoPronto = 180000, tempoPedido = 120000 } = {}) {
9
+ let filho = null, pronto = null, buf = '', id = 0;
10
+ const pend = new Map();
11
+
12
+ function soltarTudo(erro) { for (const p of pend.values()) { clearTimeout(p.t); p.res({ ok: false, error: erro }); } pend.clear(); }
13
+
14
+ function subir() {
15
+ if (filho && pronto) return pronto;
16
+ buf = '';
17
+ filho = spawn(cmd, args, { env: env || process.env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
18
+ const meu = filho;
19
+ let erro = '';
20
+ meu.stderr.on('data', (d) => { erro = (erro + d).slice(-600); });
21
+ pronto = new Promise((resolve) => {
22
+ const t = setTimeout(() => resolve({ ok: false, error: 'o processo de voz demorou demais para ficar pronto' }), tempoPronto);
23
+ meu.stdout.on('data', (d) => {
24
+ buf += d;
25
+ let i;
26
+ while ((i = buf.indexOf('\n')) >= 0) {
27
+ const linha = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
28
+ if (!linha) continue;
29
+ let m; try { m = JSON.parse(linha); } catch { continue; }
30
+ if (m.pronto) { clearTimeout(t); resolve({ ok: true }); continue; }
31
+ if (m.id === undefined || m.id === null) { if (m.ok === false) { clearTimeout(t); resolve({ ok: false, error: m.error || 'o processo de voz não subiu' }); } continue; }
32
+ const p = pend.get(m.id);
33
+ if (p) { pend.delete(m.id); clearTimeout(p.t); p.res(m); }
34
+ }
35
+ });
36
+ meu.on('error', (e) => { clearTimeout(t); resolve({ ok: false, error: e.message }); });
37
+ meu.on('close', () => {
38
+ clearTimeout(t);
39
+ const era = filho === meu;
40
+ if (era) { filho = null; pronto = null; }
41
+ resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o processo de voz saiu' });
42
+ soltarTudo('o processo de voz saiu');
43
+ });
44
+ });
45
+ return pronto;
46
+ }
47
+
48
+ return {
49
+ /** Sobe (se preciso) e espera ficar pronto. Devolve {ok} ou {ok:false,error}. */
50
+ async aquecer() { const r = await subir(); if (!r.ok) { this.parar(); } return r; },
51
+ async pedir(dados) {
52
+ const r = await subir();
53
+ if (!r.ok) { this.parar(); return r; }
54
+ return new Promise((res) => {
55
+ const n = ++id;
56
+ const t = setTimeout(() => { pend.delete(n); res({ ok: false, error: 'a voz demorou demais' }); }, tempoPedido);
57
+ pend.set(n, { res, t });
58
+ try { filho.stdin.write(`${JSON.stringify({ ...dados, id: n })}\n`); } catch (e) { pend.delete(n); clearTimeout(t); res({ ok: false, error: e.message }); }
59
+ });
60
+ },
61
+ parar() { const f = filho; filho = null; pronto = null; if (f) { try { f.kill('SIGTERM'); } catch { /* já saiu */ } } soltarTudo('parado'); },
62
+ vivo: () => !!filho,
63
+ };
64
+ }
65
+
66
+ module.exports = { criarProcessoVivo };
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
9
  const { spawn, execFileSync } = require('child_process');
10
+ const { criarProcessoVivo } = require('./processo-vivo');
10
11
 
11
12
  const PASTA_VOZ = path.join(__dirname, '..', '..', 'voz');
12
13
  const TETO_BYTES = 11 * 1024 * 1024; // ~5 min de WAV 16 kHz mono (em base64 cabe no teto de 16 MB do WebSocket)
@@ -40,8 +41,31 @@ function transcrever(wav, { python: py, idioma = 'pt', tempoMs = TEMPO_MS } = {}
40
41
  });
41
42
  }
42
43
 
44
+ /* O Whisper VIVO (modo voz ao vivo): um processo Python que carrega o modelo uma vez e transcreve arquivo por arquivo. */
45
+ let vivo = null;
46
+ function whisperVivo(py) {
47
+ if (!vivo) vivo = criarProcessoVivo({ cmd: py, args: ['-m', 'primo_voz.transcrever', 'servir', 'pt'], cwd: PASTA_VOZ, env: { ...process.env, PYTHONPATH: PASTA_VOZ } });
48
+ return vivo;
49
+ }
50
+ /** Sobe o Whisper em segundo plano (o modelo carrega antes da primeira fala). Nunca rejeita. */
51
+ function aquecer({ python: py } = {}) {
52
+ py = py || require('../plugin/voz').python();
53
+ if (faltaAlgo(py)) return Promise.resolve({ ok: false });
54
+ return whisperVivo(py).aquecer();
55
+ }
56
+ function parar() { if (vivo) { vivo.parar(); vivo = null; } }
57
+ /** Igual a `transcrever`, mas no processo vivo; se ele não sobe, cai no processo avulso. */
58
+ async function transcreverVivo(wav, opts = {}) {
59
+ const py = opts.python || require('../plugin/voz').python();
60
+ const falta = faltaAlgo(py);
61
+ if (falta) return { ok: false, ...falta };
62
+ const r = await whisperVivo(py).pedir({ arquivo: wav });
63
+ if (r.ok) return { ok: true, texto: String(r.texto || '') };
64
+ return transcrever(wav, { python: py });
65
+ }
66
+
43
67
  /** Grava o áudio (base64, WAV) num arquivo temporário, transcreve e apaga. `transcritor` troca o Whisper (teste). */
44
- async function transcreverBase64(audio, { pasta, transcritor = transcrever } = {}) {
68
+ async function transcreverBase64(audio, { pasta, transcritor = transcreverVivo } = {}) {
45
69
  if (typeof audio !== 'string' || !audio) return { ok: false, error: 'falta o áudio' };
46
70
  const bytes = Buffer.from(audio, 'base64');
47
71
  if (!bytes.length) return { ok: false, error: 'áudio vazio' };
@@ -54,4 +78,4 @@ async function transcreverBase64(audio, { pasta, transcritor = transcrever } = {
54
78
  try { return await transcritor(arq); } finally { try { fs.rmSync(arq, { force: true }); } catch { /* sem arquivo */ } }
55
79
  }
56
80
 
57
- module.exports = { transcrever, transcreverBase64, faltaAlgo, TETO_BYTES };
81
+ module.exports = { transcrever, transcreverVivo, transcreverBase64, aquecer, parar, faltaAlgo, TETO_BYTES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "11.3.0",
3
+ "version": "11.3.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": {
@@ -11,7 +11,7 @@
11
11
  "postinstall": "node lib/preparar.js || true",
12
12
  "test:server": "cd server && npm test",
13
13
  "test": "node scripts/testes.js",
14
- "test:app": "node test/app-fluxo.test.mjs && node test/app-primeira.test.mjs && node test/app-entrar.test.mjs && node test/app-tela-estudio.test.mjs && node test/app-celular-telas.test.mjs",
14
+ "test:app": "node test/app-fluxo.test.mjs && node test/app-primeira.test.mjs && node test/app-entrar.test.mjs && node test/app-tela-estudio.test.mjs && node test/app-celular-telas.test.mjs && node test/app-compositor-voz.test.mjs",
15
15
  "app:build": "cd app && npm run dist",
16
16
  "app:dev": "cd app && npm start",
17
17
  "mobile:build": "cd mobile && npm run build"
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """voz_kokoro_vivo.py - a voz local do modo voz ao vivo: o Kokoro-82M fica CARREGADO e fala frase por frase.
3
+
4
+ Entrada (uma linha de JSON por pedido): {"id": 1, "texto": "Oi.", "saida": "/tmp/x.wav", "voz": "pf_dora"}
5
+ Saída (uma linha por resposta): {"id": 1, "ok": true, "arquivo": "/tmp/x.wav", "segundos": 1.2}
6
+ Primeira linha de saída: {"pronto": true}. Só GERA arquivo WAV; nunca toca som. Roda pelo uv (lib/motor/fala-viva.js monta o comando).
7
+ """
8
+ import json
9
+ import re
10
+ import sys
11
+
12
+
13
+ def main():
14
+ import numpy as np
15
+ import soundfile as sf
16
+ from kokoro import KPipeline
17
+
18
+ pipe = KPipeline(lang_code="p")
19
+ # uma passada curta aquece o modelo: a primeira frase de verdade já não paga a carga
20
+ try:
21
+ for _ in pipe("Oi.", voice="pf_dora"):
22
+ pass
23
+ except Exception:
24
+ pass
25
+ print(json.dumps({"pronto": True}), flush=True)
26
+ for linha in sys.stdin:
27
+ linha = linha.strip()
28
+ if not linha:
29
+ continue
30
+ pedido = {}
31
+ try:
32
+ pedido = json.loads(linha)
33
+ texto = re.sub(r"\s+", " ", str(pedido.get("texto") or "")).strip()
34
+ if not texto:
35
+ print(json.dumps({"id": pedido.get("id"), "ok": False, "error": "texto vazio"}), flush=True)
36
+ continue
37
+ partes = [np.asarray(a, dtype=np.float32) for _, _, a in pipe(texto, voice=pedido.get("voz") or "pf_dora")]
38
+ audio = np.concatenate(partes)
39
+ sf.write(str(pedido["saida"]), audio, 24000)
40
+ print(json.dumps({"id": pedido.get("id"), "ok": True, "arquivo": pedido["saida"], "segundos": round(len(audio) / 24000, 3)}), flush=True)
41
+ except Exception as e:
42
+ print(json.dumps({"id": pedido.get("id"), "ok": False, "error": str(e)[:300]}), flush=True)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -200,6 +200,31 @@ def main() -> int:
200
200
  return 1
201
201
  print(json.dumps({"ok": True, "palavras": ws}, ensure_ascii=False))
202
202
  return 0
203
+ if acao == "servir":
204
+ # `... servir [idioma]`: processo vivo do app (modo voz ao vivo). O modelo carrega UMA vez; cada linha de entrada é
205
+ # {"id":N,"arquivo":"x.wav"} e cada linha de saída é {"id":N,"ok":true,"texto":"..."}. Primeira linha: {"pronto":true}.
206
+ if not disponivel():
207
+ print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}), flush=True)
208
+ return 1
209
+ idioma = sys.argv[2] if len(sys.argv) > 2 else "pt"
210
+ try:
211
+ carregar()
212
+ except Exception as e:
213
+ print(json.dumps({"ok": False, "error": str(e)}), flush=True)
214
+ return 1
215
+ print(json.dumps({"pronto": True, "modelo": MODELO}), flush=True)
216
+ for linha in sys.stdin:
217
+ linha = linha.strip()
218
+ if not linha:
219
+ continue
220
+ pedido = {}
221
+ try:
222
+ pedido = json.loads(linha)
223
+ texto = transcrever(Path(pedido["arquivo"]), idioma)
224
+ print(json.dumps({"id": pedido.get("id"), "ok": True, "texto": texto}, ensure_ascii=False), flush=True)
225
+ except Exception as e:
226
+ print(json.dumps({"id": pedido.get("id"), "ok": False, "error": str(e)}), flush=True)
227
+ return 0
203
228
  if acao == "arquivo":
204
229
  # `... arquivo <wav> [idioma]`: a fala de um arquivo vira texto (o app desktop grava no microfone e manda para cá).
205
230
  if not disponivel():