2import * as path from 'node:path'
4const USAGE = `Usage: commonroom-recorder <room> [options]
5 commonroom-recorder transcribe <recording-dir> [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. The transcribe subcommand then turns a recording directory
10into a merged, speaker-attributed transcript (transcript.md).
12Recording options:
13 --name <name> Display name in the room (default: Recorder)
14 --out <dir> Output directory (default: ./recordings/<room>-<timestamp>)
15 --duration <sec> Stop automatically after this many seconds
16 --notice <text> Chat line sent to each participant on connect
17 (default: "đź”´ This meeting is being recorded.")
18 --no-notice Don't send any recording notice
20Transcribe options:
21 --engine <e> auto | faster-whisper | whisper-cli | whisper
22 (default: auto — probes in that order)
23 --model <m> Model name (default: small), or the ggml model file path
24 for whisper-cli
25 --language <xx> Force a language (default: auto-detect per file)
26 --force Re-run ASR even where asr/*.json cache files exist
27`
29const DEFAULT_NOTICE = 'đź”´ This meeting is being recorded.'
31interface Args {
32 room: string
33 name: string
34 out: string
35 duration: number | null
36 notice: string | null
37}
39const parseArgs = (argv: string[]): Args => {
40 let room: string | null = null
41 let name = 'Recorder'
42 let out: string | null = null
43 let duration: number | null = null
44 let notice: string | null = DEFAULT_NOTICE
45 for (let i = 0; i < argv.length; i++) {
46 const a = argv[i]!
47 switch (a) {
48 case '--help':
49 case '-h':
50 process.stdout.write(USAGE)
51 process.exit(0)
52 break
53 case '--name':
54 name = (argv[++i] ?? '').trim().slice(0, 40)
55 break
56 case '--out':
57 out = argv[++i] ?? null
58 break
59 case '--duration': {
60 const n = Number(argv[++i])
61 if (!Number.isFinite(n) || n <= 0) fail('--duration needs a positive number of seconds')
62 duration = n
63 break
64 }
65 case '--notice':
66 notice = (argv[++i] ?? '').slice(0, 2000)
67 break
68 case '--no-notice':
69 notice = null
70 break
71 default:
72 if (a.startsWith('-')) fail(`Unknown option: ${a}`)
73 if (room !== null) fail('Only one room may be given')
74 room = a.replace(/\s+/g, '').slice(0, 100)
75 }
76 }
77 if (!room) fail('A room name is required')
78 if (!name) fail('--name must not be empty')
79 const ts = new Date()
80 const p = (n: number) => String(n).padStart(2, '0')
81 const defaultOut = path.join(
82 'recordings',
83 `${room.replace(/[^a-zA-Z0-9_-]+/g, '_')}-` +
84 `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-` +
85 `${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`
86 )
87 return {room, name, out: out ?? defaultOut, duration, notice: notice || null}
88}
90function fail(msg: string): never {
91 process.stderr.write(`${msg}\n\n${USAGE}`)
92 process.exit(1)
93}
95const now = (): string => {
96 const d = new Date()
97 const p = (n: number) => String(n).padStart(2, '0')
98 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
99}
101const transcribeMain = async (argv: string[]) => {
102 let dir: string | null = null
103 let engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper' = 'auto'
104 let model: string | null = null
105 let language: string | null = null
106 let force = false
107 for (let i = 0; i < argv.length; i++) {
108 const a = argv[i]!
109 switch (a) {
110 case '--help':
111 case '-h':
112 process.stdout.write(USAGE)
113 process.exit(0)
114 break
115 case '--engine': {
116 const e = argv[++i]
117 if (e !== 'auto' && e !== 'faster-whisper' && e !== 'whisper-cli' && e !== 'whisper') {
118 fail(`Unknown engine: ${e}`)
119 }
120 engine = e
121 break
122 }
123 case '--model':
124 model = argv[++i] ?? null
125 break
126 case '--language':
127 language = argv[++i] ?? null
128 break
129 case '--force':
130 force = true
131 break
132 default:
133 if (a.startsWith('-')) fail(`Unknown option: ${a}`)
134 if (dir !== null) fail('Only one recording directory may be given')
135 dir = a
136 }
137 }
138 if (!dir) fail('A recording directory is required')
139 const {transcribe} = await import('./transcribe.js')
140 await transcribe({
141 dir,
142 engine,
143 model,
144 language,
145 force,
146 onLog: line => process.stdout.write(`${line}\n`)
147 })
148 process.exit(0)
149}
151const main = async () => {
152 if (process.argv[2] === 'transcribe') {
153 await transcribeMain(process.argv.slice(3))
154 return
155 }
156 const args = parseArgs(process.argv.slice(2))
157 // Imported lazily so `transcribe` works even where the wrtc native module
158 // doesn't load.
159 const {Recorder} = await import('./recorder.js')
160 const recorder = new Recorder({
161 room: args.room,
162 name: args.name,
163 outDir: args.out,
164 notice: args.notice,
165 onLog: line => process.stdout.write(`[${now()}] ${line}\n`),
166 onFatal: message => {
167 process.stderr.write(`[${now()}] ${message}\n`)
168 recorder.stop()
169 process.exit(1)
170 }
171 })
173 // NOTE: every exit path must go through process.exit(): @roamhq/wrtc
174 // segfaults in its static destructors on a natural process exit when
175 // nonstandard media sources exist.
176 const shutdown = () => {
177 process.stdout.write(`\n[${now()}] stopping...\n`)
178 const summary = recorder.stop()
179 process.stdout.write(
180 `[${now()}] done: ${summary.segments} audio segment(s) from ` +
181 `${summary.participants} participant(s) in ${args.out}\n`
182 )
183 process.exit(0)
184 }
185 process.on('SIGINT', shutdown)
186 process.on('SIGTERM', shutdown)
187 if (args.duration !== null) setTimeout(shutdown, args.duration * 1000)
189 await recorder.start()
190}
192main().catch(err => {
193 process.stderr.write(`fatal: ${err?.stack ?? err}\n`)
194 process.exit(1)
195})