1import {spawn, spawnSync} from 'node:child_process'
2import * as fs from 'node:fs'
3import * as path from 'node:path'
5// The `transcribe` subcommand: turn a recording directory (per-speaker WAVs +
6// manifest.json + events.jsonl) into one merged, speaker-attributed
7// transcript. Because each WAV is silence-padded so sample position tracks
8// wall-clock time, an ASR timestamp within a segment plus the segment's
9// manifest `startedAt` IS the meeting timeline β no alignment step needed.
10//
11// ASR engines (probed in this order for --engine auto):
12// faster-whisper python3 + the faster_whisper package (VAD filtering on,
13// which also skips our padded silence)
14// whisper-cli whisper.cpp; needs --model <path to ggml/gguf file>
15// whisper the openai-whisper CLI
16//
17// Raw per-WAV ASR results are cached in <dir>/asr/*.json so formatting can be
18// iterated (or the engine swapped) without re-transcribing; --force redoes.
20export interface TranscribeOptions {
21 dir: string
22 engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper'
23 /** Model name (faster-whisper / whisper) or model file path (whisper-cli).
24 * null = engine default. */
25 model: string | null
26 /** ISO 639-1 code, or null for per-file auto-detection. */
27 language: string | null
28 force: boolean
29 onLog: (line: string) => void
30}
32interface AsrSegment {
33 start: number
34 end: number
35 text: string
36}
38interface ManifestSegment {
39 file: string
40 peerId: string
41 name: string
42 startedAt: string
43 endedAt: string | null
44 durationSec: number
45}
47interface Manifest {
48 room: string
49 recorder: {peerId: string; name: string}
50 startedAt: string
51 endedAt: string | null
52 participants: Record<string, string>
53 segments: ManifestSegment[]
54}
56type Item =
57 | {timeMs: number; type: 'speech'; speaker: string; text: string; endMs: number}
58 | {timeMs: number; type: 'chat'; speaker: string; text: string}
59 | {timeMs: number; type: 'system'; text: string}
61// ---- ASR engines ----------------------------------------------------------
63const FASTER_WHISPER_PY = `
64import sys, json
65from faster_whisper import WhisperModel
66model_name = sys.argv[1]
67language = None if sys.argv[2] == "-" else sys.argv[2]
68files = sys.argv[3:]
69model = WhisperModel(model_name, device="auto", compute_type="auto")
70out = {}
71for f in files:
72 print("transcribing " + f, file=sys.stderr, flush=True)
73 segments, info = model.transcribe(f, language=language, vad_filter=True)
74 out[f] = [{"start": s.start, "end": s.end, "text": s.text} for s in segments]
75json.dump(out, sys.stdout)
76`
78const hasCmd = (cmd: string, args: string[] = ['--version']): boolean =>
79 spawnSync(cmd, args, {stdio: 'ignore'}).error === undefined
81const hasFasterWhisper = (): boolean =>
82 spawnSync('python3', ['-c', 'import faster_whisper'], {stdio: 'ignore'})
83 .status === 0
85const resolveEngine = (
86 requested: TranscribeOptions['engine']
87): 'faster-whisper' | 'whisper-cli' | 'whisper' => {
88 if (requested !== 'auto') return requested
89 if (hasFasterWhisper()) return 'faster-whisper'
90 if (hasCmd('whisper-cli', ['--help'])) return 'whisper-cli'
91 if (hasCmd('whisper', ['--help'])) return 'whisper'
92 throw new Error(
93 'No ASR engine found. Install one of:\n' +
94 ' faster-whisper: pip install faster-whisper (needs python3 on PATH)\n' +
95 ' whisper.cpp: https://github.com/ggml-org/whisper.cpp (whisper-cli)\n' +
96 ' openai-whisper: pip install openai-whisper (whisper CLI)'
97 )
98}
100/** Run a command, streaming stderr lines to the log; resolve with stdout. */
101const run = (
102 cmd: string,
103 args: string[],
104 stdin: string | null,
105 onLog: (line: string) => void
106): Promise<string> =>
107 new Promise((resolve, reject) => {
108 const proc = spawn(cmd, args, {stdio: ['pipe', 'pipe', 'pipe']})
109 const out: Buffer[] = []
110 proc.stdout.on('data', d => out.push(d))
111 let errbuf = ''
112 let errtail = ''
113 proc.stderr.on('data', d => {
114 errbuf += d
115 const lines = errbuf.split('\n')
116 errbuf = lines.pop() ?? ''
117 for (const line of lines) {
118 if (line.trim()) {
119 errtail = line.trim()
120 onLog(` ${line.trim()}`)
121 }
122 }
123 })
124 proc.on('error', reject)
125 proc.on('exit', code => {
126 if (code === 0) resolve(Buffer.concat(out).toString('utf8'))
127 else reject(new Error(`${cmd} exited with code ${code}${errtail ? ` (${errtail})` : ''}`))
128 })
129 if (stdin !== null) proc.stdin.write(stdin)
130 proc.stdin.end()
131 })
133/** Transcribe the given WAV paths; returns absolute path -> segments. */
134const runAsr = async (
135 engine: 'faster-whisper' | 'whisper-cli' | 'whisper',
136 files: string[],
137 opts: TranscribeOptions
138): Promise<Map<string, AsrSegment[]>> => {
139 const results = new Map<string, AsrSegment[]>()
140 if (files.length === 0) return results
142 if (engine === 'faster-whisper') {
143 const model = opts.model ?? 'small'
144 const out = await run(
145 'python3',
146 ['-', model, opts.language ?? '-', ...files],
147 FASTER_WHISPER_PY,
148 opts.onLog
149 )
150 const parsed = JSON.parse(out) as Record<string, AsrSegment[]>
151 for (const [file, segs] of Object.entries(parsed)) results.set(file, segs)
152 return results
153 }
155 if (engine === 'whisper-cli') {
156 if (!opts.model) {
157 throw new Error(
158 'whisper-cli (whisper.cpp) needs --model <path-to-ggml-model-file>'
159 )
160 }
161 for (const file of files) {
162 opts.onLog(` transcribing ${file}`)
163 const args = ['-m', opts.model, '-f', file, '-oj', '-of', file]
164 if (opts.language) args.push('-l', opts.language)
165 await run('whisper-cli', args, null, () => undefined)
166 // -of <base> writes <base>.json
167 const raw = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')) as {
168 transcription?: {offsets: {from: number; to: number}; text: string}[]
169 }
170 fs.rmSync(`${file}.json`, {force: true})
171 results.set(
172 file,
173 (raw.transcription ?? []).map(t => ({
174 start: t.offsets.from / 1000,
175 end: t.offsets.to / 1000,
176 text: t.text
177 }))
178 )
179 }
180 return results
181 }
183 // openai-whisper CLI: writes <outdir>/<basename>.json
184 const outDir = fs.mkdtempSync(path.join(path.dirname(files[0]!), 'asr-tmp-'))
185 try {
186 for (const file of files) {
187 opts.onLog(` transcribing ${file}`)
188 const args = [
189 file,
190 '--model',
191 opts.model ?? 'small',
192 '--output_format',
193 'json',
194 '--output_dir',
195 outDir
196 ]
197 if (opts.language) args.push('--language', opts.language)
198 await run('whisper', args, null, () => undefined)
199 const jsonPath = path.join(
200 outDir,
201 path.basename(file).replace(/\.wav$/i, '') + '.json'
202 )
203 const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
204 segments?: {start: number; end: number; text: string}[]
205 }
206 results.set(
207 file,
208 (raw.segments ?? []).map(s => ({start: s.start, end: s.end, text: s.text}))
209 )
210 }
211 } finally {
212 fs.rmSync(outDir, {recursive: true, force: true})
213 }
214 return results
215}
217// ---- timeline assembly ----------------------------------------------------
219/** Merge consecutive same-speaker utterances separated by < this many ms. */
220const TURN_GAP_MS = 3000
222const localTime = (ms: number): string => {
223 const d = new Date(ms)
224 const p = (n: number) => String(n).padStart(2, '0')
225 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
226}
228const localDateTime = (ms: number): string => {
229 const d = new Date(ms)
230 const p = (n: number) => String(n).padStart(2, '0')
231 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${localTime(ms)}`
232}
234export const transcribe = async (opts: TranscribeOptions): Promise<void> => {
235 const manifestPath = path.join(opts.dir, 'manifest.json')
236 if (!fs.existsSync(manifestPath)) {
237 throw new Error(`${manifestPath} not found β is this a recording directory?`)
238 }
239 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest
241 const engine = resolveEngine(opts.engine)
242 opts.onLog(`engine: ${engine}${opts.model ? ` (model ${opts.model})` : ''}`)
244 // ---- per-segment ASR, with an on-disk cache ----
245 const asrDir = path.join(opts.dir, 'asr')
246 fs.mkdirSync(asrDir, {recursive: true})
247 const cachePath = (seg: ManifestSegment) =>
248 path.join(asrDir, path.basename(seg.file).replace(/\.wav$/i, '') + '.json')
250 const segments = manifest.segments.filter(seg =>
251 fs.existsSync(path.join(opts.dir, seg.file))
252 )
253 if (segments.length < manifest.segments.length) {
254 opts.onLog(
255 `warning: ${manifest.segments.length - segments.length} segment file(s) missing`
256 )
257 }
258 const uncached = segments.filter(
259 seg => opts.force || !fs.existsSync(cachePath(seg))
260 )
261 if (uncached.length > 0) {
262 opts.onLog(
263 `transcribing ${uncached.length} segment(s) (${segments.length - uncached.length} cached)...`
264 )
265 const wavPaths = uncached.map(seg => path.resolve(opts.dir, seg.file))
266 const results = await runAsr(engine, wavPaths, opts)
267 for (let i = 0; i < uncached.length; i++) {
268 const seg = uncached[i]!
269 const asr = results.get(wavPaths[i]!)
270 if (!asr) throw new Error(`no ASR result for ${seg.file}`)
271 fs.writeFileSync(
272 cachePath(seg),
273 JSON.stringify({engine, model: opts.model, segments: asr}, null, 2) + '\n'
274 )
275 }
276 } else if (segments.length > 0) {
277 opts.onLog(`all ${segments.length} segment(s) already transcribed (cached in asr/)`)
278 }
280 // ---- assemble the timeline ----
281 const items: Item[] = []
282 for (const seg of segments) {
283 const asr = JSON.parse(fs.readFileSync(cachePath(seg), 'utf8')) as {
284 segments: AsrSegment[]
285 }
286 const base = Date.parse(seg.startedAt)
287 for (const u of asr.segments) {
288 const text = u.text.trim()
289 if (!text) continue
290 items.push({
291 timeMs: base + u.start * 1000,
292 endMs: base + u.end * 1000,
293 type: 'speech',
294 speaker: seg.name,
295 text
296 })
297 }
298 }
300 const eventsPath = path.join(opts.dir, 'events.jsonl')
301 if (fs.existsSync(eventsPath)) {
302 for (const line of fs.readFileSync(eventsPath, 'utf8').trim().split('\n')) {
303 let ev: Record<string, unknown>
304 try {
305 ev = JSON.parse(line)
306 } catch {
307 continue
308 }
309 const timeMs = Date.parse(String(ev.time ?? ''))
310 if (!Number.isFinite(timeMs)) continue
311 if (ev.type === 'chat') {
312 items.push({
313 timeMs,
314 type: 'chat',
315 speaker: String(ev.name ?? '?'),
316 text: String(ev.text ?? '')
317 })
318 } else if (ev.type === 'join') {
319 items.push({
320 timeMs,
321 type: 'system',
322 text: `${ev.name} ${ev.alreadyHere ? 'was already here' : 'joined'}`
323 })
324 } else if (ev.type === 'left') {
325 items.push({timeMs, type: 'system', text: `${ev.name} left`})
326 }
327 }
328 }
330 items.sort((a, b) => a.timeMs - b.timeMs)
332 // Merge adjacent same-speaker utterances (with nothing in between) into
333 // conversational turns.
334 const merged: Item[] = []
335 for (const item of items) {
336 const prev = merged[merged.length - 1]
337 if (
338 item.type === 'speech' &&
339 prev?.type === 'speech' &&
340 prev.speaker === item.speaker &&
341 item.timeMs - prev.endMs < TURN_GAP_MS
342 ) {
343 prev.text += ' ' + item.text
344 prev.endMs = item.endMs
345 } else {
346 merged.push({...item})
347 }
348 }
350 // ---- render ----
351 const startMs = Date.parse(manifest.startedAt)
352 const endMs = manifest.endedAt ? Date.parse(manifest.endedAt) : null
353 const speakers = [...new Set(segments.map(s => s.name))]
355 const md: string[] = []
356 md.push(`# Transcript: ${manifest.room}`)
357 md.push('')
358 md.push(
359 `- **Recorded:** ${localDateTime(startMs)}${endMs ? ` β ${localTime(endMs)}` : ''}`
360 )
361 md.push(`- **Speakers:** ${speakers.join(', ') || '(none)'}`)
362 md.push(`- **Transcribed with:** ${engine}${opts.model ? `, model ${opts.model}` : ''}`)
363 md.push('')
364 md.push('---')
365 md.push('')
366 for (const item of merged) {
367 const t = localTime(item.timeMs)
368 if (item.type === 'speech') {
369 md.push(`**[${t}] ${item.speaker}:** ${item.text}`)
370 } else if (item.type === 'chat') {
371 md.push(`> [${t}] π¬ **${item.speaker}:** ${item.text}`)
372 } else {
373 md.push(`*[${t}] β ${item.text}*`)
374 }
375 md.push('')
376 }
377 if (merged.length === 0) md.push('*(nothing was said)*', '')
379 const mdPath = path.join(opts.dir, 'transcript.md')
380 fs.writeFileSync(mdPath, md.join('\n'))
382 const jsonPath = path.join(opts.dir, 'transcript.json')
383 fs.writeFileSync(
384 jsonPath,
385 JSON.stringify(
386 {
387 room: manifest.room,
388 startedAt: manifest.startedAt,
389 endedAt: manifest.endedAt,
390 engine,
391 model: opts.model,
392 items: merged.map(item => ({
393 time: new Date(item.timeMs).toISOString(),
394 ...item,
395 timeMs: undefined,
396 endMs: undefined
397 }))
398 },
399 null,
400 2
401 ) + '\n'
402 )
404 opts.onLog(`wrote ${mdPath}`)
405 opts.onLog(`wrote ${jsonPath}`)
406}