#!/usr/bin/env node import * as path from 'node:path' const USAGE = `Usage: commonroom-recorder record [options] commonroom-recorder transcribe [options] record 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. transcribe turns a recording directory into a merged, speaker-attributed transcript (transcript.md). Record 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 Transcribe on the fly (needs faster-whisper): the transcript grows in /transcript.md during the meeting and is finalized on stop --model Whisper model for --transcribe (default: small) --language Force a language (default: auto-detect) 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.' 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())}` } // ---- record --------------------------------------------------------------- interface RecordArgs { room: string name: string out: string duration: number | null notice: string | null transcribe: {model: string | null; language: string | null} | null } const parseRecordArgs = (argv: string[]): RecordArgs => { let room: string | null = null let name = 'Recorder' let out: string | null = null let duration: number | null = null let notice: string | null = DEFAULT_NOTICE let transcribe = false let model: string | null = null let language: string | null = null 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 case '--transcribe': transcribe = true break case '--model': model = argv[++i] ?? null break case '--language': language = argv[++i] ?? 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, transcribe: transcribe ? {model, language} : null } } const recordMain = async (argv: string[]) => { const args = parseRecordArgs(argv) if (args.transcribe) { const {hasFasterWhisper} = await import('./transcribe.js') if (!hasFasterWhisper()) { fail( 'Live transcription needs faster-whisper:\n' + ' pip install faster-whisper (python3 must be on the PATH)\n' + 'Or record without --transcribe and run the transcribe subcommand later.' ) } } // 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, transcribe: args.transcribe, onLog: line => process.stdout.write(`[${now()}] ${line}\n`), onFatal: message => { process.stderr.write(`[${now()}] ${message}\n`) void recorder.stop().then(() => 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. let shuttingDown = false const shutdown = () => { if (shuttingDown) process.exit(1) // second Ctrl-C: give up waiting shuttingDown = true process.stdout.write(`\n[${now()}] stopping...\n`) void recorder.stop().then(summary => { 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() } // ---- transcribe ----------------------------------------------------------- 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) } // ---- dispatch ------------------------------------------------------------- const main = async () => { const cmd = process.argv[2] const rest = process.argv.slice(3) if (cmd === 'record') return recordMain(rest) if (cmd === 'transcribe') return transcribeMain(rest) if (cmd === '--help' || cmd === '-h' || cmd === undefined) { process.stdout.write(USAGE) process.exit(cmd === undefined ? 1 : 0) } fail(`Unknown command: ${cmd}`) } main().catch(err => { process.stderr.write(`fatal: ${err?.stack ?? err}\n`) process.exit(1) })