create-pmos 0.109.0 → 0.111.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 +6 -1
- package/workspace/lib/notes-audio.js +17 -4
- package/workspace/lib/routes-notes.js +28 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/audio-capture.js +88 -0
- package/workspace/public/pm/notes-capture-worklet.js +15 -0
- package/workspace/public/pm/view-notes.js +79 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.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-08T04:27:20Z"}
|
|
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-08T04:27:20Z"}
|
|
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-08T04:27:20Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -26,6 +26,9 @@ The Notes view shows one meeting, top to bottom. A bar at the top shows whether
|
|
|
26
26
|
2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
|
|
27
27
|
3. Each line shows in the transcript a few seconds after it is said, with who said it, the time and its label. Your lines are **You**. The meeting's lines are **Meeting**: click the name to change it. The transcript cannot tell the people in the meeting apart.
|
|
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
|
+
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
|
+
|
|
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.
|
|
29
32
|
|
|
30
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.
|
|
31
34
|
|
|
@@ -123,6 +126,8 @@ When a meeting was heard with local transcription, its audio is kept on this mac
|
|
|
123
126
|
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.
|
|
124
127
|
3. Press a time in the transcript to play from there.
|
|
125
128
|
|
|
129
|
+
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.
|
|
130
|
+
|
|
126
131
|
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.
|
|
127
132
|
|
|
128
133
|
## Capture highlights during the meeting
|
|
@@ -163,7 +168,7 @@ The task appears in your My Work view and on the initiative's board when the not
|
|
|
163
168
|
Under the title, a note shows two lines: **Meeting** and **Initiative**.
|
|
164
169
|
|
|
165
170
|
- **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event of the note's day or the day before, or **Not on the calendar**.
|
|
166
|
-
- **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to
|
|
171
|
+
- **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to open the list. The list shows the suggestion first, then the initiatives that are active now, then **None**. Initiatives planned for Next and Later are in the menu under them, each marked with its horizon. When more than one repository is open, a **Repository** menu at the top of the list changes which repository's initiatives you see. If the repository is still being read, the list says so and fills in by itself. Press the initiative's name to open it.
|
|
167
172
|
|
|
168
173
|
When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
|
|
169
174
|
|
|
@@ -50,8 +50,21 @@ function setKeep(runtimeDir, keep) {
|
|
|
50
50
|
return { ok: true, keep };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// The mixed file passes every sample through this soft knee (notes-audio-quality R1, R2). Below KNEE the sample is unchanged, so one
|
|
54
|
+
// source alone is not made quieter. Above it the sample bends towards CEILING and never reaches full scale, so two loud sources
|
|
55
|
+
// are limited and not hard-clipped. It has no memory, so the order in which the two sources arrive does not matter.
|
|
56
|
+
const KNEE = 0.7 * 32768;
|
|
57
|
+
const CEILING = 0.98 * 32768;
|
|
58
|
+
function softLimit(x) {
|
|
59
|
+
const a = Math.abs(x);
|
|
60
|
+
if (a <= KNEE) return x;
|
|
61
|
+
const y = KNEE + (CEILING - KNEE) * Math.tanh((a - KNEE) / (CEILING - KNEE));
|
|
62
|
+
return Math.round(x < 0 ? -y : y);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, then limited by softLimit. With
|
|
66
|
+
* `mixed: false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done.
|
|
67
|
+
* The per-source files are never limited: they are what the transcription reads. */
|
|
55
68
|
function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
|
|
56
69
|
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
57
70
|
const f = fileOf(runtimeDir, id);
|
|
@@ -69,7 +82,7 @@ function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
|
|
|
69
82
|
const old = Buffer.alloc(len);
|
|
70
83
|
fs.readSync(fd, old, 0, len, at);
|
|
71
84
|
const out = Buffer.alloc(len);
|
|
72
|
-
for (let i = 0; i < len; i += 2) out.writeInt16LE(
|
|
85
|
+
for (let i = 0; i < len; i += 2) out.writeInt16LE(softLimit(old.readInt16LE(i) + pcm.readInt16LE(i)), i);
|
|
73
86
|
fs.writeSync(fd, out, 0, len, at);
|
|
74
87
|
} finally { fs.closeSync(fd); }
|
|
75
88
|
}
|
|
@@ -138,4 +151,4 @@ function serve(req, res, runtimeDir, id) {
|
|
|
138
151
|
fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
139
152
|
}
|
|
140
153
|
|
|
141
|
-
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
154
|
+
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -269,6 +269,32 @@ function glossaryOf(root) {
|
|
|
269
269
|
glossaryCache.set(root, { at: Date.now(), terms: out });
|
|
270
270
|
return out;
|
|
271
271
|
}
|
|
272
|
+
/** The open initiatives of one repository, the active ones first. The Initiatives view reads the repository's documents
|
|
273
|
+
* (lib/initiative-rows.js) and places each one Now, Next or Later; a repository kept as documents has no planning/initiatives
|
|
274
|
+
* records, so those alone left the picker empty. Placed rows come first in horizon order; records the view does not place follow
|
|
275
|
+
* as Now. `pending` is true while the first read of the documents is still running. */
|
|
276
|
+
function openInitiativesOf(w) {
|
|
277
|
+
const legacy = notesLinks.initiativesOf(w.root);
|
|
278
|
+
let placed = []; let pending = false;
|
|
279
|
+
try {
|
|
280
|
+
const rowsLib = require('./initiative-rows.js');
|
|
281
|
+
const ri = require('./routes-initiatives.js');
|
|
282
|
+
const got = rowsLib.rowsFor(w.root);
|
|
283
|
+
if (got.pending) pending = true;
|
|
284
|
+
else if (got.ok) {
|
|
285
|
+
const rm = ri.roadmapOf(w, ri.frameOf(w, got.rows, { now: Date.now() }));
|
|
286
|
+
const byId = new Map(got.rows.map((r) => [r.id, r]));
|
|
287
|
+
for (const h of ['now', 'next', 'later']) {
|
|
288
|
+
for (const r of rm.horizons[h]) { const rec = byId.get(r.id); if (!(rec && rec.stopped)) placed.push({ id: r.id, title: r.title, anchor: r.anchor, intent: '', horizon: h }); }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch { placed = []; }
|
|
292
|
+
const seen = new Set(placed.map((i) => i.id));
|
|
293
|
+
const rest = legacy.filter((i) => !seen.has(i.id)).map((i) => ({ ...i, horizon: 'now' }));
|
|
294
|
+
const order = { now: 0, next: 1, later: 2 };
|
|
295
|
+
const open = [...placed, ...rest].sort((a, b) => order[a.horizon] - order[b.horizon]);
|
|
296
|
+
return { open, pending };
|
|
297
|
+
}
|
|
272
298
|
/** The repository's terms for a note: its initiative's title, the glossary, and the product terms in the open initiatives' titles. */
|
|
273
299
|
function repoTermsFor(ctx, note) {
|
|
274
300
|
const w = note && note.ws && typeof ctx.getWs === 'function' ? ctx.getWs(note.ws) : null;
|
|
@@ -500,12 +526,12 @@ async function handle(req, res, url, ctx) {
|
|
|
500
526
|
const wsId = url.searchParams.get('ws');
|
|
501
527
|
const w = wsId ? ctx.getWs(wsId) : null;
|
|
502
528
|
if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
503
|
-
const open =
|
|
529
|
+
const { open, pending } = openInitiativesOf(w);
|
|
504
530
|
const nid = url.searchParams.get('id');
|
|
505
531
|
const r = nid ? notes.read(dir, nid) : null;
|
|
506
532
|
if (r && !r.ok) { send(ctx, res, r); return true; }
|
|
507
533
|
const s = r ? notesLinks.suggest(r.note, open) : { pick: null, ranked: [] };
|
|
508
|
-
ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title })), pick: s.pick });
|
|
534
|
+
ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title, horizon: i.horizon })), pick: s.pick, pending });
|
|
509
535
|
return true;
|
|
510
536
|
}
|
|
511
537
|
if (p === '/api/notes/engine') {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.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.111.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* audio-capture.js — how the Notes view takes sound from the browser (notes-audio-quality Run 1, R4 and R6).
|
|
2
|
+
*
|
|
3
|
+
* makeResampler(inRate) a stateful low-pass resampler: push(float32 block) → Int16Array at 16 kHz, flush() → the tail
|
|
4
|
+
* toInt16(input, inRate) one block, whole: the same filter, run once. Pure.
|
|
5
|
+
* openCapture(ac, src, sink, onBlock) → { mode, node } an AudioWorklet that hands float32 blocks to onBlock;
|
|
6
|
+
* a ScriptProcessor does the same where the browser has no worklet
|
|
7
|
+
*
|
|
8
|
+
* WHY A FILTER. A context that cannot run at 16 kHz gives 44.1 or 48 kHz blocks. Averaging the samples under each output sample
|
|
9
|
+
* is a poor low-pass: sound above 8 kHz folds back into the speech band as a hiss. The windowed-sinc filter below cuts at 7.2 kHz and
|
|
10
|
+
* keeps its state between blocks, so a block edge leaves no click. At 16 kHz the samples pass through unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export const RATE = 16000;
|
|
13
|
+
const CUT = 0.45; // the filter cuts at this share of the output rate
|
|
14
|
+
const CROSSINGS = 16; // zero crossings of the sinc on each side of the centre
|
|
15
|
+
|
|
16
|
+
const toS16 = (v) => { const c = v > 1 ? 1 : v < -1 ? -1 : v; return c < 0 ? c * 0x8000 : c * 0x7fff; };
|
|
17
|
+
|
|
18
|
+
export function makeResampler(inRate, outRate = RATE) {
|
|
19
|
+
const ratio = inRate / outRate;
|
|
20
|
+
if (ratio === 1) {
|
|
21
|
+
return { push(x) { const o = new Int16Array(x.length); for (let i = 0; i < x.length; i++) o[i] = toS16(x[i]); return o; }, flush() { return new Int16Array(0); } };
|
|
22
|
+
}
|
|
23
|
+
const fc = CUT / Math.max(1, ratio); // cycles per input sample
|
|
24
|
+
const half = Math.ceil(CROSSINGS / (2 * fc)); // input samples either side of the centre
|
|
25
|
+
let buf = new Float64Array(0); // input not yet used up; buf[0] is input sample `base`
|
|
26
|
+
let base = 0;
|
|
27
|
+
let centre = 0; // the next output sample sits at this input position
|
|
28
|
+
let seen = 0; // input samples received
|
|
29
|
+
const weight = (d) => {
|
|
30
|
+
const x = 2 * fc * d;
|
|
31
|
+
const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
|
|
32
|
+
const w = 0.42 + 0.5 * Math.cos(Math.PI * d / half) + 0.08 * Math.cos(2 * Math.PI * d / half); // Blackman, centred
|
|
33
|
+
return sinc * w;
|
|
34
|
+
};
|
|
35
|
+
const w = new Float64Array(2 * half + 2);
|
|
36
|
+
function run(limit, out) {
|
|
37
|
+
while (centre < limit && Math.floor(centre) + half < base + buf.length) {
|
|
38
|
+
const lo = Math.ceil(centre - half); const hi = Math.floor(centre + half);
|
|
39
|
+
let sum = 0; let norm = 0;
|
|
40
|
+
for (let i = lo; i <= hi; i++) { const k = weight(i - centre); w[i - lo] = k; norm += k; }
|
|
41
|
+
for (let i = lo; i <= hi; i++) { const j = i - base; if (j >= 0 && j < buf.length) sum += buf[j] * w[i - lo]; }
|
|
42
|
+
out.push(toS16(sum / norm));
|
|
43
|
+
centre += ratio;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function trim() {
|
|
47
|
+
const keep = Math.max(base, Math.floor(centre) - half);
|
|
48
|
+
if (keep > base) { buf = buf.slice(keep - base); base = keep; }
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
push(x) {
|
|
52
|
+
const merged = new Float64Array(buf.length + x.length); merged.set(buf); merged.set(x, buf.length); buf = merged; seen += x.length;
|
|
53
|
+
const out = []; run(Infinity, out); trim();
|
|
54
|
+
return Int16Array.from(out);
|
|
55
|
+
},
|
|
56
|
+
flush() {
|
|
57
|
+
const pad = new Float64Array(buf.length + half + 1); pad.set(buf); buf = pad;
|
|
58
|
+
const out = []; run(seen, out); trim();
|
|
59
|
+
return Int16Array.from(out);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Float samples at `inRate` as 16 kHz 16-bit samples. Pure. */
|
|
65
|
+
export function toInt16(input, inRate) {
|
|
66
|
+
const r = makeResampler(inRate);
|
|
67
|
+
const a = r.push(input); const b = r.flush();
|
|
68
|
+
const out = new Int16Array(a.length + b.length); out.set(a); out.set(b, a.length);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Take blocks of the first channel from `src` and give each to onBlock as a Float32Array. `sink` is a muted node that keeps the
|
|
73
|
+
* graph pulling. Returns { mode: 'worklet' | 'script', node }. */
|
|
74
|
+
export async function openCapture(ac, src, sink, onBlock) {
|
|
75
|
+
if (ac.audioWorklet && typeof AudioWorkletNode === 'function') {
|
|
76
|
+
try {
|
|
77
|
+
await ac.audioWorklet.addModule(new URL('./notes-capture-worklet.js', import.meta.url).href);
|
|
78
|
+
const node = new AudioWorkletNode(ac, 'notes-capture', { numberOfInputs: 1, numberOfOutputs: 1, channelCount: 1, channelCountMode: 'explicit' });
|
|
79
|
+
node.port.onmessage = (e) => onBlock(e.data);
|
|
80
|
+
src.connect(node); node.connect(sink);
|
|
81
|
+
return { mode: 'worklet', node };
|
|
82
|
+
} catch { /* the module did not load: the older route below */ }
|
|
83
|
+
}
|
|
84
|
+
const node = ac.createScriptProcessor(4096, 1, 1);
|
|
85
|
+
node.onaudioprocess = (e) => onBlock(new Float32Array(e.inputBuffer.getChannelData(0)));
|
|
86
|
+
src.connect(node); node.connect(sink);
|
|
87
|
+
return { mode: 'script', node };
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* notes-capture-worklet.js — runs on the audio thread (notes-audio-quality R4). It copies the first channel of what it hears into
|
|
2
|
+
* blocks of 2048 samples and posts each block to the page, which resamples and sends it. It makes no sound. */
|
|
3
|
+
class NotesCapture extends AudioWorkletProcessor {
|
|
4
|
+
constructor() { super(); this.block = new Float32Array(2048); this.at = 0; }
|
|
5
|
+
process(inputs) {
|
|
6
|
+
const ch = inputs[0] && inputs[0][0];
|
|
7
|
+
if (!ch) return true;
|
|
8
|
+
for (let i = 0; i < ch.length; i++) {
|
|
9
|
+
this.block[this.at++] = ch[i];
|
|
10
|
+
if (this.at === this.block.length) { this.port.postMessage(this.block, [this.block.buffer]); this.block = new Float32Array(2048); this.at = 0; }
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
registerProcessor('notes-capture', NotesCapture);
|
|
@@ -45,12 +45,15 @@ import { pmGet, pmPost } from './http.js';
|
|
|
45
45
|
import { mdRender } from './markdown.js';
|
|
46
46
|
import { icon } from './icons.js';
|
|
47
47
|
import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
|
|
48
|
+
import { toInt16, makeResampler, openCapture } from './audio-capture.js';
|
|
48
49
|
|
|
49
50
|
const SAVE_MS = 700; // a pause this long in typing saves the note
|
|
50
51
|
const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
|
|
51
52
|
const AGENT_POLL_MS = 3000;
|
|
52
53
|
const CHUNK_SAMPLES = 16000; // one second at 16 kHz
|
|
53
54
|
const RATE = 16000;
|
|
55
|
+
const SEND_TRIES = 3; // a chunk that the server did not take is sent again this many times in all …
|
|
56
|
+
const SEND_BACKOFF_MS = 400; // … after 400 ms, then 1200 ms
|
|
54
57
|
const SECTIONS = [['decision', 'Decisions'], ['action', 'Next steps'], ['question', 'Open questions']];
|
|
55
58
|
|
|
56
59
|
/* ── module state: survives the redraw every setState causes ─────────────────────────────── */
|
|
@@ -194,19 +197,8 @@ export function noteMarkdown(n) {
|
|
|
194
197
|
if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
|
|
195
198
|
return out.join('\n');
|
|
196
199
|
}
|
|
197
|
-
/** Float samples at `inRate` as 16 kHz 16-bit samples
|
|
198
|
-
export
|
|
199
|
-
const ratio = inRate / RATE;
|
|
200
|
-
const n = Math.floor(input.length / ratio);
|
|
201
|
-
const out = new Int16Array(n);
|
|
202
|
-
for (let i = 0; i < n; i++) {
|
|
203
|
-
const a = Math.floor(i * ratio); const b = Math.max(a + 1, Math.floor((i + 1) * ratio));
|
|
204
|
-
let s = 0; for (let k = a; k < b && k < input.length; k++) s += input[k];
|
|
205
|
-
const v = Math.max(-1, Math.min(1, s / (b - a)));
|
|
206
|
-
out[i] = v < 0 ? v * 0x8000 : v * 0x7fff;
|
|
207
|
-
}
|
|
208
|
-
return out;
|
|
209
|
-
}
|
|
200
|
+
/** Float samples at `inRate` as 16 kHz 16-bit samples, through a low-pass filter (pm/audio-capture.js). Pure. */
|
|
201
|
+
export { toInt16 };
|
|
210
202
|
function b64(int16) {
|
|
211
203
|
const u = new Uint8Array(int16.buffer, int16.byteOffset, int16.byteLength);
|
|
212
204
|
let s = '';
|
|
@@ -581,7 +573,7 @@ async function openSource(kind) {
|
|
|
581
573
|
return new MediaStream(tracks);
|
|
582
574
|
}
|
|
583
575
|
async function startAudio(ctx, n, want) {
|
|
584
|
-
const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, ended: 0 };
|
|
576
|
+
const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, lost: 0, ended: 0 };
|
|
585
577
|
const opened = [];
|
|
586
578
|
// the meeting first, straight from the press: the browser lets a page share a screen only right after a click
|
|
587
579
|
for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
|
|
@@ -603,14 +595,13 @@ async function startAudio(ctx, n, want) {
|
|
|
603
595
|
for (const [kind, stream] of opened) {
|
|
604
596
|
a.streams.push(stream); a.sources.push(kind); a.buf[kind] = []; a.queue[kind] = Promise.resolve();
|
|
605
597
|
const src = ac.createMediaStreamSource(stream);
|
|
606
|
-
const
|
|
607
|
-
|
|
598
|
+
const rs = a.rs[kind] = makeResampler(ac.sampleRate);
|
|
599
|
+
await openCapture(ac, src, sink, (block) => {
|
|
608
600
|
if (audio !== a) return;
|
|
609
|
-
const s =
|
|
601
|
+
const s = rs.push(block);
|
|
610
602
|
const b = a.buf[kind]; for (let i = 0; i < s.length; i++) b.push(s[i]);
|
|
611
603
|
if (b.length >= CHUNK_SAMPLES) sendChunk(ctx, a, kind, false);
|
|
612
|
-
};
|
|
613
|
-
src.connect(proc); proc.connect(sink);
|
|
604
|
+
});
|
|
614
605
|
for (const t of stream.getTracks()) t.addEventListener('ended', () => { if (audio !== a) return; a.ended++; if (a.ended >= a.streams.length) stopAudio(ctx); });
|
|
615
606
|
}
|
|
616
607
|
a.state = 'on';
|
|
@@ -621,9 +612,23 @@ async function startAudio(ctx, n, want) {
|
|
|
621
612
|
function sendChunk(ctx, a, kind, final) {
|
|
622
613
|
const b = a.buf[kind] || []; a.buf[kind] = [];
|
|
623
614
|
const pcm = b64(Int16Array.from(b));
|
|
615
|
+
const seconds = b.length / RATE;
|
|
624
616
|
a.queue[kind] = a.queue[kind].then(async () => {
|
|
625
|
-
|
|
626
|
-
|
|
617
|
+
let r = null;
|
|
618
|
+
for (let tries = 0; tries < SEND_TRIES; tries++) {
|
|
619
|
+
r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, final });
|
|
620
|
+
if (r && r.ok) return;
|
|
621
|
+
if (r && (r.status === 409 || r.status === 404)) break; // the server refused this note's audio: sending again changes nothing
|
|
622
|
+
if (tries + 1 < SEND_TRIES) await new Promise((res) => setTimeout(res, SEND_BACKOFF_MS * 3 ** tries));
|
|
623
|
+
}
|
|
624
|
+
if (r && (r.status === 409 || r.status === 404)) {
|
|
625
|
+
if (audio === a) { banner = { tone: 'bad', text: 'Listening stopped: ' + r.error + '.' }; stopAudio(ctx); }
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
a.lost += seconds; // not silent: the recording has a gap, and the PM is told how long
|
|
629
|
+
banner = { tone: 'bad', text: 'About ' + Math.max(1, Math.round(a.lost)) + ' s of the recording was not saved, because the workspace server did not take it'
|
|
630
|
+
+ (r && r.error ? ' (' + r.error + ')' : '') + '.' + (audio === a ? ' Listening goes on.' : '') };
|
|
631
|
+
ctx.setState({});
|
|
627
632
|
}).catch(() => {});
|
|
628
633
|
return a.queue[kind];
|
|
629
634
|
}
|
|
@@ -636,6 +641,7 @@ async function stopAudio(ctx, { discard = false } = {}) {
|
|
|
636
641
|
try { if (a.ctx) await a.ctx.close(); } catch { /* closed */ }
|
|
637
642
|
if (discard) { ctx.setState({}); return; }
|
|
638
643
|
// the rest of what was said, then the summary once the last line is transcribed
|
|
644
|
+
for (const k of a.sources) { const tail = a.rs[k] ? a.rs[k].flush() : []; const b = a.buf[k]; for (let i = 0; i < tail.length; i++) b.push(tail[i]); }
|
|
639
645
|
await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
|
|
640
646
|
await pmPost('/api/notes/pause', { id: a.id });
|
|
641
647
|
ctx.setState({});
|
|
@@ -1067,32 +1073,69 @@ async function meetingSheet(ctx, n) {
|
|
|
1067
1073
|
}), evs.length ? null : el('p', { class: 'nt-hint' }, 'No calendar events on these days.'),
|
|
1068
1074
|
opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
|
|
1069
1075
|
}
|
|
1076
|
+
const HORIZON_WORDS = { now: 'Now', next: 'Next', later: 'Later' };
|
|
1077
|
+
/** The repositories the notes can be about, one row per repository (its main tree), from the workspace server. */
|
|
1078
|
+
async function reposForNotes() {
|
|
1079
|
+
const r = await pmGet('/api/workspaces');
|
|
1080
|
+
const seen = new Map();
|
|
1081
|
+
for (const w of (r && r.workspaces) || []) {
|
|
1082
|
+
if (!w || !w.id) continue;
|
|
1083
|
+
const key = w.repo_key || w.root;
|
|
1084
|
+
if (!seen.has(key) || w.root === key) seen.set(key, { ws: w.id, name: w.label || w.repo_name || w.name || w.id });
|
|
1085
|
+
}
|
|
1086
|
+
return [...seen.values()];
|
|
1087
|
+
}
|
|
1070
1088
|
async function initiativeSheet(ctx, n) {
|
|
1071
|
-
|
|
1089
|
+
let ws = n.ws || wsOf(ctx);
|
|
1090
|
+
let repo = (n.initiative && n.initiative.repo) || (ctx.state && ctx.state.product) || ctx.repo || null;
|
|
1072
1091
|
const box = el('div', { class: 'nt-picks' }, el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
|
|
1092
|
+
const where = el('div', { 'data-nt-ini-where': '' });
|
|
1073
1093
|
let pick = n.initiative ? n.initiative.id : '';
|
|
1074
1094
|
let open = [];
|
|
1075
|
-
|
|
1095
|
+
let radios = [];
|
|
1096
|
+
let sel = null;
|
|
1097
|
+
let loadId = 0;
|
|
1098
|
+
const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [where, box], attrs: { 'data-nt-ini-sheet': '' },
|
|
1076
1099
|
actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', attrs: { 'data-nt-ini-save': '' }, onClick: () => {
|
|
1077
1100
|
h.close();
|
|
1078
1101
|
if (pick === (n.initiative ? n.initiative.id : '') && (!n.initiative || n.initiative.by === 'pm')) return;
|
|
1079
1102
|
if (!pick) return link(ctx, n, { initiative: null }, () => 'Not linked to an initiative.');
|
|
1080
1103
|
const i = open.find((x) => x.id === pick) || { id: pick, title: pick };
|
|
1081
|
-
return link(ctx, n, { initiative: { id: i.id, ws, repo
|
|
1104
|
+
return link(ctx, n, { initiative: { id: i.id, ws, repo, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
|
|
1082
1105
|
} })] });
|
|
1083
|
-
|
|
1084
|
-
const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
|
|
1085
|
-
if (!h.open) return;
|
|
1086
|
-
if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
|
|
1087
|
-
open = r.open || [];
|
|
1088
|
-
const radios = [];
|
|
1089
|
-
const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel.control.value !== v) sel.control.value = open.some((x) => x.id === v) ? v : ''; };
|
|
1106
|
+
const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel && sel.control.value !== v) sel.control.value = open.some((x) => x.id === v && x.horizon !== 'now') ? v : ''; };
|
|
1090
1107
|
const opt = (v, title, sub) => { const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': pick === v ? 'true' : 'false', 'data-nt-ini-pick': v }, onClick: () => choose(v) }); radios.push(b); return b; };
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1108
|
+
async function load(tries = 0) {
|
|
1109
|
+
const mine = ++loadId;
|
|
1110
|
+
open = []; radios = []; sel = null;
|
|
1111
|
+
if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Choose a repository: its initiatives are the ones a note can belong to.')); return; }
|
|
1112
|
+
box.replaceChildren(el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
|
|
1113
|
+
const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
|
|
1114
|
+
if (!h.open || mine !== loadId) return;
|
|
1115
|
+
if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
|
|
1116
|
+
open = r.open || [];
|
|
1117
|
+
if (r.pending && !open.length && tries < 8) { setTimeout(() => load(tries + 1), 1500); return; }
|
|
1118
|
+
// the active initiatives (Now) are listed as choices; Next and Later are in the list below them
|
|
1119
|
+
const active = open.filter((x) => x.horizon === 'now' && !(r.pick && r.pick.id === x.id));
|
|
1120
|
+
const later = open.filter((x) => x.horizon !== 'now');
|
|
1121
|
+
sel = field({ label: later.length ? 'Or choose a later initiative' : 'Or choose any open initiative', kind: 'select', value: later.some((x) => x.id === pick) ? pick : '',
|
|
1122
|
+
options: [{ value: '', label: 'Choose …' }, ...later.map((x) => ({ value: x.id, label: (HORIZON_WORDS[x.horizon] || 'Now') + ' · ' + x.title }))], attrs: { 'data-nt-ini-all': '' } });
|
|
1123
|
+
sel.control.addEventListener('change', () => choose(sel.control.value));
|
|
1124
|
+
const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
|
|
1125
|
+
const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) && !active.some((x) => x.id === n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
|
|
1126
|
+
const now = active.length ? [el('h3', { class: 'nt-sec__h' }, 'Active now'), ...active.map((x) => opt(x.id, x.title, 'active'))] : [];
|
|
1127
|
+
box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, ...now, opt('', 'None', 'not about an initiative')),
|
|
1128
|
+
later.length ? sel : (open.length ? null : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives. Choose another repository above, or open the Initiatives tab to add one.')));
|
|
1129
|
+
}
|
|
1130
|
+
const repos = await reposForNotes();
|
|
1131
|
+
if (!h.open) return;
|
|
1132
|
+
if (repos.length > 1) {
|
|
1133
|
+
const rs = field({ label: 'Repository', kind: 'select', value: ws || '', options: [...(ws && !repos.some((x) => x.ws === ws) ? [{ value: ws, label: repo || ws }] : []), ...repos.map((x) => ({ value: x.ws, label: x.name }))], attrs: { 'data-nt-ini-repo': '' } });
|
|
1134
|
+
rs.control.addEventListener('change', () => { ws = rs.control.value; const f = repos.find((x) => x.ws === ws); repo = f ? f.name : repo; pick = ''; load(); });
|
|
1135
|
+
where.replaceChildren(rs);
|
|
1136
|
+
}
|
|
1137
|
+
if (!ws && repos.length) { ws = repos[0].ws; repo = repos[0].name; if (where.firstChild) where.firstChild.control && (where.firstChild.control.value = ws); }
|
|
1138
|
+
await load();
|
|
1096
1139
|
}
|
|
1097
1140
|
async function openInitiativeOf(ctx, ini) {
|
|
1098
1141
|
const reg = await import('./registry.js');
|