From 91a649e2784bc64b1a17b5169c7d13667034d6b6 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Wed, 9 Sep 2026 09:43:26 -0500 Subject: [PATCH 1/3] bundles: meeting recorder with local transcription Records a meeting in the browser (shared tab audio plus microphone, mixed in WebAudio, uploaded every 15 s) and transcribes it on the same host with a local OpenAI-compatible endpoint, defaulting to the faster-whisper-server bundle on loopback. No cloud call, no API key, no port of its own: the page and its upload endpoints ride the gateway through panel + panelRoutes, so recordings inherit the dashboard session. - panel/meeting-recorder.js: capture UI, level meter and a silence warning so a silent recording surfaces in the first 20 seconds, notes box, recent recordings table, and an insecure-context warning (tab audio needs HTTPS) - panel/routes.js: session/chunk/finish/upload/status/sessions, auth scoped under one path prefix, bodies streamed to disk rather than buffered - server/transcribe.js: ten-minute slices with timestamps offset back into meeting time; a detached worker, so a long meeting survives a closed page - server/store.js: session directories, id validation, listing - also transcribes a recording made some other way (call-app recording, phone voice memo) through the same path Measured about 3.5x real time with large-v3 int8 on CPU. --- bundles/meeting-recorder/README.md | 55 +++ bundles/meeting-recorder/manifest.json | 45 +++ .../panel/meeting-recorder.js | 347 ++++++++++++++++++ bundles/meeting-recorder/panel/routes.js | 180 +++++++++ bundles/meeting-recorder/server/store.js | 75 ++++ bundles/meeting-recorder/server/transcribe.js | 186 ++++++++++ 6 files changed, 888 insertions(+) create mode 100644 bundles/meeting-recorder/README.md create mode 100644 bundles/meeting-recorder/manifest.json create mode 100644 bundles/meeting-recorder/panel/meeting-recorder.js create mode 100644 bundles/meeting-recorder/panel/routes.js create mode 100644 bundles/meeting-recorder/server/store.js create mode 100644 bundles/meeting-recorder/server/transcribe.js diff --git a/bundles/meeting-recorder/README.md b/bundles/meeting-recorder/README.md new file mode 100644 index 00000000..cf4d8527 --- /dev/null +++ b/bundles/meeting-recorder/README.md @@ -0,0 +1,55 @@ +# Meeting Recorder + +Record a meeting in the browser and transcribe it on the same machine that served the page. + +The panel captures two audio sources, the meeting itself (a shared tab or window) and the +microphone, mixes them in WebAudio, and uploads Opus every 15 seconds. On stop, a detached worker +converts the audio, sends it to a local OpenAI-compatible transcription endpoint in ten-minute +slices, and writes a timestamped markdown transcript. Nothing leaves the host, and no API key is +involved. + +## What it needs + +- **ffmpeg and ffprobe** on the host. +- **A transcription endpoint.** The `faster-whisper-server` bundle is the intended pairing: CPU, + int8, loopback `:8004`, which is this bundle's default. Any OpenAI-compatible + `/v1/audio/transcriptions` endpoint works. +- **A secure context.** Browsers hand over tab audio only over HTTPS or on localhost. Reach the + dashboard through Tailscale Serve, a TLS reverse proxy, or `http://localhost`. The panel says so + on screen when the context is insecure, before you record silence by accident. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `WHISPER_URL` | `http://localhost:8004/v1/audio/transcriptions` | the transcription endpoint | +| `WHISPER_MODEL` | `Systran/faster-whisper-large-v3` | model name sent with each slice | +| `WHISPER_SLICE_SECONDS` | `600` | slice length; smaller means finer progress, more requests | +| `MEETING_RECORDER_EXPORT_DIR` | unset | if set, every transcript also lands in `/-/transcript.md` | + +## Where recordings live + +`$CROW_HOME/data/meeting-recorder//` + +| File | What | +|---|---| +| `audio.webm` (or `audio.` for an upload) | the recording | +| `audio.wav` | 16 kHz mono, what the transcriber read | +| `meta.json` | title, timings, state, results | +| `transcript.json` | segments with start, end, text | +| `transcript.md` | the readable transcript, with any notes taken while listening | + +## Throughput + +Roughly 3.5x real time on an AMD Ryzen AI Max+ 395 with faster-whisper large-v3 int8 on CPU: a +ninety-minute meeting finishes about twenty-five minutes after it ends. Transcription starts when +recording stops; there is no live transcript. + +## Limits worth knowing before you rely on it + +- **No speaker labels.** Diarization is a second model and is not here. Every transcript carries a + line saying so, because a machine transcript with confident-looking text invites quotation. +- **Names get misheard.** Verify any quotation against the audio before it travels. +- **Recording other people carries obligations this bundle does not handle.** Many hosts prohibit + recording their sessions, and consent rules vary by jurisdiction. That judgement is the + operator's, before pressing record. diff --git a/bundles/meeting-recorder/manifest.json b/bundles/meeting-recorder/manifest.json new file mode 100644 index 00000000..40a37cc1 --- /dev/null +++ b/bundles/meeting-recorder/manifest.json @@ -0,0 +1,45 @@ +{ + "id": "meeting-recorder", + "name": "Meeting Recorder", + "version": "1.0.0", + "description": "Record a meeting in the browser (shared tab audio plus microphone) and transcribe it locally with faster-whisper. Nothing leaves the machine.", + "type": "bundle", + "author": "Crow", + "category": "ai", + "tags": ["audio", "transcription", "whisper", "meetings", "notes"], + "icon": "mic", + "panel": { + "id": "meeting-recorder", + "name": "Meeting Recorder", + "icon": "mic", + "route": "/dashboard/meeting-recorder", + "navOrder": 17 + }, + "panelRoutes": "panel/routes.js", + "requires": { + "env": [], + "min_ram_mb": 64, + "min_disk_mb": 500 + }, + "env_vars": [ + { + "name": "WHISPER_URL", + "description": "OpenAI-compatible transcription endpoint. Defaults to the faster-whisper-server bundle on loopback :8004.", + "required": false, + "default": "http://localhost:8004/v1/audio/transcriptions" + }, + { + "name": "WHISPER_MODEL", + "description": "Model name passed to that endpoint.", + "required": false, + "default": "Systran/faster-whisper-large-v3" + }, + { + "name": "MEETING_RECORDER_EXPORT_DIR", + "description": "Optional. A directory that also receives a dated markdown copy of every transcript, for grepping or for a git repo.", + "required": false, + "default": "" + } + ], + "notes": "Needs ffmpeg on the host and a transcription endpoint (the faster-whisper-server bundle is the intended pairing; it is CPU-only and loopback-bound). No port of its own: the capture page and its upload endpoints ride the gateway through the panel and panelRoutes, so recordings inherit the dashboard's session auth. A browser only hands over tab audio in a secure context, so reach the dashboard over HTTPS (Tailscale Serve, a reverse proxy, or localhost)." +} diff --git a/bundles/meeting-recorder/panel/meeting-recorder.js b/bundles/meeting-recorder/panel/meeting-recorder.js new file mode 100644 index 00000000..2deedffd --- /dev/null +++ b/bundles/meeting-recorder/panel/meeting-recorder.js @@ -0,0 +1,347 @@ +/** + * Meeting Recorder — Crow's Nest panel. + * + * Captures the meeting's own audio (a shared tab or window) plus the + * microphone, mixes them in WebAudio, and uploads Opus every 15 seconds to the + * companion routes. On stop, a detached worker transcribes the recording + * locally. The page also accepts a recording made some other way. + * + * A browser only offers tab audio in a secure context, so this page needs to be + * reached over HTTPS or on localhost. It says so on screen when it is not. + */ + +const API = "/dashboard/meeting-recorder-api"; + +export default { + id: "meeting-recorder", + name: "Meeting Recorder", + icon: "mic", + route: "/dashboard/meeting-recorder", + navOrder: 17, + + async handler(req, res, { layout, appRoot }) { + const { pathToFileURL } = await import("node:url"); + const { join } = await import("node:path"); + const componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js"); + const { escapeHtml, section } = await import(pathToFileURL(componentsPath).href); + + const { listSessions } = await import( + pathToFileURL(join(appRoot, "bundles/meeting-recorder/server/store.js")).href + ).catch(async () => { + const { homedir } = await import("node:os"); + return import( + pathToFileURL( + join(homedir(), ".crow", "bundles", "meeting-recorder", "server", "store.js") + ).href + ); + }); + + const sessions = listSessions(12); + const clock = (s) => { + const t = Math.floor(s || 0); + return t >= 3600 + ? `${Math.floor(t / 3600)}:${String(Math.floor((t % 3600) / 60)).padStart(2, "0")}` + : `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`; + }; + + const rows = sessions.length + ? sessions + .map((s) => { + const state = + s.state === "done" + ? `done` + : s.state === "failed" + ? `failed` + : `${escapeHtml(s.state || "")}`; + const detail = + s.state === "done" + ? `${s.word_count || 0} words` + : escapeHtml(s.progress || s.error || ""); + return `${escapeHtml(s.title || "Untitled")} + ${escapeHtml((s.started_at || "").slice(0, 16).replace("T", " "))} + ${clock(s.duration_seconds)}${state}${detail} + ${escapeHtml(s.id || "")}`; + }) + .join("") + : `Nothing recorded yet.`; + + const content = ` + + + + +
+ + +
+ + +
+
+ +
+

