import {spawn, spawnSync} from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' import {mergeTurns, renderJson, renderMarkdown, type Item} from './render.js' // The `transcribe` subcommand: turn a recording directory (per-speaker WAVs + // manifest.json + events.jsonl) into one merged, speaker-attributed // transcript. Because each WAV is silence-padded so sample position tracks // wall-clock time, an ASR timestamp within a segment plus the segment's // manifest `startedAt` IS the meeting timeline — no alignment step needed. // // ASR engines (probed in this order for --engine auto): // faster-whisper python3 + the faster_whisper package (VAD filtering on, // which also skips our padded silence) // whisper-cli whisper.cpp; needs --model // whisper the openai-whisper CLI // // Raw per-WAV ASR results are cached in /asr/*.json so formatting can be // iterated (or the engine swapped) without re-transcribing; --force redoes. export interface TranscribeOptions { dir: string engine: 'auto' | 'faster-whisper' | 'whisper-cli' | 'whisper' /** Model name (faster-whisper / whisper) or model file path (whisper-cli). * null = engine default. */ model: string | null /** ISO 639-1 code, or null for per-file auto-detection. */ language: string | null force: boolean onLog: (line: string) => void } interface AsrSegment { start: number end: number text: string } interface ManifestSegment { file: string peerId: string name: string startedAt: string endedAt: string | null durationSec: number } interface Manifest { room: string recorder: {peerId: string; name: string} startedAt: string endedAt: string | null participants: Record segments: ManifestSegment[] } // ---- ASR engines ---------------------------------------------------------- const FASTER_WHISPER_PY = ` import sys, json from faster_whisper import WhisperModel model_name = sys.argv[1] language = None if sys.argv[2] == "-" else sys.argv[2] files = sys.argv[3:] model = WhisperModel(model_name, device="auto", compute_type="auto") out = {} for f in files: print("transcribing " + f, file=sys.stderr, flush=True) segments, info = model.transcribe(f, language=language, vad_filter=True) out[f] = [{"start": s.start, "end": s.end, "text": s.text} for s in segments] json.dump(out, sys.stdout) ` const hasCmd = (cmd: string, args: string[] = ['--version']): boolean => spawnSync(cmd, args, {stdio: 'ignore'}).error === undefined export const hasFasterWhisper = (): boolean => spawnSync('python3', ['-c', 'import faster_whisper'], {stdio: 'ignore'}) .status === 0 const resolveEngine = ( requested: TranscribeOptions['engine'] ): 'faster-whisper' | 'whisper-cli' | 'whisper' => { if (requested !== 'auto') return requested if (hasFasterWhisper()) return 'faster-whisper' if (hasCmd('whisper-cli', ['--help'])) return 'whisper-cli' if (hasCmd('whisper', ['--help'])) return 'whisper' throw new Error( 'No ASR engine found. Install one of:\n' + ' faster-whisper: pip install faster-whisper (needs python3 on PATH)\n' + ' whisper.cpp: https://github.com/ggml-org/whisper.cpp (whisper-cli)\n' + ' openai-whisper: pip install openai-whisper (whisper CLI)' ) } /** Run a command, streaming stderr lines to the log; resolve with stdout. */ const run = ( cmd: string, args: string[], stdin: string | null, onLog: (line: string) => void ): Promise => new Promise((resolve, reject) => { const proc = spawn(cmd, args, {stdio: ['pipe', 'pipe', 'pipe']}) const out: Buffer[] = [] proc.stdout.on('data', d => out.push(d)) let errbuf = '' let errtail = '' proc.stderr.on('data', d => { errbuf += d const lines = errbuf.split('\n') errbuf = lines.pop() ?? '' for (const line of lines) { if (line.trim()) { errtail = line.trim() onLog(` ${line.trim()}`) } } }) proc.on('error', reject) proc.on('exit', code => { if (code === 0) resolve(Buffer.concat(out).toString('utf8')) else reject(new Error(`${cmd} exited with code ${code}${errtail ? ` (${errtail})` : ''}`)) }) if (stdin !== null) proc.stdin.write(stdin) proc.stdin.end() }) /** Transcribe the given WAV paths; returns absolute path -> segments. */ const runAsr = async ( engine: 'faster-whisper' | 'whisper-cli' | 'whisper', files: string[], opts: TranscribeOptions ): Promise> => { const results = new Map() if (files.length === 0) return results if (engine === 'faster-whisper') { const model = opts.model ?? 'small' const out = await run( 'python3', ['-', model, opts.language ?? '-', ...files], FASTER_WHISPER_PY, opts.onLog ) const parsed = JSON.parse(out) as Record for (const [file, segs] of Object.entries(parsed)) results.set(file, segs) return results } if (engine === 'whisper-cli') { if (!opts.model) { throw new Error( 'whisper-cli (whisper.cpp) needs --model ' ) } for (const file of files) { opts.onLog(` transcribing ${file}`) const args = ['-m', opts.model, '-f', file, '-oj', '-of', file] if (opts.language) args.push('-l', opts.language) await run('whisper-cli', args, null, () => undefined) // -of writes .json const raw = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')) as { transcription?: {offsets: {from: number; to: number}; text: string}[] } fs.rmSync(`${file}.json`, {force: true}) results.set( file, (raw.transcription ?? []).map(t => ({ start: t.offsets.from / 1000, end: t.offsets.to / 1000, text: t.text })) ) } return results } // openai-whisper CLI: writes /.json const outDir = fs.mkdtempSync(path.join(path.dirname(files[0]!), 'asr-tmp-')) try { for (const file of files) { opts.onLog(` transcribing ${file}`) const args = [ file, '--model', opts.model ?? 'small', '--output_format', 'json', '--output_dir', outDir ] if (opts.language) args.push('--language', opts.language) await run('whisper', args, null, () => undefined) const jsonPath = path.join( outDir, path.basename(file).replace(/\.wav$/i, '') + '.json' ) const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as { segments?: {start: number; end: number; text: string}[] } results.set( file, (raw.segments ?? []).map(s => ({start: s.start, end: s.end, text: s.text})) ) } } finally { fs.rmSync(outDir, {recursive: true, force: true}) } return results } // ---- timeline assembly ---------------------------------------------------- export const transcribe = async (opts: TranscribeOptions): Promise => { const manifestPath = path.join(opts.dir, 'manifest.json') if (!fs.existsSync(manifestPath)) { throw new Error(`${manifestPath} not found — is this a recording directory?`) } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest const engine = resolveEngine(opts.engine) opts.onLog(`engine: ${engine}${opts.model ? ` (model ${opts.model})` : ''}`) // ---- per-segment ASR, with an on-disk cache ---- const asrDir = path.join(opts.dir, 'asr') fs.mkdirSync(asrDir, {recursive: true}) const cachePath = (seg: ManifestSegment) => path.join(asrDir, path.basename(seg.file).replace(/\.wav$/i, '') + '.json') const segments = manifest.segments.filter(seg => fs.existsSync(path.join(opts.dir, seg.file)) ) if (segments.length < manifest.segments.length) { opts.onLog( `warning: ${manifest.segments.length - segments.length} segment file(s) missing` ) } const uncached = segments.filter( seg => opts.force || !fs.existsSync(cachePath(seg)) ) if (uncached.length > 0) { opts.onLog( `transcribing ${uncached.length} segment(s) (${segments.length - uncached.length} cached)...` ) const wavPaths = uncached.map(seg => path.resolve(opts.dir, seg.file)) const results = await runAsr(engine, wavPaths, opts) for (let i = 0; i < uncached.length; i++) { const seg = uncached[i]! const asr = results.get(wavPaths[i]!) if (!asr) throw new Error(`no ASR result for ${seg.file}`) fs.writeFileSync( cachePath(seg), JSON.stringify({engine, model: opts.model, segments: asr}, null, 2) + '\n' ) } } else if (segments.length > 0) { opts.onLog(`all ${segments.length} segment(s) already transcribed (cached in asr/)`) } // ---- assemble the timeline ---- const items: Item[] = [] for (const seg of segments) { const asr = JSON.parse(fs.readFileSync(cachePath(seg), 'utf8')) as { segments: AsrSegment[] } const base = Date.parse(seg.startedAt) for (const u of asr.segments) { const text = u.text.trim() if (!text) continue items.push({ timeMs: base + u.start * 1000, endMs: base + u.end * 1000, type: 'speech', speaker: seg.name, text }) } } const eventsPath = path.join(opts.dir, 'events.jsonl') if (fs.existsSync(eventsPath)) { for (const line of fs.readFileSync(eventsPath, 'utf8').trim().split('\n')) { let ev: Record try { ev = JSON.parse(line) } catch { continue } const timeMs = Date.parse(String(ev.time ?? '')) if (!Number.isFinite(timeMs)) continue if (ev.type === 'chat') { items.push({ timeMs, type: 'chat', speaker: String(ev.name ?? '?'), text: String(ev.text ?? '') }) } else if (ev.type === 'join') { items.push({ timeMs, type: 'system', text: `${ev.name} ${ev.alreadyHere ? 'was already here' : 'joined'}` }) } else if (ev.type === 'left') { items.push({timeMs, type: 'system', text: `${ev.name} left`}) } } } const merged = mergeTurns(items) const meta = { room: manifest.room, startedAtMs: Date.parse(manifest.startedAt), endedAtMs: manifest.endedAt ? Date.parse(manifest.endedAt) : null, speakers: [...new Set(segments.map(s => s.name))], engine, model: opts.model } const mdPath = path.join(opts.dir, 'transcript.md') fs.writeFileSync(mdPath, renderMarkdown(meta, merged)) const jsonPath = path.join(opts.dir, 'transcript.json') fs.writeFileSync(jsonPath, renderJson(meta, merged)) opts.onLog(`wrote ${mdPath}`) opts.onLog(`wrote ${jsonPath}`) }