#!/usr/bin/env node import * as path from 'node:path' const USAGE = `Usage: commonroom-recorder [options] commonroom-recorder transcribe [options] Joins the commonroom room as a visible, muted participant and records every other participant's audio to per-speaker WAV files, plus the room chat. Stop with Ctrl-C. The transcribe subcommand then turns a recording directory into a merged, speaker-attributed transcript (transcript.md). Recording options: --name Display name in the room (default: Recorder) --out Output directory (default: ./recordings/-) --duration Stop automatically after this many seconds --notice Chat line sent to each participant on connect (default: "🔴 This meeting is being recorded.") --no-notice Don't send any recording notice Transcribe options: --engine auto | faster-whisper | whisper-cli | whisper (default: auto — probes in that order) --model Model name (default: small), or the ggml model file path for whisper-cli --language Force a language (default: auto-detect per file) --force Re-run ASR even where asr/*.json cache files exist ` const DEFAULT_NOTICE = '🔴 This meeting is being recorded.' interface Args { room: string name: string out: string duration: number | null notice: string | null } const parseArgs = (argv: string[]): Args => { let room: string | null = null let name = 'Recorder' let out: string | null = null let duration: number | null = null let notice: string | null = DEFAULT_NOTICE for (let i = 0; i < argv.length; i++) { const a = argv[i]! switch (a) { case '--help': case '-h': process.stdout.write(USAGE) process.exit(0) break case '--name': name = (argv[++i] ?? '').trim().slice(0, 40) break case '--out': out = argv[++i] ?? null break case '--duration': { const n = Number(argv[++i]) if (!Number.isFinite(n) || n <= 0) fail('--duration needs a positive number of seconds') duration = n break } case '--notice': notice = (argv[++i] ?? '').slice(0, 2000) break case '--no-notice': notice = null break default: if (a.startsWith('-')) fail(`Unknown option: ${a}`) if (room !== null) fail('Only one room may be given') room = a.replace(/\s+/g, '').slice(0, 100) } } if (!room) fail('A room name is required') if (!name) fail('--name must not be empty') const ts = new Date() const p = (n: number) => String(n).padStart(2, '0') const defaultOut = path.join( 'recordings', `${room.replace(/[^a-zA-Z0-9_-]+/g, '_')}-` + `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-` + `${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}` ) return {room, name, out: out ?? defaultOut, duration, notice: notice || null} } function fail(msg: string): never { process.stderr.write(`${msg}\n\n${USAGE}`) process.exit(1) } const now = (): string => { const d = new Date() const p = (n: number) => String(n).padStart(2, '0') return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` } const transcribeMain = async (argv: string[]) => { let dir: string | null = null let engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper' = 'auto' let model: string | null = null let language: string | null = null let force = false for (let i = 0; i < argv.length; i++) { const a = argv[i]! switch (a) { case '--help': case '-h': process.stdout.write(USAGE) process.exit(0) break case '--engine': { const e = argv[++i] if (e !== 'auto' && e !== 'faster-whisper' && e !== 'whisper-cli' && e !== 'whisper') { fail(`Unknown engine: ${e}`) } engine = e break } case '--model': model = argv[++i] ?? null break case '--language': language = argv[++i] ?? null break case '--force': force = true break default: if (a.startsWith('-')) fail(`Unknown option: ${a}`) if (dir !== null) fail('Only one recording directory may be given') dir = a } } if (!dir) fail('A recording directory is required') const {transcribe} = await import('./transcribe.js') await transcribe({ dir, engine, model, language, force, onLog: line => process.stdout.write(`${line}\n`) }) process.exit(0) } const main = async () => { if (process.argv[2] === 'transcribe') { await transcribeMain(process.argv.slice(3)) return } const args = parseArgs(process.argv.slice(2)) // Imported lazily so `transcribe` works even where the wrtc native module // doesn't load. const {Recorder} = await import('./recorder.js') const recorder = new Recorder({ room: args.room, name: args.name, outDir: args.out, notice: args.notice, onLog: line => process.stdout.write(`[${now()}] ${line}\n`), onFatal: message => { process.stderr.write(`[${now()}] ${message}\n`) recorder.stop() process.exit(1) } }) // NOTE: every exit path must go through process.exit(): @roamhq/wrtc // segfaults in its static destructors on a natural process exit when // nonstandard media sources exist. const shutdown = () => { process.stdout.write(`\n[${now()}] stopping...\n`) const summary = recorder.stop() process.stdout.write( `[${now()}] done: ${summary.segments} audio segment(s) from ` + `${summary.participants} participant(s) in ${args.out}\n` ) process.exit(0) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) if (args.duration !== null) setTimeout(shutdown, args.duration * 1000) await recorder.start() } main().catch(err => { process.stderr.write(`fatal: ${err?.stack ?? err}\n`) process.exit(1) })