@bli-cockpit/cli 0.2.275 → 0.2.277
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/dist/commands/local-help-notes.js +1 -1
- package/dist/commands/notes-audio-direct.js +66 -11
- package/dist/commands/notes-record-capture.js +82 -1
- package/dist/commands/notes-record-wav.js +7 -172
- package/dist/commands/notes-record-win32-script.js +11 -2
- package/dist/commands/notes-record.js +190 -60
- package/dist/commands/notes-writes.js +4 -1
- package/dist/commands/public-root.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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.",
|
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
* Step 2 goes to the storage host with the signed URL ONLY: the device token is
|
|
13
13
|
* sent to Tower's own routes in steps 1 and 3 and never to anyone else.
|
|
14
14
|
*
|
|
15
|
+
* A laptop recording (`recording`, from `cockpit notes record stop`, BLI-23470)
|
|
16
|
+
* also names its session to step 3, so Tower answers a repeat with the note it
|
|
17
|
+
* already made, and keeps the object key Tower issued in the session's
|
|
18
|
+
* `upload.json` before the PUT. The next try after a failed step 3 (a timeout
|
|
19
|
+
* after the server saved, a 5xx) asks step 3 again with that key and sends no
|
|
20
|
+
* byte; only when Tower says the object is not there (`upload_not_found`) does
|
|
21
|
+
* it upload again.
|
|
22
|
+
*
|
|
15
23
|
* Nothing here throws; every failure comes back as a closed reason label and
|
|
16
24
|
* the sentence Tower (or this file) wrote for it.
|
|
17
25
|
*/
|
|
18
26
|
import { createReadStream } from "node:fs";
|
|
27
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
19
28
|
import { Readable } from "node:stream";
|
|
20
29
|
import { writeLine } from "./cli-io.js";
|
|
21
30
|
import { ask, TAG } from "./notes-door.js";
|
|
@@ -23,8 +32,61 @@ import { noteAudioMime } from "./notes-file.js";
|
|
|
23
32
|
/** A signed upload URL lives two hours; the PUT must finish inside that. */
|
|
24
33
|
const PUT_DEADLINE_MS = 2 * 60 * 60 * 1000;
|
|
25
34
|
const SMALL_DEADLINE_MS = 60_000;
|
|
35
|
+
/** The object key a previous try of this session was issued, or null (no file, unreadable, another session's). */
|
|
36
|
+
async function keptObjectKey(recording) {
|
|
37
|
+
try {
|
|
38
|
+
const kept = JSON.parse(await readFile(recording.stateFile, "utf8"));
|
|
39
|
+
return kept.session === recording.session && typeof kept.objectKey === "string" && kept.objectKey ? kept.objectKey : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function keepObjectKey(door, recording, objectKey, size) {
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(recording.stateFile, `${JSON.stringify({ session: recording.session, objectKey, bytes: size, issuedAt: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Not fatal: without it a retry sends the file again, and Tower's session check still makes one note.
|
|
51
|
+
writeLine(door.io.stderr, `${TAG} upload key not kept ${JSON.stringify({ session: recording.session, reason: error.code ?? "write_failed" })}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function confirmSaved(door, input, objectKey) {
|
|
55
|
+
const saved = await ask(door, {
|
|
56
|
+
path: "/api/notes/audio/saved", method: "POST", label: "notes audio saved", timeoutMs: SMALL_DEADLINE_MS,
|
|
57
|
+
body: {
|
|
58
|
+
object_key: objectKey, file_name: input.fileName,
|
|
59
|
+
...(input.exclude ? { exclusions: input.exclude } : {}),
|
|
60
|
+
...(input.folder ? { folder_path: input.folder } : {}),
|
|
61
|
+
...(input.recording ? { recording_session: input.recording.session } : {}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
if (!saved.ok)
|
|
65
|
+
return { ok: false, reason: saved.reason, detail: saved.detail };
|
|
66
|
+
return { ok: true, body: saved.body };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A recording whose file already reached storage on an earlier try: ask `/saved` again with the kept key. Null means
|
|
70
|
+
* "upload it" (nothing kept, or Tower says the object is not there); any other answer is final for this try.
|
|
71
|
+
*/
|
|
72
|
+
async function resumeKept(door, input, recording) {
|
|
73
|
+
const kept = await keptObjectKey(recording);
|
|
74
|
+
if (!kept)
|
|
75
|
+
return null;
|
|
76
|
+
writeLine(door.io.stderr, `${TAG} upload resumed ${JSON.stringify({ session: recording.session, bytes: input.size, reason: "object_key_kept" })}`);
|
|
77
|
+
const saved = await confirmSaved(door, input, kept);
|
|
78
|
+
if (saved.ok || saved.reason !== "upload_not_found")
|
|
79
|
+
return saved;
|
|
80
|
+
writeLine(door.io.stderr, `${TAG} upload resumed ${JSON.stringify({ session: recording.session, reason: "kept_object_not_in_storage" })}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
26
83
|
export async function uploadAudioDirect(door, input) {
|
|
27
84
|
const started = Date.now();
|
|
85
|
+
if (input.recording) {
|
|
86
|
+
const resumed = await resumeKept(door, input, input.recording);
|
|
87
|
+
if (resumed)
|
|
88
|
+
return resumed;
|
|
89
|
+
}
|
|
28
90
|
const mediaType = noteAudioMime(input.fileName) ?? "";
|
|
29
91
|
const target = await ask(door, {
|
|
30
92
|
path: "/api/notes/audio/target", method: "POST", label: "notes audio target", timeoutMs: SMALL_DEADLINE_MS,
|
|
@@ -37,6 +99,9 @@ export async function uploadAudioDirect(door, input) {
|
|
|
37
99
|
if (!issued.objectKey || !url) {
|
|
38
100
|
return { ok: false, reason: "target_unreadable", detail: "Tower answered without an upload link." };
|
|
39
101
|
}
|
|
102
|
+
// Kept BEFORE the PUT: a crash after the bytes landed still lets the next try ask `/saved` instead of re-sending.
|
|
103
|
+
if (input.recording)
|
|
104
|
+
await keepObjectKey(door, input.recording, issued.objectKey, input.size);
|
|
40
105
|
writeLine(door.io.stderr, `Uploading ${input.fileName} (${(input.size / 1_000_000).toFixed(1)} MB) straight to storage...`);
|
|
41
106
|
let put;
|
|
42
107
|
try {
|
|
@@ -63,15 +128,5 @@ export async function uploadAudioDirect(door, input) {
|
|
|
63
128
|
: `Storage refused the upload (HTTP ${put.status}). Nothing was saved; try again.` };
|
|
64
129
|
}
|
|
65
130
|
writeLine(door.io.stderr, `${TAG} storage put ok ${JSON.stringify({ bytes: input.size, elapsed_ms: Date.now() - started })}`);
|
|
66
|
-
|
|
67
|
-
path: "/api/notes/audio/saved", method: "POST", label: "notes audio saved", timeoutMs: SMALL_DEADLINE_MS,
|
|
68
|
-
body: {
|
|
69
|
-
object_key: issued.objectKey, file_name: input.fileName,
|
|
70
|
-
...(input.exclude ? { exclusions: input.exclude } : {}),
|
|
71
|
-
...(input.folder ? { folder_path: input.folder } : {}),
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
if (!saved.ok)
|
|
75
|
-
return { ok: false, reason: saved.reason, detail: saved.detail };
|
|
76
|
-
return { ok: true, body: saved.body };
|
|
131
|
+
return await confirmSaved(door, input, issued.objectKey);
|
|
77
132
|
}
|
|
@@ -18,6 +18,14 @@
|
|
|
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. BLI-23470: the same stop fires after 8 hours
|
|
26
|
+
* on the wall clock, or once the file nears the storage limit
|
|
27
|
+
* (`max_size_reached`), whichever comes first (`RECORDER_STOP_RULE`).
|
|
28
|
+
*
|
|
21
29
|
* Everything in a session lives in one directory under the collector's state
|
|
22
30
|
* dir. Nothing here touches the network.
|
|
23
31
|
*/
|
|
@@ -28,6 +36,41 @@ import { randomBytes } from "node:crypto";
|
|
|
28
36
|
import { getCollectorRuntimePaths } from "../local-state-paths.js";
|
|
29
37
|
import { launchRecorderWin32, RECORDER_SCRIPT_WIN32 } from "./notes-record-capture-win32.js";
|
|
30
38
|
export const RECORDING_SAMPLE_RATE = 16_000;
|
|
39
|
+
/** The recorder stops by itself after this much audio (BLI-23382): 8 hours. */
|
|
40
|
+
export const MAX_RECORDING_SECONDS = 28_800;
|
|
41
|
+
/** What `MAX_RECORDING_SECONDS` of 16 kHz mono 16-bit audio weighs: 921,600,000 bytes, under the bucket's 1,000,000,000. */
|
|
42
|
+
export const MAX_RECORDING_BYTES = MAX_RECORDING_SECONDS * RECORDING_SAMPLE_RATE * 2;
|
|
43
|
+
/** The marker a recorder writes when it ended by itself, holding one closed reason label. */
|
|
44
|
+
export const ENDED_MARKER = "ended.txt";
|
|
45
|
+
/** Why a recorder ends by itself: the 8-hour cap (by audio or by the clock). */
|
|
46
|
+
export const MAX_DURATION_REASON = "max_duration_reached";
|
|
47
|
+
/** BLI-23470: or its file came near the storage limit first (only if the audio is not the format asked for). */
|
|
48
|
+
export const MAX_SIZE_REASON = "max_size_reached";
|
|
49
|
+
/** The storage bucket's per-object limit (`AUDIO_DIRECT_MAX_BYTES` on the server). */
|
|
50
|
+
export const AUDIO_UPLOAD_LIMIT_BYTES = 1_000_000_000;
|
|
51
|
+
/**
|
|
52
|
+
* BLI-23470: the macOS recorder stops once `audio.wav` reaches this, 1 MB under the limit: room for the WAV header
|
|
53
|
+
* and the chunks AVAudioRecorder adds, and for what it writes in one 0.2 s poll even at a far higher rate.
|
|
54
|
+
*/
|
|
55
|
+
export const RECORDER_STOP_BYTES = AUDIO_UPLOAD_LIMIT_BYTES - 1_000_000;
|
|
56
|
+
/** BLI-23470: a recorder whose `audio.wav` has not changed for this long is not recording (both platforms write it continuously). */
|
|
57
|
+
export const RECORDER_SILENT_AFTER_MS = 60_000;
|
|
58
|
+
/** Asked again after this pause before calling it silent: a laptop just back from sleep writes again within a second. */
|
|
59
|
+
export const RECORDER_SILENT_RECHECK_MS = 3_000;
|
|
60
|
+
/** A session older than the 8-hour cap plus this has no recorder of ours behind it, whatever its pid says. */
|
|
61
|
+
export const RECORDER_PAST_CAP_MARGIN_SECONDS = 15 * 60;
|
|
62
|
+
/** BLI-23470: the session file keeping the object key Tower issued for its upload (see `notes-audio-direct.ts`). */
|
|
63
|
+
export const UPLOAD_STATE_FILE = "upload.json";
|
|
64
|
+
/**
|
|
65
|
+
* BLI-23470: when the macOS recorder stops by itself, as JavaScript it runs (and the tests run, with a fake clock).
|
|
66
|
+
* Whichever comes first: 8 hours of audio in the file, 8 hours on the wall clock since it started (the old tick
|
|
67
|
+
* count drifted), or the file nearing the storage limit. Returns the closed reason label, or null to keep recording.
|
|
68
|
+
*/
|
|
69
|
+
export const RECORDER_STOP_RULE = `function capReached(startedMs, nowMs, audioSeconds, fileBytes) {
|
|
70
|
+
if (audioSeconds >= ${MAX_RECORDING_SECONDS} || nowMs - startedMs >= ${MAX_RECORDING_SECONDS * 1000}) return '${MAX_DURATION_REASON}';
|
|
71
|
+
if (fileBytes >= ${RECORDER_STOP_BYTES}) return '${MAX_SIZE_REASON}';
|
|
72
|
+
return null;
|
|
73
|
+
}`;
|
|
31
74
|
/** The JXA recorder. `argv[0]` is the session directory. Reasons it writes to `failed.txt`: mic_denied, recorder_failed. */
|
|
32
75
|
export const RECORDER_SCRIPT = `ObjC.import('AVFoundation');
|
|
33
76
|
ObjC.import('Foundation');
|
|
@@ -37,8 +80,19 @@ function writeText(file, text) {
|
|
|
37
80
|
}
|
|
38
81
|
function exists(file) { return $.NSFileManager.defaultManager.fileExistsAtPath(file); }
|
|
39
82
|
function tick(seconds) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(seconds)); }
|
|
83
|
+
function fileBytes(file) {
|
|
84
|
+
// Never throws: a size it cannot read is 0, and the audio and clock checks still stop the recorder.
|
|
85
|
+
try {
|
|
86
|
+
var attrs = $.NSFileManager.defaultManager.attributesOfItemAtPathError(file, null);
|
|
87
|
+
if (!attrs || attrs.isNil()) return 0;
|
|
88
|
+
var size = ObjC.unwrap(attrs.objectForKey('NSFileSize'));
|
|
89
|
+
return typeof size === 'number' ? size : 0;
|
|
90
|
+
} catch (e) { return 0; }
|
|
91
|
+
}
|
|
92
|
+
${RECORDER_STOP_RULE}
|
|
40
93
|
function run(argv) {
|
|
41
94
|
var dir = argv[0];
|
|
95
|
+
var startedMs = Date.now();
|
|
42
96
|
var auth = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
|
|
43
97
|
if (auth === 1 || auth === 2) { writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
|
|
44
98
|
var settings = $.NSDictionary.dictionaryWithObjectsForKeys(
|
|
@@ -55,6 +109,14 @@ function run(argv) {
|
|
|
55
109
|
if (now === 1 || now === 2) { recorder.stop; writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
|
|
56
110
|
if (!announced && now === 3) { announced = true; writeText(dir + '/ready', 'ok'); }
|
|
57
111
|
if (!announced && waited > 120) { recorder.stop; writeText(dir + '/failed.txt', 'mic_permission_unanswered'); $.exit(5); }
|
|
112
|
+
// currentTime is the audio in the file; the wall clock and the file size are the backstops (BLI-23470).
|
|
113
|
+
var capped = capReached(startedMs, Date.now(), recorder.currentTime, fileBytes(dir + '/audio.wav'));
|
|
114
|
+
if (capped) {
|
|
115
|
+
recorder.stop;
|
|
116
|
+
writeText(dir + '/${ENDED_MARKER}', capped);
|
|
117
|
+
writeText(dir + '/done', 'ok');
|
|
118
|
+
$.exit(0);
|
|
119
|
+
}
|
|
58
120
|
}
|
|
59
121
|
recorder.stop;
|
|
60
122
|
writeText(dir + '/done', 'ok');
|
|
@@ -66,7 +128,8 @@ export function recordingsRoot(homeDir) {
|
|
|
66
128
|
}
|
|
67
129
|
export const sessionDir = (root, id) => path.join(root, id);
|
|
68
130
|
export const audioPath = (root, id) => path.join(root, id, "audio.wav");
|
|
69
|
-
|
|
131
|
+
/** The name Tower sees: a hard link to `audio.wav` (the same bytes), so the note is named after the meeting's time. */
|
|
132
|
+
export const uploadPath = (root, id) => path.join(root, id, `meeting-${id.slice(0, 13)}.wav`);
|
|
70
133
|
export function newSessionId(now) {
|
|
71
134
|
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-");
|
|
72
135
|
return `${stamp}-${randomBytes(2).toString("hex")}`;
|
|
@@ -108,6 +171,15 @@ export async function fileSize(file) {
|
|
|
108
171
|
return null;
|
|
109
172
|
}
|
|
110
173
|
}
|
|
174
|
+
/** How long ago `file` last changed, by the real clock, or null when it does not exist (BLI-23470). */
|
|
175
|
+
export async function fileQuietMs(file) {
|
|
176
|
+
try {
|
|
177
|
+
return Math.max(0, Date.now() - (await stat(file)).mtimeMs);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
111
183
|
export async function fileExists(file) {
|
|
112
184
|
return (await fileSize(file)) !== null;
|
|
113
185
|
}
|
|
@@ -119,6 +191,15 @@ export async function readReason(root, id) {
|
|
|
119
191
|
return null;
|
|
120
192
|
}
|
|
121
193
|
}
|
|
194
|
+
/** The closed reason a recorder wrote when it ended by itself (`ended.txt`), or null. */
|
|
195
|
+
export async function readEndedReason(root, id) {
|
|
196
|
+
try {
|
|
197
|
+
return (await readFile(path.join(root, id, ENDED_MARKER), "utf8")).trim() || null;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
122
203
|
export async function requestStop(root, id) {
|
|
123
204
|
await writeFile(path.join(root, id, "stop"), "stop\n", { mode: 0o600 });
|
|
124
205
|
}
|
|
@@ -1,37 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BLI-22979: the WAV file `cockpit notes record` writes
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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,41 @@
|
|
|
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
|
|
8
|
-
*
|
|
9
|
-
* so Tower transcribes and labels the
|
|
10
|
-
* for any other audio.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* and Windows (BLI-23208) record; any other platform
|
|
18
|
-
* platforms share one session directory, marker files,
|
|
19
|
-
* format, so the
|
|
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.
|
|
24
|
+
*
|
|
25
|
+
* BLI-23470: a session whose pid answers but whose recorder is plainly gone
|
|
26
|
+
* (silent file, past the cap, never opened the microphone) is crashed, so it
|
|
27
|
+
* uploads and `start` works again; an upload names its session and keeps the
|
|
28
|
+
* object key Tower issued (`upload.json`), so a retry makes no second note and
|
|
29
|
+
* sends no byte twice; failed uploads are counted on the session and `status`
|
|
30
|
+
* reports them (`upload_failures`, `last_attempt_age_seconds`) for the app's
|
|
31
|
+
* backoff.
|
|
20
32
|
*
|
|
21
33
|
* Capture is `notes-record-capture.ts` (macOS) and
|
|
22
|
-
* `notes-record-capture-win32.ts` (Windows); the
|
|
34
|
+
* `notes-record-capture-win32.ts` (Windows); the header repair is `notes-record-wav.ts`.
|
|
23
35
|
*/
|
|
36
|
+
import { link, rm } from "node:fs/promises";
|
|
24
37
|
import path from "node:path";
|
|
25
38
|
import { setTimeout as delay } from "node:timers/promises";
|
|
26
39
|
import { writeLine } from "./cli-io.js";
|
|
27
|
-
import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readReason, recordingsRoot, requestStop, sessionDir,
|
|
28
|
-
import { repairWavHeader
|
|
40
|
+
import { audioPath, fileExists, fileQuietMs, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readEndedReason, readReason, recordingsRoot, requestStop, sessionDir, uploadPath, writeSession, MAX_RECORDING_SECONDS, RECORDER_PAST_CAP_MARGIN_SECONDS, RECORDER_SILENT_AFTER_MS, RECORDER_SILENT_RECHECK_MS, RECORDING_SAMPLE_RATE, UPLOAD_STATE_FILE, } from "./notes-record-capture.js";
|
|
41
|
+
import { repairWavHeader } from "./notes-record-wav.js";
|
|
29
42
|
const TAG = "[notes record]";
|
|
30
43
|
const READY_DEADLINE_MS = 150_000;
|
|
31
44
|
const STOP_DEADLINE_MS = 10_000;
|
|
@@ -42,13 +55,72 @@ export function defaultRecordDeps(io, upload) {
|
|
|
42
55
|
}
|
|
43
56
|
catch { /* already gone */ } },
|
|
44
57
|
upload,
|
|
58
|
+
audioQuietMs: fileQuietMs,
|
|
45
59
|
};
|
|
46
60
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Where a session stands. Order matters: an end the CLI wrote, then an end the recorder wrote (`ended.txt`
|
|
63
|
+
* at the 8-hour cap, or `done`), then a live process. A marker outranks the pid, so a pid the system reused
|
|
64
|
+
* never pins a finished recorder as "recording". `endedReason` is the recorder's closed label, if it wrote one.
|
|
65
|
+
*
|
|
66
|
+
* BLI-23470: a live pid is not proof either. With no marker, a pid the system reused (or one owned by another
|
|
67
|
+
* user, EPERM) would pin a crashed session as "recording" forever: never uploaded, and `start` refused. So a
|
|
68
|
+
* process that looks alive still counts as crashed when its recorder is plainly not recording (`recorderGone`).
|
|
69
|
+
*/
|
|
70
|
+
async function viewOf(root, session, deps, io) {
|
|
71
|
+
if (session.endedAt)
|
|
72
|
+
return { state: session.endedBy === "cancel" ? "cancelled" : "stopped", endedReason: null };
|
|
73
|
+
const ended = await readEndedReason(root, session.id);
|
|
74
|
+
if (ended)
|
|
75
|
+
return { state: "stopped", endedReason: ended };
|
|
76
|
+
if (await fileExists(path.join(sessionDir(root, session.id), "done")))
|
|
77
|
+
return { state: "stopped", endedReason: null };
|
|
78
|
+
if (!deps.alive(session.pid))
|
|
79
|
+
return { state: "crashed", endedReason: null };
|
|
80
|
+
const gone = await recorderGone(root, session, deps, io);
|
|
81
|
+
return gone ? { state: "crashed", endedReason: gone } : { state: "recording", endedReason: null };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Why a session whose pid answers is not really recording, or null when it is. Judged on the session's own files:
|
|
85
|
+
* - `recorder_past_cap`: started longer ago than the 8-hour cap plus a margin; no recorder of ours runs that long.
|
|
86
|
+
* - `recorder_never_ready`: the microphone never opened (no `ready`) and `start` gave up waiting long ago.
|
|
87
|
+
* - `recorder_silent`: the microphone opened, but `audio.wav` has not changed for `RECORDER_SILENT_AFTER_MS`.
|
|
88
|
+
* Asked twice, `RECORDER_SILENT_RECHECK_MS` apart: right after a laptop wakes from sleep the file is old
|
|
89
|
+
* but the recorder writes again within a second, and that recording must not be cut off.
|
|
90
|
+
*/
|
|
91
|
+
async function recorderGone(root, session, deps, io) {
|
|
92
|
+
const ageMs = deps.now().getTime() - Date.parse(session.startedAt);
|
|
93
|
+
let reason = null;
|
|
94
|
+
if (ageMs > (MAX_RECORDING_SECONDS + RECORDER_PAST_CAP_MARGIN_SECONDS) * 1000)
|
|
95
|
+
reason = "recorder_past_cap";
|
|
96
|
+
else if (!(await fileExists(path.join(sessionDir(root, session.id), "ready")))) {
|
|
97
|
+
if (ageMs > READY_DEADLINE_MS + RECORDER_SILENT_AFTER_MS)
|
|
98
|
+
reason = "recorder_never_ready";
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const audio = audioPath(root, session.id);
|
|
102
|
+
const quiet = await deps.audioQuietMs(audio);
|
|
103
|
+
if (quiet !== null && quiet > RECORDER_SILENT_AFTER_MS) {
|
|
104
|
+
const before = await fileSize(audio);
|
|
105
|
+
await deps.sleep(RECORDER_SILENT_RECHECK_MS);
|
|
106
|
+
const again = await deps.audioQuietMs(audio);
|
|
107
|
+
// The size too: a file system that is slow to move the modified time still shows the bytes growing.
|
|
108
|
+
if (again !== null && again > RECORDER_SILENT_AFTER_MS && (await fileSize(audio)) === before)
|
|
109
|
+
reason = "recorder_silent";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (reason) {
|
|
113
|
+
writeLine(io.stderr, `${TAG} recorder gone ${JSON.stringify({ session: session.id, pid: session.pid, reason, age_s: Math.round(ageMs / 1000) })}`);
|
|
114
|
+
}
|
|
115
|
+
return reason;
|
|
116
|
+
}
|
|
117
|
+
async function findRecording(root, sessions, deps, io) {
|
|
118
|
+
for (const session of sessions) {
|
|
119
|
+
if ((await viewOf(root, session, deps, io)).state === "recording")
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
52
124
|
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
125
|
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
126
|
/** The sentence for a recorder reason, per platform: the macOS and Windows microphone switches live in different places. */
|
|
@@ -98,7 +170,7 @@ export async function runNotesRecord(command, io, deps) {
|
|
|
98
170
|
}
|
|
99
171
|
async function startRecording(command, io, deps, root) {
|
|
100
172
|
const sessions = await listSessions(root);
|
|
101
|
-
const running =
|
|
173
|
+
const running = await findRecording(root, sessions, deps, io);
|
|
102
174
|
if (running) {
|
|
103
175
|
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
176
|
}
|
|
@@ -120,7 +192,7 @@ async function startRecording(command, io, deps, root) {
|
|
|
120
192
|
}
|
|
121
193
|
if (await fileExists(`${sessionDir(root, id)}/ready`)) {
|
|
122
194
|
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
|
|
195
|
+
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
196
|
}
|
|
125
197
|
if (!deps.alive(pid)) {
|
|
126
198
|
await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
|
|
@@ -140,85 +212,143 @@ async function closeRecorder(root, session, deps) {
|
|
|
140
212
|
deps.kill(session.pid);
|
|
141
213
|
}
|
|
142
214
|
async function cancelRecording(command, io, deps, root) {
|
|
143
|
-
const running = (await listSessions(root)
|
|
215
|
+
const running = await findRecording(root, await listSessions(root), deps, io);
|
|
144
216
|
if (!running)
|
|
145
217
|
return refuse(io, command, "not_recording", "No recording is running.");
|
|
146
218
|
await closeRecorder(root, running, deps);
|
|
147
|
-
await
|
|
219
|
+
if (await fileExists(audioPath(root, running.id)))
|
|
220
|
+
await repairWavHeader(audioPath(root, running.id));
|
|
148
221
|
await writeSession(root, { ...running, endedAt: deps.now().toISOString(), endedBy: "cancel" });
|
|
149
222
|
const bytes = await fileSize(audioPath(root, running.id));
|
|
150
223
|
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)}\
|
|
224
|
+
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 });
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Why a session's audio cannot be sent, or null when it can. Records the reason on the session, so `status`
|
|
228
|
+
* stops listing it and nothing (the app's timer included) retries it forever. The file is never deleted.
|
|
229
|
+
*/
|
|
230
|
+
async function unsendable(root, session, deps) {
|
|
231
|
+
const audio = audioPath(root, session.id);
|
|
232
|
+
let reason = null;
|
|
233
|
+
if (!(await fileExists(audio)))
|
|
234
|
+
reason = "no_file";
|
|
235
|
+
else {
|
|
236
|
+
const repaired = await repairWavHeader(audio);
|
|
237
|
+
if (!repaired.ok)
|
|
238
|
+
reason = repaired.reason;
|
|
239
|
+
}
|
|
240
|
+
if (reason)
|
|
241
|
+
await writeSession(root, { ...session, nothingToUpload: { at: deps.now().toISOString(), reason } });
|
|
242
|
+
return reason;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The file handed to the uploader: `meeting-<stamp>.wav`, a hard link to `audio.wav` (the same bytes, so the
|
|
246
|
+
* note is named after the meeting's time rather than "audio"). Whatever sat at that name is removed first
|
|
247
|
+
* (CLIs before BLI-23382 wrote a trimmed copy there). If this disk cannot link, `audio.wav` itself is sent.
|
|
248
|
+
*/
|
|
249
|
+
async function fileToSend(root, id, io) {
|
|
250
|
+
const audio = audioPath(root, id);
|
|
251
|
+
const named = uploadPath(root, id);
|
|
252
|
+
try {
|
|
253
|
+
await rm(named, { force: true });
|
|
254
|
+
await link(audio, named);
|
|
255
|
+
return named;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
writeLine(io.stderr, `${TAG} upload name not linked ${JSON.stringify({ session: id, reason: error.code ?? "link_failed" })}`);
|
|
259
|
+
return audio;
|
|
260
|
+
}
|
|
152
261
|
}
|
|
153
262
|
async function stopRecording(command, io, deps, root) {
|
|
154
263
|
const sessions = await listSessions(root);
|
|
155
264
|
const target = command.sessionId
|
|
156
265
|
? sessions.find((s) => s.id === command.sessionId)
|
|
157
|
-
:
|
|
266
|
+
: await findRecording(root, sessions, deps, io);
|
|
158
267
|
if (!target) {
|
|
159
|
-
const waiting = sessions.filter((s) => !s.submittedAt);
|
|
160
|
-
const hint = waiting.length ? `
|
|
268
|
+
const waiting = sessions.filter((s) => !s.submittedAt && !s.nothingToUpload);
|
|
269
|
+
const hint = waiting.length ? ` Waiting for upload: ${waiting.map((s) => s.id).join(", ")} (\`cockpit notes record stop <id>\`).` : "";
|
|
161
270
|
return refuse(io, command, command.sessionId ? "unknown_session" : "not_recording", command.sessionId ? `No recording called ${command.sessionId}.${hint}` : `No recording is running.${hint}`);
|
|
162
271
|
}
|
|
163
272
|
if (target.submittedAt) {
|
|
164
273
|
return refuse(io, command, "already_submitted", `Recording ${target.id} was already submitted at ${target.submittedAt}.`);
|
|
165
274
|
}
|
|
166
275
|
let session = target;
|
|
167
|
-
|
|
276
|
+
const view = await viewOf(root, target, deps, io);
|
|
277
|
+
if (view.state === "recording") {
|
|
168
278
|
await closeRecorder(root, target, deps);
|
|
169
279
|
session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
|
|
170
280
|
await writeSession(root, session);
|
|
171
281
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 });
|
|
282
|
+
else if (view.endedReason?.startsWith("recorder_")) {
|
|
283
|
+
// BLI-23470: its pid answers but the recorder is gone (`recorderGone`). Ask a recorder that may still be there to
|
|
284
|
+
// close its file, but never kill: that pid may belong to somebody else's process now. Pinned as stopped.
|
|
285
|
+
await requestStop(root, target.id);
|
|
286
|
+
for (let waited = 0; waited < STOP_DEADLINE_MS && deps.alive(target.pid); waited += POLL_MS)
|
|
287
|
+
await deps.sleep(POLL_MS);
|
|
288
|
+
session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
|
|
289
|
+
await writeSession(root, session);
|
|
185
290
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
291
|
+
const audio = audioPath(root, session.id);
|
|
292
|
+
const reason = await unsendable(root, session, deps);
|
|
293
|
+
if (reason) {
|
|
294
|
+
writeLine(io.stderr, `${TAG} nothing to upload ${JSON.stringify({ session: session.id, reason })}`);
|
|
295
|
+
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)}.`);
|
|
189
296
|
}
|
|
190
|
-
const
|
|
297
|
+
const bytes = await fileSize(audio);
|
|
298
|
+
const sent = await fileToSend(root, session.id, io);
|
|
299
|
+
writeLine(io.stderr, `${TAG} uploading original ${JSON.stringify({ session: session.id, bytes, seconds: Math.round(audioSeconds(bytes)), ended_reason: view.endedReason })}`);
|
|
300
|
+
if (!command.json)
|
|
301
|
+
writeLine(io.stderr, `Uploading the whole recording (${clock(audioSeconds(bytes))}, ${megabytes(bytes)})...`);
|
|
302
|
+
// BLI-23470: the upload names this session, and keeps the object key Tower issued beside the audio, so a retry after
|
|
303
|
+
// `/saved` failed asks Tower again instead of sending the whole file twice (and Tower answers with the same note).
|
|
304
|
+
const recordingUpload = { session: session.id, stateFile: path.join(sessionDir(root, session.id), UPLOAD_STATE_FILE) };
|
|
305
|
+
const code = await deps.upload({ ...command, action: "upload", paths: [sent], recordingUpload }, sent);
|
|
191
306
|
if (code !== 0) {
|
|
192
|
-
|
|
193
|
-
|
|
307
|
+
// Counted on the session, so the app's retries back off across restarts (5 min doubling to 1 h, `backoff.rs`).
|
|
308
|
+
const failures = (session.uploadFailures ?? 0) + 1;
|
|
309
|
+
await writeSession(root, { ...session, uploadFailures: failures, lastUploadAttemptAt: deps.now().toISOString() });
|
|
310
|
+
writeLine(io.stderr, `${TAG} upload failed ${JSON.stringify({ session: session.id, exit: code, upload_failures: failures })}`);
|
|
311
|
+
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
312
|
return code;
|
|
195
313
|
}
|
|
196
314
|
await writeSession(root, { ...session, submittedAt: deps.now().toISOString() });
|
|
197
|
-
writeLine(io.stderr, `${TAG} submitted ${JSON.stringify({ session: session.id,
|
|
198
|
-
writeLine(io.stderr, `The
|
|
315
|
+
writeLine(io.stderr, `${TAG} submitted ${JSON.stringify({ session: session.id, bytes })}`);
|
|
316
|
+
writeLine(io.stderr, `The recording also stays on this machine: ${audio}`);
|
|
199
317
|
return 0;
|
|
200
318
|
}
|
|
319
|
+
const NOTE = {
|
|
320
|
+
recording: "recording now",
|
|
321
|
+
crashed: "the recorder died; the audio so far is kept",
|
|
322
|
+
cancelled: "cancelled; file kept",
|
|
323
|
+
stopped: "stopped, not yet uploaded",
|
|
324
|
+
};
|
|
201
325
|
async function recordingStatus(command, io, deps, root) {
|
|
202
|
-
const sessions = (await listSessions(root)).filter((s) => !s.submittedAt);
|
|
326
|
+
const sessions = (await listSessions(root)).filter((s) => !s.submittedAt && !s.nothingToUpload);
|
|
203
327
|
const rows = [];
|
|
204
328
|
for (const session of sessions) {
|
|
205
|
-
const state =
|
|
329
|
+
const { state, endedReason } = await viewOf(root, session, deps, io);
|
|
206
330
|
const bytes = await fileSize(audioPath(root, session.id));
|
|
207
331
|
if (state !== "recording" && bytes === null)
|
|
208
332
|
continue;
|
|
209
|
-
const
|
|
210
|
-
|
|
333
|
+
const now = deps.now().getTime();
|
|
334
|
+
const elapsed = state === "recording" ? (now - Date.parse(session.startedAt)) / 1000 : audioSeconds(bytes);
|
|
335
|
+
const lastAttempt = session.lastUploadAttemptAt ? Date.parse(session.lastUploadAttemptAt) : Number.NaN;
|
|
336
|
+
rows.push({
|
|
337
|
+
session: session.id, state, needs_upload: state !== "recording", ended_reason: endedReason,
|
|
338
|
+
started_at: session.startedAt, elapsed_seconds: Math.round(elapsed), bytes, file: audioPath(root, session.id),
|
|
339
|
+
// BLI-23470: what the app's backoff reads. Seconds since the last failed upload, so no clock is compared across processes.
|
|
340
|
+
upload_failures: session.uploadFailures ?? 0,
|
|
341
|
+
last_attempt_age_seconds: Number.isFinite(lastAttempt) ? Math.max(0, Math.round((now - lastAttempt) / 1000)) : null,
|
|
342
|
+
});
|
|
211
343
|
}
|
|
212
|
-
writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length })}`);
|
|
344
|
+
writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length, needs_upload: rows.filter((row) => row.needs_upload).length })}`);
|
|
213
345
|
if (command.json)
|
|
214
346
|
return say(io, command, "", { sessions: rows });
|
|
215
347
|
if (rows.length === 0)
|
|
216
|
-
return say(io, command, "No recording is running and none is waiting
|
|
348
|
+
return say(io, command, "No recording is running and none is waiting for upload.", {});
|
|
217
349
|
const lines = rows.map((row) => {
|
|
218
|
-
const note = row.
|
|
219
|
-
|
|
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}`;
|
|
350
|
+
const note = row.ended_reason === "max_duration_reached" ? `stopped by itself at ${MAX_RECORDING_SECONDS / 3600} hours, not yet uploaded` : NOTE[row.state];
|
|
351
|
+
const next = row.needs_upload ? ` waiting for upload (the BLI Notetaker app sends it by itself), or: cockpit notes record stop ${row.session}` : "";
|
|
222
352
|
return `${row.session} ${row.state} ${clock(row.elapsed_seconds)} ${megabytes(row.bytes)} (${note})${next ? `\n${next}` : ""}`;
|
|
223
353
|
});
|
|
224
354
|
return say(io, command, lines.join("\n"), {});
|
|
@@ -80,9 +80,12 @@ async function uploadOneNote(command, door, filePath) {
|
|
|
80
80
|
* upload route.
|
|
81
81
|
*/
|
|
82
82
|
async function sendNoteFile(command, door, filePath, read) {
|
|
83
|
-
|
|
83
|
+
// BLI-23470: a laptop recording always goes direct, whatever its size, so it names its session to `/saved` and a
|
|
84
|
+
// retry never makes a second note (the small-file route has no session check).
|
|
85
|
+
if (read.direct || (command.recordingUpload && noteAudioMime(read.fileName))) {
|
|
84
86
|
const direct = await uploadAudioDirect(door, {
|
|
85
87
|
filePath, fileName: read.fileName, size: read.size, exclude: command.exclude, folder: command.folder,
|
|
88
|
+
...(command.recordingUpload ? { recording: command.recordingUpload } : {}),
|
|
86
89
|
});
|
|
87
90
|
return direct.ok ? { ok: true, body: direct.body } : { ok: false, reason: direct.reason, detail: direct.detail };
|
|
88
91
|
}
|
|
@@ -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.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.277");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|