create-pmos 0.74.0 → 0.76.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.
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * detect(runtimeDir, { env }) → { ready, bin, model, missing, setup, model_dir }
5
5
  * setupCommand(runtimeDir) → the shell commands the PM runs once: install whisper.cpp and download its model
6
- * createTranscriber({ runtimeDir, onText, run?, detect? }) → { push, flush, pending, stopAll }
7
- * wav(pcm, rate) · levels(pcm, rate) · cleanText(text)
6
+ * createTranscriber({ runtimeDir, onText, run?, detect?, vocab?, prompt? }) → { push, flush, drop, pending, idle }
7
+ * wav(pcm, rate) · levels(pcm, rate) · cleanText(text, { voicedRatio }) · promptFor(vocab, previous)
8
8
  * getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
9
9
  * getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
10
+ * getAccuracy(runtimeDir) · setAccuracy(runtimeDir, id) the decoding: fast (greedy, default) or accurate (beam search) (ACCURACIES)
10
11
  *
11
12
  * WHAT IT IS. The page captures the microphone and, when the PM picks it, a meeting tab's audio. It sends each source as
12
13
  * 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
@@ -20,7 +21,17 @@
20
21
  * CUTTING AT PAUSES. A 100 ms frame counts as speech when its loudness is well above the quiet level measured so far. A cut
21
22
  * happens when at least MIN_CUT_MS is buffered and the last PAUSE_MS were quiet, or when MAX_CUT_MS is buffered, or when the
22
23
  * page stops. A cut with less than MIN_VOICED_MS of speech is dropped without running the program, which also keeps
23
- * Whisper from inventing words for silence.
24
+ * Whisper from inventing words for silence. A pause of LONG_PAUSE_MS also cuts a short phrase, so it is not held back.
25
+ * The cuts are long (a pause of 1 s, at least 4 s) so that a cut holds a whole sentence more often than a fragment;
26
+ * lib/notes.js joins a cut that still ends mid-sentence to the one before it.
27
+ *
28
+ * VOCABULARY AND CONTEXT. Each run gets `--prompt` with the names and terms of the repository (the `vocab` callback, or the
29
+ * `prompt` string) and the last line heard from the same source, at most PROMPT_MAX characters. Whisper then spells the
30
+ * repository's people and product terms as the repository does, and reads the cut as the continuation of the line before.
31
+ *
32
+ * INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
33
+ * quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
34
+ * for `-nth` is run again without it, and is remembered.
24
35
  */
25
36
  const fs = require('fs');
26
37
  const os = require('os');
@@ -30,9 +41,13 @@ const { execFile } = require('child_process');
30
41
 
31
42
  const RATE = 16000;
32
43
  const FRAME_MS = 100;
33
- const MIN_CUT_MS = 2000;
44
+ const MIN_CUT_MS = 4000;
34
45
  const MAX_CUT_MS = 12000;
35
- const PAUSE_MS = 600;
46
+ const PAUSE_MS = 1000;
47
+ const LONG_PAUSE_MS = 2000; // a pause this long cuts whatever was said, however short
48
+ const PROMPT_MAX = 200; // characters of --prompt: the vocabulary first, then the end of the line before
49
+ const NO_SPEECH_THOLD = '0.6';
50
+ const LOW_VOICED_RATIO = 0.4; // a cut with less speech than this, as a share of its length, is mostly quiet
36
51
  const MIN_VOICED_MS = 300;
37
52
  const LEAD_MS = 300; // speech that starts a cut keeps this much of the quiet before it
38
53
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
@@ -126,13 +141,33 @@ function levels(pcm, rate = RATE) {
126
141
  // Whisper marks what is not speech in brackets, and on near-silence it is known to produce stock closing lines
127
142
  const NOT_SPEECH = /\[[^\]]*\]|\([^)]*\)|[♪♫]+|>>/g;
128
143
  const STOCK = /^(?:thanks? (?:you )?(?:so much )?for watching[.!]?|please subscribe[.!]?|subtitles? by .*|transcribed by .*)$/i;
