#!/usr/bin/env node import * as path from 'node:path' import {Recorder} from './recorder.js' const USAGE = `Usage: commonroom-recorder [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. 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 ` 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 main = async () => { const args = parseArgs(process.argv.slice(2)) 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) })