create-pmos 0.76.0 → 0.78.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.
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ /* notes-final.js — the final pass after Stop: the whole meeting transcribed again, with who said what (meeting-notes Run 9).
3
+ *
4
+ * detect(runtimeDir, { env }) → { ready, transcriber, parakeet, whisper, vad, diarize, missing, setup }
5
+ * setupCommand(runtimeDir) → the shell commands the PM runs once for the better engine, speech detection and speakers
6
+ * createFinal({ runtimeDir, onDone, onState, run?, detect? }) → { queue(id, opts), cancel(id), state(id), idle() }
7
+ * parseWhisper(json) · parseParakeet(json) · assignSpeakers(segs, diar) · dropEcho(mic, meeting) · mergeSources(parts)
8
+ *
9
+ * WHY. The live transcript is cut every few seconds so that lines appear while people talk. A cut that short gives Whisper
10
+ * little context, so names come out wrong and sentences break in two, and nothing in it says who spoke. Meeting tools that
11
+ * write good notes (Wispr Flow, Granola, Notion) treat the live text as a draft and write the notes from one pass over the
12
+ * whole recording. This module is that pass, on this machine.
13
+ *
14
+ * WHAT IT DOES, for each source the note heard (lib/notes-audio.js keeps the microphone and the meeting apart):
15
+ * 1. The source's samples become one temporary WAV file.
16
+ * 2. The best engine set up transcribes the whole file: Parakeet (parakeet-mlx, English only, more accurate and faster on
17
+ * Apple silicon) when it is set up and the meeting is in English, else whisper.cpp with a five-way beam search, the
18
+ * repository's names and terms as the prompt, and speech detection (`--vad`) when its model is there. Whisper reads the
19
+ * whole file in 30-second windows, each with the text before it, which is what the live cuts lack.
20
+ * 3. When speaker separation is set up (lib/notes-diarize.py, sherpa-onnx, local), the meeting audio is split into
21
+ * speakers: Speaker 1, Speaker 2 and so on. The microphone is the PM. In a room with one microphone and no meeting
22
+ * audio, the microphone is split instead.
23
+ * 4. The sources are merged in time order. A microphone line that repeats a meeting line said at the same moment is the
24
+ * meeting heard through the PM's speakers, and is dropped.
25
+ * The result goes to `onDone(id, final)`; lib/routes-notes.js stores it on the note and then asks the PM's agent session for
26
+ * the notes. One job runs at a time, so the model and the speaker separation never share the memory of an 8 GB machine.
27
+ *
28
+ * WHAT IT NEVER DOES. It makes no request. The setup commands download the models in the PM's own terminal; at run time
29
+ * Parakeet is started with HF_HUB_OFFLINE=1. The temporary files are deleted when the job ends, whatever the outcome.
30
+ */
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+ const crypto = require('crypto');
35
+ const { execFile } = require('child_process');
36
+ const whisper = require('./notes-whisper.js');
37
+ const notesAudio = require('./notes-audio.js');
38
+
39
+ const RUN_TIMEOUT_MS = 45 * 60 * 1000;
40
+ const PARAKEET_MODEL = 'mlx-community/parakeet-tdt-0.6b-v2';
41
+ const VAD_FILE = 'ggml-silero-v5.1.2.bin';
42
+ const VAD_URL = 'https://huggingface.co/ggml-org/whisper-vad/resolve/main/' + VAD_FILE;
43
+ const SEG_URL = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2';
44
+ const EMB_URL = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx';
45
+ const DIARIZE_THRESHOLD = '0.9'; // tried on a four-speaker sample: 0.9 found four, 0.5 to 0.7 split them into more
46
+ const ECHO_OVERLAP = 0.5;
47
+ const ECHO_WORDS = 0.5;
48
+
49
+ const finalDir = (runtimeDir) => path.join(runtimeDir, 'notes', 'final');
50
+ const venvBin = (runtimeDir, name) => path.join(finalDir(runtimeDir), 'venv', 'bin', name);
51
+ const diarDir = (runtimeDir) => path.join(finalDir(runtimeDir), 'speakers');
52
+ const SCRIPT = path.join(__dirname, 'notes-diarize.py');
53
+ const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
54
+ const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return isFile(p); } catch { return false; } };
55
+ const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
56
+
57
+ /** What the final pass can use on this machine. `ready` is true when any engine can transcribe: Whisper alone is enough. */
58
+ function detect(runtimeDir, { env = process.env } = {}) {
59
+ const w = whisper.detect(runtimeDir, { env });
60
+ const pk = env.PMOS_NOTES_PARAKEET_BIN || venvBin(runtimeDir, 'parakeet-mlx');
61
+ const parakeet = isExec(pk) ? pk : null;
62
+ const lang = whisper.getLanguage(runtimeDir, { env });
63
+ const transcriber = parakeet && lang === 'en' ? 'parakeet' : w.ready ? 'whisper' : null;
64
+ const vadFile = env.PMOS_NOTES_VAD_MODEL || path.join(whisper.modelDir(runtimeDir), VAD_FILE);
65
+ const python = env.PMOS_NOTES_DIARIZE_PYTHON || venvBin(runtimeDir, 'python3');
66
+ const seg = path.join(diarDir(runtimeDir), 'sherpa-onnx-pyannote-segmentation-3-0', 'model.onnx');
67
+ const emb = path.join(diarDir(runtimeDir), 'nemo_en_titanet_small.onnx');
68
+ const diarize = isExec(python) && isFile(seg) && isFile(emb) && isFile(SCRIPT) ? { python, script: SCRIPT, seg, emb } : null;
69
+ const missing = [];
70
+ if (!parakeet) missing.push('parakeet');
71
+ if (!isFile(vadFile)) missing.push('vad');
72
+ if (!diarize) missing.push('speakers');
73
+ return { ready: !!transcriber, transcriber, parakeet, whisper: w.ready ? { bin: w.bin, model: w.model } : null, vad: isFile(vadFile) ? vadFile : null,
74
+ diarize, missing, lang, setup: setupCommand(runtimeDir) };
75
+ }
76
+
77
+ /** The commands the PM runs once, in their own terminal, for the better final pass. The server never runs them.
78
+ * Python 3.12 from Homebrew (the system Python is too old for MLX), a virtual environment in the workspace's own folder
79
+ * with parakeet-mlx and sherpa-onnx, the Parakeet model fetched once so it runs offline, ffmpeg (parakeet-mlx reads audio
80
+ * with it), Whisper's speech detection model, and the two speaker models. Each download goes to a `.part` file first. */
81
+ function setupCommand(runtimeDir) {
82
+ const dir = finalDir(runtimeDir);
83
+ const sp = diarDir(runtimeDir);
84
+ const wdir = whisper.modelDir(runtimeDir);
85
+ const py = venvBin(runtimeDir, 'python3');
86
+ const dl = (url, file) => 'curl -L --fail -C - -# -o ' + shq(file + '.part') + ' ' + url + ' && mv ' + shq(file + '.part') + ' ' + shq(file);
87
+ return '{ command -v ffmpeg >/dev/null 2>&1 || brew install ffmpeg; }'
88
+ + ' && { command -v python3.12 >/dev/null 2>&1 || brew install python@3.12; }'
89
+ + ' && mkdir -p ' + shq(dir) + ' ' + shq(sp) + ' ' + shq(wdir)
90
+ + ' && { [ -x ' + shq(py) + ' ] || python3.12 -m venv ' + shq(path.join(dir, 'venv')) + '; }'
91
+ + ' && ' + shq(py) + ' -m pip install --upgrade parakeet-mlx sherpa-onnx numpy'
92
+ + ' && echo ' + shq('Downloading the Parakeet model (about 2.5 GB). Keep this terminal open.')
93
+ + ' && ' + shq(py) + ' -c ' + shq("from parakeet_mlx import from_pretrained; from_pretrained('" + PARAKEET_MODEL + "')")
94
+ + ' && ' + dl(VAD_URL, path.join(wdir, VAD_FILE))
95
+ + ' && ' + dl(SEG_URL, path.join(sp, 'segmentation.tar.bz2')) + ' && tar -xjf ' + shq(path.join(sp, 'segmentation.tar.bz2')) + ' -C ' + shq(sp)
96
+ + ' && ' + dl(EMB_URL, path.join(sp, 'nemo_en_titanet_small.onnx'))
97
+ + ' && echo ' + shq('The final pass is set up. Open the Notes settings again.');
98
+ }
99
+
100
+ /* ── parsing what the programs write ──────────────────────────────────────────────────────────── */
101
+ const clean = (t) => whisper.cleanText(String(t || ''));
102
+ /** whisper-cli -oj: { transcription: [{ offsets: { from, to }, text }] } in ms. → [{ start, end, text }] in ms. */
103
+ function parseWhisper(json) {
104
+ const rows = json && Array.isArray(json.transcription) ? json.transcription : [];
105
+ return rows.map((r) => ({ start: Number(r.offsets && r.offsets.from) || 0, end: Number(r.offsets && r.offsets.to) || 0, text: clean(r.text) })).filter((s) => s.text);
106
+ }
107
+ /** parakeet-mlx --output-format json: { sentences: [{ start, end, text }] } in seconds (or segments). → ms. */
108
+ function parseParakeet(json) {
109
+ const rows = json && (Array.isArray(json.sentences) ? json.sentences : Array.isArray(json.segments) ? json.segments : []);
110
+ const out = (rows || []).map((r) => ({ start: Math.round((Number(r.start) || 0) * 1000), end: Math.round((Number(r.end) || 0) * 1000), text: clean(r.text) })).filter((s) => s.text);
111
+ if (!out.length && json && typeof json.text === 'string' && clean(json.text)) out.push({ start: 0, end: 0, text: clean(json.text) });
112
+ return out;
113
+ }
114
+ /** Each segment gets the speaker who talks most during it. `diar`: [{ start, end, speaker }] in seconds. Speakers are
115
+ * numbered in the order they first speak. → segments with `spk` (0, 1, …) and the number of speakers. */
116
+ function assignSpeakers(segs, diar) {
117
+ const d = (diar || []).map((x) => ({ s: x.start * 1000, e: x.end * 1000, k: x.speaker }));
118
+ const order = new Map();
119
+ for (const x of [...d].sort((a, b) => a.s - b.s)) if (!order.has(x.k)) order.set(x.k, order.size);
120
+ const out = segs.map((g) => {
121
+ const talk = new Map();
122
+ for (const x of d) { const o = Math.min(g.end || g.start + 1, x.e) - Math.max(g.start, x.s); if (o > 0) talk.set(x.k, (talk.get(x.k) || 0) + o); }
123
+ let best = null; let most = 0;
124
+ for (const [k, v] of talk) if (v > most) { most = v; best = k; }
125
+ if (best === null && d.length) {
126
+ // a segment between two turns goes to the nearest turn
127
+ let near = Infinity;
128
+ for (const x of d) { const gap = Math.min(Math.abs(g.start - x.e), Math.abs(x.s - (g.end || g.start))); if (gap < near) { near = gap; best = x.k; } }
129
+ }
130
+ return { ...g, spk: best === null ? null : order.get(best) };
131
+ });
132
+ return { segments: out, speakers: order.size };
133
+ }
134
+ const wordsOf = (t) => new Set(String(t || '').toLowerCase().match(/[\p{L}\p{N}']{3,}/gu) || []);
135
+ function sameWords(a, b) {
136
+ const x = wordsOf(a); const y = wordsOf(b);
137
+ if (!x.size || !y.size) return 0;
138
+ let both = 0; for (const w of x) if (y.has(w)) both++;
139
+ return both / Math.min(x.size, y.size);
140
+ }
141
+ /** The microphone lines that are not the meeting heard through the PM's speakers. */
142
+ function dropEcho(mic, meeting) {
143
+ return mic.filter((m) => {
144
+ const len = Math.max(1, (m.end || m.start) - m.start);
145
+ return !meeting.some((g) => {
146
+ const o = Math.min(m.end || m.start, g.end || g.start) - Math.max(m.start, g.start);
147
+ return o / len >= ECHO_OVERLAP && sameWords(m.text, g.text) >= ECHO_WORDS;
148
+ });
149
+ });
150
+ }
151
+ /** parts: [{ source, segments, speakers }] → one transcript in time order: [{ t, end, text, source, spk }], where `spk` is
152
+ * the speaker key: the source alone ('mic', 'meeting') or the source and its speaker ('meeting-1'). */
153
+ function mergeSources(parts) {
154
+ const mic = parts.find((p) => p.source === 'mic');
155
+ const meet = parts.find((p) => p.source === 'meeting');
156
+ if (mic && meet) mic.segments = dropEcho(mic.segments, meet.segments);
157
+ const out = [];
158
+ for (const p of parts) {
159
+ for (const g of p.segments) {
160
+ const spk = p.speakers > 1 && g.spk !== null && g.spk !== undefined ? p.source + '-' + (g.spk + 1) : p.source;
161
+ out.push({ t: g.start, end: g.end, text: g.text, source: p.source, spk });
162
+ }
163
+ }
164
+ return out.sort((a, b) => a.t - b.t || (a.source < b.source ? -1 : 1));
165
+ }
166
+
167
+ /* ── running the programs ─────────────────────────────────────────────────────────────────────── */
168
+ function runProgram(bin, args, { env, timeout = RUN_TIMEOUT_MS } = {}) {
169
+ return new Promise((resolve) => {
170
+ execFile(bin, args, { timeout, maxBuffer: 16 << 20, env: env || process.env }, (e, stdout, stderr) => {
171
+ const out = String(stdout || ''); const err = String(stderr || '');
172
+ resolve(e ? { ok: false, stdout: out, stderr: err, error: e.killed ? 'it took too long' : (err || e.message || 'the program failed').trim().split('\n').pop().slice(0, 300) } : { ok: true, stdout: out, stderr: err });
173
+ });
174
+ });
175
+ }
176
+ const refusedFlag = (r, flag) => !r.ok && /unknown argument|unrecognized option|invalid option|error: unknown/i.test(r.stderr + ' ' + r.error) && (r.stderr + r.error).includes(flag);
177
+
178
+ function writeWav(pcmFile, wavFile) {
179
+ const size = fs.statSync(pcmFile).size;
180
+ fs.writeFileSync(wavFile, notesAudio.wavHeader(size), { mode: 0o600 });
181
+ fs.appendFileSync(wavFile, fs.readFileSync(pcmFile));
182
+ }
183
+ async function transcribeWhisper(run, d, wav, tmp, { lang, prompt }) {
184
+ const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
185
+ const base = path.join(tmp, 'whisper-' + crypto.randomBytes(4).toString('hex'));
186
+ let vad = d.vad ? ['--vad', '-vm', d.vad] : [];
187
+ let nth = ['-nth', whisper.NO_SPEECH_THOLD];
188
+ const pr = prompt ? ['--prompt', String(prompt)] : [];
189
+ for (let i = 0; i < 3; i++) {
190
+ const r = await run(d.whisper.bin, ['-m', d.whisper.model, '-f', wav, '-l', lang, '-t', threads, '-bs', '5', '-bo', '5', '-np', '-oj', '-of', base, ...nth, ...vad, ...pr]);
191
+ if (r.ok) {
192
+ let json = null;
193
+ try { json = JSON.parse(fs.readFileSync(base + '.json', 'utf8')); } catch { return { ok: false, error: 'Whisper wrote no transcript' }; }
194
+ return { ok: true, segments: parseWhisper(json), engine: 'whisper', vad: vad.length > 0 };
195
+ }
196
+ if (vad.length && refusedFlag(r, '--vad')) { vad = []; continue; } // a whisper.cpp older than its speech detection
197
+ if (nth.length && refusedFlag(r, '-nth')) { nth = []; continue; }
198
+ return { ok: false, error: 'Whisper: ' + r.error };
199
+ }
200
+ return { ok: false, error: 'Whisper refused its options' };
201
+ }
202
+ async function transcribeParakeet(run, d, wav, tmp) {
203
+ const out = path.join(tmp, 'parakeet');
204
+ fs.mkdirSync(out, { recursive: true });
205
+ const r = await run(d.parakeet, [wav, '--model', PARAKEET_MODEL, '--output-dir', out, '--output-format', 'json', '--max-duration', '20'], { env: { ...process.env, HF_HUB_OFFLINE: '1' } });
206
+ if (!r.ok) return { ok: false, error: 'Parakeet: ' + r.error };
207
+ const f = fs.readdirSync(out).find((x) => x.endsWith('.json'));
208
+ if (!f) return { ok: false, error: 'Parakeet wrote no transcript' };
209
+ try { return { ok: true, segments: parseParakeet(JSON.parse(fs.readFileSync(path.join(out, f), 'utf8'))), engine: 'parakeet', vad: false }; } catch { return { ok: false, error: 'Parakeet wrote a transcript that does not read' }; }
210
+ }
211
+ async function diarizeFile(run, d, wav) {
212
+ const r = await run(d.diarize.python, [d.diarize.script, '--segmentation', d.diarize.seg, '--embedding', d.diarize.emb, '--wav', wav, '--threshold', DIARIZE_THRESHOLD]);
213
+ let j = null;
214
+ try { j = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* below */ }
215
+ if (!j || !j.ok) return { ok: false, error: 'speaker separation: ' + ((j && j.error) || r.error || 'it gave no answer') };
216
+ return { ok: true, segments: j.segments || [] };
217
+ }
218
+
219
+ /**
220
+ * The queue of final passes.
221
+ * @param {object} a
222
+ * @param {string} a.runtimeDir
223
+ * @param {(id:string, final:object) => void|Promise<void>} a.onDone the merged transcript of a finished pass
224
+ * @param {(id:string, state:{state,step?,reason?}) => void} [a.onState]
225
+ * @param {Function} [a.run] runProgram, replaceable in the suites
226
+ * @param {Function} [a.detect] detect, replaceable in the suites
227
+ */
228
+ function createFinal({ runtimeDir, onDone, onState = () => {}, run = runProgram, detect: det = () => detect(runtimeDir) } = {}) {
229
+ let queue = Promise.resolve();
230
+ const gen = new Map(); // id → the generation of its latest job; a cancelled or newer job makes an older one moot
231
+ const states = new Map();
232
+ const set = (id, s) => { states.set(id, s); try { onState(id, s); } catch { /* the caller's */ } };
233
+
234
+ async function job(id, g, { prompt = '' }) {
235
+ const live = () => gen.get(id) === g;
236
+ if (!live()) return;
237
+ const d = det();
238
+ if (!d.ready) { set(id, { state: 'failed', reason: 'no engine is set up for the final pass' }); return; }
239
+ const srcs = notesAudio.sources(runtimeDir, id);
240
+ if (!srcs.length) { set(id, { state: 'failed', reason: 'no audio was kept for this note' }); return; }
241
+ const tmp = path.join(finalDir(runtimeDir), 'tmp-' + crypto.randomBytes(6).toString('hex'));
242
+ const began = Date.now();
243
+ try {
244
+ fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
245
+ const parts = []; let engine = d.transcriber; let vad = false; let diarized = false;
246
+ const splitMic = !srcs.some((s) => s.source === 'meeting');
247
+ for (const s of srcs) {
248
+ if (!live()) return;
249
+ const wav = path.join(tmp, s.source + '.wav');
250
+ writeWav(s.file, wav);
251
+ set(id, { state: 'running', step: 'transcribing ' + (s.source === 'mic' ? 'your microphone' : 'the meeting') + ' again' });
252
+ let r = d.transcriber === 'parakeet' ? await transcribeParakeet(run, d, wav, tmp) : await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt });
253
+ if (!r.ok && d.transcriber === 'parakeet' && d.whisper) r = await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt }); // Parakeet failed: Whisper still can
254
+ if (!r.ok) { set(id, { state: 'failed', reason: r.error }); return; }
255
+ engine = r.engine; vad = vad || r.vad;
256
+ let part = { source: s.source, segments: r.segments, speakers: 1 };
257
+ if (d.diarize && (s.source === 'meeting' || splitMic) && r.segments.length) {
258
+ if (!live()) return;
259
+ set(id, { state: 'running', step: 'telling the speakers apart' });
260
+ const dz = await diarizeFile(run, d, wav);
261
+ if (dz.ok) { const a = assignSpeakers(r.segments, dz.segments); part = { source: s.source, segments: a.segments, speakers: a.speakers }; diarized = true; }
262
+ }
263
+ parts.push(part);
264
+ try { fs.unlinkSync(wav); } catch { /* gone */ }
265
+ }
266
+ if (!live()) return;
267
+ const final = { at: new Date().toISOString(), engine, vad, diarized, took_ms: Date.now() - began, segments: mergeSources(parts) };
268
+ await onDone(id, final);
269
+ set(id, { state: 'done' });
270
+ } catch (e) {
271
+ set(id, { state: 'failed', reason: String((e && e.message) || e).slice(0, 300) });
272
+ } finally {
273
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* gone */ }
274
+ }
275
+ }
276
+ return {
277
+ /** Queue the final pass of a note; a pass already queued for it is replaced. */
278
+ queue(id, opts = {}) {
279
+ const g = (gen.get(id) || 0) + 1;
280
+ gen.set(id, g);
281
+ set(id, { state: 'queued' });
282
+ queue = queue.then(() => job(id, g, opts)).catch(() => {});
283
+ return { ok: true };
284
+ },
285
+ /** Listening started again, the note was deleted or made private: its pass is dropped. */
286
+ cancel(id) { gen.set(id, (gen.get(id) || 0) + 1); states.delete(id); },
287
+ state(id) { return states.get(id) || null; },
288
+ idle() { return queue; },
289
+ };
290
+ }
291
+
292
+ module.exports = { detect, setupCommand, createFinal, runProgram, parseWhisper, parseParakeet, assignSpeakers, dropEcho, mergeSources, finalDir,
293
+ PARAKEET_MODEL, VAD_FILE, DIARIZE_THRESHOLD };