2import * as path from 'node:path'
3import {Recorder} from './recorder.js'
5const USAGE = `Usage: commonroom-recorder <room> [options]
7Joins the commonroom room as a visible, muted participant and records every
8other participant's audio to per-speaker WAV files, plus the room chat.
9Stop with Ctrl-C.
11Options:
12 --name <name> Display name in the room (default: Recorder)
13 --out <dir> Output directory (default: ./recordings/<room>-<timestamp>)
14 --duration <sec> Stop automatically after this many seconds
15 --notice <text> Chat line sent to each participant on connect
16 (default: "đź”´ This meeting is being recorded.")
17 --no-notice Don't send any recording notice
18`
20const DEFAULT_NOTICE = 'đź”´ This meeting is being recorded.'
22interface Args {
23 room: string
24 name: string
25 out: string
26 duration: number | null
27 notice: string | null
28}
30const parseArgs = (argv: string[]): Args => {
31 let room: string | null = null
32 let name = 'Recorder'
33 let out: string | null = null
34 let duration: number | null = null
35 let notice: string | null = DEFAULT_NOTICE
36 for (let i = 0; i < argv.length; i++) {
37 const a = argv[i]!
38 switch (a) {
39 case '--help':
40 case '-h':
41 process.stdout.write(USAGE)
42 process.exit(0)
43 break
44 case '--name':
45 name = (argv[++i] ?? '').trim().slice(0, 40)
46 break
47 case '--out':
48 out = argv[++i] ?? null
49 break
50 case '--duration': {
51 const n = Number(argv[++i])
52 if (!Number.isFinite(n) || n <= 0) fail('--duration needs a positive number of seconds')
53 duration = n
54 break
55 }
56 case '--notice':
57 notice = (argv[++i] ?? '').slice(0, 2000)
58 break
59 case '--no-notice':
60 notice = null
61 break
62 default:
63 if (a.startsWith('-')) fail(`Unknown option: ${a}`)
64 if (room !== null) fail('Only one room may be given')
65 room = a.replace(/\s+/g, '').slice(0, 100)
66 }
67 }
68 if (!room) fail('A room name is required')
69 if (!name) fail('--name must not be empty')
70 const ts = new Date()
71 const p = (n: number) => String(n).padStart(2, '0')
72 const defaultOut = path.join(
73 'recordings',
74 `${room.replace(/[^a-zA-Z0-9_-]+/g, '_')}-` +
75 `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-` +
76 `${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`
77 )
78 return {room, name, out: out ?? defaultOut, duration, notice: notice || null}
79}
81function fail(msg: string): never {
82 process.stderr.write(`${msg}\n\n${USAGE}`)
83 process.exit(1)
84}
86const now = (): string => {
87 const d = new Date()
88 const p = (n: number) => String(n).padStart(2, '0')
89 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
90}
92const main = async () => {
93 const args = parseArgs(process.argv.slice(2))
94 const recorder = new Recorder({
95 room: args.room,
96 name: args.name,
97 outDir: args.out,
98 notice: args.notice,
99 onLog: line => process.stdout.write(`[${now()}] ${line}\n`),
100 onFatal: message => {
101 process.stderr.write(`[${now()}] ${message}\n`)
102 recorder.stop()
103 process.exit(1)
104 }
105 })
107 // NOTE: every exit path must go through process.exit(): @roamhq/wrtc
108 // segfaults in its static destructors on a natural process exit when
109 // nonstandard media sources exist.
110 const shutdown = () => {
111 process.stdout.write(`\n[${now()}] stopping...\n`)
112 const summary = recorder.stop()
113 process.stdout.write(
114 `[${now()}] done: ${summary.segments} audio segment(s) from ` +
115 `${summary.participants} participant(s) in ${args.out}\n`
116 )
117 process.exit(0)
118 }
119 process.on('SIGINT', shutdown)
120 process.on('SIGTERM', shutdown)
121 if (args.duration !== null) setTimeout(shutdown, args.duration * 1000)
123 await recorder.start()
124}
126main().catch(err => {
127 process.stderr.write(`fatal: ${err?.stack ?? err}\n`)
128 process.exit(1)
129})