primocode 9.10.0 → 10.0.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.
Files changed (60) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/estilo.js +51 -0
  12. package/lib/estudio/glifos.js +215 -0
  13. package/lib/estudio/index.js +41 -18
  14. package/lib/estudio/kit/base.jsx +285 -0
  15. package/lib/estudio/kit/index.jsx +11 -0
  16. package/lib/estudio/kit/marca.jsx +741 -0
  17. package/lib/estudio/kit/vox.jsx +357 -0
  18. package/lib/estudio/kit.js +72 -0
  19. package/lib/estudio/lancamento.js +413 -0
  20. package/lib/estudio/motion.js +2 -5
  21. package/lib/estudio/plano.js +28 -3
  22. package/lib/estudio/remotion.js +87 -43
  23. package/lib/estudio/ritmo.js +77 -0
  24. package/lib/estudio/vox.js +385 -0
  25. package/lib/fala.js +1 -94
  26. package/lib/pipeline.js +55 -56
  27. package/lib/repeticao.js +3 -1
  28. package/lib/skills.js +8 -2
  29. package/lib/snapshot.js +111 -0
  30. package/lib/tools.js +79 -20
  31. package/lib/ui.js +13 -6
  32. package/lib/video/analise.js +89 -0
  33. package/lib/video/cli.js +74 -0
  34. package/lib/video/delta.js +90 -0
  35. package/lib/video/edits.js +379 -0
  36. package/lib/video/filters.js +247 -0
  37. package/lib/video/ops.js +322 -0
  38. package/lib/video/projeto.js +133 -0
  39. package/lib/video/render.js +221 -0
  40. package/lib/video/texto.js +142 -0
  41. package/lib/video/timeline.js +310 -0
  42. package/lib/video/transcript.js +124 -0
  43. package/lib/voz.js +75 -223
  44. package/package.json +2 -3
  45. package/skills/motion-de-primeira/SKILL.md +3 -4
  46. package/studio/texto.py +208 -0
  47. package/voz/primo_voz/__main__.py +96 -236
  48. package/voz/primo_voz/transcrever.py +31 -1
  49. package/app/ansi.js +0 -55
  50. package/app/app.css +0 -500
  51. package/app/app.js +0 -1017
  52. package/app/icone.svg +0 -6
  53. package/app/index.html +0 -220
  54. package/app/primo-marca.js +0 -192
  55. package/lib/app.js +0 -615
  56. package/lib/icone.js +0 -263
  57. package/lib/instalar-app.js +0 -231
  58. package/lib/painel.js +0 -291
  59. package/voz/primo_voz/desperta.py +0 -195
  60. package/voz/primo_voz/orbe.py +0 -406
