create-pmos 0.113.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.113.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@6d04787
1
+ kernel-source: pmos@9fd15f4
2
2
  assembled: 2026-10-08
3
3
  mode: vendor
@@ -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-08T05:19:33Z"}
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-08T05:19:33Z"}
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-08T05:19:33Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T06:01:13Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -28,7 +28,7 @@ The Notes view shows one meeting, top to bottom. A bar at the top shows whether
28
28
  4. Press **Stop** to end. The summary shows at the top of your notes. To carry on, press **Listen again**: the clock carries on from where it stopped, and the time between does not count. Line times and the kept audio use the same clock.
29
29
  5. If the workspace does not take a part of the audio, the page sends it again two times. If it still fails, a red message tells you how many seconds were not saved. Listening goes on. That part of the recording has a gap.
30
30
 
31
- To avoid an echo, listen to the meeting through headphones. When the meeting plays from a speaker, your microphone hears it a second time, and the replay has two copies of each voice.
31
+ To avoid an echo, listen to the meeting through headphones. When the meeting plays from a speaker, your microphone hears it a second time. After **Stop**, the workspace removes most of that echo from the replay (see **Replay the meeting**).
32
32
 
33
33
  The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
34
34
 
@@ -130,8 +130,20 @@ When a meeting was heard with local transcription, its audio is kept on this mac
130
130
  2. While it plays, the transcript line, its item and the line of your notes that you wrote at that moment are highlighted. Your notes cannot be edited while it plays: pause to edit them.
131
131
  3. Press a time in the transcript to play from there.
132
132
 
133
+ When you listened to both your microphone and the meeting, the workspace removes the meeting's echo from your microphone after you press **Stop**. A speaker plays the meeting, your microphone hears it a second time, and this second copy is the echo. The workspace uses the meeting's own recording as the guide. This takes a few minutes for each hour of meeting. It runs in the background, and the replay changes when it is done. Your own voice stays, because it is not in the meeting's recording. A part of the meeting where the clean-up would not help stays as it was recorded. The transcript does not change, because it is made from each source on its own. Headphones are still the best fix, because they stop the echo at its source.
134
+
133
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.
134
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
+
135
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.
136
148
 
137
149
  ## Capture highlights during the meeting
@@ -193,7 +205,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
193
205
 
194
206
  ## What stays on your machine
195
207
 
196
- - 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.
197
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.
198
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.
199
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 getKeep(runtimeDir) {
41
- try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s.keep !== false; } catch { return true; }
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({ keep }), { mode: 0o600 });
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,6 +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 */ } }
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 */ } }
110
121
  try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
111
122
  }
