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