create-pmos 0.110.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 +5 -0
- package/workspace/lib/notes-audio.js +17 -4
- 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 +27 -21
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-08T04:
|
|
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-08T04:
|
|
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-08T04:
|
|
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
|
|
@@ -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 };
|
|
@@ -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({});
|