concept-collection / commonroom-recorder
commonroom-recorder / src / cli.ts
248 lines · 7.8 KBBlameHistoryRaw
1#!/usr/bin/env node
2import * as path from 'node:path'
4const USAGE = `Usage: commonroom-recorder record <room> [options]
5 commonroom-recorder transcribe <recording-dir> [options]
7record joins the commonroom room as a visible, muted participant and records
8every other participant's audio to per-speaker WAV files, plus the room chat.
9Stop with Ctrl-C. transcribe turns a recording directory into a merged,
10speaker-attributed transcript (transcript.md).
12Record 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
19 --transcribe Transcribe on the fly (needs faster-whisper): the
20 transcript grows in <out>/transcript.md during the
21 meeting and is finalized on stop
22 --model <m> Whisper model for --transcribe (default: small)
23 --language <xx> Force a language (default: auto-detect)
25Transcribe options:
26 --engine <e> auto | faster-whisper | whisper-cli | whisper
27 (default: auto — probes in that order)
28 --model <m> Model name (default: small), or the ggml model file path
29 for whisper-cli
30 --language <xx> Force a language (default: auto-detect per file)
31 --force Re-run ASR even where asr/*.json cache files exist
34const DEFAULT_NOTICE = '🔴 This meeting is being recorded.'
36function fail(msg: string): never {
37 process.stderr.write(`${msg}\n\n${USAGE}`)
38 process.exit(1)
41const now = (): string => {
42 const d = new Date()
43 const p = (n: number) => String(n).padStart(2, '0')
44 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
47// ---- record ---------------------------------------------------------------
49interface RecordArgs {
50 room: string
51 name: string
52 out: string
53 duration: number | null
54 notice: string | null
55 transcribe: {model: string | null; language: string | null} | null
58const parseRecordArgs = (argv: string[]): RecordArgs => {
59 let room: string | null = null
60 let name = 'Recorder'
61 let out: string | null = null
62 let duration: number | null = null
63 let notice: string | null = DEFAULT_NOTICE
64 let transcribe = false
65 let model: string | null = null
66 let language: string | null = null
67 for (let i = 0; i < argv.length; i++) {
68 const a = argv[i]!
69 switch (a) {
70 case '--help':
71 case '-h':
72 process.stdout.write(USAGE)
73 process.exit(0)
74 break
75 case '--name':
76 name = (argv[++i] ?? '').trim().slice(0, 40)
77 break
78 case '--out':
79 out = argv[++i] ?? null
80 break
81 case '--duration': {
82 const n = Number(argv[++i])
83 if (!Number.isFinite(n) || n <= 0) fail('--duration needs a positive number of seconds')
84 duration = n
85 break
86 }
87 case '--notice':
88 notice = (argv[++i] ?? '').slice(0, 2000)
89 break
90 case '--no-notice':
91 notice = null
92 break
93 case '--transcribe':
94 transcribe = true
95 break
96 case '--model':
97 model = argv[++i] ?? null
98 break
99 case '--language':
100 language = argv[++i] ?? null
101 break
102 default:
103 if (a.startsWith('-')) fail(`Unknown option: ${a}`)
104 if (room !== null) fail('Only one room may be given')
105 room = a.replace(/\s+/g, '').slice(0, 100)
106 }
107 }
108 if (!room) fail('A room name is required')
109 if (!name) fail('--name must not be empty')
110 const ts = new Date()
111 const p = (n: number) => String(n).padStart(2, '0')
112 const defaultOut = path.join(
113 'recordings',
114 `${room.replace(/[^a-zA-Z0-9_-]+/g, '_')}-` +
115 `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-` +
116 `${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`
117 )
118 return {
119 room,
120 name,
121 out: out ?? defaultOut,
122 duration,
123 notice: notice || null,
124 transcribe: transcribe ? {model, language} : null
125 }
128const recordMain = async (argv: string[]) => {
129 const args = parseRecordArgs(argv)
130 if (args.transcribe) {
131 const {hasFasterWhisper} = await import('./transcribe.js')
132 if (!hasFasterWhisper()) {
133 fail(
134 'Live transcription needs faster-whisper:\n' +
135 ' pip install faster-whisper (python3 must be on the PATH)\n' +
136 'Or record without --transcribe and run the transcribe subcommand later.'
137 )
138 }
139 }
140 // Imported lazily so `transcribe` works even where the wrtc native module
141 // doesn't load.
142 const {Recorder} = await import('./recorder.js')
143 const recorder = new Recorder({
144 room: args.room,
145 name: args.name,
146 outDir: args.out,
147 notice: args.notice,
148 transcribe: args.transcribe,
149 onLog: line => process.stdout.write(`[${now()}] ${line}\n`),
150 onFatal: message => {
151 process.stderr.write(`[${now()}] ${message}\n`)
152 void recorder.stop().then(() => process.exit(1))
153 }
154 })
156 // NOTE: every exit path must go through process.exit(): @roamhq/wrtc
157 // segfaults in its static destructors on a natural process exit when
158 // nonstandard media sources exist.
159 let shuttingDown = false
160 const shutdown = () => {
161 if (shuttingDown) process.exit(1) // second Ctrl-C: give up waiting
162 shuttingDown = true
163 process.stdout.write(`\n[${now()}] stopping...\n`)
164 void recorder.stop().then(summary => {
165 process.stdout.write(
166 `[${now()}] done: ${summary.segments} audio segment(s) from ` +
167 `${summary.participants} participant(s) in ${args.out}\n`
168 )
169 process.exit(0)
170 })
171 }
172 process.on('SIGINT', shutdown)
173 process.on('SIGTERM', shutdown)
174 if (args.duration !== null) setTimeout(shutdown, args.duration * 1000)
176 await recorder.start()
179// ---- transcribe -----------------------------------------------------------
181const transcribeMain = async (argv: string[]) => {
182 let dir: string | null = null
183 let engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper' = 'auto'
184 let model: string | null = null
185 let language: string | null = null
186 let force = false
187 for (let i = 0; i < argv.length; i++) {
188 const a = argv[i]!
189 switch (a) {
190 case '--help':
191 case '-h':
192 process.stdout.write(USAGE)
193 process.exit(0)
194 break
195 case '--engine': {
196 const e = argv[++i]
197 if (e !== 'auto' && e !== 'faster-whisper' && e !== 'whisper-cli' && e !== 'whisper') {
198 fail(`Unknown engine: ${e}`)
199 }
200 engine = e
201 break
202 }
203 case '--model':
204 model = argv[++i] ?? null
205 break
206 case '--language':
207 language = argv[++i] ?? null
208 break
209 case '--force':
210 force = true
211 break
212 default:
213 if (a.startsWith('-')) fail(`Unknown option: ${a}`)
214 if (dir !== null) fail('Only one recording directory may be given')
215 dir = a
216 }
217 }
218 if (!dir) fail('A recording directory is required')
219 const {transcribe} = await import('./transcribe.js')
220 await transcribe({
221 dir,
222 engine,
223 model,
224 language,
225 force,
226 onLog: line => process.stdout.write(`${line}\n`)
227 })
228 process.exit(0)
231// ---- dispatch -------------------------------------------------------------
233const main = async () => {
234 const cmd = process.argv[2]
235 const rest = process.argv.slice(3)
236 if (cmd === 'record') return recordMain(rest)
237 if (cmd === 'transcribe') return transcribeMain(rest)
238 if (cmd === '--help' || cmd === '-h' || cmd === undefined) {
239 process.stdout.write(USAGE)
240 process.exit(cmd === undefined ? 1 : 0)
241 }
242 fail(`Unknown command: ${cmd}`)
245main().catch(err => {
246 process.stderr.write(`fatal: ${err?.stack ?? err}\n`)
247 process.exit(1)
248})