package/lib/painel.js DELETED
@@ -1,291 +0,0 @@
1
- /**
2
- * painel.js — o painel da direita da janela: a prévia do site e os arquivos.
3
- *
4
- * "Remove a IDE ou deixa idêntico ao Codex."
5
- *
6
- * Antes isto era a PrimoCode IDE: árvore, EDITOR de código, prévia e chat. O
7
- * editor saiu. Quem usa o PrimoCode não programa — um editor de código na
8
- * cara dessa pessoa é uma porta para quebrar o que o agente fez, sem saber
9
- * como voltar. O que ficou é o que ela usa de verdade: ver o site que está
10
- * nascendo (Prévia) e olhar o que tem na pasta (Arquivos), só leitura.
11
- *
12
- * ── POR QUE MORA DENTRO DO SERVIDOR DA JANELA ────────────────────────────
13
- * A janela (lib/app.js) já resolveu o difícil: 127.0.0.1, chave sorteada e o
14
- * fluxo de eventos (SSE). Aqui só entram as rotas do painel (árvore, ler,
15
- * prévia) e o vigia da pasta, que empurra "arquivo mudou" pelo MESMO fluxo —
16
- * é o que faz a prévia recarregar enquanto o agente escreve.
17
- *
18
- * ── SEGURANÇA ────────────────────────────────────────────────────────────
19
- * · a chave do app.js vale aqui também (tudo mora em /api/);
20
- * · todo caminho é resolvido DENTRO da pasta do projeto — "../", caminho
21
- * absoluto e link simbólico que aponta para fora são recusados;
22
- * · nada aqui escreve no disco: sem editor, não há rota de salvar;
23
- * · a prévia roda num iframe sandbox SEM allow-same-origin, e o servidor
24
- * manda o mesmo sandbox no cabeçalho. O código da prévia foi escrito pelo
25
- * agente; se ele herdasse a origem da janela, poderia chamar /api/enviar
26
- * e dar ordens ao agente. Com origem opaca, o fetch dele sai com
27
- * `Origin: null` e o app.js recusa.
28
- */
29
-
30
- 'use strict';
31
-
32
- const fs = require('fs');
33
- const path = require('path');
34
-
35
- /* O que a árvore não mostra e o vigia não escuta. node_modules sozinho tem
36
- dezenas de milhares de arquivos: listar travaria a janela, e vigiar faria
37
- cada `npm install` virar uma avalanche de eventos. */
38
- const IGNORADOS = new Set(['node_modules', '.git', '.DS_Store', '.next', '.cache',
39
- '__pycache__', '.venv', 'venv', '.turbo', '.parcel-cache']);
40
- const MAX_ITENS = 4000;
41
- const MAX_PROFUNDIDADE = 12;
42
- // 1 MB: acima disso o visualizador engasga, e arquivo desse tamanho num
43
- // projeto de iniciante é quase sempre gerado (bundle, dado).
44
- const MAX_LEITURA = 1024 * 1024;
45
-
46
- // ── caminhos ─────────────────────────────────────────────────────────────
47
-
48
- /* Resolve `relativo` dentro de `raiz`, ou devolve null.
49
- *
50
- * `path.relative` pega o "../" e o caminho absoluto; o `realpath` pega o link
51
- * simbólico, que passa pelo primeiro teste com cara de arquivo de dentro e
52
- * aponta para ~/.ssh. Caminho que ainda não existe é checado
53
- * pela pasta onde vai nascer. */
54
- function dentro(raiz, relativo) {
55
- if (typeof relativo !== 'string' || relativo.includes('\0')) return null;
56
- // Absoluto é recusado de cara, e não "reinterpretado" como relativo:
57
- // quem manda /etc/hosts quer o /etc/hosts, e ler <projeto>/etc/hosts
58
- // no lugar seria uma surpresa. `C:\` e `\\servidor` também contam.
59
- if (path.isAbsolute(relativo) || path.win32.isAbsolute(relativo)) return null;
60
- const base = path.resolve(raiz);
61
- const alvo = path.resolve(base, relativo);
62
- if (!contido(base, alvo)) return null;
63
- let real = base;
64
- try { real = fs.realpathSync(base); } catch { return null; }
65
- let existente = alvo;
66
- while (!fs.existsSync(existente) && existente !== base) existente = path.dirname(existente);
67
- try {
68
- if (!contido(real, fs.realpathSync(existente))) return null;
69
- } catch { return null; }
70
- return alvo;
71
- }
72
-
73
- function contido(base, alvo) {
74
- const rel = path.relative(base, alvo);
75
- return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
76
- }
77
-
78
- function relativoDe(raiz, abs) {
79
- return path.relative(raiz, abs).split(path.sep).join('/');
80
- }
81
-
82
- function ignorado(rel) {
83
- return rel.split(/[/\\]/).some((p) => IGNORADOS.has(p));
84
- }
85
-
86
- // ── árvore ───────────────────────────────────────────────────────────────
87
-
88
- /* Lista plana, pastas antes de arquivos, em ordem alfabética. Plana porque a
89
- página monta a árvore dela do jeito que quiser, e o teste confere sem ter de
90
- descer em objetos aninhados. */
91
- function arvore(raiz) {
92
- const itens = [];
93
- let cortada = false;
94
- function andar(dir, prof) {
95
- if (prof > MAX_PROFUNDIDADE || cortada) return;
96
- let entradas;
97
- try { entradas = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
98
- entradas = entradas.filter((e) => !IGNORADOS.has(e.name));
99
- entradas.sort((a, b) => (b.isDirectory() - a.isDirectory())
100
- || a.name.localeCompare(b.name, 'pt-BR', { sensitivity: 'base' }));
101
- for (const e of entradas) {
102
- if (itens.length >= MAX_ITENS) { cortada = true; return; }
103
- const abs = path.join(dir, e.name);
104
- const rel = relativoDe(raiz, abs);
105
- if (e.isDirectory()) {
106
- itens.push({ caminho: rel, tipo: 'pasta' });
107
- andar(abs, prof + 1);
108
- } else if (e.isFile()) {
109
- let tamanho = 0;
110
- try { tamanho = fs.statSync(abs).size; } catch {}
111
- itens.push({ caminho: rel, tipo: 'arquivo', tamanho });
112
- }
113
- }
114
- }
115
- andar(raiz, 0);
116
- return { itens, cortada };
117
- }
118
-
119
- // ── ler ──────────────────────────────────────────────────────────────────
120
-
121
- /* Binário é o que tem byte zero nos primeiros 8 KB — o mesmo teste do git.
122
- Mostrar um PNG como texto é uma tela de lixo que parece arquivo quebrado. */
123
- function ehBinario(buf) {
124
- const n = Math.min(buf.length, 8000);
125
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
126
- return false;
127
- }
128
-
129
- function ler(raiz, rel) {
130
- const abs = dentro(raiz, rel);
131
- if (!abs) return { status: 403, corpo: { ok: false, error: 'fora da pasta do projeto' } };
132
- let st;
133
- try { st = fs.statSync(abs); } catch { return { status: 404, corpo: { ok: false, error: 'não existe' } }; }
134
- if (!st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
135
- const base = { ok: true, caminho: relativoDe(raiz, abs), tamanho: st.size, mtime: st.mtimeMs };
136
- if (st.size > MAX_LEITURA) return { status: 200, corpo: Object.assign(base, { grande: true }) };
137
- const buf = fs.readFileSync(abs);
138
- if (ehBinario(buf)) return { status: 200, corpo: Object.assign(base, { binario: true }) };
139
- return { status: 200, corpo: Object.assign(base, { conteudo: buf.toString('utf8') }) };
140
- }
141
-
142
- // ── prévia ───────────────────────────────────────────────────────────────
143
-
144
- const TIPOS = {
145
- '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
146
- '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
147
- '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
148
- '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
149
- '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8',
150
- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm',
151
- '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.md': 'text/plain; charset=utf-8',
152
- };
153
-
154
- /* Onde está a página de entrada do projeto. index.html na raiz é o caso de
155
- quase todo pedido ("crie um site"); os kits de Vite e afins põem em public/
156
- ou src/. Sem nenhum, a prévia diz isso em vez de mostrar um 404. */
157
- function entradaDaPrevia(raiz) {
158
- for (const c of ['index.html', 'public/index.html', 'src/index.html', 'dist/index.html']) {
159
- if (fs.existsSync(path.join(raiz, c))) return c;
160
- }
161
- return null;
162
- }
163
-
164
- function servirPrevia(raiz, rel, res) {
165
- const abs = dentro(raiz, decodeURIComponent(rel || '') || 'index.html');
166
- const alvo = abs && fs.existsSync(abs) && fs.statSync(abs).isDirectory() ? path.join(abs, 'index.html') : abs;
167
- if (!alvo || !fs.existsSync(alvo)) {
168
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
169
- return res.end('não encontrado');
170
- }
171
- res.writeHead(200, {
172
- 'Content-Type': TIPOS[path.extname(alvo).toLowerCase()] || 'application/octet-stream',
173
- 'Cache-Control': 'no-store',
174
- // O mesmo sandbox do iframe, dito pelo servidor: vale até se alguém
175
- // abrir o endereço da prévia fora da janela.
176
- 'Content-Security-Policy': 'sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock',
177
- });
178
- fs.createReadStream(alvo).pipe(res);
179
- }
180
-
181
- // ── vigia ────────────────────────────────────────────────────────────────
182
-
183
- /* fs.watch recursivo existe no Mac e no Windows desde sempre e no Linux desde
184
- o Node 20. Onde não existe, vigia só a raiz — a prévia de um index.html
185
- continua acompanhando, que é o que mais importa.
186
-
187
- Os eventos vêm em rajada (um write vira 2–3 avisos, um `npm run build` vira
188
- centenas), então juntam-se por 150 ms e saem numa mensagem só. */
189
- function vigiar(raiz, aoMudar, espera = 150) {
190
- const pendentes = new Set();
191
- let timer = null;
192
- const soltar = () => {
193
- timer = null;
194
- const lista = [...pendentes];
195
- pendentes.clear();
196
- if (lista.length) aoMudar(lista);
197
- };
198
- const anotar = (_tipo, nome) => {
199
- if (!nome) return;
200
- const rel = String(nome).split(path.sep).join('/');
201
- if (ignorado(rel)) return;
202
- pendentes.add(rel);
203
- if (!timer) timer = setTimeout(soltar, espera);
204
- };
205
- let vigia;
206
- try { vigia = fs.watch(raiz, { recursive: true }, anotar); } catch {
207
- try { vigia = fs.watch(raiz, anotar); } catch { return () => {}; }
208
- }
209
- vigia.on('error', () => {});
210
- return function parar() {
211
- try { vigia.close(); } catch {}
212
- if (timer) clearTimeout(timer);
213
- };
214
- }
215
-
216
- // ── as rotas ─────────────────────────────────────────────────────────────
217
-
218
- /**
219
- * Monta o painel num servidor do app.js.
220
- *
221
- * @param {object} op
222
- * @param {()=>string} op.pasta a pasta do projeto AGORA (o /dir muda ela)
223
- * @param {(evento:string, dados:any)=>void} op.emitir o fluxo SSE do app.js
224
- * @returns {{atender:Function, fechar:Function, conferirPasta:Function}}
225
- */
226
- function montar(op) {
227
- let pastaVigiada = null;
228
- let pararVigia = () => {};
229
-
230
- function conferirPasta() {
231
- const atual = op.pasta && op.pasta();
232
- if (!atual || atual === pastaVigiada) return atual;
233
- pararVigia();
234
- pastaVigiada = atual;
235
- if (!fs.existsSync(atual)) { pararVigia = () => {}; return atual; }
236
- pararVigia = vigiar(atual, (mudados) => op.emitir('arquivos', { pasta: atual, mudados }));
237
- op.emitir('arquivos', { pasta: atual, mudados: [], trocou: true });
238
- return atual;
239
- }
240
- /* O /dir e os projetos da barra lateral trocam a pasta sem passar por
241
- aqui: sem esta ronda o painel vigiaria o projeto anterior. E só depois
242
- que o painel foi aberto de verdade — no Linux o fs.watch recursivo custa
243
- um aviso do sistema por pasta, e quem só conversa não precisa disso. */
244
- let usada = false;
245
- const ronda = setInterval(() => { if (usada) conferirPasta(); }, 2000);
246
- if (ronda.unref) ronda.unref();
247
-
248
- function json(res, status, corpo) {
249
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
250
- res.end(JSON.stringify(corpo));
251
- }
252
-
253
- /* Devolve true se a rota era do painel. A chave já foi conferida pelo
254
- app.js antes de chegar aqui — por isso a prévia também mora em /api/. */
255
- async function atender(req, res, url) {
256
- const rota = url.pathname;
257
- const ehArquivos = rota.startsWith('/api/arquivos/');
258
- const m = rota.match(/^\/api\/previa\/[0-9a-f]+\/?(.*)$/);
259
- if (!ehArquivos && !m) return false;
260
- usada = true;
261
- const raiz = conferirPasta();
262
- if (!raiz || !fs.existsSync(raiz)) { json(res, 404, { ok: false, error: 'sem pasta de projeto' }); return true; }
263
-
264
- if (rota === '/api/arquivos/arvore') {
265
- const a = arvore(raiz);
266
- json(res, 200, { ok: true, pasta: raiz, nome: path.basename(raiz), itens: a.itens,
267
- cortada: a.cortada, entrada: entradaDaPrevia(raiz) });
268
- return true;
269
- }
270
- if (rota === '/api/arquivos/ler') {
271
- const r = ler(raiz, url.searchParams.get('p') || '');
272
- json(res, r.status, r.corpo);
273
- return true;
274
- }
275
- /* /api/previa/<chave>/<caminho>: a chave vai no CAMINHO, não na
276
- query, porque a página da prévia pede style.css e script.js por
277
- endereço relativo — e o relativo herda o caminho, não a query. */
278
- if (m) { servirPrevia(raiz, m[1], res); return true; }
279
-
280
- json(res, 404, { ok: false, error: 'rota desconhecida' });
281
- return true;
282
- }
283
-
284
- return {
285
- atender,
286
- conferirPasta,
287
- fechar() { clearInterval(ronda); pararVigia(); },
288
- };
289
- }
290
-
291
- module.exports = { montar, dentro, arvore, ler, vigiar, ehBinario, entradaDaPrevia, MAX_LEITURA };
@@ -1,195 +0,0 @@
1
- """desperta.py — ouvir o nome, e só o nome, sem mandar áudio para lugar nenhum.
2
-
3
- ── POR QUE OFFLINE, E POR QUE ISSO NÃO É DETALHE ───────────────────────────
4
- No modo de espera o microfone fica ABERTO o tempo todo. Existem dois jeitos de
5
- saber se alguém disse "ei, primo":
6
-
7
- 1. Mandar tudo o que o microfone ouve para um servidor transcrever.
8
- 2. Reconhecer a palavra aqui dentro, sem nada sair da máquina.
9
-
10
- O primeiro funciona e é mais simples de escrever. Também significa que TODA
11
- conversa perto do computador — a ligação, a reunião, a briga em casa — sobe
12
- para um servidor, o dia inteiro, para que uma vez por hora alguém diga o nome
13
- do programa. Isso não é uma troca aceitável, e não é decisão que um programa
14
- toma pelo dono da máquina.
15
-
16
- Então é o segundo. O Vosk roda local, o modelo mora no disco da pessoa, e
17
- NADA sai daqui enquanto o nome não for dito. Depois do nome, sim: a frase vai
18
- para a transcrição, como no modo normal — mas aí foi a pessoa que chamou.
19
-
20
- ── A GRAMÁTICA RESTRITA ────────────────────────────────────────────────────
21
- O reconhecedor é criado sabendo APENAS as palavras de despertar. Não é
22
- otimização: é o que impede ele de "ouvir" o nome no meio de uma frase
23
- qualquer. Um reconhecedor livre transcreve tudo e depois se procura a palavra
24
- no texto — e aí "prima", "primeiro" e "exprimo" acordam o assistente. Com a
25
- gramática restrita ele só pode devolver uma das palavras da lista ou "[unk]".
26
-
27
- Herdado do Jarvis (src/jarvis/core/wake.py), que resolveu isto primeiro; o
28
- modelo aqui é o de PORTUGUÊS, porque "primo" é palavra portuguesa — ao
29
- contrário de "Jarvis", que é nome inglês e por isso lá usava o modelo de
30
- inglês.
31
- """
32
- from __future__ import annotations
33
-
34
- import json
35
- import os
36
- import threading
37
- import urllib.request
38
- import zipfile
39
- from pathlib import Path
40
- from typing import Callable, Optional
41
-
42
- TAXA = 16000
43
-
44
- # Onde o modelo mora. Fora do pacote npm de propósito: são 31 MB, e um pacote
45
- # que engorda 31 MB para todo mundo por causa de um modo que nem todos usam é
46
- # um pacote que demora a instalar para todo mundo.
47
- PASTA_MODELO = Path.home() / ".primocode" / "modelos"
48
- NOME_MODELO = "vosk-model-small-pt-0.3"
49
- URL_MODELO = f"https://alphacephei.com/vosk/models/{NOME_MODELO}.zip"
50
-
51
- # O que ACORDA. Variantes porque o reconhecedor pequeno erra a vogal átona, e
52
- # porque as pessoas falam de jeitos diferentes ("ei primo", "oi primo", "ô
53
- # primo"). O "primo" sozinho entra: dentro da gramática restrita ele não
54
- # dispara em conversa comum, porque o reconhecedor não transcreve conversa
55
- # comum — só consegue devolver estas palavras.
56
- VOCABULARIO = ("primo", "ei", "oi", "olá", "ô")
57
- DESPERTA = ("primo",)
58
-
59
-
60
- def caminho_do_modelo() -> Path:
61
- return PASTA_MODELO / NOME_MODELO
62
-
63
-
64
- def tem_modelo() -> bool:
65
- caminho = caminho_do_modelo()
66
- return caminho.is_dir() and any(caminho.iterdir())
67
-
68
-
69
- def baixar_modelo(aoFalar: Optional[Callable[[str], None]] = None) -> dict:
70
- """Traz o modelo, uma vez. ~31 MB.
71
-
72
- Baixa para um arquivo temporário e só então move para o lugar: interromper
73
- no meio deixaria uma pasta pela metade que `tem_modelo()` daria por boa, e
74
- o erro apareceria depois, como "falha ao carregar o Vosk".
75
- """
76
- fala = aoFalar or (lambda _t: None)
77
- if tem_modelo():
78
- return {"ok": True, "jaTinha": True}
79
- try:
80
- PASTA_MODELO.mkdir(parents=True, exist_ok=True)
81
- temporario = PASTA_MODELO / (NOME_MODELO + ".zip.parcial")
82
- fala("baixando o reconhecedor de voz (uma vez, ~31 MB)")
83
- with urllib.request.urlopen(URL_MODELO, timeout=120) as resposta, open(temporario, "wb") as saida:
84
- while True:
85
- pedaco = resposta.read(1 << 16)
86
- if not pedaco:
87
- break
88
- saida.write(pedaco)
89
- fala("descompactando")
90
- with zipfile.ZipFile(temporario) as z:
91
- z.extractall(PASTA_MODELO)
92
- temporario.unlink(missing_ok=True)
93
- if not tem_modelo():
94
- return {"ok": False, "error": "o modelo baixou mas não apareceu onde devia"}
95
- return {"ok": True}
96
- except Exception as e:
97
- return {"ok": False, "error": str(e),
98
- "comoResolver": f"baixe {URL_MODELO} à mão e descompacte em {PASTA_MODELO}"}
99
-
100
-
101
- def contem_nome(texto: str) -> bool:
102
- """O texto que o reconhecedor devolveu contém o nome?
103
-
104
- Comparação por PALAVRA INTEIRA. Com `in` de substring, "prima" e
105
- "primeiro" acordariam o assistente — e a gramática restrita não protege
106
- disso sozinha, porque ela devolve as palavras da lista e o resto vira
107
- "[unk]", que pode vir colado.
108
- """
109
- palavras = set(str(texto or "").lower().replace("[unk]", " ").split())
110
- return any(n in palavras for n in DESPERTA)
111
-
112
-
113
- class Desperta:
114
- """Escuta o nome. Nada sai da máquina até ele ser dito."""
115
-
116
- def __init__(self, ao_despertar: Callable[[], None]) -> None:
117
- self.ao_despertar = ao_despertar
118
- self._rec = None
119
- self._modelo = None
120
- self.pronto = False
121
- self.ligado = True
122
- self.motivo = ""
123
- # O reconhecedor do Vosk NÃO é seguro entre threads: a do áudio chama
124
- # process() dez vezes por segundo enquanto o laço principal pode
125
- # chamar reset() ao despertar. Sem trava, o estado interno corrompe e
126
- # ele passa a levantar "Failed to process waveform" — sintoma que não
127
- # fala de thread nenhuma, e por isso custa caro de achar.
128
- self._trava = threading.Lock()
129
- self._carregar()
130
-
131
- def _carregar(self) -> None:
132
- if not tem_modelo():
133
- self.motivo = "o reconhecedor de voz não está baixado"
134
- return
135
- try:
136
- import vosk
137
- vosk.SetLogLevel(-1)
138
- self._modelo = vosk.Model(str(caminho_do_modelo()))
139
- self._rec = self._novo()
140
- self.pronto = True
141
- except ImportError:
142
- self.motivo = "falta o vosk (pip install vosk)"
143
- except Exception as e:
144
- self.motivo = f"não consegui carregar o reconhecedor: {e}"
145
-
146
- def _novo(self):
147
- import vosk
148
- gramatica = json.dumps(sorted({*VOCABULARIO, "[unk]"}))
149
- return vosk.KaldiRecognizer(self._modelo, TAXA, gramatica)
150
-
151
- def alimentar(self, bloco) -> bool:
152
- """Um bloco de áudio. Devolve True se o nome foi dito."""
153
- if not self.pronto or self._rec is None or not self.ligado:
154
- return False
155
- with self._trava:
156
- try:
157
- if self._rec.AcceptWaveform(bloco.tobytes()):
158
- texto = json.loads(self._rec.Result()).get("text", "")
159
- else:
160
- texto = json.loads(self._rec.PartialResult()).get("partial", "")
161
- except Exception:
162
- # Reconhecedor travado: recria e segue. Desligar o modo de
163
- # espera por causa de um bloco ruim seria perder o recurso
164
- # inteiro por um soluço de áudio.
165
- try:
166
- self._rec = self._novo()
167
- except Exception:
168
- self.pronto = False
169
- self.motivo = "o reconhecedor parou e não voltou"
170
- return False
171
-
172
- achou = bool(texto and contem_nome(texto))
173
- if achou:
174
- self._rec.Reset()
175
-
176
- if achou:
177
- try:
178
- self.ao_despertar()
179
- except Exception:
180
- pass
181
- return achou
182
-
183
- def zerar(self) -> None:
184
- """Esquece o que ouviu até agora.
185
-
186
- Chamado ao voltar do atendimento: sem isso, o resto da frase que a
187
- pessoa falou DEPOIS do nome ainda está no reconhecedor, e ele desperta
188
- de novo no instante seguinte.
189
- """
190
- with self._trava:
191
- if self._rec is not None:
192
- try:
193
- self._rec.Reset()
194
- except Exception:
195
- pass