1import {spawn, spawnSync} from 'node:child_process'
2import * as fs from 'node:fs'
3import * as path from 'node:path'
4import {mergeTurns, renderJson, renderMarkdown, type Item} from './render.js'
6// The `transcribe` subcommand: turn a recording directory (per-speaker WAVs +
7// manifest.json + events.jsonl) into one merged, speaker-attributed
8// transcript. Because each WAV is silence-padded so sample position tracks
9// wall-clock time, an ASR timestamp within a segment plus the segment's
10// manifest `startedAt` IS the meeting timeline — no alignment step needed.
11//
12// ASR engines (probed in this order for --engine auto):
13// faster-whisper python3 + the faster_whisper package (VAD filtering on,
14// which also skips our padded silence)
15// whisper-cli whisper.cpp; needs --model <path to ggml/gguf file>
16// whisper the openai-whisper CLI
17//
18// Raw per-WAV ASR results are cached in <dir>/asr/*.json so formatting can be
19// iterated (or the engine swapped) without re-transcribing; --force redoes.
21export interface TranscribeOptions {
22 dir: string
23 engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper'
24 /** Model name (faster-whisper / whisper) or model file path (whisper-cli).
25 * null = engine default. */
26 model: string | null
27 /** ISO 639-1 code, or null for per-file auto-detection. */
28 language: string | null
29 force: boolean
30 onLog: (line: string) => void
31}
33interface AsrSegment {
34 start: number
35 end: number
36 text: string
37}
39interface ManifestSegment {
40 file: string
41 peerId: string
42 name: string
43 startedAt: string
44 endedAt: string | null
45 durationSec: number
46}
48interface Manifest {
49 room: string
50 recorder: {peerId: string; name: string}
51 startedAt: string
52 endedAt: string | null
53 participants: Record<string, string>
54 segments: ManifestSegment[]
55}
57// ---- ASR engines ----------------------------------------------------------
59const FASTER_WHISPER_PY = `
60import sys, json
61from faster_whisper import WhisperModel
62model_name = sys.argv[1]
63language = None if sys.argv[2] == "-" else sys.argv[2]
64files = sys.argv[3:]
65model = WhisperModel(model_name, device="auto", compute_type="auto")
66out = {}
67for f in files:
68 print("transcribing " + f, file=sys.stderr, flush=True)
69 segments, info = model.transcribe(f, language=language, vad_filter=True)
70 out[f] = [{"start": s.start, "end": s.end, "text": s.text} for s in segments]
71json.dump(out, sys.stdout)
72`
74const hasCmd = (cmd: string, args: string[] = ['--version']): boolean =>
75 spawnSync(cmd, args, {stdio: 'ignore'}).error === undefined
77export const hasFasterWhisper = (): boolean =>
78 spawnSync('python3', ['-c', 'import faster_whisper'], {stdio: 'ignore'})
79 .status === 0
81const resolveEngine = (
82 requested: TranscribeOptions['engine']
83): 'faster-whisper' | 'whisper-cli' | 'whisper' => {
84 if (requested !== 'auto') return requested
85 if (hasFasterWhisper()) return 'faster-whisper'
86 if (hasCmd('whisper-cli', ['--help'])) return 'whisper-cli'
87 if (hasCmd('whisper', ['--help'])) return 'whisper'
88 throw new Error(
89 'No ASR engine found. Install one of:\n' +
90 ' faster-whisper: pip install faster-whisper (needs python3 on PATH)\n' +
91 ' whisper.cpp: https://github.com/ggml-org/whisper.cpp (whisper-cli)\n' +
92 ' openai-whisper: pip install openai-whisper (whisper CLI)'
93 )
94}
96/** Run a command, streaming stderr lines to the log; resolve with stdout. */
97const run = (
98 cmd: string,
99 args: string[],
100 stdin: string | null,
101 onLog: (line: string) => void
102): Promise<string> =>
103 new Promise((resolve, reject) => {
104 const proc = spawn(cmd, args, {stdio: ['pipe', 'pipe', 'pipe']})
105 const out: Buffer[] = []
106 proc.stdout.on('data', d => out.push(d))
107 let errbuf = ''
108 let errtail = ''
109 proc.stderr.on('data', d => {
110 errbuf += d
111 const lines = errbuf.split('\n')
112 errbuf = lines.pop() ?? ''
113 for (const line of lines) {
114 if (line.trim()) {
115 errtail = line.trim()
116 onLog(` ${line.trim()}`)
117 }
118 }
119 })
120 proc.on('error', reject)
121 proc.on('exit', code => {
122 if (code === 0) resolve(Buffer.concat(out).toString('utf8'))
123 else reject(new Error(`${cmd} exited with code ${code}${errtail ? ` (${errtail})` : ''}`))
124 })
125 if (stdin !== null) proc.stdin.write(stdin)
126 proc.stdin.end()
127 })
129/** Transcribe the given WAV paths; returns absolute path -> segments. */
130const runAsr = async (
131 engine: 'faster-whisper' | 'whisper-cli' | 'whisper',
132 files: string[],
133 opts: TranscribeOptions
134): Promise<Map<string, AsrSegment[]>> => {
135 const results = new Map<string, AsrSegment[]>()
136 if (files.length === 0) return results
138 if (engine === 'faster-whisper') {
139 const model = opts.model ?? 'small'
140 const out = await run(
141 'python3',
142 ['-', model, opts.language ?? '-', ...files],
143 FASTER_WHISPER_PY,
144 opts.onLog
145 )
146 const parsed = JSON.parse(out) as Record<string, AsrSegment[]>
147 for (const [file, segs] of Object.entries(parsed)) results.set(file, segs)
148 return results
149 }
151 if (engine === 'whisper-cli') {
152 if (!opts.model) {
153 throw new Error(
154 'whisper-cli (whisper.cpp) needs --model <path-to-ggml-model-file>'
155 )
156 }
157 for (const file of files) {
158 opts.onLog(` transcribing ${file}`)
159 const args = ['-m', opts.model, '-f', file, '-oj', '-of', file]
160 if (opts.language) args.push('-l', opts.language)
161 await run('whisper-cli', args, null, () => undefined)
162 // -of <base> writes <base>.json
163 const raw = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')) as {
164 transcription?: {offsets: {from: number; to: number}; text: string}[]
165 }
166 fs.rmSync(`${file}.json`, {force: true})
167 results.set(
168 file,
169 (raw.transcription ?? []).map(t => ({
170 start: t.offsets.from / 1000,
171 end: t.offsets.to / 1000,
172 text: t.text
173 }))
174 )
175 }
176 return results
177 }
179 // openai-whisper CLI: writes <outdir>/<basename>.json
180 const outDir = fs.mkdtempSync(path.join(path.dirname(files[0]!), 'asr-tmp-'))
181 try {
182 for (const file of files) {
183 opts.onLog(` transcribing ${file}`)
184 const args = [
185 file,
186 '--model',
187 opts.model ?? 'small',
188 '--output_format',
189 'json',
190 '--output_dir',
191 outDir
192 ]
193 if (opts.language) args.push('--language', opts.language)
194 await run('whisper', args, null, () => undefined)
195 const jsonPath = path.join(
196 outDir,
197 path.basename(file).replace(/\.wav$/i, '') + '.json'
198 )
199 const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
200 segments?: {start: number; end: number; text: string}[]
201 }
202 results.set(
203 file,
204 (raw.segments ?? []).map(s => ({start: s.start, end: s.end, text: s.text}))
205 )
206 }
207 } finally {
208 fs.rmSync(outDir, {recursive: true, force: true})
209 }
210 return results
211}
213// ---- timeline assembly ----------------------------------------------------
215export const transcribe = async (opts: TranscribeOptions): Promise<void> => {
216 const manifestPath = path.join(opts.dir, 'manifest.json')
217 if (!fs.existsSync(manifestPath)) {
218 throw new Error(`${manifestPath} not found — is this a recording directory?`)
219 }
220 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest
222 const engine = resolveEngine(opts.engine)
223 opts.onLog(`engine: ${engine}${opts.model ? ` (model ${opts.model})` : ''}`)
225 // ---- per-segment ASR, with an on-disk cache ----
226 const asrDir = path.join(opts.dir, 'asr')
227 fs.mkdirSync(asrDir, {recursive: true})
228 const cachePath = (seg: ManifestSegment) =>
229 path.join(asrDir, path.basename(seg.file).replace(/\.wav$/i, '') + '.json')
231 const segments = manifest.segments.filter(seg =>
232 fs.existsSync(path.join(opts.dir, seg.file))
233 )
234 if (segments.length < manifest.segments.length) {
235 opts.onLog(
236 `warning: ${manifest.segments.length - segments.length} segment file(s) missing`
237 )
238 }
239 const uncached = segments.filter(
240 seg => opts.force || !fs.existsSync(cachePath(seg))
241 )
242 if (uncached.length > 0) {
243 opts.onLog(
244 `transcribing ${uncached.length} segment(s) (${segments.length - uncached.length} cached)...`
245 )
246 const wavPaths = uncached.map(seg => path.resolve(opts.dir, seg.file))
247 const results = await runAsr(engine, wavPaths, opts)
248 for (let i = 0; i < uncached.length; i++) {
249 const seg = uncached[i]!
250 const asr = results.get(wavPaths[i]!)
251 if (!asr) throw new Error(`no ASR result for ${seg.file}`)
252 fs.writeFileSync(
253 cachePath(seg),
254 JSON.stringify({engine, model: opts.model, segments: asr}, null, 2) + '\n'
255 )
256 }
257 } else if (segments.length > 0) {
258 opts.onLog(`all ${segments.length} segment(s) already transcribed (cached in asr/)`)
259 }
261 // ---- assemble the timeline ----
262 const items: Item[] = []
263 for (const seg of segments) {
264 const asr = JSON.parse(fs.readFileSync(cachePath(seg), 'utf8')) as {
265 segments: AsrSegment[]
266 }
267 const base = Date.parse(seg.startedAt)
268 for (const u of asr.segments) {
269 const text = u.text.trim()
270 if (!text) continue
271 items.push({
272 timeMs: base + u.start * 1000,
273 endMs: base + u.end * 1000,
274 type: 'speech',
275 speaker: seg.name,
276 text
277 })
278 }
279 }
281 const eventsPath = path.join(opts.dir, 'events.jsonl')
282 if (fs.existsSync(eventsPath)) {
283 for (const line of fs.readFileSync(eventsPath, 'utf8').trim().split('\n')) {
284 let ev: Record<string, unknown>
285 try {
286 ev = JSON.parse(line)
287 } catch {
288 continue
289 }
290 const timeMs = Date.parse(String(ev.time ?? ''))
291 if (!Number.isFinite(timeMs)) continue
292 if (ev.type === 'chat') {
293 items.push({
294 timeMs,
295 type: 'chat',
296 speaker: String(ev.name ?? '?'),
297 text: String(ev.text ?? '')
298 })
299 } else if (ev.type === 'join') {
300 items.push({
301 timeMs,
302 type: 'system',
303 text: `${ev.name} ${ev.alreadyHere ? 'was already here' : 'joined'}`
304 })
305 } else if (ev.type === 'left') {
306 items.push({timeMs, type: 'system', text: `${ev.name} left`})
307 }
308 }
309 }
311 const merged = mergeTurns(items)
312 const meta = {
313 room: manifest.room,
314 startedAtMs: Date.parse(manifest.startedAt),
315 endedAtMs: manifest.endedAt ? Date.parse(manifest.endedAt) : null,
316 speakers: [...new Set(segments.map(s => s.name))],
317 engine,
318 model: opts.model
319 }
321 const mdPath = path.join(opts.dir, 'transcript.md')
322 fs.writeFileSync(mdPath, renderMarkdown(meta, merged))
323 const jsonPath = path.join(opts.dir, 'transcript.json')
324 fs.writeFileSync(jsonPath, renderJson(meta, merged))
325 opts.onLog(`wrote ${mdPath}`)
326 opts.onLog(`wrote ${jsonPath}`)
327}