primocode 9.9.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.
- package/README.md +30 -6
- package/bin/primocode.js +100 -341
- package/lib/act.js +245 -23
- package/lib/browser.js +17 -0
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +4 -2
- package/lib/estudio/diretor.js +9 -1
- package/lib/estudio/ende.js +2 -0
- package/lib/estudio/estilo.js +51 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/index.js +63 -11
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/index.jsx +11 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +72 -0
- package/lib/estudio/lancamento.js +413 -0
- package/lib/estudio/morfar.js +191 -0
- package/lib/estudio/motion.js +333 -0
- package/lib/estudio/plano.js +54 -7
- package/lib/estudio/remotion.js +167 -52
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +62 -0
- package/lib/estudio/vox.js +385 -0
- package/lib/fala.js +1 -94
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +8 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +79 -20
- package/lib/ui.js +13 -6
- package/lib/video/analise.js +89 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +247 -0
- package/lib/video/ops.js +322 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/render.js +221 -0
- package/lib/video/texto.js +142 -0
- package/lib/video/timeline.js +310 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +21 -1
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- 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
|