nio-cat 0.9.5 → 0.9.7

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/src/voice.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // Veu: transcripció (STT) i lectura en veu alta (TTS) amb les claus de l'usuari.
3
3
  // Les claus viuen en aquest ordinador (~/.nio/config.json → voice) i mai no tornen a l'app.
4
4
  // L'àudio fa: mòbil → aquest ordinador → proveïdor. Proveïdors intercanviables: ElevenLabs i OpenAI.
5
+ // Per entendre't també hi ha Whisper, gratis i sense clau, que funciona a l'ordinador mateix (whisper.js).
5
6
  const config = require('./config');
7
+ const whisper = require('./whisper');
6
8
 
7
9
  const httpErr = (status, message) => Object.assign(new Error(message), { status });
8
10
  const MAX_AUDIO = 25 * 1024 * 1024;
@@ -67,11 +69,14 @@ async function errText(r) {
67
69
  }
68
70
 
69
71
  const cfgOf = (cfg) => cfg.voice || {};
70
- // Proveïdor per a cada cosa: el triat, si en té la clau; si no, qualsevol que en tingui.
72
+ // Proveïdor per a cada cosa: el triat, si en té la clau (o Whisper, si és a punt); si no, Whisper
73
+ // (gratis) per entendre't i, si tampoc, qualsevol que tingui clau.
71
74
  function pick(cfg, which) {
72
75
  const v = cfgOf(cfg), keys = v.keys || {};
73
76
  const want = v[which] || v.provider;
77
+ if (which === 'stt' && want === 'whisper' && whisper.ready(cfg)) return 'whisper';
74
78
  if (want && keys[want]) return want;
79
+ if (which === 'stt' && whisper.ready(cfg)) return 'whisper';
75
80
  return Object.keys(PROVIDERS).find((p) => keys[p]) || null;
76
81
  }
77
82
 
@@ -84,13 +89,15 @@ function status(cfg) {
84
89
  speak: !!v.speak,
85
90
  lang: v.lang || 'ca',
86
91
  voice: v.voice || {},
92
+ whisper: whisper.status(cfg),
87
93
  };
88
94
  }
89
95
 
90
96
  async function transcribe(cfg, audio, mime) {
91
97
  const p = pick(cfg, 'stt');
92
- if (!p) throw httpErr(400, 'Aquest ordinador no té cap clau de veu. Posa-la a Ajustos → Veu.');
98
+ if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
93
99
  if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
100
+ if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, cfgOf(cfg).lang || 'ca'), provider: p };
94
101
  return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, cfgOf(cfg).lang || 'ca')).trim(), provider: p };
95
102
  }
96
103
 
@@ -132,7 +139,8 @@ function routes(cfg) {
132
139
  v.keys[id] = k.trim();
133
140
  }
134
141
  }
135
- for (const w of ['stt', 'tts']) if (body[w] === null || PROVIDERS[body[w]]) v[w] = body[w] || undefined;
142
+ for (const w of ['stt', 'tts']) if (body[w] === null || PROVIDERS[body[w]] || (w === 'stt' && body[w] === 'whisper')) v[w] = body[w] || undefined;
143
+ if (body.whisper && typeof body.whisper === 'object' && whisper.MODELS[body.whisper.model]) v.whisper = { ...v.whisper, model: body.whisper.model };
136
144
  if (typeof body.speak === 'boolean') v.speak = body.speak;
137
145
  if (typeof body.lang === 'string' && /^[a-z]{2}$/.test(body.lang)) v.lang = body.lang;
138
146
  if (body.voice && typeof body.voice === 'object') {
@@ -143,6 +151,16 @@ function routes(cfg) {
143
151
  return status(cfg);
144
152
  },
145
153
  },