112
123
  /** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
@@ -120,6 +131,22 @@ function sources(runtimeDir, id) {
120
131
  return out;
121
132
  }
122
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
+
123
150
  function wavHeader(dataBytes) {
124
151
  const h = Buffer.alloc(44);
125
152
  h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
@@ -130,7 +157,7 @@ function wavHeader(dataBytes) {
130
157
  }
131
158
  /** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
132
159
  function serve(req, res, runtimeDir, id) {
133
- const inf = info(runtimeDir, id);
160
+ const inf = replayOf(runtimeDir, id);
134
161
  if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
135
162
  const header = wavHeader(inf.bytes);
136
163
  const total = header.length + inf.bytes;
@@ -148,7 +175,7 @@ function serve(req, res, runtimeDir, id) {
148
175
  if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
149
176
  const from = Math.max(0, start - header.length); const to = end - header.length;
150
177
  if (to < from) { res.end(); return; }
151
- fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
178
+ fs.createReadStream(inf.file, { start: from, end: to }).on('error', () => res.end()).pipe(res);
152
179
  }
153
180
 
154
- module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, 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,6 @@
1
+ 'use strict';
2
+ /* notes-echo-worker.js — runs notes-echo.cleanReplay on a worker thread (notes-audio-quality Run 2) and posts its result. */
3
+ const { parentPort, workerData } = require('worker_threads');
4
+ const echo = require('./notes-echo');
5
+
6
+ parentPort.postMessage(echo.cleanReplay(workerData.runtimeDir, workerData.id));
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+ /* notes-echo.js — take the meeting's echo out of the microphone in a note's replay (notes-audio-quality Run 2). Local only.
3
+ *
4
+ * cleanStream({ n, readFar, readMic, onChunk }) the canceller, over any two tracks of n samples → a report
5
+ * cleanReplay(runtimeDir, id) rebuild the note's replay file from its two source files → { ok, ... }
6
+ * cleanReplayInWorker(runtimeDir, id) the same, on a worker thread, so the server stays awake → Promise
7
+ * report(runtimeDir, id) what the last clean-up did, or null
8
+ *
9
+ * THE PROBLEM. The microphone hears the meeting through the speakers, so each voice is recorded twice, the second copy late.
10
+ * lib/notes-audio.js keeps the meeting on its own (<id>.meeting.pcm), which is exactly the signal that leaked into the microphone.
11
+ *
12
+ * THE METHOD. For a window of 30 s, find the echo path from the meeting to the microphone by least squares: the cross-correlation
13
+ * of the two tracks (summed over the whole window, so no frame edge biases it) gives a Toeplitz system, solved by conjugate
14
+ * gradient with FFTs alone. Your own voice is not correlated with the meeting, so it averages out of the fit and survives. The
15
+ * predicted echo is subtracted from the microphone. The path is 512 ms long, so a delay of up to about 300 ms and a room tail fit.
16
+ *
17
+ * THE TWO GUARDS (R7b). (1) A window whose parts disagree — some kept little of their energy, some a lot, because the path changed,
18
+ * for example the volume went down — is cut in two and each half is fitted again, down to about 7 s. (2) In every 10 s stretch,
19
+ * on the frames where the meeting speaks, the result must keep less than 70% of the microphone's energy (1.5 dB gained). If it
20
+ * does not, that stretch keeps the microphone as it was. The clean-up can lose a little echo; it cannot make a stretch louder.
21
+ *
22
+ * WHAT IT NEVER TOUCHES. The two source files (what the transcription reads) are only read. Only the mixed replay file is
23
+ * rewritten, through a temporary file and a rename, and only if the sources did not grow while it ran. It opens no network module.
24
+ */
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { Worker } = require('worker_threads');
28
+ const notesAudio = require('./notes-audio');
29
+
30
+ const FS = notesAudio.RATE; // 16000
31
+ const L = 8192; // the echo path kept: 512 ms
32
+ const FB = 32768; // FFT size for the correlations and the prediction
33
+ const BLK = FB - L;
34
+ const FRAME = 320; // 20 ms
35
+ const ACTIVE = 1e-5; // mean square (full scale = 1) above which the meeting is speaking: -50 dB
36
+ const WINDOW = 30 * FS;
37
+ const MIN_PIECE = 6 * FS;
38
+ const CHECK = 10 * FS; // the stretch R7b is held over
39
+ const PART = Math.round(2.5 * FS); // the part of a window looked at for a bad fit
40
+ const MARGIN = 2000; // samples of data either side of a window the fit may also use
41
+ const SPLIT_RATIO = 0.35; // a part that kept more than this share of its energy is a bad fit …
42
+ const SPLIT_SPREAD = 0.25; // … when another part of the window kept this much less: the parts disagree, so the path changed
43
+ const KEEP_RATIO = 0.7; // a stretch must keep less than this share (1.5 dB gained), or it is left as it was
44
+ const MIN_ACTIVE_FRAMES = 25; // half a second of meeting speech before a part is judged
45
+ const ITERATIONS = 150;
46
+ const LOADING = 1e-3;
47
+ const RAMP = 800; // samples over which subtraction fades in or out between stretches
48
+ const TIMEOUT_MS = 30 * 60 * 1000;
49
+
50
+ /* ── FFT ──────────────────────────────────────────────────────────────────────────────────────── */
51
+ const twiddles = new Map();
52
+ function table(n) {
53
+ let t = twiddles.get(n);
54
+ if (!t) {
55
+ t = { cos: new Float64Array(n / 2), sin: new Float64Array(n / 2) };
56
+ for (let i = 0; i < n / 2; i++) { t.cos[i] = Math.cos(2 * Math.PI * i / n); t.sin[i] = Math.sin(2 * Math.PI * i / n); }
57
+ twiddles.set(n, t);
58
+ }
59
+ return t;
60
+ }
61
+ /** In-place radix-2 FFT of (re, im). `inv` runs the inverse and divides by n. */
62
+ function fft(re, im, inv) {
63
+ const n = re.length; const t = table(n);
64
+ for (let i = 1, j = 0; i < n; i++) {
65
+ let bit = n >> 1;
66
+ for (; j & bit; bit >>= 1) j ^= bit;
67
+ j ^= bit;
68
+ if (i < j) { const a = re[i]; re[i] = re[j]; re[j] = a; const b = im[i]; im[i] = im[j]; im[j] = b; }
69
+ }
70
+ for (let len = 2; len <= n; len <<= 1) {
71
+ const step = n / len; const half = len >> 1;
72
+ for (let i = 0; i < n; i += len) {
73
+ for (let k = 0; k < half; k++) {
74
+ const wr = t.cos[k * step]; const wi = inv ? t.sin[k * step] : -t.sin[k * step];
75
+ const a = i + k; const b = a + half;
76
+ const tr = re[b] * wr - im[b] * wi; const ti = re[b] * wi + im[b] * wr;
77
+ re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
78
+ }
79
+ }
80
+ }
81
+ if (inv) for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
82
+ }
83
+
84
+ /* ── the fit ──────────────────────────────────────────────────────────────────────────────────── */
85
+ /** r[t] = sum x[u] x[u-t] and c[t] = sum m[u] x[u-t], for t < L and u in [from, to). Samples before 0 count as silence. */
86
+ function correlations(x, m, from, to) {
87
+ const r = new Float64Array(L); const c = new Float64Array(L);
88
+ const xb = new Float64Array(FB), xi = new Float64Array(FB), cur = new Float64Array(FB), ci = new Float64Array(FB), mc = new Float64Array(FB), mi = new Float64Array(FB);
89
+ const ar = new Float64Array(FB), ai = new Float64Array(FB), br = new Float64Array(FB), bi = new Float64Array(FB);
90
+ for (let s = from; s < to; s += BLK) {
91
+ const len = Math.min(BLK, to - s);
92
+ xb.fill(0); xi.fill(0); cur.fill(0); ci.fill(0); mc.fill(0); mi.fill(0);
93
+ let e = 0;
94
+ for (let j = 0; j < L + len; j++) { const t = s - L + j; if (t >= 0 && t < x.length) xb[j] = x[t]; }
95
+ for (let j = 0; j < len; j++) { cur[L + j] = x[s + j]; mc[L + j] = m[s + j]; e += x[s + j] * x[s + j]; }
96
+ if (e / len < 1e-8) continue; // the meeting is silent here: nothing to learn
97
+ fft(xb, xi, false); fft(cur, ci, false); fft(mc, mi, false);
98
+ for (let k = 0; k < FB; k++) { // CUR * conj(XB) and MC * conj(XB)
99
+ ar[k] = cur[k] * xb[k] + ci[k] * xi[k]; ai[k] = ci[k] * xb[k] - cur[k] * xi[k];
100
+ br[k] = mc[k] * xb[k] + mi[k] * xi[k]; bi[k] = mi[k] * xb[k] - mc[k] * xi[k];
101
+ }
102
+ fft(ar, ai, true); fft(br, bi, true);
103
+ for (let t = 0; t < L; t++) { r[t] += ar[t]; c[t] += br[t]; }
104
+ }
105
+ return { r, c };
106
+ }
107
+ /** R v for the symmetric Toeplitz matrix R whose first column is held, as the FFT of its circulant embedding. */
108
+ function toeplitzMul(col, v) {
109
+ const n = 2 * L; const re = new Float64Array(n); const im = new Float64Array(n);
110
+ re.set(v); fft(re, im, false);
111
+ for (let k = 0; k < n; k++) { const a = re[k] * col.re[k] - im[k] * col.im[k]; im[k] = re[k] * col.im[k] + im[k] * col.re[k]; re[k] = a; }
112
+ fft(re, im, true);
113
+ return re.subarray(0, L);
114
+ }
115
+ /** The impulse response from the meeting to the microphone over [from, to), or null when the meeting said nothing. */
116
+ function estimatePath(x, m, from, to) {
117
+ const { r, c } = correlations(x, m, from, to);
118
+ if (!(r[0] > 0)) return null;
119
+ const n = 2 * L; const re = new Float64Array(n); const im = new Float64Array(n);
120
+ for (let t = 0; t < L; t++) re[t] = r[t];
121
+ for (let t = 1; t < L; t++) re[n - t] = r[t];
122
+ fft(re, im, false);
123
+ const col = { re, im };
124
+ const lam = LOADING * r[0];
125
+ const h = new Float64Array(L); const res = Float64Array.from(c); const p = Float64Array.from(res);
126
+ let rs = 0; for (let i = 0; i < L; i++) rs += res[i] * res[i];
127
+ const rs0 = rs;
128
+ for (let it = 0; it < ITERATIONS && rs > 1e-10 * rs0; it++) {
129
+ const Ap = toeplitzMul(col, p); let pAp = 0;
130
+ for (let i = 0; i < L; i++) { Ap[i] += lam * p[i]; pAp += p[i] * Ap[i]; }
131
+ if (!(pAp > 0)) break;
132
+ const alpha = rs / pAp; let rn = 0;
133
+ for (let i = 0; i < L; i++) { h[i] += alpha * p[i]; res[i] -= alpha * Ap[i]; rn += res[i] * res[i]; }
134
+ const beta = rn / rs; rs = rn;
135
+ for (let i = 0; i < L; i++) p[i] = res[i] + beta * p[i];
136
+ }
137
+ return { h };
138
+ }
139
+ /** The first tap that reaches a quarter of the biggest: when the meeting's sound first arrives at the microphone. */
140
+ function arrival(h) {
141
+ let mx = 0; for (let i = 0; i < h.length; i++) mx = Math.max(mx, Math.abs(h[i]));
142
+ for (let i = 0; i < h.length; i++) if (Math.abs(h[i]) >= 0.25 * mx) return i;
143
+ return 0;
144
+ }
145
+ /** The echo that h predicts at samples [from, to): conv(x, h) by overlap-save. */
146
+ function predict(x, h, from, to) {
147
+ const out = new Float64Array(to - from);
148
+ const Hr = new Float64Array(FB); const Hi = new Float64Array(FB);
149
+ Hr.set(h); fft(Hr, Hi, false);
150
+ const re = new Float64Array(FB); const im = new Float64Array(FB);
151
+ for (let s = from; s < to; s += BLK) {
152
+ const len = Math.min(BLK, to - s);
153
+ re.fill(0); im.fill(0);
154
+ for (let j = 0; j < L + len; j++) { const t = s - L + j; if (t >= 0 && t < x.length) re[j] = x[t]; }
155
+ fft(re, im, false);
156
+ for (let k = 0; k < FB; k++) { const a = re[k] * Hr[k] - im[k] * Hi[k]; im[k] = re[k] * Hi[k] + im[k] * Hr[k]; re[k] = a; }
157
+ fft(re, im, true);
158
+ for (let j = 0; j < len; j++) out[s - from + j] = re[L + j];
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /* ── where the meeting speaks, and what a stretch kept ───────────────────────────────────────── */
164
+ /** Energy of mic and of out over the 20 ms frames of [a, b) in which the meeting speaks: { eIn, eOut, frames }. */
165
+ function kept(far, mic, out, a, b, base) {
166
+ let eIn = 0; let eOut = 0; let frames = 0;
167
+ for (let f = a; f + FRAME <= b; f += FRAME) {
168
+ let ex = 0;
169
+ for (let i = 0; i < FRAME; i++) ex += far[f + i] * far[f + i];
170
+ if (ex / FRAME <= ACTIVE) continue;
171
+ frames++;
172
+ for (let i = 0; i < FRAME; i++) { eIn += mic[f + i] * mic[f + i]; eOut += out[f - base + i] * out[f - base + i]; }
173
+ }
174
+ return { eIn, eOut, frames };
175
+ }
176
+ const speaks = (far, a, b) => { let n = 0; for (let f = a; f + FRAME <= b; f += FRAME) { let ex = 0; for (let i = 0; i < FRAME; i++) ex += far[f + i] * far[f + i]; if (ex / FRAME > ACTIVE) n++; } return n; };
177
+
178
+ /** Smooth a 0/1 gain so subtraction fades over RAMP samples instead of switching. */
179
+ function smooth(gain) {
180
+ const n = gain.length; const out = new Float64Array(n); const w = 2 * RAMP + 1;
181
+ let acc = 0;
182
+ for (let i = -RAMP; i <= RAMP; i++) acc += gain[Math.min(n - 1, Math.max(0, i))];
183
+ for (let i = 0; i < n; i++) {
184
+ out[i] = acc / w;
185
+ acc += gain[Math.min(n - 1, i + RAMP + 1)] - gain[Math.max(0, i - RAMP)];
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** The microphone over [from, to) with the echo of `far` taken out. far and mic are Float64 arrays of one span, full scale = 1.
191
+ * state collects { leaves: [{ delay }], skipped, eIn, eOut }. */
192
+ function cleanSpan(far, mic, from, to, state) {
193
+ const len = to - from;
194
+ const plain = () => Float64Array.from(mic.subarray(from, to));
195
+ if (speaks(far, from, to) < MIN_ACTIVE_FRAMES) return plain();
196
+ const p = estimatePath(far, mic, Math.max(0, from - MARGIN), Math.min(far.length, to + MARGIN));
197
+ if (!p) return plain();
198
+ const echo = predict(far, p.h, from, to);
199
+ const trial = new Float64Array(len);
200
+ for (let i = 0; i < len; i++) trial[i] = mic[from + i] - echo[i];
201
+ // guard 1: parts of one window that disagree — some kept little of their energy, some kept a lot — mean the path changed
202
+ // inside it: cut the window in two. Parts that all keep about the same (no echo at all, or one steady poor fit) do not.
203
+ if (len >= 2 * MIN_PIECE) {
204
+ let lo = Infinity; let hi = -Infinity;
205
+ for (let q = from; q < to; q += PART) {
206
+ const k = kept(far, mic, trial, q, Math.min(to, q + PART), from);
207
+ if (k.frames < MIN_ACTIVE_FRAMES || !(k.eIn > 0)) continue;
208
+ const r = k.eOut / k.eIn; lo = Math.min(lo, r); hi = Math.max(hi, r);
209
+ }
210
+ if (hi > SPLIT_RATIO && hi - lo > SPLIT_SPREAD) {
211
+ const mid = from + Math.floor(len / 2 / FRAME) * FRAME;
212
+ const left = cleanSpan(far, mic, from, mid, state); const right = cleanSpan(far, mic, mid, to, state);
213
+ const joined = new Float64Array(len); joined.set(left, 0); joined.set(right, mid - from);
214
+ return joined;
215
+ }
216
+ }
217
+ // guard 2 (R7b): in every 10 s stretch the result keeps less energy than the microphone, or the stretch is left as it was
218
+ const gain = new Float64Array(len).fill(1);
219
+ const chunks = [];
220
+ for (let q = from; q < to; q += CHECK) chunks.push([q, Math.min(to, q + CHECK)]);
221
+ let off = false;
222
+ for (const [a, b] of chunks) {
223
+ const k = kept(far, mic, trial, a, b, from);
224
+ if (k.frames && k.eOut > KEEP_RATIO * k.eIn) { gain.fill(0, a - from, b - from); off = true; state.skipped++; }
225
+ }
226
+ const g = off ? smooth(gain) : gain;
227
+ const out = new Float64Array(len);
228
+ for (let i = 0; i < len; i++) out[i] = mic[from + i] - g[i] * echo[i];
229
+ if (off) { // the ramps can let a little echo back in at an edge: a stretch that still got louder is cut hard
230
+ for (const [a, b] of chunks) {
231
+ const k = kept(far, mic, out, a, b, from);
232
+ if (k.frames && k.eOut > k.eIn) for (let i = a - from; i < b - from; i++) out[i] = mic[from + i];
233
+ }
234
+ }
235
+ for (const [a, b] of chunks) { const k = kept(far, mic, out, a, b, from); state.eIn += k.eIn; state.eOut += k.eOut; }
236
+ state.leaves.push({ delay: arrival(p.h) });
237
+ return out;
238
+ }
239
+
240
+ /** The canceller over two tracks of n samples. readFar(from, count) and readMic(from, count) give Float64Array of that many
241
+ * samples (full scale = 1; zeros past the end); onChunk(at, Float64Array) receives each cleaned stretch in order. Returns
242
+ * { windows, skipped, reduction_db, delay_ms }. */
243
+ function cleanStream({ n, readFar, readMic, onChunk }) {
244
+ const state = { leaves: [], skipped: 0, eIn: 0, eOut: 0 };
245
+ let windows = 0;
246
+ for (let a = 0; a < n; a += WINDOW) {
247
+ const b = Math.min(n, a + WINDOW);
248
+ const c0 = Math.max(0, Math.floor((a - (L + MARGIN)) / FRAME) * FRAME); const c1 = Math.min(n, b + MARGIN);
249
+ const far = readFar(c0, c1 - c0); const mic = readMic(c0, c1 - c0);
250
+ onChunk(a, cleanSpan(far, mic, a - c0, b - c0, state), b - a);
251
+ windows++;
252
+ }
253
+ const delays = state.leaves.map((l) => l.delay).sort((x, y) => x - y);
254
+ return {
255
+ windows, fits: state.leaves.length, skipped: state.skipped,
256
+ reduction_db: state.eOut > 0 && state.eIn > 0 ? Math.round(10 * Math.log10(state.eIn / state.eOut) * 10) / 10 : 0,
257
+ delay_ms: delays.length ? Math.round(delays[delays.length >> 1] * 1000 / FS) : null,
258
+ };
259
+ }
260
+
261
+ /* ── the replay file ──────────────────────────────────────────────────────────────────────────── */
262
+ const reportFile = (runtimeDir, id) => notesAudio.replayFile(runtimeDir, id).replace(/\.pcm$/, '.echo.json');
263
+ function report(runtimeDir, id) { try { return JSON.parse(fs.readFileSync(reportFile(runtimeDir, id), 'utf8')); } catch { return null; } }
264
+
265
+ function readSamples(fd, size, from, count) {
266
+ const out = new Float64Array(count);
267
+ if (fd === null) return out;
268
+ const bytes = Math.max(0, Math.min(count * 2, size - from * 2));
269
+ if (bytes <= 0) return out;
270
+ const buf = Buffer.alloc(bytes);
271
+ fs.readSync(fd, buf, 0, bytes, from * 2);
272
+ for (let i = 0; i < bytes / 2; i++) out[i] = buf.readInt16LE(i * 2) / 32768;
273
+ return out;
274
+ }
275
+
276
+ /** Rebuild the replay file of a note from its two source files, with the echo taken out of the microphone. Only when the note
277
+ * kept its audio and heard both sources. The replay is replaced by a rename, and only if the sources did not grow meanwhile.
278
+ * `beforeCommit` is called just before that check; it is for the suites. */
279
+ function cleanReplay(runtimeDir, id, { beforeCommit } = {}) {
280
+ const began = Date.now();
281
+ if (!notesAudio.getKeep(runtimeDir)) return { ok: true, skipped: 'the audio is not kept for replay' };
282
+ const srcs = notesAudio.sources(runtimeDir, id);
283
+ const mic = srcs.find((s) => s.source === 'mic'); const meet = srcs.find((s) => s.source === 'meeting');
284
+ if (!mic || !meet) return { ok: true, skipped: 'only one source was heard, so there is no echo to take out' };
285
+ if (!notesAudio.info(runtimeDir, id)) return { ok: true, skipped: 'the note has no replay file' };
286
+ const out = notesAudio.replayFile(runtimeDir, id); const tmp = out + '.echo.tmp';
287
+ let fdM = null; let fdX = null; let fdO = null;
288
+ try {
289
+ fdM = fs.openSync(mic.file, 'r'); fdX = fs.openSync(meet.file, 'r'); fdO = fs.openSync(tmp, 'w', 0o600);
290
+ const n = Math.floor(Math.max(mic.bytes, meet.bytes) / 2);
291
+ const rep = cleanStream({
292
+ n,
293
+ readFar: (from, count) => readSamples(fdX, meet.bytes, from, count),
294
+ readMic: (from, count) => readSamples(fdM, mic.bytes, from, count),
295
+ onChunk: (at, cleaned, count) => {
296
+ const m = readSamples(fdX, meet.bytes, at, count);
297
+ const buf = Buffer.alloc(count * 2);
298
+ for (let i = 0; i < count; i++) {
299
+ const a = Math.max(-32768, Math.min(32767, Math.round(cleaned[i] * 32768)));
300
+ buf.writeInt16LE(notesAudio.softLimit(a + Math.round(m[i] * 32768)), i * 2);
301
+ }
302
+ fs.writeSync(fdO, buf, 0, buf.length, at * 2);
303
+ },
304
+ });
305
+ fs.closeSync(fdO); fdO = null;
306
+ if (beforeCommit) beforeCommit(); // the suites use this to make listening go on at the last moment
307
+ // the sources must be as they were: if listening went on meanwhile, the plain replay is the truth and this one is dropped
308
+ const now = notesAudio.sources(runtimeDir, id);
309
+ const same = now.length === srcs.length && now.every((s) => { const o = srcs.find((x) => x.source === s.source); return o && o.bytes === s.bytes; });
310
+ if (!same) { try { fs.unlinkSync(tmp); } catch { /* gone */ } return { ok: false, error: 'listening went on while the echo was taken out' }; }
311
+ fs.renameSync(tmp, out);
312
+ const result = { ok: true, at: new Date().toISOString(), ...rep, took_ms: Date.now() - began };
313
+ fs.writeFileSync(reportFile(runtimeDir, id), JSON.stringify(result), { mode: 0o600 });
314
+ return result;
315
+ } catch (e) {
316
+ try { fs.unlinkSync(tmp); } catch { /* none */ }
317
+ return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
318
+ } finally {
319
+ for (const fd of [fdM, fdX, fdO]) if (fd !== null) { try { fs.closeSync(fd); } catch { /* closed */ } }
320
+ }
321
+ }
322
+
323
+ const running = new Map(); // `${dir}|${id}` → the promise of a clean-up that is under way
324
+ /** cleanReplay on a worker thread. A second call for the same note while one runs gets the same promise. Never rejects. */
325
+ function cleanReplayInWorker(runtimeDir, id) {
326
+ const key = runtimeDir + '|' + id;
327
+ if (running.has(key)) return running.get(key);
328
+ const p = new Promise((resolve) => {
329
+ let done = false;
330
+ const w = new Worker(path.join(__dirname, 'notes-echo-worker.js'), { workerData: { runtimeDir, id } });
331
+ const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
332
+ const timer = setTimeout(() => { w.terminate(); try { fs.unlinkSync(notesAudio.replayFile(runtimeDir, id) + '.echo.tmp'); } catch { /* none */ } finish({ ok: false, error: 'it took too long' }); }, TIMEOUT_MS);
333
+ w.once('message', finish);
334
+ w.once('error', (e) => finish({ ok: false, error: String((e && e.message) || e).slice(0, 300) }));
335
+ w.once('exit', () => finish({ ok: false, error: 'the worker ended without a result' }));
336
+ }).finally(() => running.delete(key));
337
+ running.set(key, p);
338
+ return p;
339
+ }
340
+
341
+ module.exports = { cleanStream, cleanReplay, cleanReplayInWorker, report, reportFile, fft, WINDOW, FS };
@@ -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
@@ -66,6 +67,8 @@ const notes = require('./notes.js');
66
67
  const whisper = require('./notes-whisper.js');
67
68
  const notesRepo = require('./notes-repo.js');
68
69
  const notesAudio = require('./notes-audio.js');
70
+ const notesEcho = require('./notes-echo.js');
71
+ const notesPolish = require('./notes-polish.js');
69
72
  const notesFinal = require('./notes-final.js');
70
73
  const notesQwen = require('./notes-qwen.js');
71
74
  const notesLinks = require('./notes-links.js');
@@ -81,7 +84,7 @@ const VOCAB_TTL_MS = 60 * 1000;
81
84
  const prefixes = ['/api/notes'];
82
85
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
83
86
  '/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
84
- '/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']);
85
88
 
86
89
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
87
90
  const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
@@ -354,7 +357,7 @@ function engineInfo(ctx, w) {
354
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,
355
358
  language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
356
359
  model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })),
