create-pmos 0.114.0 → 0.115.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/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +11 -1
- package/workspace/lib/notes-audio.js +36 -10
- package/workspace/lib/notes-polish.js +76 -0
- package/workspace/lib/routes-notes.js +22 -7
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/view-notes.js +28 -6
- package/workspace/test-lists/meeting-notes.json +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T06:01:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T06:01:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T06:01:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -134,6 +134,16 @@ When you listened to both your microphone and the meeting, the workspace removes
|
|
|
134
134
|
|
|
135
135
|
The replay is your microphone and the meeting added together. When both are loud at the same moment, the workspace lowers the peaks softly, so the sound does not distort. A quiet voice is not changed. The transcript is made from each source on its own, so this does not change what is written.
|
|
136
136
|
|
|
137
|
+
### Clean up the replay sound
|
|
138
|
+
|
|
139
|
+
The replay can play a cleaned copy of the sound. The copy has less rumble and hiss, and an even loudness. It needs **ffmpeg** on your machine. To install it, run `brew install ffmpeg` in a terminal.
|
|
140
|
+
|
|
141
|
+
1. Open the settings. Select **Clean up the replay sound**. It is off until you select it. The setting is for this machine.
|
|
142
|
+
2. After **Stop**, the workspace makes the cleaned copy when the echo is out of the replay. It makes the copy of a new note by itself.
|
|
143
|
+
3. For a note you already have, open the note's settings and press **Clean up this note's sound**. The replay changes when the copy is ready.
|
|
144
|
+
|
|
145
|
+
The workspace makes the copy from the replay only. It does not change the transcript. If ffmpeg is not on your machine, the replay plays as it is, and the settings say that ffmpeg was not found. When you clear the setting, the replay plays the plain sound again, and the cleaned copy stays on your machine until you delete the note's audio.
|
|
146
|
+
|
|
137
147
|
To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
|
|
138
148
|
|
|
139
149
|
## Capture highlights during the meeting
|
|
@@ -195,7 +205,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
|
|
|
195
205
|
|
|
196
206
|
## What stays on your machine
|
|
197
207
|
|
|
198
|
-
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, until you delete the audio, the note, or mark the note private. The browser's own recognition is used only on the device, never in the cloud.
|
|
208
|
+
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, with a cleaned copy beside it when you use **Clean up the replay sound**, until you delete the audio, the note, or mark the note private. The cleaned copy is made by ffmpeg on this machine, and nothing is sent anywhere. The browser's own recognition is used only on the device, never in the cloud.
|
|
199
209
|
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
200
210
|
- Only Jev, with the switches on, and your agent session (the final notes, **Summary by agent**, and the Wispr Flow search) receive any text from a note. The Wispr Flow search receives the meeting's time and title only.
|
|
201
211
|
- The final pass runs Qwen3-ASR, Parakeet, Whisper and the speaker models on this machine, from temporary files that it deletes when it is done.
|
|
@@ -33,22 +33,32 @@ const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
|
33
33
|
const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
|
|
34
34
|
const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
|
|
35
35
|
const SOURCES = ['mic', 'meeting'];
|
|
36
|
+
const cleanFileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.clean.pcm');
|
|
36
37
|
const sourceFileOf = (runtimeDir, id, source) => path.join(dirOf(runtimeDir), id + '.' + source + '.pcm');
|
|
37
38
|
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
|
|
38
39
|
const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
|
|
39
40
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
function setKeep(runtimeDir, keep) {
|
|
44
|
-
if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
|
|
41
|
+
function readSettings(runtimeDir) { try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; } }
|
|
42
|
+
function writeSettings(runtimeDir, patch) {
|
|
45
43
|
const f = settingsFile(runtimeDir);
|
|
46
44
|
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
47
45
|
const tmp = f + '.' + process.pid + '.tmp';
|
|
48
|
-
fs.writeFileSync(tmp, JSON.stringify({
|
|
46
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...readSettings(runtimeDir), ...patch }), { mode: 0o600 });
|
|
49
47
|
fs.renameSync(tmp, f);
|
|
48
|
+
}
|
|
49
|
+
function getKeep(runtimeDir) { return readSettings(runtimeDir).keep !== false; }
|
|
50
|
+
function setKeep(runtimeDir, keep) {
|
|
51
|
+
if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
|
|
52
|
+
writeSettings(runtimeDir, { keep });
|
|
50
53
|
return { ok: true, keep };
|
|
51
54
|
}
|
|
55
|
+
/** "Clean up the replay sound" (notes-audio-quality Run 3): off until the PM turns it on, for this machine. */
|
|
56
|
+
function getClean(runtimeDir) { return readSettings(runtimeDir).clean === true; }
|
|
57
|
+
function setClean(runtimeDir, clean) {
|
|
58
|
+
if (typeof clean !== 'boolean') return { ok: false, error: 'say clean: true or clean: false' };
|
|
59
|
+
writeSettings(runtimeDir, { clean });
|
|
60
|
+
return { ok: true, clean };
|
|
61
|
+
}
|
|
52
62
|
|
|
53
63
|
// The mixed file passes every sample through this soft knee (notes-audio-quality R1, R2). Below KNEE the sample is unchanged, so one
|
|
54
64
|
// source alone is not made quieter. Above it the sample bends towards CEILING and never reaches full scale, so two loud sources
|
|
@@ -107,7 +117,7 @@ function remove(runtimeDir, id) {
|
|
|
107
117
|
if (!ID_RE.test(String(id || ''))) return false;
|
|
108
118
|
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
109
119
|
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
110
|
-
for (const extra of ['.echo.json', '.pcm.echo.tmp']) { try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); } catch { /* none */ } }
|
|
120
|
+
for (const extra of ['.echo.json', '.pcm.echo.tmp', '.clean.pcm', '.clean.pcm.tmp']) { try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); } catch { /* none */ } }
|
|
111
121
|
try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
|
|
112
122
|
}
|
|
113
123
|
/** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
|
|
@@ -121,6 +131,22 @@ function sources(runtimeDir, id) {
|
|
|
121
131
|
return out;
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/** The file the replay plays: the cleaned copy when the PM switched cleaning on and the copy is as long as the mix (a copy made
|
|
135
|
+
* before listening went on is shorter, so it is stale and the plain mix plays); otherwise the mix. { file, bytes, cleaned } | null. */
|
|
136
|
+
function replayOf(runtimeDir, id) {
|
|
137
|
+
const inf = info(runtimeDir, id);
|
|
138
|
+
if (!inf) return null;
|
|
139
|
+
if (getClean(runtimeDir)) {
|
|
140
|
+
try { const st = fs.statSync(cleanFileOf(runtimeDir, id)); if (st.size === inf.bytes) return { file: cleanFileOf(runtimeDir, id), bytes: st.size, cleaned: true }; } catch { /* none */ }
|
|
141
|
+
}
|
|
142
|
+
return { file: fileOf(runtimeDir, id), bytes: inf.bytes, cleaned: false };
|
|
143
|
+
}
|
|
144
|
+
/** True when a cleaned copy exists and is as long as the mix. */
|
|
145
|
+
function hasClean(runtimeDir, id) {
|
|
146
|
+
const inf = info(runtimeDir, id);
|
|
147
|
+
try { return !!inf && fs.statSync(cleanFileOf(runtimeDir, id)).size === inf.bytes; } catch { return false; }
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
function wavHeader(dataBytes) {
|
|
125
151
|
const h = Buffer.alloc(44);
|
|
126
152
|
h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
|
|
@@ -131,7 +157,7 @@ function wavHeader(dataBytes) {
|
|
|
131
157
|
}
|
|
132
158
|
/** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
|
|
133
159
|
function serve(req, res, runtimeDir, id) {
|
|
134
|
-
const inf =
|
|
160
|
+
const inf = replayOf(runtimeDir, id);
|
|
135
161
|
if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
|
|
136
162
|
const header = wavHeader(inf.bytes);
|
|
137
163
|
const total = header.length + inf.bytes;
|
|
@@ -149,7 +175,7 @@ function serve(req, res, runtimeDir, id) {
|
|
|
149
175
|
if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
|
|
150
176
|
const from = Math.max(0, start - header.length); const to = end - header.length;
|
|
151
177
|
if (to < from) { res.end(); return; }
|
|
152
|
-
fs.createReadStream(
|
|
178
|
+
fs.createReadStream(inf.file, { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
153
179
|
}
|
|
154
180
|
|
|
155
|
-
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, replayFile: fileOf, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
181
|
+
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: fileOf, cleanFile: cleanFileOf, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-polish.js — an optional cleaned copy of a note's replay (notes-audio-quality Run 3). Local only.
|
|
3
|
+
*
|
|
4
|
+
* detect({ env? }) → { bin } | null the ffmpeg the PM installed
|
|
5
|
+
* build(runtimeDir, id, { run?, bin?, force? }) → Promise<{ ok, ... }> make <id>.clean.pcm from the replay mix
|
|
6
|
+
* (`bin: null` means no ffmpeg; leave bin out to look for one)
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT DOES. When the PM turned "Clean up the replay sound" on and has ffmpeg, the server runs ffmpeg on the note's replay mix:
|
|
9
|
+
* a high-pass filter at 80 Hz (rumble and desk thumps), light noise reduction, and loudness normalization to -16 LUFS with
|
|
10
|
+
* a true peak of -1.5 dB. The result is a second file next to the mix. The replay plays it while the setting is on and the copy is
|
|
11
|
+
* as long as the mix; with the setting off, or with no copy, it plays the plain mix.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT NEVER DOES. It reads only the replay mix, never the two source files, so the transcription input is not touched. It runs
|
|
14
|
+
* a program the PM installed (as whisper-cli is run), downloads nothing, installs nothing, and opens no network module. With ffmpeg
|
|
15
|
+
* missing it does nothing and says so. The copy is deleted with the note's audio.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const notesAudio = require('./notes-audio');
|
|
20
|
+
const { runProgram } = require('./notes-final');
|
|
21
|
+
|
|
22
|
+
const RATE = notesAudio.RATE;
|
|
23
|
+
const FILTER = 'highpass=f=80,afftdn=nr=10:nf=-45,loudnorm=I=-16:TP=-1.5:LRA=11';
|
|
24
|
+
const TIMEOUT_MS = 20 * 60 * 1000;
|
|
25
|
+
const LENGTH_SLACK = 0.02; // the copy may differ from the mix by this share of its length, no more
|
|
26
|
+
|
|
27
|
+
const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
|
|
28
|
+
|
|
29
|
+
/** The ffmpeg on this machine: PMOS_NOTES_FFMPEG_BIN, else the PATH, else where Homebrew puts it. */
|
|
30
|
+
function detect({ env = process.env, fallbacks = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'] } = {}) {
|
|
31
|
+
const named = env.PMOS_NOTES_FFMPEG_BIN;
|
|
32
|
+
if (named) return isExec(named) ? { bin: named } : null;
|
|
33
|
+
const dirs = String(env.PATH || '').split(path.delimiter).concat(fallbacks);
|
|
34
|
+
for (const d of dirs) { if (!d) continue; const f = path.join(d, 'ffmpeg'); if (isExec(f)) return { bin: f }; }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const running = new Map();
|
|
39
|
+
/** Make the cleaned copy of a note's replay. Resolves with { ok: true, ... } or { ok: false, error } or { ok: true, skipped }. Never rejects. */
|
|
40
|
+
function build(runtimeDir, id, opts = {}) {
|
|
41
|
+
const { run = runProgram, force = false } = opts;
|
|
42
|
+
const key = runtimeDir + '|' + id;
|
|
43
|
+
if (running.has(key)) return running.get(key);
|
|
44
|
+
const p = (async () => {
|
|
45
|
+
if (!force && !notesAudio.getClean(runtimeDir)) return { ok: true, skipped: 'cleaning the replay sound is off' };
|
|
46
|
+
const found = 'bin' in opts ? (opts.bin ? { bin: opts.bin } : null) : detect();
|
|
47
|
+
if (!found) return { ok: true, skipped: 'ffmpeg was not found on this machine' };
|
|
48
|
+
const inf = notesAudio.info(runtimeDir, id);
|
|
49
|
+
if (!inf) return { ok: true, skipped: 'the note has no replay file' };
|
|
50
|
+
const input = notesAudio.replayFile(runtimeDir, id); const out = notesAudio.cleanFile(runtimeDir, id); const tmp = out + '.tmp';
|
|
51
|
+
const began = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
const r = await run(found.bin, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-f', 's16le', '-ar', String(RATE), '-ac', '1', '-i', input,
|
|
54
|
+
'-af', FILTER, '-f', 's16le', '-ar', String(RATE), '-ac', '1', tmp], { timeout: TIMEOUT_MS });
|
|
55
|
+
if (!r.ok) { try { fs.unlinkSync(tmp); } catch { /* none */ } return { ok: false, error: r.error || 'ffmpeg failed' }; }
|
|
56
|
+
const size = fs.statSync(tmp).size;
|
|
57
|
+
const now = notesAudio.info(runtimeDir, id);
|
|
58
|
+
if (!now || now.bytes !== inf.bytes) { fs.unlinkSync(tmp); return { ok: false, error: 'listening went on while the sound was cleaned up' }; }
|
|
59
|
+
if (Math.abs(size - inf.bytes) > LENGTH_SLACK * inf.bytes) { fs.unlinkSync(tmp); return { ok: false, error: 'the cleaned copy came out a different length' }; }
|
|
60
|
+
// the copy has the mix's length to the byte, so the replay can seek in it with the same times
|
|
61
|
+
if (size !== inf.bytes) {
|
|
62
|
+
const fd = fs.openSync(tmp, 'r+');
|
|
63
|
+
try { if (size > inf.bytes) fs.ftruncateSync(fd, inf.bytes); else fs.writeSync(fd, Buffer.alloc(inf.bytes - size), 0, inf.bytes - size, size); } finally { fs.closeSync(fd); }
|
|
64
|
+
}
|
|
65
|
+
fs.renameSync(tmp, out);
|
|
66
|
+
return { ok: true, bytes: inf.bytes, took_ms: Date.now() - began };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
try { fs.unlinkSync(tmp); } catch { /* none */ }
|
|
69
|
+
return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
|
|
70
|
+
}
|
|
71
|
+
})().finally(() => running.delete(key));
|
|
72
|
+
running.set(key, p);
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { detect, build, FILTER };
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
|
|
26
26
|
* GET /api/notes/recording?id=… → audio/wav, with byte ranges the note's kept audio (Run 3, D92)
|
|
27
27
|
* POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
|
|
28
|
+
* POST /api/notes/clean-audio { id } → { ok, started } makes the cleaned copy of the replay now (Run 3 of notes-audio-quality; needs ffmpeg)
|
|
28
29
|
* GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
|
|
29
30
|
* POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
|
|
30
31
|
* { language?, model?, accuracy?, keep_audio? } → { ok, whisper } the language, the model, the decoding
|
|
@@ -67,6 +68,7 @@ const whisper = require('./notes-whisper.js');
|
|
|
67
68
|
const notesRepo = require('./notes-repo.js');
|
|
68
69
|
const notesAudio = require('./notes-audio.js');
|
|
69
70
|
const notesEcho = require('./notes-echo.js');
|
|
71
|
+
const notesPolish = require('./notes-polish.js');
|
|
70
72
|
const notesFinal = require('./notes-final.js');
|
|
71
73
|
const notesQwen = require('./notes-qwen.js');
|
|
72
74
|
const notesLinks = require('./notes-links.js');
|
|
@@ -82,7 +84,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
82
84
|
const prefixes = ['/api/notes'];
|
|
83
85
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
84
86
|
'/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
85
|
-
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
|
|
87
|
+
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
|
|
86
88
|
|
|
87
89
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
88
90
|
const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
|
|
@@ -355,7 +357,7 @@ function engineInfo(ctx, w) {
|
|
|
355
357
|
const out = { ready: live.ready, live: live.ready ? live.engine : null, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
|
|
356
358
|
language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
|
|
357
359
|
model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })),
|
|
358
|
-
accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir),
|
|
360
|
+
accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir), clean_audio: notesAudio.getClean(ctx.runtimeDir), ffmpeg: !!notesPolish.detect(),
|
|
359
361
|
final: finalInfo(ctx), wispr: wisprOn(ctx) };
|
|
360
362
|
if (!w) return { whisper: out, jev: null };
|
|
361
363
|
const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
|
|
@@ -393,7 +395,9 @@ function finalFor(ctx) {
|
|
|
393
395
|
const dir = ctx.runtimeDir;
|
|
394
396
|
const r = notes.setFinal(dir, { id, final });
|
|
395
397
|
if (!notesAudio.getKeep(dir)) notesAudio.removeSources(dir, id);
|
|
396
|
-
else
|
|
398
|
+
else { // best effort, off the main thread: on any failure the replay keeps its plain mix. The echo comes out first, then the sound is cleaned up
|
|
399
|
+
(echoOff(ctx) ? Promise.resolve() : notesEcho.cleanReplayInWorker(dir, id)).then(() => notesPolish.build(dir, id)).catch(() => {});
|
|
400
|
+
}
|
|
397
401
|
if (!r.ok) return;
|
|
398
402
|
const cur = notes.read(dir, id);
|
|
399
403
|
const w = cur.ok && cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
@@ -544,7 +548,7 @@ async function handle(req, res, url, ctx) {
|
|
|
544
548
|
// the page is open: the agent that wrote the notes before Wispr Flow's arrived writes them again, merged
|
|
545
549
|
if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid);
|
|
546
550
|
if (r.ok) {
|
|
547
|
-
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid);
|
|
551
|
+
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); if (r.recording) r.recording = { ...r.recording, cleaned: notesAudio.hasClean(dir, nid) };
|
|
548
552
|
if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
|
|
549
553
|
r.bullets_state = bulletsState(ctx, r.note);
|
|
550
554
|
}
|
|
@@ -680,22 +684,33 @@ async function handle(req, res, url, ctx) {
|
|
|
680
684
|
if (!r.ok) return done(r);
|
|
681
685
|
return done({ ok: true, deleted: notesAudio.remove(dir, id) });
|
|
682
686
|
}
|
|
687
|
+
if (p === '/api/notes/clean-audio') {
|
|
688
|
+
// the PM asks for the cleaned copy of one note now; the work goes on in the background and the replay changes when it ends
|
|
689
|
+
const r = notes.read(dir, id);
|
|
690
|
+
if (!r.ok) return done(r);
|
|
691
|
+
if (!notesAudio.getClean(dir)) return done({ ok: false, status: 409, error: 'Clean up the replay sound is switched off in Settings' });
|
|
692
|
+
if (!notesPolish.detect()) return done({ ok: false, status: 409, error: 'ffmpeg was not found on this machine' });
|
|
693
|
+
if (!notesAudio.info(dir, id)) return done({ ok: false, status: 404, error: 'this note has no audio' });
|
|
694
|
+
notesPolish.build(dir, id);
|
|
695
|
+
return done({ ok: true, started: true });
|
|
696
|
+
}
|
|
683
697
|
if (p === '/api/notes/switch') {
|
|
684
698
|
const o = wsOf();
|
|
685
699
|
// the spoken language belongs to this machine, not to a repository
|
|
686
700
|
if (body.wispr !== undefined) {
|
|
687
701
|
if (typeof body.wispr !== 'boolean') { ctx.json(res, { ok: false, error: 'say wispr: true or wispr: false' }, 400); return true; }
|
|
688
702
|
setWisprOn(dir, body.wispr);
|
|
689
|
-
if (body.language === undefined && body.model === undefined && body.accuracy === undefined && body.keep_audio === undefined && body.jev === undefined && body.check === undefined) {
|
|
703
|
+
if (body.language === undefined && body.model === undefined && body.accuracy === undefined && body.keep_audio === undefined && body.clean_audio === undefined && body.jev === undefined && body.check === undefined) {
|
|
690
704
|
ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true;
|
|
691
705
|
}
|
|
692
706
|
}
|
|
693
|
-
if (body.language !== undefined || body.model !== undefined || body.accuracy !== undefined || body.keep_audio !== undefined) {
|
|
707
|
+
if (body.language !== undefined || body.model !== undefined || body.accuracy !== undefined || body.keep_audio !== undefined || body.clean_audio !== undefined) {
|
|
694
708
|
const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
|
|
695
709
|
const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
|
|
696
710
|
const a = r.ok && m.ok && body.accuracy !== undefined ? whisper.setAccuracy(dir, String(body.accuracy)) : { ok: true };
|
|
697
711
|
const k = r.ok && m.ok && a.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
|
|
698
|
-
|
|
712
|
+
const c = r.ok && m.ok && a.ok && k.ok && body.clean_audio !== undefined ? notesAudio.setClean(dir, body.clean_audio) : { ok: true };
|
|
713
|
+
if (!r.ok || !m.ok || !a.ok || !k.ok || !c.ok) { ctx.json(res, { ok: false, error: r.error || m.error || a.error || k.error || c.error }, 400); return true; }
|
|
699
714
|
if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
|
|
700
715
|
}
|
|
701
716
|
if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.115.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -214,7 +214,7 @@ async function loadList(ctx) {
|
|
|
214
214
|
if (!currentId && list.notes.length) currentId = list.notes[0].id;
|
|
215
215
|
ctx.setState({});
|
|
216
216
|
}
|
|
217
|
-
const sig = (n) => JSON.stringify([n.final_state, n.final && n.final.at, n.final && n.final.names, n.final_notes && n.final_notes.at, n.wispr && [n.wispr.state, n.wispr.tries, n.wispr.reason], n.transcript, n.bullets, n.bullets_state && n.bullets_state.agent, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
|
|
217
|
+
const sig = (n) => JSON.stringify([n.final_state, n.final && n.final.at, n.final && n.final.names, n.final_notes && n.final_notes.at, n.wispr && [n.wispr.state, n.wispr.tries, n.wispr.reason], n.transcript, n.bullets, n.bullets_state && n.bullets_state.agent, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && [n.__recording.bytes, n.__recording.cleaned], n.meeting, n.initiative]);
|
|
218
218
|
async function loadNote(ctx, id) {
|
|
219
219
|
const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
|
|
220
220
|
if (r && r.ok) {
|
|
@@ -516,10 +516,10 @@ function dropPlayer() { if (player) { try { player.el.pause(); } catch { /* gone
|
|
|
516
516
|
function playerFor(ctx, n) {
|
|
517
517
|
const rec = n.__recording;
|
|
518
518
|
if (!rec || n.private) { if (player && player.id === n.id) dropPlayer(); return null; }
|
|
519
|
-
if (player && (player.id !== n.id || (player.bytes !== rec.bytes && !player.playing))) dropPlayer();
|
|
519
|
+
if (player && (player.id !== n.id || ((player.bytes !== rec.bytes || player.cleaned !== !!rec.cleaned) && !player.playing))) dropPlayer();
|
|
520
520
|
if (player) return player;
|
|
521
|
-
const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes });
|
|
522
|
-
const p = { id: n.id, bytes: rec.bytes, el: a, playing: false };
|
|
521
|
+
const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes + (rec.cleaned ? '&c=1' : '') });
|
|
522
|
+
const p = { id: n.id, bytes: rec.bytes, cleaned: !!rec.cleaned, el: a, playing: false };
|
|
523
523
|
a.addEventListener('play', () => { p.playing = true; ctx.setState({}); });
|
|
524
524
|
a.addEventListener('pause', () => { p.playing = false; ctx.setState({}); });
|
|
525
525
|
a.addEventListener('ended', () => { p.playing = false; ctx.setState({}); });
|
|
@@ -561,6 +561,21 @@ async function deleteAudio(ctx, n) {
|
|
|
561
561
|
ctx.setState({});
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
+
/** Ask for the cleaned copy of one note's replay, then read the note until the copy is there. */
|
|
565
|
+
async function cleanNow(ctx, n) {
|
|
566
|
+
const r = await pmPost('/api/notes/clean-audio', { id: n.id });
|
|
567
|
+
if (!r || !r.ok) { banner = { tone: 'bad', text: (r && r.error) || 'the sound was not cleaned up' }; ctx.setState({}); return; }
|
|
568
|
+
banner = { tone: 'ok', text: 'Cleaning up the sound. The replay changes when this is done; it takes a minute or two for a long meeting.' };
|
|
569
|
+
ctx.setState({});
|
|
570
|
+
for (let i = 0; i < 60; i++) {
|
|
571
|
+
await new Promise((res) => setTimeout(res, 3000));
|
|
572
|
+
const o = await loadNote(ctx, n.id);
|
|
573
|
+
if (o && o.ok && o.recording && o.recording.cleaned) { banner = { tone: 'ok', text: 'The replay now plays the cleaned sound.' }; ctx.setState({}); return; }
|
|
574
|
+
}
|
|
575
|
+
banner = { tone: 'bad', text: 'The cleaned sound is not ready yet. Open Settings and try again later.' };
|
|
576
|
+
ctx.setState({});
|
|
577
|
+
}
|
|
578
|
+
|
|
564
579
|
/* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
|
|
565
580
|
async function openSource(kind) {
|
|
566
581
|
const md = navigator.mediaDevices;
|
|
@@ -892,7 +907,7 @@ async function chooseSources(ctx, n) {
|
|
|
892
907
|
}
|
|
893
908
|
async function setSwitch(ctx, patch) {
|
|
894
909
|
const ws = wsOf(ctx);
|
|
895
|
-
if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined) return;
|
|
910
|
+
if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined && patch.clean_audio === undefined) return;
|
|
896
911
|
const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
|
|
897
912
|
if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
|
|
898
913
|
ctx.setState({});
|
|
@@ -926,7 +941,13 @@ function openSettings(ctx, n) {
|
|
|
926
941
|
onCommit: (v) => setSwitch(ctx, { keep_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-keep-audio': '' } }) : null,
|
|
927
942
|
w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
|
|
928
943
|
? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
|
|
929
|
-
: 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null
|
|
944
|
+
: 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null,
|
|
945
|
+
w && w.keep_audio !== false ? field({ label: 'Clean up the replay sound', kind: 'check', value: w.clean_audio === true,
|
|
946
|
+
onCommit: (v) => setSwitch(ctx, { clean_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-clean-audio': '' } }) : null,
|
|
947
|
+
w && w.keep_audio !== false ? el('p', { class: 'nt-hint', 'data-nt-clean-hint': w.ffmpeg ? 'ready' : 'missing' }, w.ffmpeg
|
|
948
|
+
? (w.clean_audio === true ? 'After Stop, the replay gets a cleaned copy: less rumble and hiss, and an even loudness. The transcript is not changed.'
|
|
949
|
+
: 'Off. When on, the replay gets a cleaned copy after Stop: less rumble and hiss, and an even loudness.')
|
|
950
|
+
: 'This needs ffmpeg, which was not found on this machine. Install it with brew install ffmpeg, then open Settings again.') : null));
|
|
930
951
|
const fp = w && w.final;
|
|
931
952
|
if (fp) {
|
|
932
953
|
const have = [fp.transcriber === 'qwen' ? 'Qwen3-ASR' : fp.transcriber === 'parakeet' ? 'Parakeet' : fp.transcriber === 'whisper' ? 'Whisper with beam search' : null,
|
|
@@ -958,6 +979,7 @@ function openSettings(ctx, n) {
|
|
|
958
979
|
button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
|
|
959
980
|
button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
|
|
960
981
|
n.__recording || (n.final_state && n.final_state.state === 'failed') ? button({ label: 'Transcribe again', disabled: n.private || finalPending(n), onClick: () => { h.close(); runFinal(ctx, n); }, attrs: { 'data-nt-final-again': '' } }) : null,
|
|
982
|
+
n.__recording && w && w.clean_audio === true && w.ffmpeg && !n.__recording.cleaned ? button({ label: 'Clean up this note\'s sound', disabled: n.private || listening(n), onClick: () => { h.close(); cleanNow(ctx, n); }, attrs: { 'data-nt-clean-now': '' } }) : null,
|
|
961
983
|
n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
|
|
962
984
|
button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
|
|
963
985
|
return parts;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
|
|
2
|
+
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2) and the optional cleaned copy of the replay (notes-polish.test.js, Run 3), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
|
|
3
3
|
"modules": [
|
|
4
4
|
"notes.test.js",
|
|
5
5
|
"notes-listen.test.js",
|
|
6
6
|
"notes-final.test.js",
|
|
7
7
|
"notes-qwen.test.js",
|
|
8
|
-
"notes-echo.test.js"
|
|
8
|
+
"notes-echo.test.js",
|
|
9
|
+
"notes-polish.test.js"
|
|
9
10
|
],
|
|
10
11
|
"panels": [
|
|
11
12
|
"pm-notes-test.js",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
"notes-final.test.js": 6,
|
|
19
20
|
"notes-qwen.test.js": 4,
|
|
20
21
|
"pm-note-dock-test.js": 20,
|
|
21
|
-
"notes-echo.test.js": 25
|
|
22
|
+
"notes-echo.test.js": 25,
|
|
23
|
+
"notes-polish.test.js": 3
|
|
22
24
|
}
|
|
23
25
|
}
|