154
+ {
155
+ // Whisper: «install» (Homebrew + model) o «download» (només el model).
156
+ method: 'POST',
157
+ path: '/api/voice/whisper',
158
+ handler: ({ body }) => {
159
+ if (body.action === 'install') whisper.install(cfg);
160
+ else whisper.download(cfg);
161
+ return status(cfg);
162
+ },
163
+ },
146
164
  {
147
165
  method: 'POST',
148
166
  path: '/api/voice/stt',
package/src/whisper.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+ // Whisper a l'ordinador: transcripció gratuïta i sense clau. L'àudio no surt d'aquest ordinador.
3
+ // Fa servir whisper.cpp (whisper-cli) amb ffmpeg per convertir l'àudio del navegador, o el
4
+ // «whisper» de Python (openai-whisper) si és el que hi ha. El model de whisper.cpp es baixa una
5
+ // sola vegada a ~/.nio/whisper.
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { execFile, spawn } = require('child_process');
10
+ const { HOME } = require('./config');
11
+ const { which, childEnv } = require('./proc');
12
+
13
+ const DIR = path.join(HOME, 'whisper');
14
+ const HF = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main';
15
+ // Als Mac amb xip d'Apple, el turbo (quantitzat) va ràpid i entén molt bé el català. A la resta, el small.
16
+ const MODELS = {
17
+ 'large-v3-turbo-q5_0': { file: 'ggml-large-v3-turbo-q5_0.bin', size: 574e6, label: 'Turbo (millor)' },
18
+ 'small-q5_1': { file: 'ggml-small-q5_1.bin', size: 190e6, label: 'Small (més lleuger)' },
19
+ };
20
+ const defaultModel = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'large-v3-turbo-q5_0' : 'small-q5_1');
21
+
22
+ const job = { download: null, install: null, error: null }; // feines en curs (una de cada)
23
+
24
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
25
+ const cppBin = () => which('whisper-cli') || which('whisper-cpp');
26
+ const pyBin = () => which('whisper');
27
+ const modelId = (cfg) => (MODELS[cfg.voice?.whisper?.model] ? cfg.voice.whisper.model : defaultModel());
28
+ const modelPath = (cfg) => path.join(DIR, MODELS[modelId(cfg)].file);
29
+
30
+ function engine() {
31
+ if (cppBin() && which('ffmpeg')) return 'cpp';
32
+ if (pyBin() && which('ffmpeg')) return 'python';
33
+ return null;
34
+ }
35
+
36
+ function ready(cfg) {
37
+ const e = engine();
38
+ return e === 'python' || (e === 'cpp' && fs.existsSync(modelPath(cfg)));
39
+ }
40
+
41
+ function status(cfg) {
42
+ const e = engine(), missing = [];
43
+ if (!cppBin() && !pyBin()) missing.push('whisper-cpp');
44
+ if (!which('ffmpeg')) missing.push('ffmpeg');
45
+ return {
46
+ name: 'Whisper',
47
+ engine: e,
48
+ ready: ready(cfg),
49
+ missing,
50
+ model: modelId(cfg),
51
+ models: Object.fromEntries(Object.entries(MODELS).map(([id, m]) => [id, { label: m.label, size: m.size, have: fs.existsSync(path.join(DIR, m.file)) }])),
52
+ needsModel: e === 'cpp' && !fs.existsSync(modelPath(cfg)),
53
+ downloading: job.download ? { model: job.download.model, pct: job.download.pct } : null,
54
+ installing: !!job.install,
55
+ canInstall: process.platform === 'darwin' && !!which('brew'),
56
+ install: installHint(),
57
+ error: job.error,
58
+ };
59
+ }
60
+
61
+ function installHint() {
62
+ if (process.platform === 'darwin') return 'brew install whisper-cpp ffmpeg';
63
+ if (which('apt-get')) return 'sudo apt install ffmpeg pipx && pipx install openai-whisper';
64
+ return 'Instal·la whisper.cpp (whisper-cli) i ffmpeg';
65
+ }
66
+
67
+ // Baixa el model (una sola vegada). Torna de seguida; el progrés surt a status().
68
+ function download(cfg) {
69
+ const id = modelId(cfg), m = MODELS[id], dest = path.join(DIR, m.file);
70
+ if (fs.existsSync(dest) || job.download) return;
71
+ fs.mkdirSync(DIR, { recursive: true });
72
+ const tmp = `${dest}.part`, d = (job.download = { model: id, pct: 0 });
73
+ job.error = null;
74
+ (async () => {
75
+ const res = await fetch(`${HF}/${m.file}`);
76
+ if (!res.ok || !res.body) throw new Error(`No he pogut baixar el model (${res.status})`);
77
+ const total = Number(res.headers.get('content-length')) || m.size;
78
+ const out = fs.createWriteStream(tmp);
79
+ let got = 0;
80
+ for await (const chunk of res.body) {
81
+ got += chunk.length;
82
+ d.pct = Math.min(99, Math.round((got / total) * 100));
83
+ if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
84
+ }
85
+ await new Promise((r, j) => out.end((e) => (e ? j(e) : r())));
86
+ fs.renameSync(tmp, dest);
87
+ })()
88
+ .catch((e) => { job.error = e.message; fs.rm(tmp, { force: true }, () => {}); })
89
+ .finally(() => { job.download = null; });
90
+ }
91
+
92
+ // Al Mac amb Homebrew, nio ho pot instal·lar sol. Després baixa el model.
93
+ function install(cfg) {
94
+ if (job.install) return;
95
+ if (!(process.platform === 'darwin' && which('brew'))) throw httpErr(400, `Instal·la-ho al terminal de l’ordinador: ${installHint()}`);
96
+ job.error = null;
97
+ const want = ['whisper-cpp', 'ffmpeg'].filter((p) => (p === 'ffmpeg' ? !which('ffmpeg') : !cppBin()));
98
+ if (!want.length) return download(cfg);
99
+ const p = (job.install = spawn(which('brew'), ['install', ...want], { env: childEnv({ HOMEBREW_NO_AUTO_UPDATE: '1' }), stdio: ['ignore', 'ignore', 'pipe'] }));
100
+ let errOut = '';
101
+ p.stderr.on('data', (c) => { errOut = (errOut + c).slice(-400); });
102
+ p.on('error', (e) => { job.error = e.message; job.install = null; });
103
+ p.on('close', (code) => {
104
+ job.install = null;
105
+ if (code) job.error = `brew install ha fallat: ${errOut.trim().split('\n').pop() || code}`;
106
+ else download(cfg);
107
+ });
108
+ }
109
+
110
+ function run(bin, args, timeout = 180000) {
111
+ return new Promise((resolve, reject) => {
112
+ execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
113
+ if (err) reject(httpErr(502, `Whisper: ${String(stderr || err.message).trim().split('\n').pop().slice(0, 200)}`));
114
+ else resolve(String(stdout));
115
+ });
116
+ });
117
+ }
118
+
119
+ function ext(mime) {
120
+ return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
121
+ }
122
+
123
+ async function transcribe(cfg, audio, mime, lang = 'ca') {
124
+ const e = engine();
125
+ if (!e) throw httpErr(400, `Falta Whisper en aquest ordinador. Instal·la’l: ${installHint()}`);
126
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-veu-'));
127
+ try {
128
+ const input = path.join(tmp, `audio.${ext(mime)}`);
129
+ fs.writeFileSync(input, audio);
130
+ if (e === 'python') {
131
+ await run(pyBin(), [input, '--model', 'turbo', '--language', lang, '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
132
+ return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
133
+ }
134
+ if (!fs.existsSync(modelPath(cfg))) {
135
+ download(cfg);
136
+ throw httpErr(409, 'Baixant el model de Whisper (només el primer cop). Torna-ho a provar d’aquí a uns minuts.');
137
+ }
138
+ const wav = path.join(tmp, 'audio.wav');
139
+ await run(which('ffmpeg'), ['-nostdin', '-loglevel', 'error', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], 60000);
140
+ const threads = String(Math.max(1, Math.min(8, os.cpus().length - 1)));
141
+ const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np']);
142
+ return out.split('\n').map((l) => l.trim()).filter((l) => l && !/^\[(BLANK_AUDIO|Música|Music)\]$/i.test(l)).join(' ').trim();
143
+ } finally {
144
+ fs.rm(tmp, { recursive: true, force: true }, () => {});
145
+ }
146
+ }
147
+
148
+ module.exports = { status, ready, download, install, transcribe, MODELS, DIR };