@bli-cockpit/cli 0.2.275 → 0.2.276

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.
@@ -6,7 +6,7 @@ export const NOTES_HELP = [
6
6
  "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
- "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording and there is no auto-stop. `stop` trims the silence away locally, uploads only the speech, and Tower writes a note with Speaker 1, Speaker 2... lines (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording or waiting; `stop <session-id>` finishes a cancelled or crashed session. The original recording stays on this machine.",
9
+ "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording; the recorder stops by itself after 8 hours (under the 1 GB upload limit). `stop` uploads the whole original recording, silence included, and Tower writes a note with Speaker 1, Speaker 2... lines, or says no speech was detected (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording and every session waiting for upload (`needs_upload`); `stop <session-id>` uploads a cancelled, crashed or auto-stopped session. The BLI Notetaker app uploads waiting sessions by itself. The recording also stays on this machine.",
10
10
  "app install|uninstall|status [--json] — the BLI Notetaker app (Apple Silicon macOS menu bar, or 64-bit Windows 10/11 taskbar notification area; Linux and Windows on Arm say they are not supported yet). `install` asks Tower for the newest release for this machine, downloads it, refuses it if its sha256 differs, and starts it at login: on the Mac it goes in ~/Applications/BLI Notetaker.app (replacing an older one, download quarantine cleared) with a LaunchAgent; on Windows it goes in %LOCALAPPDATA%\\Programs\\BLI Notetaker\\bli-notetaker.exe with a Startup entry (HKCU Run value, no admin; Windows does not block it because the CLI downloaded it). The menu runs the `record` verbs above: Record, Stop, Open last note, Finish last recording. Log: ~/Library/Logs/BLI Notetaker.log on the Mac, %LOCALAPPDATA%\\BLI Notetaker\\notetaker.log on Windows. `uninstall` removes the app and its startup entry and keeps your recordings and the log; `status` says what is installed, whether it runs, whether it starts at login, and whether a newer release exists.",
11
11
  "folders [--tree] [--json]: list folders and visible note counts.",
12
12
  "mkdir <path> [--json]: create a folder and any missing ancestors.",
@@ -18,6 +18,12 @@
18
18
  * always closes the file itself. It writes `ready` once the microphone is
19
19
  * really open and `failed.txt` (a closed reason label) if it cannot be.
20
20
  *
21
+ * BLI-23382: it also stops by itself once the file holds
22
+ * `MAX_RECORDING_SECONDS` of audio (8 hours, about 922 MB of 16 kHz mono
23
+ * 16-bit, under the 1,000,000,000-byte storage limit), writing `ended.txt`
24
+ * (`max_duration_reached`) and `done`. `status` then shows the session as
25
+ * stopped and waiting for upload.
26
+ *
21
27
  * Everything in a session lives in one directory under the collector's state
22
28
  * dir. Nothing here touches the network.
23
29
  */
@@ -28,6 +34,14 @@ import { randomBytes } from "node:crypto";
28
34
  import { getCollectorRuntimePaths } from "../local-state-paths.js";
29
35
  import { launchRecorderWin32, RECORDER_SCRIPT_WIN32 } from "./notes-record-capture-win32.js";
30
36
  export const RECORDING_SAMPLE_RATE = 16_000;
37
+ /** The recorder stops by itself after this much audio (BLI-23382): 8 hours. */
38
+ export const MAX_RECORDING_SECONDS = 28_800;
39
+ /** What `MAX_RECORDING_SECONDS` of 16 kHz mono 16-bit audio weighs: 921,600,000 bytes, under the bucket's 1,000,000,000. */
40
+ export const MAX_RECORDING_BYTES = MAX_RECORDING_SECONDS * RECORDING_SAMPLE_RATE * 2;
41
+ /** The marker a recorder writes when it ended by itself, holding one closed reason label. */
42
+ export const ENDED_MARKER = "ended.txt";
43
+ /** The only reason a recorder ends by itself today. */
44
+ export const MAX_DURATION_REASON = "max_duration_reached";
31
45
  /** The JXA recorder. `argv[0]` is the session directory. Reasons it writes to `failed.txt`: mic_denied, recorder_failed. */
32
46
  export const RECORDER_SCRIPT = `ObjC.import('AVFoundation');
33
47
  ObjC.import('Foundation');
@@ -55,6 +69,13 @@ function run(argv) {
55
69
  if (now === 1 || now === 2) { recorder.stop; writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
56
70
  if (!announced && now === 3) { announced = true; writeText(dir + '/ready', 'ok'); }
57
71
  if (!announced && waited > 120) { recorder.stop; writeText(dir + '/failed.txt', 'mic_permission_unanswered'); $.exit(5); }
72
+ // currentTime is the audio in the file, so it bounds the file size; the tick count is a backstop if it ever reads wrong.
73
+ if (recorder.currentTime >= ${MAX_RECORDING_SECONDS} || waited >= ${MAX_RECORDING_SECONDS + 600}) {
74
+ recorder.stop;
75
+ writeText(dir + '/${ENDED_MARKER}', '${MAX_DURATION_REASON}');
76
+ writeText(dir + '/done', 'ok');
77
+ $.exit(0);
78
+ }
58
79
  }
59
80
  recorder.stop;
60
81
  writeText(dir + '/done', 'ok');
@@ -66,7 +87,8 @@ export function recordingsRoot(homeDir) {
66
87
  }
67
88
  export const sessionDir = (root, id) => path.join(root, id);
68
89
  export const audioPath = (root, id) => path.join(root, id, "audio.wav");
69
- export const trimmedPath = (root, id) => path.join(root, id, `meeting-${id.slice(0, 13)}.wav`);
90
+ /** The name Tower sees: a hard link to `audio.wav` (the same bytes), so the note is named after the meeting's time. */
91
+ export const uploadPath = (root, id) => path.join(root, id, `meeting-${id.slice(0, 13)}.wav`);
70
92
  export function newSessionId(now) {
71
93
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-");
72
94
  return `${stamp}-${randomBytes(2).toString("hex")}`;
@@ -119,6 +141,15 @@ export async function readReason(root, id) {
119
141
  return null;
120
142
  }
121
143
  }
144
+ /** The closed reason a recorder wrote when it ended by itself (`ended.txt`), or null. */
145
+ export async function readEndedReason(root, id) {
146
+ try {
147
+ return (await readFile(path.join(root, id, ENDED_MARKER), "utf8")).trim() || null;
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
122
153
  export async function requestStop(root, id) {
123
154
  await writeFile(path.join(root, id, "stop"), "stop\n", { mode: 0o600 });
124
155
  }
@@ -1,37 +1,14 @@
1
1
  /**
2
- * BLI-22979: the WAV file `cockpit notes record` writes, and the speech trim
3
- * that runs on it when a recording stops.
2
+ * BLI-22979: the WAV file `cockpit notes record` writes.
4
3
  *
5
- * The recorder (see `notes-record-capture.ts`) writes 16 kHz mono 16-bit PCM,
6
- * so a trim is plain arithmetic on samples: no codec, no native module, and it
7
- * runs the same on every machine. A recording that was killed leaves a header
8
- * that still says "0 bytes of audio"; `repairWavHeader` rewrites the two size
9
- * fields from the real file length so the file plays and trims again (R37: a
10
- * crashed session is recoverable, never lost).
11
- *
12
- * The trim is a voice-activity detector with a floor learned from the file
13
- * itself, so a quiet speaker in a quiet room is kept and a loud room is not
14
- * mistaken for speech. Every kept span is padded on both sides so a soft word
15
- * start or a trailing consonant is not clipped. The original file is never
16
- * modified by the trim; the caller keeps it.
4
+ * The recorder (see `notes-record-capture.ts`) writes 16 kHz mono 16-bit PCM.
5
+ * A recording that was killed leaves a header that still says "0 bytes of
6
+ * audio"; `repairWavHeader` rewrites the two size fields from the real file
7
+ * length so the file plays (R37: a crashed session is recoverable, never lost).
8
+ * That is the only change ever made to the file: BLI-23382 removed the local
9
+ * speech trim, so Tower receives the original recording, silence included.
17
10
  */
18
11
  import { open, stat } from "node:fs/promises";
19
- /** Frame length the detector measures, in seconds. */
20
- export const VAD_FRAME_SECONDS = 0.03;
21
- /** Padding kept on both sides of every speech span, in seconds. */
22
- export const VAD_PAD_SECONDS = 0.6;
23
- /** Silence shorter than this between two bursts of speech does not split them. */
24
- export const VAD_JOIN_GAP_SECONDS = 0.8;
25
- /** A burst shorter than this is a click or a cough, not speech. */
26
- export const VAD_MIN_SPEECH_SECONDS = 0.3;
27
- /** A frame must sit this far above the learned noise floor to count as speech. */
28
- export const VAD_MARGIN_DB = 12;
29
- /** Nothing quieter than this is ever speech, whatever the floor says. */
30
- export const VAD_ABSOLUTE_MIN_DB = -55;
31
- /** The bar never rises above this, so a file that is nearly all speech (a high floor) is still kept. */
32
- export const VAD_MAX_THRESHOLD_DB = -35;
33
- /** Less speech than this in total is "no speech": nothing is submitted. */
34
- export const VAD_MIN_TOTAL_SPEECH_SECONDS = 0.6;
35
12
  const HEADER_SCAN_BYTES = 4096;
36
13
  export async function readWavInfo(filePath) {
37
14
  const handle = await open(filePath, "r");
@@ -99,146 +76,4 @@ export async function repairWavHeader(filePath) {
99
76
  await handle.close();
100
77
  }
101
78
  return { ok: true, info: { ...read.info, dataBytes: read.info.dataBytes } };
102
- }
103
- /**
104
- * Speech spans (padded, merged) from per-frame loudness in dBFS. Pure, so the
105
- * trimming rules are tested without a file.
106
- */
107
- export function detectSpeechSpans(frameDb, frameSeconds = VAD_FRAME_SECONDS) {
108
- const count = frameDb.length;
109
- if (count === 0)
110
- return { spans: [], speechSeconds: 0, noiseFloorDb: VAD_ABSOLUTE_MIN_DB, thresholdDb: VAD_ABSOLUTE_MIN_DB };
111
- const sorted = Array.from({ length: count }, (_, i) => frameDb[i]).sort((a, b) => a - b);
112
- const noiseFloorDb = sorted[Math.floor(count * 0.1)];
113
- const thresholdDb = Math.min(Math.max(noiseFloorDb + VAD_MARGIN_DB, VAD_ABSOLUTE_MIN_DB), VAD_MAX_THRESHOLD_DB);
114
- const total = count * frameSeconds;
115
- // 1. Raw runs of loud frames.
116
- let runs = [];
117
- let runStart = -1;
118
- for (let i = 0; i <= count; i += 1) {
119
- const loud = i < count && frameDb[i] > thresholdDb;
120
- if (loud && runStart < 0)
121
- runStart = i;
122
- if (!loud && runStart >= 0) {
123
- runs.push({ startSeconds: runStart * frameSeconds, endSeconds: i * frameSeconds });
124
- runStart = -1;
125
- }
126
- }
127
- // 2. Join runs separated by a short gap, so a pause between words is not a cut.
128
- const joined = [];
129
- for (const run of runs) {
130
- const last = joined.at(-1);
131
- if (last && run.startSeconds - last.endSeconds <= VAD_JOIN_GAP_SECONDS)
132
- last.endSeconds = run.endSeconds;
133
- else
134
- joined.push({ ...run });
135
- }
136
- // 3. Drop bursts too short to be speech.
137
- runs = joined.filter((run) => run.endSeconds - run.startSeconds >= VAD_MIN_SPEECH_SECONDS);
138
- const speechSeconds = runs.reduce((sum, run) => sum + (run.endSeconds - run.startSeconds), 0);
139
- if (speechSeconds < VAD_MIN_TOTAL_SPEECH_SECONDS)
140
- return { spans: [], speechSeconds, noiseFloorDb, thresholdDb };
141
- // 4. Pad every span, clamp to the file, merge spans the padding made overlap.
142
- const padded = [];
143
- for (const run of runs) {
144
- const span = { startSeconds: Math.max(0, run.startSeconds - VAD_PAD_SECONDS), endSeconds: Math.min(total, run.endSeconds + VAD_PAD_SECONDS) };
145
- const last = padded.at(-1);
146
- if (last && span.startSeconds <= last.endSeconds)
147
- last.endSeconds = Math.max(last.endSeconds, span.endSeconds);
148
- else
149
- padded.push(span);
150
- }
151
- return { spans: padded, speechSeconds, noiseFloorDb, thresholdDb };
152
- }
153
- const CHUNK_BYTES = 1 << 20;
154
- /** Loudness of every frame, read in chunks so a long meeting is never held in memory. */
155
- export async function measureFrames(filePath, info) {
156
- const frameSamples = Math.max(1, Math.round(info.sampleRate * VAD_FRAME_SECONDS));
157
- const bytesPerFrame = frameSamples * info.channels * 2;
158
- const frames = Math.floor(info.dataBytes / bytesPerFrame);
159
- const out = new Float32Array(frames);
160
- const handle = await open(filePath, "r");
161
- try {
162
- const framesPerChunk = Math.max(1, Math.floor(CHUNK_BYTES / bytesPerFrame));
163
- const buffer = Buffer.alloc(framesPerChunk * bytesPerFrame);
164
- for (let first = 0; first < frames; first += framesPerChunk) {
165
- const want = Math.min(framesPerChunk, frames - first);
166
- const { bytesRead } = await handle.read(buffer, 0, want * bytesPerFrame, info.dataOffset + first * bytesPerFrame);
167
- const got = Math.floor(bytesRead / bytesPerFrame);
168
- for (let f = 0; f < got; f += 1) {
169
- let sum = 0;
170
- const base = f * bytesPerFrame;
171
- // First channel only: the recorder writes mono; a stereo file is judged on its left side.
172
- for (let s = 0; s < frameSamples; s += 1) {
173
- const v = buffer.readInt16LE(base + s * info.channels * 2) / 32768;
174
- sum += v * v;
175
- }
176
- out[first + f] = 10 * Math.log10(Math.max(sum / frameSamples, 1e-10));
177
- }
178
- }
179
- }
180
- finally {
181
- await handle.close();
182
- }
183
- return out;
184
- }
185
- /** Write only the padded speech spans of `inputPath` to `outputPath`. Writes nothing when no speech remains. */
186
- export async function trimToSpeech(inputPath, outputPath) {
187
- const read = await readWavInfo(inputPath);
188
- if (!read.ok)
189
- return read;
190
- const { info } = read;
191
- const db = await measureFrames(inputPath, info);
192
- const frameSeconds = Math.max(1, Math.round(info.sampleRate * VAD_FRAME_SECONDS)) / info.sampleRate;
193
- const totalSeconds = info.dataBytes / (info.sampleRate * info.channels * 2);
194
- const found = detectSpeechSpans(db, frameSeconds);
195
- if (found.spans.length === 0) {
196
- return { ok: true, speech: false, totalSeconds, speechSeconds: found.speechSeconds, noiseFloorDb: found.noiseFloorDb };
197
- }
198
- const frameBytes = info.channels * 2;
199
- const ranges = found.spans.map((span) => {
200
- const start = Math.floor(span.startSeconds * info.sampleRate) * frameBytes;
201
- const end = Math.min(info.dataBytes - (info.dataBytes % frameBytes), Math.floor(span.endSeconds * info.sampleRate) * frameBytes);
202
- return { start, end };
203
- });
204
- const dataBytes = ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
205
- const header = Buffer.alloc(44);
206
- header.write("RIFF", 0, "ascii");
207
- header.writeUInt32LE(36 + dataBytes, 4);
208
- header.write("WAVEfmt ", 8, "ascii");
209
- header.writeUInt32LE(16, 16);
210
- header.writeUInt16LE(1, 20);
211
- header.writeUInt16LE(info.channels, 22);
212
- header.writeUInt32LE(info.sampleRate, 24);
213
- header.writeUInt32LE(info.sampleRate * info.channels * 2, 28);
214
- header.writeUInt16LE(info.channels * 2, 32);
215
- header.writeUInt16LE(16, 34);
216
- header.write("data", 36, "ascii");
217
- header.writeUInt32LE(dataBytes, 40);
218
- const source = await open(inputPath, "r");
219
- const target = await open(outputPath, "w", 0o600);
220
- try {
221
- await target.write(header, 0, header.length, 0);
222
- let position = header.length;
223
- const buffer = Buffer.alloc(CHUNK_BYTES);
224
- for (const range of ranges) {
225
- for (let at = range.start; at < range.end;) {
226
- const want = Math.min(buffer.length, range.end - at);
227
- const { bytesRead } = await source.read(buffer, 0, want, info.dataOffset + at);
228
- if (bytesRead === 0)
229
- break;
230
- await target.write(buffer, 0, bytesRead, position);
231
- position += bytesRead;
232
- at += bytesRead;
233
- }
234
- }
235
- }
236
- finally {
237
- await source.close();
238
- await target.close();
239
- }
240
- return {
241
- ok: true, speech: true, totalSeconds, speechSeconds: found.speechSeconds,
242
- keptSeconds: dataBytes / (info.sampleRate * info.channels * 2), spans: found.spans.length, bytes: header.length + dataBytes,
243
- };
244
79
  }
@@ -3,11 +3,17 @@
3
3
  * `notes-record-capture-win32.ts` (which launches it) so each file stays readable; the design notes live there.
4
4
  * Pure text: nothing here runs on import.
5
5
  */
6
+ /**
7
+ * BLI-23382: the recorder stops by itself once it has written this many audio bytes: 8 hours of 16 kHz mono
8
+ * 16-bit (`MAX_RECORDING_BYTES` in `notes-record-capture.ts`, which imports this file, so the number is
9
+ * repeated here and a test pins the two together).
10
+ */
11
+ export const WIN32_MAX_RECORDING_BYTES = 921_600_000;
6
12
  /**
7
13
  * The C# recorder. C# 5 and APIs of .NET Framework 4.x AND .NET 6+ only, so it
8
14
  * compiles under Windows PowerShell 5.1 (CodeDom) and PowerShell 7 (Roslyn).
9
15
  * Unmanaged WAVEHDRs and buffers, polled for WHDR_DONE: no delegate callback
10
- * that the GC could collect. Exit codes: 0 stopped, 2 no microphone, 3 mic
16
+ * that the GC could collect. Exit codes: 0 stopped (by `stop` or at the 8-hour cap, BLI-23382), 2 no microphone, 3 mic
11
17
  * denied, 4 recorder failed.
12
18
  */
13
19
  const RECORDER_CSHARP = String.raw `using System;
@@ -58,7 +64,8 @@ public sealed class BliRecorder
58
64
  const uint WhdrDone = 1;
59
65
  const int BufferCount = 8;
60
66
  const int BufferBytes = 3200;
61
- const string Consent = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone";
67
+ const long MaxBytes = ${WIN32_MAX_RECORDING_BYTES}L;
68
+ const string Consent ="SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone";
62
69
 
63
70
  readonly string dir;
64
71
  bool ready;
@@ -242,6 +249,7 @@ public sealed class BliRecorder
242
249
  while (true)
243
250
  {
244
251
  if (File.Exists(stopPath)) break;
252
+ if (total >= MaxBytes) { endedBy = "max_duration_reached"; break; }
245
253
  if (clock.ElapsedMilliseconds - lastPrivacy >= 1000)
246
254
  {
247
255
  lastPrivacy = clock.ElapsedMilliseconds;
@@ -292,6 +300,7 @@ public sealed class BliRecorder
292
300
 
293
301
  Log("closed ended_by=" + endedBy + " buffers=" + buffers + " bytes=" + total);
294
302
  if (endedBy == "mic_denied") { Fail("mic_denied"); return 3; }
303
+ if (endedBy == "max_duration_reached") File.WriteAllText(Path.Combine(dir, "ended.txt"), endedBy);
295
304
  File.WriteAllText(Path.Combine(dir, "done"), "ok");
296
305
  return 0;
297
306
  }
@@ -4,28 +4,33 @@
4
4
  * note with "Speaker N" lines.
5
5
  *
6
6
  * start opens the microphone and returns; the recorder keeps running.
7
- * stop closes it, trims the silence away on this machine, and uploads the
8
- * trimmed audio through `cockpit notes upload` (the direct uploader),
9
- * so Tower transcribes and labels the speakers exactly as it does
10
- * for any other audio. `stop <session-id>` finishes a cancelled or
11
- * crashed session the same way.
12
- * cancel closes the microphone and KEEPS the file (R37); nothing is uploaded.
13
- * status shows what is recording and what is recoverable, with time and size.
7
+ * stop closes it and uploads the ORIGINAL recording, untouched (only a
8
+ * killed recorder's WAV header is repaired), through `cockpit notes
9
+ * upload` (the direct uploader), so Tower transcribes and labels the
10
+ * speakers exactly as it does for any other audio. Silence is
11
+ * uploaded too; Tower names a recording with no speech. `stop
12
+ * <session-id>` finishes a cancelled, crashed, auto-stopped or
13
+ * failed-upload session the same way.
14
+ * cancel closes the microphone and KEEPS the file (R37); nothing is uploaded
15
+ * yet. The BLI Notetaker app uploads it by itself (BLI-23382).
16
+ * status shows what is recording and every session waiting for upload
17
+ * (`needs_upload: true`), with time and size.
14
18
  *
15
- * Nothing leaves the machine while recording, and there is no auto-stop. The
16
- * original file is never deleted or changed by the trim. Apple Silicon macOS
17
- * and Windows (BLI-23208) record; any other platform refuses by name. Both
18
- * platforms share one session directory, marker files, JSON answers and WAV
19
- * format, so the trim, upload and recovery below are the same code.
19
+ * Nothing leaves the machine while recording. The recorder stops by itself at
20
+ * `MAX_RECORDING_SECONDS` (8 hours) so the file stays under the 1 GB upload
21
+ * limit. Apple Silicon macOS and Windows (BLI-23208) record; any other platform
22
+ * refuses by name. Both platforms share one session directory, marker files,
23
+ * JSON answers and WAV format, so the upload and recovery below are one code path.
20
24
  *
21
25
  * Capture is `notes-record-capture.ts` (macOS) and
22
- * `notes-record-capture-win32.ts` (Windows); the speech trim is `notes-record-wav.ts`.
26
+ * `notes-record-capture-win32.ts` (Windows); the header repair is `notes-record-wav.ts`.
23
27
  */
28
+ import { link, rm } from "node:fs/promises";
24
29
  import path from "node:path";
25
30
  import { setTimeout as delay } from "node:timers/promises";
26
31
  import { writeLine } from "./cli-io.js";
27
- import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readReason, recordingsRoot, requestStop, sessionDir, trimmedPath, writeSession, RECORDING_SAMPLE_RATE, } from "./notes-record-capture.js";
28
- import { repairWavHeader, trimToSpeech } from "./notes-record-wav.js";
32
+ import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readEndedReason, readReason, recordingsRoot, requestStop, sessionDir, uploadPath, writeSession, MAX_RECORDING_SECONDS, RECORDING_SAMPLE_RATE, } from "./notes-record-capture.js";
33
+ import { repairWavHeader } from "./notes-record-wav.js";
29
34
  const TAG = "[notes record]";
30
35
  const READY_DEADLINE_MS = 150_000;
31
36
  const STOP_DEADLINE_MS = 10_000;
@@ -44,11 +49,28 @@ export function defaultRecordDeps(io, upload) {
44
49
  upload,
45
50
  };
46
51
  }
47
- const stateOf = (session, deps) => {
48
- if (!session.endedAt)
49
- return deps.alive(session.pid) ? "recording" : "crashed";
50
- return session.endedBy === "cancel" ? "cancelled" : "stopped";
51
- };
52
+ /**
53
+ * Where a session stands. Order matters: an end the CLI wrote, then an end the recorder wrote (`ended.txt`
54
+ * at the 8-hour cap, or `done`), then a live process. A marker outranks the pid, so a pid the system reused
55
+ * never pins a finished recorder as "recording". `endedReason` is the recorder's closed label, if it wrote one.
56
+ */
57
+ async function viewOf(root, session, deps) {
58
+ if (session.endedAt)
59
+ return { state: session.endedBy === "cancel" ? "cancelled" : "stopped", endedReason: null };
60
+ const ended = await readEndedReason(root, session.id);
61
+ if (ended)
62
+ return { state: "stopped", endedReason: ended };
63
+ if (await fileExists(path.join(sessionDir(root, session.id), "done")))
64
+ return { state: "stopped", endedReason: null };
65
+ return { state: deps.alive(session.pid) ? "recording" : "crashed", endedReason: null };
66
+ }
67
+ async function findRecording(root, sessions, deps) {
68
+ for (const session of sessions) {
69
+ if ((await viewOf(root, session, deps)).state === "recording")
70
+ return session;
71
+ }
72
+ return undefined;
73
+ }
52
74
  const MIC_DENIED_DARWIN = "The microphone is blocked for this terminal. Open System Settings > Privacy & Security > Microphone, switch your terminal app on, then run `cockpit notes record start` again.";
53
75
  const MIC_DENIED_WIN32 = "The microphone is blocked. Open Settings > Privacy & security > Microphone, turn on Microphone access and Let desktop apps access your microphone, then run `cockpit notes record start` again.";
54
76
  /** The sentence for a recorder reason, per platform: the macOS and Windows microphone switches live in different places. */
@@ -98,7 +120,7 @@ export async function runNotesRecord(command, io, deps) {
98
120
  }
99
121
  async function startRecording(command, io, deps, root) {
100
122
  const sessions = await listSessions(root);
101
- const running = sessions.find((s) => stateOf(s, deps) === "recording");
123
+ const running = await findRecording(root, sessions, deps);
102
124
  if (running) {
103
125
  return refuse(io, command, "already_recording", `A recording is already running (started ${running.startedAt}). Run \`cockpit notes record stop\` to finish it or \`cockpit notes record cancel\` to keep the file and stop.`);
104
126
  }
@@ -120,7 +142,7 @@ async function startRecording(command, io, deps, root) {
120
142
  }
121
143
  if (await fileExists(`${sessionDir(root, id)}/ready`)) {
122
144
  writeLine(io.stderr, `${TAG} recording ${JSON.stringify({ session: id, reason: "mic_open" })}`);
123
- return say(io, command, `Recording. Nothing leaves this machine until you run \`cockpit notes record stop\`.\nFile: ${audioPath(root, id)}`, { session: id, state: "recording", file: audioPath(root, id) });
145
+ return say(io, command, `Recording. Nothing leaves this machine until you run \`cockpit notes record stop\`. It stops by itself after ${MAX_RECORDING_SECONDS / 3600} hours.\nFile: ${audioPath(root, id)}`, { session: id, state: "recording", file: audioPath(root, id) });
124
146
  }
125
147
  if (!deps.alive(pid)) {
126
148
  await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
@@ -140,85 +162,123 @@ async function closeRecorder(root, session, deps) {
140
162
  deps.kill(session.pid);
141
163
  }
142
164
  async function cancelRecording(command, io, deps, root) {
143
- const running = (await listSessions(root)).find((s) => stateOf(s, deps) === "recording");
165
+ const running = await findRecording(root, await listSessions(root), deps);
144
166
  if (!running)
145
167
  return refuse(io, command, "not_recording", "No recording is running.");
146
168
  await closeRecorder(root, running, deps);
147
- await repairWavHeader(audioPath(root, running.id));
169
+ if (await fileExists(audioPath(root, running.id)))
170
+ await repairWavHeader(audioPath(root, running.id));
148
171
  await writeSession(root, { ...running, endedAt: deps.now().toISOString(), endedBy: "cancel" });
149
172
  const bytes = await fileSize(audioPath(root, running.id));
150
173
  writeLine(io.stderr, `${TAG} cancelled ${JSON.stringify({ session: running.id, bytes })}`);
151
- return say(io, command, `Stopped. Nothing was uploaded and the file is kept (${megabytes(bytes)}): ${audioPath(root, running.id)}\nFinish it later with \`cockpit notes record stop ${running.id}\`.`, { session: running.id, state: "cancelled", file: audioPath(root, running.id), bytes });
174
+ return say(io, command, `Stopped. Nothing was uploaded yet and the file is kept (${megabytes(bytes)}): ${audioPath(root, running.id)}\nThe BLI Notetaker app uploads it by itself; or run \`cockpit notes record stop ${running.id}\`.`, { session: running.id, state: "cancelled", file: audioPath(root, running.id), bytes });
175
+ }
176
+ /**
177
+ * Why a session's audio cannot be sent, or null when it can. Records the reason on the session, so `status`
178
+ * stops listing it and nothing (the app's timer included) retries it forever. The file is never deleted.
179
+ */
180
+ async function unsendable(root, session, deps) {
181
+ const audio = audioPath(root, session.id);
182
+ let reason = null;
183
+ if (!(await fileExists(audio)))
184
+ reason = "no_file";
185
+ else {
186
+ const repaired = await repairWavHeader(audio);
187
+ if (!repaired.ok)
188
+ reason = repaired.reason;
189
+ }
190
+ if (reason)
191
+ await writeSession(root, { ...session, nothingToUpload: { at: deps.now().toISOString(), reason } });
192
+ return reason;
193
+ }
194
+ /**
195
+ * The file handed to the uploader: `meeting-<stamp>.wav`, a hard link to `audio.wav` (the same bytes, so the
196
+ * note is named after the meeting's time rather than "audio"). Whatever sat at that name is removed first
197
+ * (CLIs before BLI-23382 wrote a trimmed copy there). If this disk cannot link, `audio.wav` itself is sent.
198
+ */
199
+ async function fileToSend(root, id, io) {
200
+ const audio = audioPath(root, id);
201
+ const named = uploadPath(root, id);
202
+ try {
203
+ await rm(named, { force: true });
204
+ await link(audio, named);
205
+ return named;
206
+ }
207
+ catch (error) {
208
+ writeLine(io.stderr, `${TAG} upload name not linked ${JSON.stringify({ session: id, reason: error.code ?? "link_failed" })}`);
209
+ return audio;
210
+ }
152
211
  }
153
212
  async function stopRecording(command, io, deps, root) {
154
213
  const sessions = await listSessions(root);
155
214
  const target = command.sessionId
156
215
  ? sessions.find((s) => s.id === command.sessionId)
157
- : sessions.find((s) => stateOf(s, deps) === "recording");
216
+ : await findRecording(root, sessions, deps);
158
217
  if (!target) {
159
- const waiting = sessions.filter((s) => !s.submittedAt);
160
- const hint = waiting.length ? ` Recoverable: ${waiting.map((s) => s.id).join(", ")} (\`cockpit notes record stop <id>\`).` : "";
218
+ const waiting = sessions.filter((s) => !s.submittedAt && !s.nothingToUpload);
219
+ const hint = waiting.length ? ` Waiting for upload: ${waiting.map((s) => s.id).join(", ")} (\`cockpit notes record stop <id>\`).` : "";
161
220
  return refuse(io, command, command.sessionId ? "unknown_session" : "not_recording", command.sessionId ? `No recording called ${command.sessionId}.${hint}` : `No recording is running.${hint}`);
162
221
  }
163
222
  if (target.submittedAt) {
164
223
  return refuse(io, command, "already_submitted", `Recording ${target.id} was already submitted at ${target.submittedAt}.`);
165
224
  }
166
225
  let session = target;
167
- if (stateOf(target, deps) === "recording") {
226
+ const view = await viewOf(root, target, deps);
227
+ if (view.state === "recording") {
168
228
  await closeRecorder(root, target, deps);
169
229
  session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
170
230
  await writeSession(root, session);
171
231
  }
172
232
  const audio = audioPath(root, session.id);
173
- const repaired = await repairWavHeader(audio);
174
- if (!repaired.ok) {
175
- return refuse(io, command, `audio_${repaired.reason}`, `The recording could not be read (${repaired.reason}). The file is kept at ${audio}; nothing was uploaded.`);
176
- }
177
- const trimmed = trimmedPath(root, session.id);
178
- const result = await trimToSpeech(audio, trimmed);
179
- if (!result.ok) {
180
- return refuse(io, command, `audio_${result.reason}`, `The recording could not be trimmed (${result.reason}). The file is kept at ${audio}; nothing was uploaded.`);
181
- }
182
- if (!result.speech) {
183
- writeLine(io.stderr, `${TAG} no speech ${JSON.stringify({ session: session.id, total_s: Math.round(result.totalSeconds), speech_s: Number(result.speechSeconds.toFixed(2)) })}`);
184
- return say(io, command, `No speech was found in ${clock(result.totalSeconds)} of audio, so nothing was submitted. The recording is kept: ${audio}`, { session: session.id, state: "no_speech", submitted: false, total_seconds: Math.round(result.totalSeconds), file: audio });
233
+ const reason = await unsendable(root, session, deps);
234
+ if (reason) {
235
+ writeLine(io.stderr, `${TAG} nothing to upload ${JSON.stringify({ session: session.id, reason })}`);
236
+ return refuse(io, command, `audio_${reason}`, `The recording has no audio that can be sent (${reason}). Nothing was uploaded; it will not be tried again. Any file is kept in ${sessionDir(root, session.id)}.`);
185
237
  }
186
- writeLine(io.stderr, `${TAG} trimmed ${JSON.stringify({ session: session.id, total_s: Math.round(result.totalSeconds), kept_s: Math.round(result.keptSeconds), spans: result.spans, bytes: result.bytes })}`);
187
- if (!command.json) {
188
- writeLine(io.stderr, `Kept ${clock(result.keptSeconds)} of speech from ${clock(result.totalSeconds)}. Uploading...`);
189
- }
190
- const code = await deps.upload({ ...command, action: "upload", paths: [trimmed] }, trimmed);
238
+ const bytes = await fileSize(audio);
239
+ const sent = await fileToSend(root, session.id, io);
240
+ writeLine(io.stderr, `${TAG} uploading original ${JSON.stringify({ session: session.id, bytes, seconds: Math.round(audioSeconds(bytes)), ended_reason: view.endedReason })}`);
241
+ if (!command.json)
242
+ writeLine(io.stderr, `Uploading the whole recording (${clock(audioSeconds(bytes))}, ${megabytes(bytes)})...`);
243
+ const code = await deps.upload({ ...command, action: "upload", paths: [sent] }, sent);
191
244
  if (code !== 0) {
192
245
  writeLine(io.stderr, `${TAG} upload failed ${JSON.stringify({ session: session.id, exit: code })}`);
193
- writeLine(io.stderr, `The upload did not finish. Both files are kept; run \`cockpit notes record stop ${session.id}\` to try again.`);
246
+ writeLine(io.stderr, `The upload did not finish. The recording is kept; the BLI Notetaker app tries again by itself, or run \`cockpit notes record stop ${session.id}\`.`);
194
247
  return code;
195
248
  }
196
249
  await writeSession(root, { ...session, submittedAt: deps.now().toISOString() });
197
- writeLine(io.stderr, `${TAG} submitted ${JSON.stringify({ session: session.id, kept_s: Math.round(result.keptSeconds) })}`);
198
- writeLine(io.stderr, `The original recording stays on this machine: ${audio}`);
250
+ writeLine(io.stderr, `${TAG} submitted ${JSON.stringify({ session: session.id, bytes })}`);
251
+ writeLine(io.stderr, `The recording also stays on this machine: ${audio}`);
199
252
  return 0;
200
253
  }
254
+ const NOTE = {
255
+ recording: "recording now",
256
+ crashed: "the recorder died; the audio so far is kept",
257
+ cancelled: "cancelled; file kept",
258
+ stopped: "stopped, not yet uploaded",
259
+ };
201
260
  async function recordingStatus(command, io, deps, root) {
202
- const sessions = (await listSessions(root)).filter((s) => !s.submittedAt);
261
+ const sessions = (await listSessions(root)).filter((s) => !s.submittedAt && !s.nothingToUpload);
203
262
  const rows = [];
204
263
  for (const session of sessions) {
205
- const state = stateOf(session, deps);
264
+ const { state, endedReason } = await viewOf(root, session, deps);
206
265
  const bytes = await fileSize(audioPath(root, session.id));
207
266
  if (state !== "recording" && bytes === null)
208
267
  continue;
209
268
  const elapsed = state === "recording" ? (deps.now().getTime() - Date.parse(session.startedAt)) / 1000 : audioSeconds(bytes);
210
- rows.push({ session: session.id, state, started_at: session.startedAt, elapsed_seconds: Math.round(elapsed), bytes, file: audioPath(root, session.id) });
269
+ rows.push({
270
+ session: session.id, state, needs_upload: state !== "recording", ended_reason: endedReason,
271
+ started_at: session.startedAt, elapsed_seconds: Math.round(elapsed), bytes, file: audioPath(root, session.id),
272
+ });
211
273
  }
212
- writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length })}`);
274
+ writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length, needs_upload: rows.filter((row) => row.needs_upload).length })}`);
213
275
  if (command.json)
214
276
  return say(io, command, "", { sessions: rows });
215
277
  if (rows.length === 0)
216
- return say(io, command, "No recording is running and none is waiting to be finished.", {});
278
+ return say(io, command, "No recording is running and none is waiting for upload.", {});
217
279
  const lines = rows.map((row) => {
218
- const note = row.state === "recording" ? "recording now"
219
- : row.state === "crashed" ? "the recorder died; the audio so far is kept"
220
- : row.state === "cancelled" ? "cancelled; file kept" : "stopped, not yet uploaded";
221
- const next = row.state === "recording" ? "" : ` finish with: cockpit notes record stop ${row.session}`;
280
+ const note = row.ended_reason === "max_duration_reached" ? `stopped by itself at ${MAX_RECORDING_SECONDS / 3600} hours, not yet uploaded` : NOTE[row.state];
281
+ const next = row.needs_upload ? ` waiting for upload (the BLI Notetaker app sends it by itself), or: cockpit notes record stop ${row.session}` : "";
222
282
  return `${row.session} ${row.state} ${clock(row.elapsed_seconds)} ${megabytes(row.bytes)} (${note})${next ? `\n${next}` : ""}`;
223
283
  });
224
284
  return say(io, command, lines.join("\n"), {});
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.275");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.276");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.275",
3
+ "version": "0.2.276",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {