create-pmos 0.62.0 → 0.63.1
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/state/discoveries.md +1 -1
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/layout.md +1 -1
- package/workspace/guide/notes.md +79 -0
- package/workspace/lib/jev-notes.js +162 -0
- package/workspace/lib/notes-repo.js +133 -0
- package/workspace/lib/notes-whisper.js +262 -0
- package/workspace/lib/notes.js +511 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +240 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +272 -0
- package/workspace/public/pm/panel-cockpit.js +3 -0
- package/workspace/public/pm/view-notes.js +727 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +3 -1
- package/workspace/test-lists/meeting-notes.json +15 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-whisper.js — local speech-to-text for the Notes view: whisper.cpp on the PM's machine (meeting-notes Run 2, D91).
|
|
3
|
+
*
|
|
4
|
+
* detect(runtimeDir, { env }) → { ready, bin, model, missing, setup, model_dir }
|
|
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)
|
|
8
|
+
* getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
|
|
9
|
+
* getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
|
|
10
|
+
*
|
|
11
|
+
* 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
|
+
* 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
|
|
13
|
+
* at a pause, writes the cut to a temporary WAV file, runs `whisper-cli` on it, deletes the file and hands the words to
|
|
14
|
+
* `onText`. One run at a time, in the order the cuts were made, so the transcript keeps the order things were said.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT NEVER DOES. It keeps no audio: a buffer is dropped once it is cut, and the WAV file is deleted when the program
|
|
17
|
+
* ends, whatever the outcome. It makes no request: the PM installs whisper.cpp and downloads the model in their own terminal
|
|
18
|
+
* (setupCommand), and this module only looks for the program and the model file. It opens no network module.
|
|
19
|
+
*
|
|
20
|
+
* 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
|
+
* 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
|
+
* 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
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { execFile } = require('child_process');
|
|
30
|
+
|
|
31
|
+
const RATE = 16000;
|
|
32
|
+
const FRAME_MS = 100;
|
|
33
|
+
const MIN_CUT_MS = 2000;
|
|
34
|
+
const MAX_CUT_MS = 12000;
|
|
35
|
+
const PAUSE_MS = 600;
|
|
36
|
+
const MIN_VOICED_MS = 300;
|
|
37
|
+
const LEAD_MS = 300; // speech that starts a cut keeps this much of the quiet before it
|
|
38
|
+
const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
|
|
39
|
+
const MAX_QUEUE = 24;
|
|
40
|
+
const RUN_TIMEOUT_MS = 120000;
|
|
41
|
+
const MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
|
|
42
|
+
// The faster model is the same one quantized to 5 bits: a third of the size, so it loads and runs faster on an M1, and it
|
|
43
|
+
// makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
|
|
44
|
+
const MODELS = Object.freeze([
|
|
45
|
+
Object.freeze({ id: 'accurate', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', label: 'Most accurate (1.6 GB)' }),
|
|
46
|
+
Object.freeze({ id: 'fast', file: 'ggml-large-v3-turbo-q5_0.bin', size: 'about 550 MB', label: 'Faster (about 550 MB)' }),
|
|
47
|
+
]);
|
|
48
|
+
const DEFAULT_MODEL = 'accurate';
|
|
49
|
+
const MODEL_FILE = MODELS[0].file;
|
|
50
|
+
const MODEL_URL = MODEL_BASE + MODEL_FILE;
|
|
51
|
+
const BIN_NAMES = ['whisper-cli', 'whisper-cpp'];
|
|
52
|
+
const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
|
|
53
|
+
|
|
54
|
+
const modelDir = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper');
|
|
55
|
+
const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
56
|
+
const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return isFile(p); } catch { return false; } };
|
|
57
|
+
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
58
|
+
|
|
59
|
+
function findBin(env) {
|
|
60
|
+
if (env.PMOS_WHISPER_BIN) return isExec(env.PMOS_WHISPER_BIN) ? env.PMOS_WHISPER_BIN : null;
|
|
61
|
+
const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(BIN_DIRS);
|
|
62
|
+
for (const name of BIN_NAMES) for (const d of dirs) { const p = path.join(d, name); if (isExec(p)) return p; }
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function findModel(runtimeDir, env) {
|
|
66
|
+
if (env.PMOS_WHISPER_MODEL) return isFile(env.PMOS_WHISPER_MODEL) ? env.PMOS_WHISPER_MODEL : null;
|
|
67
|
+
const dir = modelDir(runtimeDir);
|
|
68
|
+
const chosen = modelOf(runtimeDir);
|
|
69
|
+
if (isFile(path.join(dir, chosen.file))) return path.join(dir, chosen.file);
|
|
70
|
+
// a model the PM chose and has not downloaded yet is missing, so the setup downloads it; with no choice made, any
|
|
71
|
+
// whole model in the folder will do
|
|
72
|
+
if (readSettings(runtimeDir).model) return null;
|
|
73
|
+
let names = [];
|
|
74
|
+
try { names = fs.readdirSync(dir).filter((f) => /^ggml-.+\.bin$/.test(f)).sort(); } catch { /* none */ }
|
|
75
|
+
return names.length ? path.join(dir, names[names.length - 1]) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The commands the PM runs once, in their own terminal. The server never runs them.
|
|
79
|
+
*
|
|
80
|
+
* Homebrew's own note after the install says that no model is downloaded; the next command is that download. It goes to a
|
|
81
|
+
* `.part` file that is renamed only when it is complete, so a stopped download never reads as ready, and `-C -` continues
|
|
82
|
+
* a stopped download when the PM runs the commands again. A program already installed is not installed again. */
|
|
83
|
+
function setupCommand(runtimeDir) {
|
|
84
|
+
const dir = modelDir(runtimeDir);
|
|
85
|
+
const m = modelOf(runtimeDir);
|
|
86
|
+
const file = path.join(dir, m.file);
|
|
87
|
+
return '{ command -v whisper-cli >/dev/null 2>&1 || brew install whisper-cpp; }'
|
|
88
|
+
+ ' && mkdir -p ' + shq(dir)
|
|
89
|
+
+ ' && echo ' + shq('Downloading the Whisper model (' + m.size + '). Keep this terminal open.')
|
|
90
|
+
+ ' && curl -L --fail -C - -# -o ' + shq(file + '.part') + ' ' + MODEL_BASE + m.file
|
|
91
|
+
+ ' && mv ' + shq(file + '.part') + ' ' + shq(file)
|
|
92
|
+
+ ' && echo ' + shq('Whisper is set up. Open the Notes settings again.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Is local transcription ready? `missing` names what is not there: 'program', 'model'. */
|
|
96
|
+
function detect(runtimeDir, { env = process.env } = {}) {
|
|
97
|
+
const bin = findBin(env);
|
|
98
|
+
const model = findModel(runtimeDir, env);
|
|
99
|
+
const missing = [];
|
|
100
|
+
if (!bin) missing.push('program');
|
|
101
|
+
if (!model) missing.push('model');
|
|
102
|
+
return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: modelOf(runtimeDir).size };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A mono 16-bit PCM buffer as a WAV file. */
|
|
106
|
+
function wav(pcm, rate = RATE) {
|
|
107
|
+
const h = Buffer.alloc(44);
|
|
108
|
+
h.write('RIFF', 0); h.writeUInt32LE(36 + pcm.length, 4); h.write('WAVE', 8);
|
|
109
|
+
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
|
|
110
|
+
h.writeUInt32LE(rate, 24); h.writeUInt32LE(rate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
|
|
111
|
+
h.write('data', 36); h.writeUInt32LE(pcm.length, 40);
|
|
112
|
+
return Buffer.concat([h, pcm]);
|
|
113
|
+
}
|
|
114
|
+
/** The loudness (RMS, 16-bit units) of each 100 ms frame. */
|
|
115
|
+
function levels(pcm, rate = RATE) {
|
|
116
|
+
const per = Math.round(rate * FRAME_MS / 1000);
|
|
117
|
+
const n = Math.floor(pcm.length / 2);
|
|
118
|
+
const out = [];
|
|
119
|
+
for (let i = 0; i < n; i += per) {
|
|
120
|
+
const end = Math.min(n, i + per); let sum = 0;
|
|
121
|
+
for (let k = i; k < end; k++) { const v = pcm.readInt16LE(k * 2); sum += v * v; }
|
|
122
|
+
out.push(Math.sqrt(sum / Math.max(1, end - i)));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
// Whisper marks what is not speech in brackets, and on near-silence it is known to produce stock closing lines
|
|
127
|
+
const NOT_SPEECH = /\[[^\]]*\]|\([^)]*\)|[♪♫]+|>>/g;
|
|
128
|
+
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) {
|
|
131
|
+
const t = String(text || '').split(/\r?\n/).map((l) => l.replace(/^\s*\[\d[\d:.]*\s*-->\s*[\d:.]*\]\s*/, '')).join(' ')
|
|
132
|
+
.replace(NOT_SPEECH, ' ').replace(/\s+/g, ' ').trim();
|
|
133
|
+
if (!/[\p{L}\p{N}]/u.test(t) || STOCK.test(t)) return '';
|
|
134
|
+
return t;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
|
|
138
|
+
* few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
|
|
139
|
+
* the PM picks the language of the meeting once (English by default) and every cut uses it. PMOS_WHISPER_LANG wins. */
|
|
140
|
+
const LANGUAGES = Object.freeze([['en', 'English'], ['id', 'Indonesian'], ['auto', 'Detect for each line']]);
|
|
141
|
+
const DEFAULT_LANGUAGE = 'en';
|
|
142
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper-settings.json');
|
|
143
|
+
function readSettings(runtimeDir) {
|
|
144
|
+
try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; }
|
|
145
|
+
}
|
|
146
|
+
function writeSettings(runtimeDir, patch) {
|
|
147
|
+
const f = settingsFile(runtimeDir);
|
|
148
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
149
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
150
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...readSettings(runtimeDir), ...patch }), { mode: 0o600 });
|
|
151
|
+
fs.renameSync(tmp, f);
|
|
152
|
+
}
|
|
153
|
+
function getLanguage(runtimeDir, { env = process.env } = {}) {
|
|
154
|
+
if (env.PMOS_WHISPER_LANG) return env.PMOS_WHISPER_LANG;
|
|
155
|
+
const c = readSettings(runtimeDir).language;
|
|
156
|
+
return LANGUAGES.some(([k]) => k === c) ? c : DEFAULT_LANGUAGE;
|
|
157
|
+
}
|
|
158
|
+
function setLanguage(runtimeDir, code) {
|
|
159
|
+
if (!LANGUAGES.some(([k]) => k === code)) return { ok: false, error: 'the language must be one of ' + LANGUAGES.map(([k]) => k).join(', ') };
|
|
160
|
+
writeSettings(runtimeDir, { language: code });
|
|
161
|
+
return { ok: true, language: code };
|
|
162
|
+
}
|
|
163
|
+
function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
|
|
164
|
+
function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
|
|
165
|
+
function setModel(runtimeDir, id) {
|
|
166
|
+
if (!MODELS.some((m) => m.id === id)) return { ok: false, error: 'the model must be one of ' + MODELS.map((m) => m.id).join(', ') };
|
|
167
|
+
writeSettings(runtimeDir, { model: id });
|
|
168
|
+
return { ok: true, model: id };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
|
|
172
|
+
function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
|
|
173
|
+
// The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
|
|
174
|
+
// 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.
|
|
176
|
+
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
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The buffers and the one queue.
|
|
187
|
+
* @param {object} a
|
|
188
|
+
* @param {string} a.runtimeDir
|
|
189
|
+
* @param {(cut:{id,source,t,text,ms}) => void} a.onText called with the words of each cut, in order
|
|
190
|
+
* @param {(cut:{id,source,error}) => void} [a.onError]
|
|
191
|
+
* @param {Function} [a.run] runWhisper, replaceable in the suites
|
|
192
|
+
* @param {Function} [a.detect] detect, replaceable in the suites
|
|
193
|
+
*/
|
|
194
|
+
function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir) }) {
|
|
195
|
+
const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
|
|
196
|
+
const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
|
|
197
|
+
let queue = Promise.resolve();
|
|
198
|
+
let waiting = 0;
|
|
199
|
+
const tmpDir = path.join(runtimeDir, 'notes', 'audio');
|
|
200
|
+
// a cut left by a process that ended while the program ran is deleted when the next transcriber starts
|
|
201
|
+
try { for (const f of fs.readdirSync(tmpDir)) if (/^cut-[0-9a-f]+\.wav$/.test(f)) fs.unlinkSync(path.join(tmpDir, f)); } catch { /* no folder yet */ }
|
|
202
|
+
|
|
203
|
+
function cut(b) {
|
|
204
|
+
bufs.delete(b.id + '|' + b.source);
|
|
205
|
+
if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) return false;
|
|
206
|
+
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
|
+
const pcm = Buffer.concat(b.chunks);
|
|
208
|
+
const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
|
|
209
|
+
waiting++;
|
|
210
|
+
queue = queue.then(async () => {
|
|
211
|
+
const d = det();
|
|
212
|
+
if (!d.ready) { onError({ ...job, error: 'local transcription is not set up' }); return; }
|
|
213
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
214
|
+
const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
|
|
215
|
+
try {
|
|
216
|
+
fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
|
|
217
|
+
const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
|
|
218
|
+
if (!r.ok) { onError({ ...job, error: r.error }); return; }
|
|
219
|
+
const text = cleanText(r.text);
|
|
220
|
+
if (text) await onText({ ...job, text });
|
|
221
|
+
} catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
|
|
222
|
+
finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
|
|
223
|
+
}).catch(() => {}).finally(() => { waiting--; });
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One chunk from the page. `t` is the chunk's start, in ms from the note's recording start.
|
|
229
|
+
* @returns {{ ok:true, cut:boolean }}
|
|
230
|
+
*/
|
|
231
|
+
function push({ id, source, pcm, t = 0, final = false }) {
|
|
232
|
+
const k = id + '|' + source;
|
|
233
|
+
let b = bufs.get(k);
|
|
234
|
+
const lv = pcm && pcm.length ? levels(pcm) : [];
|
|
235
|
+
if (!b) { b = { id, source, chunks: [], ms: 0, voicedMs: 0, quietMs: 0, t, noise: noiseOf.has(source) ? noiseOf.get(source) : null }; bufs.set(k, b); }
|
|
236
|
+
for (const v of lv) {
|
|
237
|
+
// the quiet level is learnt from quiet frames only, so speech at the very start is not taken for the room's noise
|
|
238
|
+
const voiced = v > Math.max(MIN_RMS, (b.noise == null ? 0 : b.noise) * 3);
|
|
239
|
+
if (voiced) { b.voicedMs += FRAME_MS; b.quietMs = 0; }
|
|
240
|
+
else { b.quietMs += FRAME_MS; b.noise = b.noise == null ? v : b.noise * 0.9 + v * 0.1; }
|
|
241
|
+
}
|
|
242
|
+
if (pcm && pcm.length) { b.chunks.push(pcm); b.ms += (pcm.length / 2 / RATE) * 1000; }
|
|
243
|
+
if (b.noise != null) noiseOf.set(source, b.noise);
|
|
244
|
+
// quiet before anyone speaks is not kept: only the last LEAD_MS of it stays in front of the speech
|
|
245
|
+
if (!b.voicedMs && b.ms > LEAD_MS + 1000) {
|
|
246
|
+
const keepBytes = Math.round(RATE * LEAD_MS / 1000) * 2;
|
|
247
|
+
const all = Buffer.concat(b.chunks);
|
|
248
|
+
b.chunks = [all.subarray(Math.max(0, all.length - keepBytes))];
|
|
249
|
+
b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
|
|
250
|
+
}
|
|
251
|
+
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);
|
|
253
|
+
return { ok: true, cut: did };
|
|
254
|
+
}
|
|
255
|
+
/** Cut whatever is buffered for a note (the page stopped). */
|
|
256
|
+
function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
|
|
257
|
+
/** 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); }
|
|
259
|
+
return { push, flush, drop, pending: () => waiting, idle: () => queue };
|
|
260
|
+
}
|
|
261
|
+
|
|
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 };
|