129
- /** The words of a run, without the non-speech marks. Empty when nothing was said. */
130
- function cleanText(text) {
144
+ // what Whisper writes for a mostly quiet cut: a lone thanks, "you", "bye", "okay"; dropped only when the cut was mostly quiet
145
+ const QUIET_STOCK = /^(?:(?:thank you|thanks)(?: (?:so much|very much|everyone|all))?|you|bye(?: bye)?|okay|ok|oh|uh|um|hmm|so)(?:[.!?,…]+(?:\s+(?:(?:thank you|thanks)(?: (?:so much|very much))?|you|bye|okay))?)*[.!?…]*$/i;
146
+ /** The words of a run, without the non-speech marks. Empty when nothing was said, or when a mostly quiet cut
147
+ * (`voicedRatio` under LOW_VOICED_RATIO) came back as only a stock line such as "Thank you." */
148
+ function cleanText(text, { voicedRatio = 1 } = {}) {
131
149
  const t = String(text || '').split(/\r?\n/).map((l) => l.replace(/^\s*\[\d[\d:.]*\s*-->\s*[\d:.]*\]\s*/, '')).join(' ')
132
150
  .replace(NOT_SPEECH, ' ').replace(/\s+/g, ' ').trim();
133
151
  if (!/[\p{L}\p{N}]/u.test(t) || STOCK.test(t)) return '';
152
+ if (voicedRatio < LOW_VOICED_RATIO && QUIET_STOCK.test(t)) return '';
134
153
  return t;
135
154
  }
155
+ /** The --prompt for one run: the vocabulary, then the end of the previous line, at most PROMPT_MAX characters. '' when
156
+ * there is neither. The vocabulary keeps at most two thirds of the room when there is a previous line. */
157
+ function promptFor(vocab, previous) {
158
+ const v = String(vocab || '').replace(/\s+/g, ' ').trim().replace(/[.,;:\s]+$/, '');
159
+ const p = String(previous || '').replace(/\s+/g, ' ').trim();
160
+ const cutAt = (s, n, fromEnd) => {
161
+ if (s.length <= n) return s;
162
+ if (fromEnd) { const x = s.slice(s.length - n); const sp = x.indexOf(' '); return (sp > 0 && sp < 30 ? x.slice(sp + 1) : x).trim(); }
163
+ const x = s.slice(0, n); const c = Math.max(x.lastIndexOf(','), x.lastIndexOf(' ')); return (c > n / 2 ? x.slice(0, c) : x).replace(/[.,;:\s]+$/, '');
164
+ };
165
+ if (!p) return cutAt(v, PROMPT_MAX, false);
166
+ if (!v) return cutAt(p, PROMPT_MAX, true);
167
+ const vv = cutAt(v, Math.min(v.length, Math.floor(PROMPT_MAX * 2 / 3)), false);
168
+ const room = PROMPT_MAX - vv.length - 2;
169
+ return room > 10 ? vv + '. ' + cutAt(p, room, true) : vv;
170
+ }
136
171
 
137
172
  /* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
138
173
  * few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
@@ -160,6 +195,19 @@ function setLanguage(runtimeDir, code) {
160
195
  writeSettings(runtimeDir, { language: code });
161
196
  return { ok: true, language: code };
162
197
  }
198
+ /* THE DECODING. Greedy (fast, the default) or a five-way beam search (accurate): slower on every line, better on hard words. */
199
+ const ACCURACIES = Object.freeze([
200
+ Object.freeze({ id: 'fast', label: 'Faster (greedy decoding)', args: ['-bs', '1', '-bo', '1'] }),
201
+ Object.freeze({ id: 'accurate', label: 'More accurate (beam search, slower)', args: ['-bs', '5', '-bo', '5'] }),
202
+ ]);
203
+ const DEFAULT_ACCURACY = 'fast';
204
+ function accuracyOf(runtimeDir) { const id = readSettings(runtimeDir).accuracy; return ACCURACIES.find((a) => a.id === id) || ACCURACIES.find((a) => a.id === DEFAULT_ACCURACY); }
205
+ function getAccuracy(runtimeDir) { return accuracyOf(runtimeDir).id; }
206
+ function setAccuracy(runtimeDir, id) {
207
+ if (!ACCURACIES.some((a) => a.id === id)) return { ok: false, error: 'the accuracy must be one of ' + ACCURACIES.map((a) => a.id).join(', ') };
208
+ writeSettings(runtimeDir, { accuracy: id });
209
+ return { ok: true, accuracy: id };
210
+ }
163
211
  function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
164
212
  function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
165
213
  function setModel(runtimeDir, id) {
@@ -168,18 +216,32 @@ function setModel(runtimeDir, id) {
168
216
  return { ok: true, model: id };
169
217
  }
170
218
 
171
- /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
172
- function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
219
+ const noNth = new Set(); // programs too old for -nth, found when they refused it
220
+ /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }.
221
+ * `accuracy` is an ACCURACIES id; `prompt` is the --prompt text, left out when empty. */
222
+ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE, accuracy = DEFAULT_ACCURACY, prompt = '' }) {
173
223
  // The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
174
224
  // Greedy decoding (-bs 1 -bo 1) instead of the program's default five-way beam search makes each line several times
175
- // faster for a small loss on hard words.
225
+ // faster for a small loss on hard words; the accurate setting uses the beam search.
176
226
  const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
177
- return new Promise((resolve) => {
178
- execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, '-bs', '1', '-bo', '1'], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
179
- if (e) resolve({ ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) });
180
- else resolve({ ok: true, text: String(stdout || '') });
181
- });
227
+ const dec = (ACCURACIES.find((a) => a.id === accuracy) || ACCURACIES[0]).args;
228
+ const base = ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, ...dec];
229
+ const extra = prompt ? ['--prompt', String(prompt)] : [];
230
+ const once = (args) => new Promise((resolve) => {
231
+ execFile(bin, args, { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => resolve({ e, stdout: String(stdout || ''), stderr: String(stderr || '') }));
182
232
  });
233
+ const done = ({ e, stdout, stderr }) => (e
234
+ ? { ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) }
235
+ : { ok: true, text: stdout });
236
+ const refused = (r) => /unknown argument|unrecognized option|invalid option/i.test(r.stderr) && /-nth|no-speech/i.test(r.stderr);
237
+ return (async () => {
238
+ if (!noNth.has(bin)) {
239
+ const r = await once([...base, '-nth', NO_SPEECH_THOLD, ...extra]);
240
+ if (!refused(r)) return done(r);
241
+ noNth.add(bin);
242
+ }
243
+ return done(await once([...base, ...extra]));
244
+ })();
183
245
  }