+
+ + + + + +
+ +

Any audio or video file. It takes the same path and + lands in the same place.

+ + +
+ +
+ +

+
+ +${section( + "Recent recordings", + ` + ${rows}
MeetingStartedLengthStateSession
` +)} + +`; + + return layout({ title: "Meeting Recorder", content }); + }, +}; diff --git a/bundles/meeting-recorder/panel/routes.js b/bundles/meeting-recorder/panel/routes.js new file mode 100644 index 00000000..5a93a25d --- /dev/null +++ b/bundles/meeting-recorder/panel/routes.js @@ -0,0 +1,180 @@ +/** + * Meeting Recorder — panel routes. + * + * Everything is scoped under /dashboard/meeting-recorder-api and behind the + * dashboard's own auth middleware, so a recording inherits the session the + * operator already has. Nothing here is public. + * + * Audio arrives as a raw body (audio/webm chunks while recording, an arbitrary + * media file on upload) and is streamed to disk. The gateway's global JSON + * parser ignores those content types, so the request stream reaches the handler + * untouched. + */ + +import { spawn } from "node:child_process"; +import { createWriteStream, existsSync, mkdirSync, openSync } from "node:fs"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Router } from "express"; + +import { listSessions, newId, readMeta, sessionDir, writeMeta } from "../server/store.js"; + +const HERE = fileURLToPath(new URL(".", import.meta.url)); +const MAX_CHUNK = 32 * 1024 * 1024; +const MAX_UPLOAD = 4 * 1024 * 1024 * 1024; +const SAFE_SUFFIX = /^\.[a-z0-9]{1,8}$/; + +/** + * The worker lives beside this file in the repo and in the installed copy, so + * resolve it relative to the panel rather than to any app root. + */ +function workerPath() { + const installed = join(HERE, "..", "server", "transcribe.js"); + if (existsSync(installed)) return installed; + return join(HERE, "..", "server", "transcribe.js"); +} + +function startTranscription(id) { + const log = openSync(join(sessionDir(id), "transcribe.log"), "a"); + const child = spawn(process.execPath, [workerPath(), id], { + detached: true, + stdio: ["ignore", log, log], + env: process.env, + }); + child.unref(); +} + +/** Stream a request body to a file, refusing anything past `cap`. */ +function streamToFile(req, path, cap, append = false) { + return new Promise((resolve, reject) => { + let written = 0; + let aborted = false; + const out = createWriteStream(path, { flags: append ? "a" : "w" }); + req.on("data", (chunk) => { + written += chunk.length; + if (written > cap && !aborted) { + aborted = true; + out.destroy(); + reject(new Error("too large")); + } + }); + req.on("error", reject); + out.on("error", reject); + out.on("close", () => (aborted ? undefined : resolve(written))); + req.pipe(out); + }); +} + +export default function meetingRecorderRouter(authMiddleware) { + const router = Router(); + const base = "/dashboard/meeting-recorder-api"; + + // Path-scoped, per the gateway's mount-time check on unpathed middleware. + router.use(base, authMiddleware); + + router.post(`${base}/session`, (req, res) => { + const body = req.body || {}; + const id = newId(); + mkdirSync(sessionDir(id), { recursive: true }); + res.json( + writeMeta(id, { + id, + title: String(body.title || "Untitled meeting").slice(0, 200), + started_at: new Date().toISOString(), + state: "recording", + bytes: 0, + chunks: 0, + sources: Array.isArray(body.sources) ? body.sources : [], + }) + ); + }); + + router.post(`${base}/chunk`, async (req, res) => { + const id = String(req.query.id || ""); + let dir; + try { + dir = sessionDir(id); + } catch { + return res.status(400).json({ error: "bad session id" }); + } + if (!existsSync(dir)) return res.status(404).json({ error: "unknown session" }); + try { + const written = await streamToFile(req, join(dir, "audio.webm"), MAX_CHUNK, true); + const meta = readMeta(id); + res.json( + writeMeta(id, { + bytes: (meta.bytes || 0) + written, + chunks: (meta.chunks || 0) + 1, + last_chunk_at: new Date().toISOString(), + }) + ); + } catch (err) { + res.status(err.message === "too large" ? 413 : 500).json({ error: err.message }); + } + }); + + router.post(`${base}/finish`, (req, res) => { + const id = String(req.query.id || ""); + let dir; + try { + dir = sessionDir(id); + } catch { + return res.status(400).json({ error: "bad session id" }); + } + if (!existsSync(dir)) return res.status(404).json({ error: "unknown session" }); + const body = req.body || {}; + const meta = writeMeta(id, { + state: "transcribing", + ended_at: new Date().toISOString(), + duration_seconds: Number(body.duration_seconds) || 0, + notes: String(body.notes || "").slice(0, 4000), + }); + startTranscription(id); + res.json(meta); + }); + + // A recording made some other way: a call app's own local recording, a phone + // voice memo, an old meeting. Same path from here on. + router.post(`${base}/upload`, async (req, res) => { + const name = String(req.query.name || "recording"); + const suffix = SAFE_SUFFIX.test(extname(name).toLowerCase()) + ? extname(name).toLowerCase() + : ".bin"; + const id = newId(); + mkdirSync(sessionDir(id), { recursive: true }); + try { + const written = await streamToFile(req, join(sessionDir(id), `audio${suffix}`), MAX_UPLOAD); + const meta = writeMeta(id, { + id, + title: String(req.query.title || name).slice(0, 200), + started_at: new Date().toISOString(), + ended_at: new Date().toISOString(), + state: "transcribing", + bytes: written, + chunks: 0, + sources: ["file"], + original_filename: name.slice(0, 200), + notes: String(req.query.notes || "").slice(0, 4000), + }); + startTranscription(id); + res.json(meta); + } catch (err) { + res.status(err.message === "too large" ? 413 : 500).json({ error: err.message }); + } + }); + + router.get(`${base}/status`, (req, res) => { + try { + res.json(readMeta(String(req.query.id || ""))); + } catch { + res.status(400).json({ error: "bad session id" }); + } + }); + + router.get(`${base}/sessions`, (_req, res) => { + res.json({ sessions: listSessions(25) }); + }); + + return router; +} diff --git a/bundles/meeting-recorder/server/store.js b/bundles/meeting-recorder/server/store.js new file mode 100644 index 00000000..d6cde3ec --- /dev/null +++ b/bundles/meeting-recorder/server/store.js @@ -0,0 +1,75 @@ +/** + * Meeting Recorder — session storage. + * + * One directory per recording under $CROW_HOME/data/meeting-recorder//: + * audio.webm | audio. the recording (appended chunk by chunk, or uploaded) + * audio.wav 16 kHz mono, what the transcriber reads + * meta.json title, timings, state, results + * transcript.json segments with start, end, text + * transcript.md the readable transcript + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const dataDir = () => + join(process.env.CROW_HOME || join(homedir(), ".crow"), "data", "meeting-recorder"); + +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** Reject anything path-shaped before it reaches the filesystem. */ +export function sessionDir(id) { + if (!ID_RE.test(id || "")) throw new Error("bad session id"); + return join(dataDir(), id); +} + +export function newId() { + const t = new Date(); + const p = (n) => String(n).padStart(2, "0"); + const stamp = `${t.getFullYear()}${p(t.getMonth() + 1)}${p(t.getDate())}-${p(t.getHours())}${p(t.getMinutes())}${p(t.getSeconds())}`; + return `${stamp}-${Math.random().toString(16).slice(2, 8)}`; +} + +export function readMeta(id) { + const p = join(sessionDir(id), "meta.json"); + return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {}; +} + +export function writeMeta(id, fields) { + const dir = sessionDir(id); + mkdirSync(dir, { recursive: true }); + const meta = { ...readMeta(id), ...fields }; + writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2)); + return meta; +} + +export function listSessions(limit = 25) { + const root = dataDir(); + if (!existsSync(root)) return []; + return readdirSync(root) + .filter((name) => ID_RE.test(name) && existsSync(join(root, name, "meta.json"))) + .sort() + .reverse() + .slice(0, limit) + .map((name) => { + try { + return readMeta(name); + } catch { + return null; + } + }) + .filter(Boolean); +} + +/** The recorder writes audio.webm; an upload keeps its own suffix. */ +export function findSource(id) { + const dir = sessionDir(id); + const preferred = join(dir, "audio.webm"); + if (existsSync(preferred)) return preferred; + const other = readdirSync(dir).find( + (f) => f.startsWith("audio.") && !f.endsWith(".wav") && !f.endsWith(".json") + ); + if (!other) throw new Error(`no source audio in ${dir}`); + return join(dir, other); +} diff --git a/bundles/meeting-recorder/server/transcribe.js b/bundles/meeting-recorder/server/transcribe.js new file mode 100644 index 00000000..ac95303f --- /dev/null +++ b/bundles/meeting-recorder/server/transcribe.js @@ -0,0 +1,186 @@ +/** + * Meeting Recorder — the transcription worker. + * + * node server/transcribe.js + * + * Runs detached from the request that started it, so a ninety-minute meeting + * finishes even if the page is closed. Audio goes to the transcription endpoint + * in slices rather than one request: a single upload of a long meeting is a + * fragile thing, and slices give the page something to show while it waits. + * Slice timestamps are offset back into meeting time before anything is written. + */ + +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { findSource, readMeta, sessionDir, writeMeta } from "./store.js"; + +const WHISPER_URL = process.env.WHISPER_URL || "http://localhost:8004/v1/audio/transcriptions"; +const WHISPER_MODEL = process.env.WHISPER_MODEL || "Systran/faster-whisper-large-v3"; +const SLICE_SECONDS = Number(process.env.WHISPER_SLICE_SECONDS || 600); +const EXPORT_DIR = process.env.MEETING_RECORDER_EXPORT_DIR || ""; + +function run(cmd, args) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + let err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + child.on("error", reject); + child.on("close", (code) => + code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 400)}`)) + ); + }); +} + +const toWav = (src, dest) => + run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", src, + "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", dest]).then(() => dest); + +async function durationSeconds(path) { + const out = await run("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path]); + return Number(out.trim()) || 0; +} + +async function postSlice(path) { + const form = new FormData(); + form.append("model", WHISPER_MODEL); + form.append("response_format", "verbose_json"); + form.append("file", new Blob([readFileSync(path)], { type: "audio/wav" }), basename(path)); + const res = await fetch(WHISPER_URL, { method: "POST", body: form }); + if (!res.ok) throw new Error(`transcription endpoint returned ${res.status}`); + return res.json(); +} + +async function transcribeWav(wav, id, total) { + const segments = []; + const sliceDir = join(sessionDir(id), "slices"); + mkdirSync(sliceDir, { recursive: true }); + for (let start = 0; start < Math.max(total, 1); start += SLICE_SECONDS) { + const part = join(sliceDir, `part-${String(start / SLICE_SECONDS).padStart(3, "0")}.wav`); + await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-ss", String(start), "-t", String(SLICE_SECONDS), "-i", wav, + "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", part]); + if (!existsSync(part) || statSync(part).size < 2000) { + rmSync(part, { force: true }); + break; + } + writeMeta(id, { + progress: `transcribing minute ${Math.round(start / 60)} of ${Math.round(total / 60)}`, + }); + const result = await postSlice(part); + for (const seg of result.segments || []) { + segments.push({ + start: Math.round((Number(seg.start || 0) + start) * 100) / 100, + end: Math.round((Number(seg.end || 0) + start) * 100) / 100, + text: (seg.text || "").trim(), + }); + } + if (!(result.segments || []).length && result.text) { + segments.push({ start, end: start, text: result.text.trim() }); + } + rmSync(part, { force: true }); + } + rmSync(sliceDir, { recursive: true, force: true }); + return segments; +} + +export function clock(seconds) { + const s = Math.floor(seconds || 0); + const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); + const ss = String(s % 60).padStart(2, "0"); + return s >= 3600 ? `${Math.floor(s / 3600)}:${mm}:${ss}` : `${Math.floor(s / 60)}:${ss}`; +} + +export function toMarkdown(meta, segments) { + const lines = [ + `# ${meta.title || "Untitled meeting"}`, + "", + `Recorded ${(meta.started_at || "").slice(0, 19).replace("T", " ")}. ` + + `Duration ${clock(meta.duration_seconds)}. Transcribed locally with ` + + `${WHISPER_MODEL.split("/").pop()}, session \`${meta.id}\`.`, + "", + "Machine transcript. Speaker labels are absent and names are often misheard; " + + "check any quotation against the audio before it travels.", + "", + ]; + if (meta.notes) lines.push("## Notes taken while listening", "", meta.notes.trim(), ""); + lines.push("## Transcript", ""); + let para = []; + let paraStart = null; + for (const seg of segments) { + if (paraStart === null) paraStart = seg.start; + para.push(seg.text); + // Break every ~45 seconds so the transcript reads in paragraphs. + if (seg.end - paraStart > 45) { + lines.push(`**[${clock(paraStart)}]** ${para.join(" ").trim()}`, ""); + para = []; + paraStart = null; + } + } + if (para.length) lines.push(`**[${clock(paraStart || 0)}]** ${para.join(" ").trim()}`, ""); + return lines.join("\n"); +} + +const slug = (text) => + (text || "meeting").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || + "meeting"; + +function exportCopy(meta, markdown) { + if (!EXPORT_DIR) return ""; + const day = (meta.started_at || new Date().toISOString()).slice(0, 10); + const dir = join(EXPORT_DIR, `${day}-${slug(meta.title)}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "transcript.md"), markdown); + writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2)); + return join(dir, "transcript.md"); +} + +export async function transcribeSession(id) { + const started = Date.now(); + const dir = sessionDir(id); + try { + writeMeta(id, { state: "transcribing", progress: "preparing audio" }); + const wav = await toWav(findSource(id), join(dir, "audio.wav")); + const total = await durationSeconds(wav); + writeMeta(id, { audio_seconds: Math.round(total * 10) / 10 }); + const segments = await transcribeWav(wav, id, total); + writeFileSync(join(dir, "transcript.json"), JSON.stringify(segments, null, 2)); + const meta = readMeta(id); + if (!meta.duration_seconds) meta.duration_seconds = Math.round(total); + const markdown = toMarkdown(meta, segments); + writeFileSync(join(dir, "transcript.md"), markdown); + const exported = exportCopy(meta, markdown); + writeMeta(id, { + state: "done", + duration_seconds: meta.duration_seconds, + word_count: segments.reduce((n, s) => n + s.text.split(/\s+/).filter(Boolean).length, 0), + segment_count: segments.length, + transcribe_seconds: Math.round((Date.now() - started) / 100) / 10, + transcript_path: join(dir, "transcript.md"), + export_path: exported, + progress: "", + }); + } catch (err) { + writeMeta(id, { state: "failed", error: String(err.message || err).slice(0, 500) }); + throw err; + } +} + +// Run as a detached child by the panel routes: `node server/transcribe.js `. +if (process.argv[1] && process.argv[1].endsWith("transcribe.js")) { + const id = process.argv[2]; + if (!id) { + console.error("usage: node server/transcribe.js "); + process.exit(2); + } + try { + await transcribeSession(id); + } catch (err) { + console.error(err); + process.exit(1); + } +} From 75a63876a3c804e4a578eb63e7c066981e260e2c Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Wed, 9 Sep 2026 09:45:17 -0500 Subject: [PATCH 2/3] registry: add meeting-recorder --- registry/add-ons.json | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/registry/add-ons.json b/registry/add-ons.json index 933e1044..6155c287 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3393,6 +3393,58 @@ ], "official": true }, + { + "id": "meeting-recorder", + "name": "Meeting Recorder", + "version": "1.0.0", + "description": "Record a meeting in the browser (shared tab audio plus microphone) and transcribe it locally with faster-whisper. Nothing leaves the machine.", + "type": "bundle", + "author": "Crow", + "category": "ai", + "tags": [ + "audio", + "transcription", + "whisper", + "meetings", + "notes" + ], + "icon": "mic", + "panel": { + "id": "meeting-recorder", + "name": "Meeting Recorder", + "icon": "mic", + "route": "/dashboard/meeting-recorder", + "navOrder": 17 + }, + "panelRoutes": "panel/routes.js", + "requires": { + "env": [], + "min_ram_mb": 64, + "min_disk_mb": 500 + }, + "env_vars": [ + { + "name": "WHISPER_URL", + "description": "OpenAI-compatible transcription endpoint. Defaults to the faster-whisper-server bundle on loopback :8004.", + "required": false, + "default": "http://localhost:8004/v1/audio/transcriptions" + }, + { + "name": "WHISPER_MODEL", + "description": "Model name passed to that endpoint.", + "required": false, + "default": "Systran/faster-whisper-large-v3" + }, + { + "name": "MEETING_RECORDER_EXPORT_DIR", + "description": "Optional. A directory that also receives a dated markdown copy of every transcript, for grepping or for a git repo.", + "required": false, + "default": "" + } + ], + "notes": "Needs ffmpeg on the host and a transcription endpoint (the faster-whisper-server bundle is the intended pairing; it is CPU-only and loopback-bound). No port of its own: the capture page and its upload endpoints ride the gateway through the panel and panelRoutes, so recordings inherit the dashboard's session auth. A browser only hands over tab audio in a secure context, so reach the dashboard over HTTPS (Tailscale Serve, a reverse proxy, or localhost).", + "official": true + }, { "id": "meta-glasses", "name": "Meta Glasses", From 9ac8ffe007a33cf6f389ff78f86f03ba45aaa197 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Wed, 9 Sep 2026 09:49:28 -0500 Subject: [PATCH 3/3] meeting-recorder: resolve bundle code the way installed panels must The panel registry copies panel/*.js into /panels/, which breaks bundle-relative imports: a static "../server/store.js" resolves to /server/store.js and the router never loads. Both files now try the installed bundle path first (honouring CROW_HOME, so alternate instances find their own copy) and fall back to the repo layout, the same pattern knowledge-base-routes.js uses. Verified end to end in both layouts against a real faster-whisper server: chunked record, finish, detached worker, transcript written, panel rendered with the session table. --- .../panel/meeting-recorder.js | 21 +++++----- bundles/meeting-recorder/panel/routes.js | 38 +++++++++++++------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/bundles/meeting-recorder/panel/meeting-recorder.js b/bundles/meeting-recorder/panel/meeting-recorder.js index 2deedffd..ecc8c534 100644 --- a/bundles/meeting-recorder/panel/meeting-recorder.js +++ b/bundles/meeting-recorder/panel/meeting-recorder.js @@ -25,16 +25,17 @@ export default { const componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js"); const { escapeHtml, section } = await import(pathToFileURL(componentsPath).href); - const { listSessions } = await import( - pathToFileURL(join(appRoot, "bundles/meeting-recorder/server/store.js")).href - ).catch(async () => { - const { homedir } = await import("node:os"); - return import( - pathToFileURL( - join(homedir(), ".crow", "bundles", "meeting-recorder", "server", "store.js") - ).href - ); - }); + // Installed copy first (an alternate instance sets CROW_HOME), repo second. + const { homedir } = await import("node:os"); + const { existsSync } = await import("node:fs"); + const storeCandidates = [ + join(process.env.CROW_HOME || join(homedir(), ".crow"), + "bundles", "meeting-recorder", "server", "store.js"), + join(appRoot, "bundles/meeting-recorder/server/store.js"), + ]; + const storePath = storeCandidates.find((p) => existsSync(p)); + if (!storePath) throw new Error("meeting-recorder: store.js not found"); + const { listSessions } = await import(pathToFileURL(storePath).href); const sessions = listSessions(12); const clock = (s) => { diff --git a/bundles/meeting-recorder/panel/routes.js b/bundles/meeting-recorder/panel/routes.js index 5a93a25d..17cf4922 100644 --- a/bundles/meeting-recorder/panel/routes.js +++ b/bundles/meeting-recorder/panel/routes.js @@ -13,28 +13,44 @@ import { spawn } from "node:child_process"; import { createWriteStream, existsSync, mkdirSync, openSync } from "node:fs"; -import { extname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { extname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { Router } from "express"; -import { listSessions, newId, readMeta, sessionDir, writeMeta } from "../server/store.js"; - const HERE = fileURLToPath(new URL(".", import.meta.url)); const MAX_CHUNK = 32 * 1024 * 1024; const MAX_UPLOAD = 4 * 1024 * 1024 * 1024; const SAFE_SUFFIX = /^\.[a-z0-9]{1,8}$/; -/** - * The worker lives beside this file in the repo and in the installed copy, so - * resolve it relative to the panel rather than to any app root. - */ +// The panel registry installs this file to /panels/, which breaks +// bundle-relative imports. Look in the installed bundle first, then the repo +// layout. Same list serves the store module and the worker script. +const CANDIDATE_DIRS = [ + join(process.env.CROW_HOME || join(homedir(), ".crow"), "bundles", "meeting-recorder", "server"), + resolve(HERE, "../server"), +]; + +async function loadStore() { + for (const dir of CANDIDATE_DIRS) { + const path = join(dir, "store.js"); + if (!existsSync(path)) continue; + return import(pathToFileURL(path).href); + } + throw new Error("meeting-recorder: store.js not found in " + CANDIDATE_DIRS.join(" or ")); +} + function workerPath() { - const installed = join(HERE, "..", "server", "transcribe.js"); - if (existsSync(installed)) return installed; - return join(HERE, "..", "server", "transcribe.js"); + for (const dir of CANDIDATE_DIRS) { + const path = join(dir, "transcribe.js"); + if (existsSync(path)) return path; + } + throw new Error("meeting-recorder: transcribe.js not found"); } +const { listSessions, newId, readMeta, sessionDir, writeMeta } = await loadStore(); + function startTranscription(id) { const log = openSync(join(sessionDir(id), "transcribe.log"), "a"); const child = spawn(process.execPath, [workerPath(), id], {