@bli-cockpit/cli 0.2.254 → 0.2.256

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * BLI-22979: the microphone half of `cockpit notes record` (Apple Silicon macOS).
3
+ *
4
+ * Capture mechanism: `osascript -l JavaScript` driving AVFoundation's
5
+ * AVAudioRecorder. Why this one: `osascript` ships with every Mac, so nothing
6
+ * is downloaded, signed, notarized or copied as a binary into the npm package
7
+ * (the packed-CLI copier reads files as UTF-8 and would corrupt one); the
8
+ * microphone permission prompt is the normal macOS one, shown for the terminal
9
+ * the person ran `record start` in; and AVAudioRecorder writes linear PCM
10
+ * straight to disk as it records, so a killed session leaves audio on disk
11
+ * (`repairWavHeader` fixes the size fields). A native helper would be the
12
+ * heavier path and is the follow-up if this ever proves flaky.
13
+ *
14
+ * The recorder runs as a detached process that outlives `record start`. It is
15
+ * stopped by a marker file (`stop`) it polls for, not by a signal, so it
16
+ * always closes the file itself. It writes `ready` once the microphone is
17
+ * really open and `failed.txt` (a closed reason label) if it cannot be.
18
+ *
19
+ * Everything in a session lives in one directory under the collector's state
20
+ * dir. Nothing here touches the network.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { randomBytes } from "node:crypto";
26
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
27
+ export const RECORDING_SAMPLE_RATE = 16_000;
28
+ /** The JXA recorder. `argv[0]` is the session directory. Reasons it writes to `failed.txt`: mic_denied, recorder_failed. */
29
+ export const RECORDER_SCRIPT = `ObjC.import('AVFoundation');
30
+ ObjC.import('Foundation');
31
+ ObjC.import('stdlib');
32
+ function writeText(file, text) {
33
+ $.NSString.alloc.initWithUTF8String(text).writeToFileAtomicallyEncodingError(file, true, $.NSUTF8StringEncoding, null);
34
+ }
35
+ function exists(file) { return $.NSFileManager.defaultManager.fileExistsAtPath(file); }
36
+ function tick(seconds) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(seconds)); }
37
+ function run(argv) {
38
+ var dir = argv[0];
39
+ var auth = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
40
+ if (auth === 1 || auth === 2) { writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
41
+ var settings = $.NSDictionary.dictionaryWithObjectsForKeys(
42
+ $([1819304813, ${RECORDING_SAMPLE_RATE}, 1, 16, 0, 0]),
43
+ $(['AVFormatIDKey', 'AVSampleRateKey', 'AVNumberOfChannelsKey', 'AVLinearPCMBitDepthKey', 'AVLinearPCMIsFloatKey', 'AVLinearPCMIsBigEndianKey']));
44
+ var recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError($.NSURL.fileURLWithPath(dir + '/audio.wav'), settings, null);
45
+ if (!recorder || !recorder.prepareToRecord || !recorder.record) { writeText(dir + '/failed.txt', 'recorder_failed'); $.exit(4); }
46
+ var announced = false;
47
+ var waited = 0;
48
+ while (!exists(dir + '/stop')) {
49
+ tick(0.2);
50
+ waited += 0.2;
51
+ var now = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
52
+ if (now === 1 || now === 2) { recorder.stop; writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
53
+ if (!announced && now === 3) { announced = true; writeText(dir + '/ready', 'ok'); }
54
+ if (!announced && waited > 120) { recorder.stop; writeText(dir + '/failed.txt', 'mic_permission_unanswered'); $.exit(5); }
55
+ }
56
+ recorder.stop;
57
+ writeText(dir + '/done', 'ok');
58
+ $.exit(0);
59
+ }
60
+ `;
61
+ export function recordingsRoot(homeDir) {
62
+ return path.join(getCollectorRuntimePaths(homeDir).state_dir, "notes-recordings");
63
+ }
64
+ export const sessionDir = (root, id) => path.join(root, id);
65
+ export const audioPath = (root, id) => path.join(root, id, "audio.wav");
66
+ export const trimmedPath = (root, id) => path.join(root, id, `meeting-${id.slice(0, 13)}.wav`);
67
+ export function newSessionId(now) {
68
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-");
69
+ return `${stamp}-${randomBytes(2).toString("hex")}`;
70
+ }
71
+ export async function writeSession(root, session) {
72
+ await writeFile(path.join(root, session.id, "session.json"), `${JSON.stringify(session)}\n`, { mode: 0o600 });
73
+ }
74
+ export async function readSession(root, id) {
75
+ try {
76
+ const parsed = JSON.parse(await readFile(path.join(root, id, "session.json"), "utf8"));
77
+ return typeof parsed.id === "string" && typeof parsed.startedAt === "string" && typeof parsed.pid === "number" ? parsed : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** Every session on disk, newest first. */
84
+ export async function listSessions(root) {
85
+ let names;
86
+ try {
87
+ names = await readdir(root);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const sessions = [];
93
+ for (const name of names) {
94
+ const session = await readSession(root, name);
95
+ if (session)
96
+ sessions.push(session);
97
+ }
98
+ return sessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
99
+ }
100
+ export async function fileSize(file) {
101
+ try {
102
+ return (await stat(file)).size;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ export async function fileExists(file) {
109
+ return (await fileSize(file)) !== null;
110
+ }
111
+ export async function readReason(root, id) {
112
+ try {
113
+ return (await readFile(path.join(root, id, "failed.txt"), "utf8")).trim() || null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ export async function requestStop(root, id) {
120
+ await writeFile(path.join(root, id, "stop"), "stop\n", { mode: 0o600 });
121
+ }
122
+ /** Start the detached recorder in `dir`. Returns its pid, or null when it could not be launched. */
123
+ export function launchRecorder(dir) {
124
+ try {
125
+ const child = spawn("/usr/bin/osascript", ["-l", "JavaScript", path.join(dir, "record.js"), dir], {
126
+ windowsHide: true,
127
+ detached: true,
128
+ stdio: "ignore",
129
+ });
130
+ child.on("error", () => undefined);
131
+ child.unref();
132
+ return child.pid ?? null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ export function processAlive(pid) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ return error.code === "EPERM";
145
+ }
146
+ }
147
+ export async function prepareSessionDir(root, id) {
148
+ const dir = sessionDir(root, id);
149
+ await mkdir(dir, { recursive: true, mode: 0o700 });
150
+ await writeFile(path.join(dir, "record.js"), RECORDER_SCRIPT, { mode: 0o600 });
151
+ return dir;
152
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * BLI-22979: the WAV file `cockpit notes record` writes, and the speech trim
3
+ * that runs on it when a recording stops.
4
+ *
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.
17
+ */
18
+ 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
+ const HEADER_SCAN_BYTES = 4096;
36
+ export async function readWavInfo(filePath) {
37
+ const handle = await open(filePath, "r");
38
+ try {
39
+ const { size } = await handle.stat();
40
+ const head = Buffer.alloc(Math.min(HEADER_SCAN_BYTES, size));
41
+ await handle.read(head, 0, head.length, 0);
42
+ if (head.length < 12 || head.toString("ascii", 0, 4) !== "RIFF" || head.toString("ascii", 8, 12) !== "WAVE") {
43
+ return { ok: false, reason: "not_wav" };
44
+ }
45
+ let offset = 12;
46
+ let fmt = null;
47
+ while (offset + 8 <= head.length) {
48
+ const id = head.toString("ascii", offset, offset + 4);
49
+ const chunkSize = head.readUInt32LE(offset + 4);
50
+ if (id === "fmt " && offset + 24 <= head.length) {
51
+ fmt = {
52
+ format: head.readUInt16LE(offset + 8),
53
+ channels: head.readUInt16LE(offset + 10),
54
+ sampleRate: head.readUInt32LE(offset + 12),
55
+ bitsPerSample: head.readUInt16LE(offset + 22),
56
+ };
57
+ }
58
+ if (id === "data") {
59
+ if (!fmt)
60
+ return { ok: false, reason: "not_wav" };
61
+ if ((fmt.format !== 1 && fmt.format !== 0xfffe) || fmt.bitsPerSample !== 16)
62
+ return { ok: false, reason: "not_pcm16" };
63
+ const dataOffset = offset + 8;
64
+ const dataBytes = Math.max(0, size - dataOffset);
65
+ if (dataBytes < 2)
66
+ return { ok: false, reason: "no_data" };
67
+ return {
68
+ ok: true,
69
+ info: { sampleRate: fmt.sampleRate, channels: fmt.channels, bitsPerSample: 16, dataOffset, dataBytes, dataSizeFieldOffset: offset + 4 },
70
+ };
71
+ }
72
+ offset += 8 + chunkSize + (chunkSize % 2);
73
+ }
74
+ return { ok: false, reason: "not_wav" };
75
+ }
76
+ finally {
77
+ await handle.close();
78
+ }
79
+ }
80
+ /** Rewrite the RIFF and data sizes from the real file length. Idempotent. */
81
+ export async function repairWavHeader(filePath) {
82
+ const read = await readWavInfo(filePath);
83
+ if (!read.ok)
84
+ return read;
85
+ const { size } = await stat(filePath);
86
+ const handle = await open(filePath, "r+");
87
+ try {
88
+ const riff = Buffer.alloc(4);
89
+ riff.writeUInt32LE(Math.min(size - 8, 0xffffffff));
90
+ await handle.write(riff, 0, 4, 4);
91
+ const data = Buffer.alloc(4);
92
+ // Whole sample frames only, so a torn last write does not leave half a sample.
93
+ const frameBytes = (read.info.bitsPerSample / 8) * read.info.channels;
94
+ const usable = read.info.dataBytes - (read.info.dataBytes % frameBytes);
95
+ data.writeUInt32LE(Math.min(usable, 0xffffffff));
96
+ await handle.write(data, 0, 4, read.info.dataSizeFieldOffset);
97
+ }
98
+ finally {
99
+ await handle.close();
100
+ }
101
+ 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
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `cockpit notes record start | stop | status | cancel` (BLI-22979): record a
3
+ * meeting in a room from this machine's microphone and turn it into a Tower
4
+ * note with "Speaker N" lines.
5
+ *
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.
14
+ *
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
+ * only for now: Windows says so by name (a follow-up ticket adds it).
18
+ *
19
+ * Capture is `notes-record-capture.ts`; the speech trim is `notes-record-wav.ts`.
20
+ */
21
+ import { setTimeout as delay } from "node:timers/promises";
22
+ import { writeLine } from "./cli-io.js";
23
+ import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readReason, recordingsRoot, requestStop, sessionDir, trimmedPath, writeSession, RECORDING_SAMPLE_RATE, } from "./notes-record-capture.js";
24
+ import { repairWavHeader, trimToSpeech } from "./notes-record-wav.js";
25
+ const TAG = "[notes record]";
26
+ const READY_DEADLINE_MS = 150_000;
27
+ const STOP_DEADLINE_MS = 10_000;
28
+ const POLL_MS = 250;
29
+ export function defaultRecordDeps(io, upload) {
30
+ return {
31
+ platform: process.platform,
32
+ now: () => new Date(),
33
+ sleep: io.sleep ?? ((ms) => delay(ms)),
34
+ launch: launchRecorder,
35
+ alive: processAlive,
36
+ kill: (pid) => { try {
37
+ process.kill(pid, "SIGTERM");
38
+ }
39
+ catch { /* already gone */ } },
40
+ upload,
41
+ };
42
+ }
43
+ const stateOf = (session, deps) => {
44
+ if (!session.endedAt)
45
+ return deps.alive(session.pid) ? "recording" : "crashed";
46
+ return session.endedBy === "cancel" ? "cancelled" : "stopped";
47
+ };
48
+ const MIC_DENIED = "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.";
49
+ const REFUSALS = {
50
+ mic_denied: MIC_DENIED,
51
+ mic_permission_unanswered: "macOS asked for microphone access and nobody answered. Run `cockpit notes record start` again and choose Allow.",
52
+ recorder_failed: "The recorder could not open the microphone. Check that a microphone is connected and not in use, then try again.",
53
+ };
54
+ function say(io, command, human, json, ok = true) {
55
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok, ...json }) : human);
56
+ return ok ? 0 : 1;
57
+ }
58
+ function refuse(io, command, reason, sentence) {
59
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
60
+ if (command.json)
61
+ writeLine(io.stdout, JSON.stringify({ ok: false, reason, detail: sentence }));
62
+ else
63
+ writeLine(io.stderr, sentence);
64
+ return 1;
65
+ }
66
+ const clock = (seconds) => {
67
+ const s = Math.max(0, Math.round(seconds));
68
+ const h = Math.floor(s / 3600);
69
+ const m = Math.floor((s % 3600) / 60);
70
+ return `${h > 0 ? `${h}:${String(m).padStart(2, "0")}` : String(m)}:${String(s % 60).padStart(2, "0")}`;
71
+ };
72
+ const megabytes = (bytes) => (bytes === null ? "no file yet" : `${(bytes / 1_000_000).toFixed(1)} MB`);
73
+ /** Seconds of 16 kHz mono 16-bit audio in a file of `bytes` (header included, close enough for a status line). */
74
+ const audioSeconds = (bytes) => (bytes === null ? 0 : Math.max(0, bytes - 44) / (RECORDING_SAMPLE_RATE * 2));
75
+ export async function runNotesRecord(command, io, deps) {
76
+ const verb = command.recordAction ?? "status";
77
+ if (deps.platform !== "darwin") {
78
+ const where = deps.platform === "win32" ? "Windows" : deps.platform;
79
+ return refuse(io, command, "platform_not_supported", `Recording a meeting is not supported yet on ${where}. It works on Apple Silicon Macs today; Windows is a follow-up.`);
80
+ }
81
+ const root = recordingsRoot(command.homeDir);
82
+ switch (verb) {
83
+ case "start": return await startRecording(command, io, deps, root);
84
+ case "stop": return await stopRecording(command, io, deps, root);
85
+ case "cancel": return await cancelRecording(command, io, deps, root);
86
+ default: return await recordingStatus(command, io, deps, root);
87
+ }
88
+ }
89
+ async function startRecording(command, io, deps, root) {
90
+ const sessions = await listSessions(root);
91
+ const running = sessions.find((s) => stateOf(s, deps) === "recording");
92
+ if (running) {
93
+ 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.`);
94
+ }
95
+ const id = newSessionId(deps.now());
96
+ const dir = await prepareSessionDir(root, id);
97
+ const pid = deps.launch(dir);
98
+ if (pid === null) {
99
+ return refuse(io, command, "recorder_not_launched", "Could not start the macOS recorder (osascript). Nothing was recorded.");
100
+ }
101
+ const session = { id, startedAt: deps.now().toISOString(), pid };
102
+ await writeSession(root, session);
103
+ writeLine(io.stderr, `${TAG} launched ${JSON.stringify({ session: id, pid })}`);
104
+ // Wait until the microphone is really open (the person may be answering the macOS prompt) or the recorder says why not.
105
+ for (let waited = 0; waited < READY_DEADLINE_MS; waited += POLL_MS) {
106
+ const failed = await readReason(root, id);
107
+ if (failed) {
108
+ await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
109
+ return refuse(io, command, failed, REFUSALS[failed] ?? "The recorder stopped before it started.");
110
+ }
111
+ if (await fileExists(`${sessionDir(root, id)}/ready`)) {
112
+ writeLine(io.stderr, `${TAG} recording ${JSON.stringify({ session: id, reason: "mic_open" })}`);
113
+ 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) });
114
+ }
115
+ if (!deps.alive(pid)) {
116
+ await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
117
+ return refuse(io, command, "recorder_exited", "The recorder exited before the microphone opened. Nothing was recorded.");
118
+ }
119
+ await deps.sleep(POLL_MS);
120
+ }
121
+ return refuse(io, command, "recorder_not_ready", `The recorder is running but the microphone did not open within ${READY_DEADLINE_MS / 1000} seconds. Run \`cockpit notes record cancel\` and try again.`);
122
+ }
123
+ /** Ask the recorder to close its file and wait for it; kill it only if it will not. */
124
+ async function closeRecorder(root, session, deps) {
125
+ await requestStop(root, session.id);
126
+ for (let waited = 0; waited < STOP_DEADLINE_MS && deps.alive(session.pid); waited += POLL_MS)
127
+ await deps.sleep(POLL_MS);
128
+ if (deps.alive(session.pid))
129
+ deps.kill(session.pid);
130
+ }
131
+ async function cancelRecording(command, io, deps, root) {
132
+ const running = (await listSessions(root)).find((s) => stateOf(s, deps) === "recording");
133
+ if (!running)
134
+ return refuse(io, command, "not_recording", "No recording is running.");
135
+ await closeRecorder(root, running, deps);
136
+ await repairWavHeader(audioPath(root, running.id));
137
+ await writeSession(root, { ...running, endedAt: deps.now().toISOString(), endedBy: "cancel" });
138
+ const bytes = await fileSize(audioPath(root, running.id));
139
+ writeLine(io.stderr, `${TAG} cancelled ${JSON.stringify({ session: running.id, bytes })}`);
140
+ 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 });
141
+ }
142
+ async function stopRecording(command, io, deps, root) {
143
+ const sessions = await listSessions(root);
144
+ const target = command.sessionId
145
+ ? sessions.find((s) => s.id === command.sessionId)
146
+ : sessions.find((s) => stateOf(s, deps) === "recording");
147
+ if (!target) {
148
+ const waiting = sessions.filter((s) => !s.submittedAt);
149
+ const hint = waiting.length ? ` Recoverable: ${waiting.map((s) => s.id).join(", ")} (\`cockpit notes record stop <id>\`).` : "";
150
+ return refuse(io, command, command.sessionId ? "unknown_session" : "not_recording", command.sessionId ? `No recording called ${command.sessionId}.${hint}` : `No recording is running.${hint}`);
151
+ }
152
+ if (target.submittedAt) {
153
+ return refuse(io, command, "already_submitted", `Recording ${target.id} was already submitted at ${target.submittedAt}.`);
154
+ }
155
+ let session = target;
156
+ if (stateOf(target, deps) === "recording") {
157
+ await closeRecorder(root, target, deps);
158
+ session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
159
+ await writeSession(root, session);
160
+ }
161
+ const audio = audioPath(root, session.id);
162
+ const repaired = await repairWavHeader(audio);
163
+ if (!repaired.ok) {
164
+ return refuse(io, command, `audio_${repaired.reason}`, `The recording could not be read (${repaired.reason}). The file is kept at ${audio}; nothing was uploaded.`);
165
+ }
166
+ const trimmed = trimmedPath(root, session.id);
167
+ const result = await trimToSpeech(audio, trimmed);
168
+ if (!result.ok) {
169
+ return refuse(io, command, `audio_${result.reason}`, `The recording could not be trimmed (${result.reason}). The file is kept at ${audio}; nothing was uploaded.`);
170
+ }
171
+ if (!result.speech) {
172
+ writeLine(io.stderr, `${TAG} no speech ${JSON.stringify({ session: session.id, total_s: Math.round(result.totalSeconds), speech_s: Number(result.speechSeconds.toFixed(2)) })}`);
173
+ 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 });
174
+ }
175
+ 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 })}`);
176
+ if (!command.json) {
177
+ writeLine(io.stderr, `Kept ${clock(result.keptSeconds)} of speech from ${clock(result.totalSeconds)}. Uploading...`);
178
+ }
179
+ const code = await deps.upload({ ...command, action: "upload", paths: [trimmed] }, trimmed);
180
+ if (code !== 0) {
181
+ writeLine(io.stderr, `${TAG} upload failed ${JSON.stringify({ session: session.id, exit: code })}`);
182
+ writeLine(io.stderr, `The upload did not finish. Both files are kept; run \`cockpit notes record stop ${session.id}\` to try again.`);
183
+ return code;
184
+ }
185
+ await writeSession(root, { ...session, submittedAt: deps.now().toISOString() });
186
+ writeLine(io.stderr, `${TAG} submitted ${JSON.stringify({ session: session.id, kept_s: Math.round(result.keptSeconds) })}`);
187
+ writeLine(io.stderr, `The original recording stays on this machine: ${audio}`);
188
+ return 0;
189
+ }
190
+ async function recordingStatus(command, io, deps, root) {
191
+ const sessions = (await listSessions(root)).filter((s) => !s.submittedAt);
192
+ const rows = [];
193
+ for (const session of sessions) {
194
+ const state = stateOf(session, deps);
195
+ const bytes = await fileSize(audioPath(root, session.id));
196
+ if (state !== "recording" && bytes === null)
197
+ continue;
198
+ const elapsed = state === "recording" ? (deps.now().getTime() - Date.parse(session.startedAt)) / 1000 : audioSeconds(bytes);
199
+ rows.push({ session: session.id, state, started_at: session.startedAt, elapsed_seconds: Math.round(elapsed), bytes, file: audioPath(root, session.id) });
200
+ }
201
+ writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length })}`);
202
+ if (command.json)
203
+ return say(io, command, "", { sessions: rows });
204
+ if (rows.length === 0)
205
+ return say(io, command, "No recording is running and none is waiting to be finished.", {});
206
+ const lines = rows.map((row) => {
207
+ const note = row.state === "recording" ? "recording now"
208
+ : row.state === "crashed" ? "the recorder died; the audio so far is kept"
209
+ : row.state === "cancelled" ? "cancelled; file kept" : "stopped, not yet uploaded";
210
+ const next = row.state === "recording" ? "" : ` finish with: cockpit notes record stop ${row.session}`;
211
+ return `${row.session} ${row.state} ${clock(row.elapsed_seconds)} ${megabytes(row.bytes)} (${note})${next ? `\n${next}` : ""}`;
212
+ });
213
+ return say(io, command, lines.join("\n"), {});
214
+ }
@@ -50,6 +50,7 @@ import { showBotUpcoming } from "./notes-bot-upcoming.js";
50
50
  import { showGranolaStatus } from "./notes-granola-status.js";
51
51
  import { showTranscriptionStats } from "./notes-transcription-stats.js";
52
52
  import { listRecordings, showRecording } from "./notes-recordings.js";
53
+ import { defaultRecordDeps, runNotesRecord } from "./notes-record.js";
53
54
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
54
55
  export { uploadNotes, pasteNote, shareNote, summarizeNote, moveNote, placeNote } from "./notes-writes.js";
55
56
  export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
@@ -90,6 +91,15 @@ const NOTES_VERBS = {
90
91
  "transcription-stats": showTranscriptionStats,
91
92
  };
92
93
  export async function runNotes(command, io) {
94
+ // BLI-22979: recording is local until `stop` uploads, so only the upload needs the paired session.
95
+ if (command.action === "record") {
96
+ return runNotesRecord(command, io, defaultRecordDeps(io, async (uploadCommand) => {
97
+ const session = await loadPairedSession("notes", command.homeDir);
98
+ return uploadNotes(uploadCommand, {
99
+ dashboardUrl: command.dashboardUrl ?? session.dashboard_url, deviceToken: session.device_token, io, json: command.json,
100
+ });
101
+ }));
102
+ }
93
103
  const session = await loadPairedSession("notes", command.homeDir);
94
104
  const door = {
95
105
  dashboardUrl: command.dashboardUrl ?? session.dashboard_url,
@@ -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.254");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.256");
25
25
  return 0;
26
26
  }
27
27