184
246
 
185
247
  /**
@@ -191,8 +253,10 @@ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
191
253
  * @param {Function} [a.run] runWhisper, replaceable in the suites
192
254
  * @param {Function} [a.detect] detect, replaceable in the suites
193
255
  */
194
- function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir) }) {
256
+ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '' }) {
195
257
  const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
258
+ const lastText = new Map(); // `${id}|${source}` → the last words heard there, the context of the next cut
259
+ const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
196
260
  const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
197
261
  let queue = Promise.resolve();
198
262
  let waiting = 0;
@@ -206,6 +270,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
206
270
  if (waiting >= MAX_QUEUE) { onError({ id: b.id, source: b.source, error: 'transcription is falling behind, so a few seconds were skipped' }); return false; }
207
271
  const pcm = Buffer.concat(b.chunks);
208
272
  const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
273
+ const voicedRatio = b.ms > 0 ? b.voicedMs / b.ms : 1;
209
274
  waiting++;
210
275
  queue = queue.then(async () => {
211
276
  const d = det();
@@ -214,10 +279,11 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
214
279
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
215
280
  try {
216
281
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
217
- const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
282
+ const key = job.id + '|' + job.source;
283
+ const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir), accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), lastText.get(key)) });
218
284
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
219
- const text = cleanText(r.text);
220
- if (text) await onText({ ...job, text });
285
+ const text = cleanText(r.text, { voicedRatio });
286
+ if (text) { lastText.set(key, text); await onText({ ...job, text }); }
221
287
  } catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
222
288
  finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
223
289
  }).catch(() => {}).finally(() => { waiting--; });
@@ -249,14 +315,20 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
249
315
  b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
250
316
  }
251
317
  let did = false;
252
- if (final || b.ms >= MAX_CUT_MS || (b.ms >= MIN_CUT_MS && b.voicedMs >= MIN_VOICED_MS && b.quietMs >= PAUSE_MS)) did = cut(b);
318
+ const spoke = b.voicedMs >= MIN_VOICED_MS;
319
+ if (final || b.ms >= MAX_CUT_MS || (spoke && b.ms >= MIN_CUT_MS && b.quietMs >= PAUSE_MS) || (spoke && b.quietMs >= LONG_PAUSE_MS)) did = cut(b);
253
320
  return { ok: true, cut: did };
254
321
  }
255
322
  /** Cut whatever is buffered for a note (the page stopped). */
256
323
  function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
257
324
  /** Drop a note's buffers without transcribing them (the note was marked private or deleted). */
258
- function drop(id) { for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k); }
325
+ function drop(id) {
326
+ for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
327
+ for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
328
+ }
259
329
  return { push, flush, drop, pending: () => waiting, idle: () => queue };
260
330
  }
261
331
 
262
- module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
332
+ module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL,
333
+ getAccuracy, setAccuracy, ACCURACIES, DEFAULT_ACCURACY, createTranscriber, runWhisper, wav, levels, cleanText, promptFor, modelDir, RATE, MODEL_FILE, MODEL_URL,
334
+ MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS, LONG_PAUSE_MS, PROMPT_MAX, LOW_VOICED_RATIO, NO_SPEECH_THOLD };