create-pmos 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/layout.md +10 -11
- package/workspace/guide/notes.md +32 -3
- package/workspace/lib/mobile-nav.js +9 -2
- package/workspace/lib/notes-audio.js +41 -11
- package/workspace/lib/notes-diarize.py +61 -0
- package/workspace/lib/notes-final.js +293 -0
- package/workspace/lib/notes.js +268 -29
- package/workspace/lib/routes-notes.js +175 -8
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +14 -20
- package/workspace/public/pm/components.css +27 -8
- package/workspace/public/pm/mobile.css +77 -93
- package/workspace/public/pm/mobile.js +6 -2
- package/workspace/public/pm/phone-deck.js +67 -143
- package/workspace/public/pm/view-notes.js +159 -7
- package/workspace/public/vendor/lucide-sprite.js +9 -2
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-timings.json +1 -0
- package/workspace/third-party/README.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.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-07T10:39:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T10:39:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T10:39:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: layout"
|
|
4
|
-
description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and
|
|
4
|
+
description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and lists; the keys; and the older layout."
|
|
5
5
|
timestamp: 2026-10-07
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -119,20 +119,19 @@ A key that you type in a field, or in a terminal, goes to the field or the termi
|
|
|
119
119
|
|
|
120
120
|
## On a phone
|
|
121
121
|
|
|
122
|
-
A phone shows one panel at a time. The tabs at the bottom are **Agenda, Initiatives, Board,
|
|
122
|
+
A phone shows one panel at a time. The tabs at the bottom are **Agenda, Initiatives, Board, Docs and Agent**. Each tab has an icon and a short name. The Agenda tab shows how many things need you. Agent shows the dock, with your terminals. Reports is in the Board tab.
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
Where a tab has a list, the list goes from top to bottom. Only the list scrolls. The bar at the top, the line above the list and the tabs at the bottom stay in place. The line above the list shows how many items the list has, for example "5 need you".
|
|
125
125
|
|
|
126
|
-
Some tabs have a second view.
|
|
126
|
+
Some tabs have a second view. Use the two buttons at the right of the line above the list to change the view:
|
|
127
127
|
|
|
128
|
-
| Tab | The
|
|
128
|
+
| Tab | The list | The other view | Not on a phone |
|
|
129
129
|
| --- | --- | --- | --- |
|
|
130
|
-
| Agenda | **
|
|
131
|
-
| Initiatives | **
|
|
132
|
-
| Board |
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
| Agent | No deck. Talk to a session. | None. | The scratchpad bar when no terminal is in front. |
|
|
130
|
+
| Agenda | **Needs you**: one card for each thing that needs you. When your day is not prepared yet, the first card is **Prepare your day**. Press **Prepare my day** to start it, or **See the day** to open My day. | **My day**: your calendar, your goals and the jobs the agent does for the day. | The Repository / All repositories switch (use Repository in the bar). |
|
|
131
|
+
| Initiatives | **Roadmap**: the initiatives in Now, then Next, then Later. | **All**: the list of every initiative. Press one to open it. | The title, New initiative, the count line and the drag grips. |
|
|
132
|
+
| Board | **Board**: the items in the column you choose. Choose the initiative in the menu at the top, and the column with the column buttons. | **Reports**: read a report, or draft one. The Board tab stays lit. | The item count, the report title and Report schedules. |
|
|
133
|
+
| Docs | **Out of sync**: one row for each document that needs you. | **All**: every document, in its groups. | The title. |
|
|
134
|
+
| Agent | Talk to a session. | None. | The scratchpad bar when no terminal is in front. |
|
|
136
135
|
|
|
137
136
|
When you open a terminal, the terminal fills the screen. The tabs at the bottom are not shown, so the output has the most space. The control keys stay at the bottom. Press Back to return to the tabs.
|
|
138
137
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
|
-
description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
|
|
4
|
+
description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, and Wispr Flow's notes beside them; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
|
|
5
5
|
timestamp: 2026-10-07
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -33,11 +33,39 @@ Every minute, your agent session reads that minute of the transcript, with the m
|
|
|
33
33
|
|
|
34
34
|
When the agent session does not write a minute's points within two minutes, that minute's lines are labelled as below. When no agent session can start, or the note is private, the lines are labelled on this machine, and Jev labels the clear ones once a minute. A line that ends in the middle of a sentence waits for the rest of it.
|
|
35
35
|
|
|
36
|
-
After **Stop**,
|
|
36
|
+
After **Stop**, the final notes replace this live draft. See [Final notes after Stop](#final-notes-after-stop). Until they are written, and when no agent session can start, the overview is picked from what was said, on this machine. Facts and statements with a number go under **Facts mentioned**.
|
|
37
37
|
|
|
38
38
|
- To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
|
|
39
39
|
- When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
|
|
40
40
|
|
|
41
|
+
## Final notes after Stop
|
|
42
|
+
|
|
43
|
+
The notes taken live are a draft. After **Stop**, the workspace transcribes the whole recording again on this machine, with more time for each sentence, and then your agent session writes the final notes from that transcript and your typed lines.
|
|
44
|
+
|
|
45
|
+
1. The workspace transcribes your microphone and the meeting apart. It uses **Parakeet** when it is set up and the meeting is in English, else Whisper with beam search, speech detection and the names of this meeting.
|
|
46
|
+
2. It tells the speakers apart. Your microphone is you. The meeting's voices are **Speaker 1**, **Speaker 2** and so on. When no meeting audio was captured, the voices on your microphone are told apart instead.
|
|
47
|
+
3. Your agent session writes the purpose, the overview, the topics, the decisions, the next steps with an owner, and the open questions. Each item shows its meeting time and **Said:** with the words that support it. An item with no supporting words in the transcript is left out, and the note says how many were left out.
|
|
48
|
+
|
|
49
|
+
For a 30-minute meeting on an M1, this takes about 3 to 10 minutes. Until it is done, the note shows the live notes and says what it is doing.
|
|
50
|
+
|
|
51
|
+
- To name a speaker, press the name in the transcript, type the name and press **Save**. The name is kept when the meeting is transcribed again.
|
|
52
|
+
- **Show the live transcript** shows what was heard while listening. **Show the final transcript** goes back.
|
|
53
|
+
- **Transcribe again** in the settings runs the final pass again, for example after you set up Parakeet.
|
|
54
|
+
|
|
55
|
+
To set up Parakeet, speech detection and speakers, open the settings and press **Set up in a terminal** under **Final notes after Stop**. A terminal opens with the commands typed in. They install ffmpeg and Python with Homebrew, then Parakeet and sherpa-onnx in the workspace's own folder, and download the models (up to about 2.5 GB). Read the commands and press Enter. Without them, the final pass uses Whisper and does not tell speakers apart.
|
|
56
|
+
|
|
57
|
+
The kept audio holds your microphone and the meeting apart until the final pass is done. When **Keep the audio for replay** is off, both are deleted after the final pass.
|
|
58
|
+
|
|
59
|
+
## Wispr Flow notes
|
|
60
|
+
|
|
61
|
+
When you also record the meeting with Wispr Flow, PMOS fetches Wispr Flow's notes and shows them under **Wispr Flow notes**, below yours.
|
|
62
|
+
|
|
63
|
+
1. Five minutes after **Stop**, PMOS starts an agent session that looks for this meeting in Wispr Flow, by its time and title.
|
|
64
|
+
2. When Wispr Flow has no notes yet, PMOS asks again every two minutes, for up to 20 minutes.
|
|
65
|
+
3. When it finds them, the summary shows in the note, with **Open in Wispr Flow**.
|
|
66
|
+
|
|
67
|
+
This needs the Wispr Flow connector in your agent session: add it in Claude Code once and sign in to Wispr Flow in the browser. Cloud Sync must be on in Wispr Flow. PMOS itself sends no request to Wispr Flow. To stop it, clear **Fetch Wispr Flow notes after the meeting** in the settings. A private note is never looked up.
|
|
68
|
+
|
|
41
69
|
## Set up local transcription
|
|
42
70
|
|
|
43
71
|
Local transcription uses whisper.cpp, a free program that runs on your machine. A Mac with Apple silicon (M1 or later) and 8 GB of memory is enough. The model is about 1.6 GB on disk and uses about 3 GB of memory while it runs.
|
|
@@ -119,6 +147,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
|
|
|
119
147
|
|
|
120
148
|
- 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.
|
|
121
149
|
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
122
|
-
- Only Jev, with the switches on, and **Summary by agent**,
|
|
150
|
+
- 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.
|
|
151
|
+
- The final pass runs Parakeet, Whisper and the speaker models on this machine, from temporary files that it deletes when it is done.
|
|
123
152
|
|
|
124
153
|
Live notes is a different tool: it maps each talking point to an initiative. See [Live notes](live-notes.md).
|
|
@@ -72,6 +72,13 @@ const DETAIL_TITLE = {
|
|
|
72
72
|
* is not a tab: it is pushed on top of the current tab as a `dest` screen with id `settings`, so Back returns.
|
|
73
73
|
* The old cockpit's list modes still map to a tab (MOBILE_DEST), for the old shell (fixture mode, ?layout=legacy). */
|
|
74
74
|
const MOBILE_DESTS = ['today', 'initiatives', 'board', 'reports', 'docs', 'agent'];
|
|
75
|
+
/* The bottom bar draws FIVE of them (workspace-phone-scroll-layout, 2026-10-07): an icon and a short word each, the
|
|
76
|
+
* pattern most phone apps use. Reports is still a destination with its own root, but it has no button of its own: it is
|
|
77
|
+
* the second view of the Board tab, switched from the head above the list. MOBILE_TAB_OF says which button a
|
|
78
|
+
* destination lights; a destination absent from it lights its own. */
|
|
79
|
+
const MOBILE_TABS = ['today', 'initiatives', 'board', 'docs', 'agent'];
|
|
80
|
+
const MOBILE_TAB_OF = { reports: 'board' };
|
|
81
|
+
function mobTabOf(dest) { return MOBILE_TAB_OF[dest] || dest; }
|
|
75
82
|
// What each tab and Settings is called. The same words as the desktop bar (public/pm/frame-copy.js COPY.nav), which
|
|
76
83
|
// MOBNAV-P7 holds equal.
|
|
77
84
|
const DEST_TITLE = { today: 'Agenda', initiatives: 'Initiatives', board: 'Board', reports: 'Reports', docs: 'Docs', agent: 'Agent', settings: 'Settings' };
|
|
@@ -154,9 +161,9 @@ function mobMigrateView(v) {
|
|
|
154
161
|
|
|
155
162
|
return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
|
|
156
163
|
current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, panelOf: mobPanelOf,
|
|
157
|
-
destForMode: mobDestForMode,
|
|
164
|
+
destForMode: mobDestForMode, tabOf: mobTabOf,
|
|
158
165
|
MODE_TITLE,
|
|
159
166
|
migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
|
|
160
|
-
DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
|
|
167
|
+
DESTS: MOBILE_DESTS, TABS: MOBILE_TABS, TAB_OF: MOBILE_TAB_OF, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
|
|
161
168
|
PANEL_OF_KIND: MOBILE_PANEL, DETAIL_TITLE, DEST_TITLE, LEGACY_DEST };
|
|
162
169
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* write(runtimeDir, { id, source, pcm, t }) add one chunk of 16 kHz mono 16-bit samples at meeting time t (ms)
|
|
5
5
|
* info(runtimeDir, id) → { bytes, ms } | null
|
|
6
6
|
* remove(runtimeDir, id) delete the note's audio
|
|
7
|
+
* sources(runtimeDir, id) → [{ source, file, bytes, ms }] each source's own samples (Run 9)
|
|
7
8
|
* serve(req, res, runtimeDir, id) the audio as a WAV file, with byte ranges so the page can seek
|
|
8
9
|
* getKeep(runtimeDir) · setKeep(runtimeDir, bool) "Keep the audio for replay", on by default, for this machine
|
|
9
10
|
*
|
|
@@ -13,6 +14,10 @@
|
|
|
13
14
|
* arrives on time or early, so the small jitter of the page's timer leaves no clicks; a chunk that arrives much later (Listen again)
|
|
14
15
|
* is placed at its own time, and the gap stays silent.
|
|
15
16
|
*
|
|
17
|
+
* EACH SOURCE APART (Run 9). Each source is also written to its own file, `<id>.mic.pcm` and `<id>.meeting.pcm`, at the same
|
|
18
|
+
* place and with nothing added, so the final pass after Stop (lib/notes-final.js) can tell the PM's microphone from the
|
|
19
|
+
* meeting. remove deletes all three.
|
|
20
|
+
*
|
|
16
21
|
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
|
|
17
22
|
* private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
|
|
18
23
|
*/
|
|
@@ -27,6 +32,8 @@ const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
|
27
32
|
|
|
28
33
|
const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
|
|
29
34
|
const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
|
|
35
|
+
const SOURCES = ['mic', 'meeting'];
|
|
36
|
+
const sourceFileOf = (runtimeDir, id, source) => path.join(dirOf(runtimeDir), id + '.' + source + '.pcm');
|
|
30
37
|
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
|
|
31
38
|
const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
|
|
32
39
|
|
|
@@ -43,8 +50,9 @@ function setKeep(runtimeDir, keep) {
|
|
|
43
50
|
return { ok: true, keep };
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped.
|
|
47
|
-
|
|
53
|
+
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. With `mixed:
|
|
54
|
+
* false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done. */
|
|
55
|
+
function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
|
|
48
56
|
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
49
57
|
const f = fileOf(runtimeDir, id);
|
|
50
58
|
const key = f + '|' + source;
|
|
@@ -55,14 +63,21 @@ function write(runtimeDir, { id, source, pcm, t }) {
|
|
|
55
63
|
if (at >= MAX_BYTES) return { ok: true, kept: false };
|
|
56
64
|
const len = Math.min(pcm.length, MAX_BYTES - at);
|
|
57
65
|
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
if (mixed) {
|
|
67
|
+
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
68
|
+
try {
|
|
69
|
+
const old = Buffer.alloc(len);
|
|
70
|
+
fs.readSync(fd, old, 0, len, at);
|
|
71
|
+
const out = Buffer.alloc(len);
|
|
72
|
+
for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
|
|
73
|
+
fs.writeSync(fd, out, 0, len, at);
|
|
74
|
+
} finally { fs.closeSync(fd); }
|
|
75
|
+
}
|
|
76
|
+
if (SOURCES.includes(source)) {
|
|
77
|
+
const sf = sourceFileOf(runtimeDir, id, source);
|
|
78
|
+
const sd = fs.openSync(sf, fs.existsSync(sf) ? 'r+' : 'w+', 0o600);
|
|
79
|
+
try { fs.writeSync(sd, pcm, 0, len, at); } finally { fs.closeSync(sd); }
|
|
80
|
+
}
|
|
66
81
|
next.set(key, at + len);
|
|
67
82
|
return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
|
|
68
83
|
}
|
|
@@ -71,11 +86,26 @@ function info(runtimeDir, id) {
|
|
|
71
86
|
if (!ID_RE.test(String(id || ''))) return null;
|
|
72
87
|
try { const st = fs.statSync(fileOf(runtimeDir, id)); return st.size ? { bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) } : null; } catch { return null; }
|
|
73
88
|
}
|
|
89
|
+
function removeSources(runtimeDir, id) {
|
|
90
|
+
if (!ID_RE.test(String(id || ''))) return;
|
|
91
|
+
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
92
|
+
}
|
|
74
93
|
function remove(runtimeDir, id) {
|
|
75
94
|
if (!ID_RE.test(String(id || ''))) return false;
|
|
76
95
|
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
96
|
+
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
77
97
|
try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
|
|
78
98
|
}
|
|
99
|
+
/** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
|
|
100
|
+
function sources(runtimeDir, id) {
|
|
101
|
+
if (!ID_RE.test(String(id || ''))) return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const source of SOURCES) {
|
|
104
|
+
const file = sourceFileOf(runtimeDir, id, source);
|
|
105
|
+
try { const st = fs.statSync(file); if (st.size) out.push({ source, file, bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) }); } catch { /* not heard */ }
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
79
109
|
|
|
80
110
|
function wavHeader(dataBytes) {
|
|
81
111
|
const h = Buffer.alloc(44);
|
|
@@ -108,4 +138,4 @@ function serve(req, res, runtimeDir, id) {
|
|
|
108
138
|
fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
module.exports = { write, info, remove, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
141
|
+
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""notes-diarize.py: who spoke when in one audio file, on this machine (meeting-notes Run 9).
|
|
3
|
+
|
|
4
|
+
python3 notes-diarize.py --segmentation <model.onnx> --embedding <model.onnx> --wav <file.wav> [--speakers N]
|
|
5
|
+
|
|
6
|
+
The WAV is 16 kHz mono 16-bit. Prints one JSON object to stdout:
|
|
7
|
+
{"ok": true, "segments": [{"start": 0.5, "end": 3.2, "speaker": 0}, ...]}
|
|
8
|
+
or {"ok": false, "error": "..."} with exit code 1.
|
|
9
|
+
|
|
10
|
+
It uses sherpa-onnx's offline speaker diarization (a pyannote segmentation model and a speaker embedding model,
|
|
11
|
+
clustered). It reads no network and writes no file. lib/notes-final.js runs it after Stop, one file at a time.
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import wave
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fail(msg):
|
|
20
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
ap = argparse.ArgumentParser()
|
|
26
|
+
ap.add_argument("--segmentation", required=True)
|
|
27
|
+
ap.add_argument("--embedding", required=True)
|
|
28
|
+
ap.add_argument("--wav", required=True)
|
|
29
|
+
ap.add_argument("--speakers", type=int, default=0, help="the number of speakers when known, else 0")
|
|
30
|
+
ap.add_argument("--threshold", type=float, default=0.5, help="clustering threshold when the number is not known")
|
|
31
|
+
a = ap.parse_args()
|
|
32
|
+
try:
|
|
33
|
+
import numpy as np
|
|
34
|
+
import sherpa_onnx
|
|
35
|
+
except Exception as e: # noqa: BLE001
|
|
36
|
+
fail("sherpa-onnx is not installed: " + str(e))
|
|
37
|
+
try:
|
|
38
|
+
with wave.open(a.wav, "rb") as w:
|
|
39
|
+
if w.getframerate() != 16000 or w.getnchannels() != 1 or w.getsampwidth() != 2:
|
|
40
|
+
fail("the audio must be 16 kHz mono 16-bit")
|
|
41
|
+
samples = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
|
42
|
+
except wave.Error as e:
|
|
43
|
+
fail("cannot read the audio: " + str(e))
|
|
44
|
+
config = sherpa_onnx.OfflineSpeakerDiarizationConfig(
|
|
45
|
+
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
|
|
46
|
+
pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model=a.segmentation)),
|
|
47
|
+
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=a.embedding),
|
|
48
|
+
clustering=sherpa_onnx.FastClusteringConfig(num_clusters=a.speakers if a.speakers > 0 else -1, threshold=a.threshold),
|
|
49
|
+
min_duration_on=0.3,
|
|
50
|
+
min_duration_off=0.5,
|
|
51
|
+
)
|
|
52
|
+
if not config.validate():
|
|
53
|
+
fail("the diarization models are missing or do not load")
|
|
54
|
+
sd = sherpa_onnx.OfflineSpeakerDiarization(config)
|
|
55
|
+
result = sd.process(samples).sort_by_start_time()
|
|
56
|
+
segs = [{"start": round(r.start, 2), "end": round(r.end, 2), "speaker": int(r.speaker)} for r in result]
|
|
57
|
+
print(json.dumps({"ok": True, "segments": segs}))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|