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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +45 -4
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/initiatives-screen.md +10 -0
- package/workspace/guide/notes.md +15 -10
- package/workspace/lib/engine/workflow-state.js +45 -4
- package/workspace/lib/jev-documents.js +224 -0
- package/workspace/lib/notes-whisper.js +94 -22
- package/workspace/lib/notes.js +559 -59
- package/workspace/lib/routes-initiatives.js +76 -3
- package/workspace/lib/routes-notes.js +276 -46
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +142 -0
- package/workspace/public/pm/initiatives-api.js +8 -2
- package/workspace/public/pm/initiatives-copy.js +19 -0
- package/workspace/public/pm/initiatives-ui.js +88 -21
- package/workspace/public/pm/note-dock.js +12 -6
- package/workspace/public/pm/view-notes.js +245 -102
- package/workspace/server.js +12 -1
|
@@ -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,
|
|
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 =
|
|
44
|
+
const MIN_CUT_MS = 4000;
|
|
34
45
|
const MAX_CUT_MS = 12000;
|
|
35
|
-
const PAUSE_MS =
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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,
|
|
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 };
|