357
- 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(),
358
361
  final: finalInfo(ctx), wispr: wisprOn(ctx) };
359
362
  if (!w) return { whisper: out, jev: null };
360
363
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
@@ -376,6 +379,7 @@ function engineInfo(ctx, w) {
376
379
  * while it is not ready, it is asked again every WISPR_RETRY_MS, up to WISPR_DEADLINE_MS after Stop. The workspace itself
377
380
  * makes no request: the agent session does, with the connector the PM signed in to. On by default ("Fetch Wispr Flow
378
381
  * notes" in the settings); PMOS_NOTES_WISPR (or ctx.notesWispr) 'off' turns it off on this machine. Never for a private note. */
382
+ const echoOff = (ctx) => String(ctx.notesEcho !== undefined ? ctx.notesEcho : process.env.PMOS_NOTES_ECHO || '') === 'off';
379
383
  const finalOff = (ctx) => String(ctx.notesFinal !== undefined ? ctx.notesFinal : process.env.PMOS_NOTES_FINAL || '') === 'off';
380
384
  let finalRunner = null; let finalDir = null;
381
385
  function finalFor(ctx) {
@@ -391,6 +395,9 @@ function finalFor(ctx) {
391
395
  const dir = ctx.runtimeDir;
392
396
  const r = notes.setFinal(dir, { id, final });
393
397
  if (!notesAudio.getKeep(dir)) notesAudio.removeSources(dir, id);
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
+ }
394
401
  if (!r.ok) return;
395
402
  const cur = notes.read(dir, id);
396
403
  const w = cur.ok && cur.note.ws ? ctx.getWs(cur.note.ws) : null;
@@ -541,7 +548,7 @@ async function handle(req, res, url, ctx) {
541
548
  // the page is open: the agent that wrote the notes before Wispr Flow's arrived writes them again, merged
542
549
  if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid);
543
550
  if (r.ok) {
544
- 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) };
545
552
  if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
546
553
  r.bullets_state = bulletsState(ctx, r.note);
547
554
  }
@@ -677,22 +684,33 @@ async function handle(req, res, url, ctx) {
677
684
  if (!r.ok) return done(r);
678
685
  return done({ ok: true, deleted: notesAudio.remove(dir, id) });
679
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
+ }
680
697
  if (p === '/api/notes/switch') {
681
698
  const o = wsOf();
682
699
  // the spoken language belongs to this machine, not to a repository
683
700
  if (body.wispr !== undefined) {
684
701
  if (typeof body.wispr !== 'boolean') { ctx.json(res, { ok: false, error: 'say wispr: true or wispr: false' }, 400); return true; }
685
702
  setWisprOn(dir, body.wispr);
686
- 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) {
687
704
  ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true;
688
705
  }
689
706
  }
690
- 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) {
691
708
  const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
692
709
  const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
693
710
  const a = r.ok && m.ok && body.accuracy !== undefined ? whisper.setAccuracy(dir, String(body.accuracy)) : { ok: true };
694
711
  const k = r.ok && m.ok && a.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
695
- if (!r.ok || !m.ok || !a.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || a.error || k.error }, 400); return true; }
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; }
696
714
  if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
697
715
  }
698
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.113.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.113.0",
9
+ "version": "0.115.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.113.0",
3
+ "version": "0.115.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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,10 +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), 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
- "notes-qwen.test.js"
7
+ "notes-qwen.test.js",
8
+ "notes-echo.test.js",
9
+ "notes-polish.test.js"
8
10
  ],
9
11
  "panels": [
10
12
  "pm-notes-test.js",
@@ -16,6 +18,8 @@
16
18
  "notes-listen.test.js": 10,
17
19
  "notes-final.test.js": 6,
18
20
  "notes-qwen.test.js": 4,
19
- "pm-note-dock-test.js": 20
21
+ "pm-note-dock-test.js": 20,
22
+ "notes-echo.test.js": 25,
23
+ "notes-polish.test.js": 3
20
24
  }
21
25
  }