Add transcribe subcommand: merged speaker-attributed transcript
commonroom-recorder transcribe <recording-dir> runs local ASR per segment
WAV (faster-whisper via python3, whisper.cpp's whisper-cli, or the
openai-whisper CLI — probed in that order), caches raw results in asr/, and
merges utterances with the chat and join/left events onto one wall-clock
timeline as transcript.md + transcript.json. The WAVs' silence padding makes
segment-relative ASR timestamps line up with wall clock directly.
Tested with real speech: test:transcribe fabricates a two-speaker recording
from the whisper.cpp JFK sample and checks ordering and words.
6 changed files+665−11
CLAUDE.mdmodified+16−3View file
@@ -15,10 +15,13 @@ src/
1515 peer.ts ported, adapted to @roamhq/wrtc; receive-only media (see below)
1616 wav.ts incremental WAV writer (buffers ~1 s, re-patches header sizes)
1717 recorder.ts the heart: presence, mesh, control channel, audio sinks, files
18- cli.ts arg parsing, signal handling, log lines
18+ transcribe.ts `transcribe` subcommand: per-WAV ASR + merged transcript.md
19+ cli.ts arg parsing, subcommand dispatch, signal handling, log lines
1920 test/
20- speaker.ts synthetic participant: sine tone + one chat message
21- loopback.js test: recorder + speaker in a random room, verify tone + chat
21+ speaker.ts synthetic participant: sine tone + one chat message
22+ loopback.ts test: recorder + speaker in a random room -> tone + chat
23+ transcribe-test.ts test: fabricated 2-speaker recording dir (JFK sample)
24+ -> transcript ordering + words; uses --model tiny
2225 ```
2326
2427 ## Key design decisions
@@ -56,12 +59,22 @@ src/
5659 - **Crash-safe outputs.** events.jsonl and chat.txt are appended per event;
5760 WAVs flush (with header re-patch) about once a second; manifest.json is
5861 written atomically (tmp + rename) at segment boundaries and every 30 s.
62+- **Transcription needs no alignment step.** The silence padding means an ASR
63+ timestamp within a segment plus the manifest `startedAt` is the wall-clock
64+ time; transcribe.ts just merges utterances with chat/join/left events and
65+ groups adjacent same-speaker utterances (< 3 s gap) into turns. ASR engines
66+ are probed (faster-whisper via an embedded python3 stdin script — VAD on,
67+ which also skips the padded silence — then whisper-cli, then whisper);
68+ raw ASR is cached per WAV in `<dir>/asr/`. cli.ts imports recorder.js
69+ LAZILY so transcribe works where the wrtc native module doesn't load.
5970
6071 ## Testing
6172
6273 `npm run build && npm run test:loopback` — full end-to-end over the real
6374 public relays (needs network): asserts the recorded WAV contains the 440 Hz
6475 tone (RMS + zero-crossing rate) and the chat message landed exactly once.
76+`npm run test:transcribe` — real-speech transcription test (downloads the
77+whisper.cpp JFK sample + the tiny model on first run).
6578 Segfault-at-exit in a child process = some path bypassed `process.exit()`.
6679 For manual testing against real browsers, record a room and join it at
6780 https://concept-collection.github.io/commonroom/ — let the user do
README.mdmodified+32−3View file
@@ -43,6 +43,35 @@ Stop with Ctrl-C. Requires Node >= 22 (built-in WebSocket). The WebRTC stack
4343 is [`@roamhq/wrtc`](https://github.com/WonderInventions/node-webrtc), which
4444 ships prebuilt binaries for Linux and macOS.
4545
46+## Transcribing
47+
48+```
49+npx https://concept-collection.github.io/commonroom-recorder/commonroom-recorder.tgz transcribe <recording-dir>
50+```
51+
52+produces `transcript.md` — a merged, speaker-attributed transcript of the
53+meeting with the chat and join/left events interleaved on one timeline:
54+
55+```
56+**[10:00:02] Alice:** So the agenda today...
57+
58+> [10:00:15] 💬 **Alice:** here's the doc link
59+
60+*[10:00:19] — Bob joined*
61+
62+**[10:00:20] Bob:** Sorry I'm late...
63+```
64+
65+(plus `transcript.json` with the same items, structured). Speech recognition
66+runs locally through whichever engine is found (`--engine` to force one):
67+[faster-whisper](https://github.com/SYSTRAN/faster-whisper)
68+(`pip install faster-whisper`), whisper.cpp's `whisper-cli` (pass the ggml
69+model file via `--model`), or the
70+[openai-whisper](https://github.com/openai/whisper) CLI. `--model` defaults to
71+`small`; `--language` forces a language instead of auto-detecting. Raw
72+per-file ASR output is cached in `<dir>/asr/`, so re-running is instant —
73+use `--force` to re-transcribe.
74+
4675 ## Output
4776
4877 ```
@@ -65,9 +94,9 @@ manifest's per-segment start times let a transcript interleave speakers on one
6594 timeline. During a segment, silence is padded by wall clock, so a sample's
6695 position in the file always tracks elapsed time.
6796
68-To transcribe: run each `audio/*.wav` through your transcriber of choice
69-(e.g. whisper), offset each result by its segment's `startedAt` from
70-`manifest.json`, and merge.
97+The `transcribe` subcommand consumes exactly these files; because each WAV is
98+silence-padded to track wall-clock time, an ASR timestamp within a segment
99+plus the segment's `startedAt` is already the meeting timeline.
71100
72101 ## How it works
73102
package.jsonmodified+3−2View file
@@ -1,6 +1,6 @@
11 {
22 "name": "commonroom-recorder",
3- "version": "0.1.0",
3+ "version": "0.2.0",
44 "description": "CLI bot that joins a commonroom room and records every participant's audio (and the chat) to disk for transcription",
55 "type": "module",
66 "bin": {
@@ -14,7 +14,8 @@
1414 "build": "tsc -b",
1515 "prepare": "tsc -b",
1616 "record": "node dist/cli.js",
17- "test:loopback": "node dist/test/loopback.js"
17+ "test:loopback": "node dist/test/loopback.js",
18+ "test:transcribe": "node dist/test/transcribe-test.js"
1819 },
1920 "engines": {
2021 "node": ">=22"
src/cli.tsmodified+69−3View file
@@ -1,20 +1,29 @@
11 #!/usr/bin/env node
22 import * as path from 'node:path'
3-import {Recorder} from './recorder.js'
43
54 const USAGE = `Usage: commonroom-recorder <room> [options]
5+ commonroom-recorder transcribe <recording-dir> [options]
66
77 Joins the commonroom room as a visible, muted participant and records every
88 other participant's audio to per-speaker WAV files, plus the room chat.
9-Stop with Ctrl-C.
9+Stop with Ctrl-C. The transcribe subcommand then turns a recording directory
10+into a merged, speaker-attributed transcript (transcript.md).
1011
11-Options:
12+Recording options:
1213 --name <name> Display name in the room (default: Recorder)
1314 --out <dir> Output directory (default: ./recordings/<room>-<timestamp>)
1415 --duration <sec> Stop automatically after this many seconds
1516 --notice <text> Chat line sent to each participant on connect
1617 (default: "🔴 This meeting is being recorded.")
1718 --no-notice Don't send any recording notice
19+
20+Transcribe 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
1827 `
1928
2029 const DEFAULT_NOTICE = '🔴 This meeting is being recorded.'
@@ -89,8 +98,65 @@ const now = (): string => {
8998 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
9099 }
91100
101+const 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+}
150+
92151 const main = async () => {
152+ if (process.argv[2] === 'transcribe') {
153+ await transcribeMain(process.argv.slice(3))
154+ return
155+ }
93156 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')
94160 const recorder = new Recorder({
95161 room: args.room,
96162 name: args.name,
src/test/transcribe-test.tsadded+139−0View file
@@ -0,0 +1,139 @@
1+// Test of the transcribe subcommand with real speech, no room needed: builds
2+// a fabricated recording directory whose two "speakers" are both the classic
3+// 11 s JFK sample (downloaded once into the OS temp dir), offset in time,
4+// with a chat message and join events between them — then checks the merged
5+// transcript has the right speakers, ordering, and words.
6+//
7+// Needs network (sample + model download on first run) and an ASR engine
8+// (faster-whisper etc.). Uses --model tiny to keep the download/compute small.
9+
10+import {spawnSync} from 'node:child_process'
11+import * as fs from 'node:fs'
12+import * as os from 'node:os'
13+import * as path from 'node:path'
14+
15+const JFK_URL =
16+ 'https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/samples/jfk.wav'
17+
18+const failures: string[] = []
19+const check = (ok: boolean, what: string) => {
20+ process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`)
21+ if (!ok) failures.push(what)
22+}
23+
24+const main = async () => {
25+ // ---- fixture ----
26+ const jfkPath = path.join(os.tmpdir(), 'commonroom-recorder-jfk-sample.wav')
27+ if (!fs.existsSync(jfkPath)) {
28+ process.stdout.write(`downloading ${JFK_URL}\n`)
29+ const res = await fetch(JFK_URL)
30+ if (!res.ok) throw new Error(`sample download failed: ${res.status}`)
31+ fs.writeFileSync(jfkPath, Buffer.from(await res.arrayBuffer()))
32+ }
33+
34+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'commonroom-transcribe-test-'))
35+ fs.mkdirSync(path.join(dir, 'audio'))
36+ fs.copyFileSync(jfkPath, path.join(dir, 'audio', 'Alice-aaaa1111-seg1.wav'))
37+ fs.copyFileSync(jfkPath, path.join(dir, 'audio', 'Bob-bbbb2222-seg1.wav'))
38+
39+ const t0 = Date.parse('2026-07-23T14:00:00.000Z')
40+ const iso = (offsetSec: number) => new Date(t0 + offsetSec * 1000).toISOString()
41+ const manifest = {
42+ room: 'transcribe-test',
43+ recorder: {peerId: 'f'.repeat(64), name: 'Recorder'},
44+ startedAt: iso(0),
45+ endedAt: iso(40),
46+ participants: {['a'.repeat(64)]: 'Alice', ['b'.repeat(64)]: 'Bob'},
47+ segments: [
48+ {
49+ file: 'audio/Alice-aaaa1111-seg1.wav',
50+ peerId: 'a'.repeat(64),
51+ name: 'Alice',
52+ startedAt: iso(2),
53+ endedAt: iso(13),
54+ durationSec: 11,
55+ sampleRate: 16000,
56+ channels: 1
57+ },
58+ {
59+ file: 'audio/Bob-bbbb2222-seg1.wav',
60+ peerId: 'b'.repeat(64),
61+ name: 'Bob',
62+ startedAt: iso(20),
63+ endedAt: iso(31),
64+ durationSec: 11,
65+ sampleRate: 16000,
66+ channels: 1
67+ }
68+ ]
69+ }
70+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2))
71+ const events = [
72+ {time: iso(1), type: 'join', peerId: 'a'.repeat(64), name: 'Alice', alreadyHere: true},
73+ {time: iso(15), type: 'chat', peerId: 'a'.repeat(64), name: 'Alice', text: 'over to you, Bob'},
74+ {time: iso(19), type: 'join', peerId: 'b'.repeat(64), name: 'Bob', alreadyHere: false},
75+ {time: iso(35), type: 'left', peerId: 'b'.repeat(64), name: 'Bob'}
76+ ]
77+ fs.writeFileSync(
78+ path.join(dir, 'events.jsonl'),
79+ events.map(e => JSON.stringify(e)).join('\n') + '\n'
80+ )
81+
82+ // ---- run transcribe ----
83+ const cli = path.join(import.meta.dirname, '..', 'cli.js')
84+ const run1 = spawnSync('node', [cli, 'transcribe', dir, '--model', 'tiny'], {
85+ encoding: 'utf8',
86+ timeout: 600000
87+ })
88+ process.stdout.write(run1.stdout + run1.stderr)
89+ check(run1.status === 0, 'transcribe exited 0')
90+
91+ const mdPath = path.join(dir, 'transcript.md')
92+ check(fs.existsSync(mdPath), 'transcript.md written')
93+ const md = fs.readFileSync(mdPath, 'utf8')
94+
95+ const aliceIdx = md.indexOf('Alice:** ')
96+ const chatIdx = md.indexOf('over to you, Bob')
97+ const bobIdx = md.indexOf('Bob:** ')
98+ check(aliceIdx !== -1, 'Alice has a speech turn')
99+ check(bobIdx !== -1, 'Bob has a speech turn')
100+ check(chatIdx !== -1, 'chat message in transcript')
101+ check(
102+ aliceIdx < chatIdx && chatIdx < bobIdx,
103+ 'ordering: Alice speech < chat < Bob speech'
104+ )
105+ const countryCount = (md.match(/your country/gi) ?? []).length
106+ check(countryCount >= 2, `both clips transcribed ("your country" x${countryCount})`)
107+ check(md.includes('Alice was already here'), 'join (already here) event rendered')
108+ check(md.includes('Bob joined'), 'join event rendered')
109+ check(md.includes('Bob left'), 'left event rendered')
110+ check(md.includes('# Transcript: transcribe-test'), 'header present')
111+
112+ check(fs.existsSync(path.join(dir, 'transcript.json')), 'transcript.json written')
113+ check(
114+ fs.existsSync(path.join(dir, 'asr', 'Alice-aaaa1111-seg1.json')),
115+ 'ASR cache written'
116+ )
117+
118+ // ---- second run must reuse the cache ----
119+ const run2 = spawnSync('node', [cli, 'transcribe', dir, '--model', 'tiny'], {
120+ encoding: 'utf8',
121+ timeout: 60000
122+ })
123+ check(
124+ run2.status === 0 && run2.stdout.includes('cached'),
125+ 'second run reuses the ASR cache'
126+ )
127+
128+ process.stdout.write(
129+ failures.length === 0
130+ ? `\nALL PASS (output kept in ${dir})\n`
131+ : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${dir})\n`
132+ )
133+ process.exit(failures.length === 0 ? 0 : 1)
134+}
135+
136+main().catch(err => {
137+ process.stderr.write(`transcribe-test fatal: ${err?.stack ?? err}\n`)
138+ process.exit(1)
139+})
src/transcribe.tsadded+406−0View file
@@ -0,0 +1,406 @@
1+import {spawn, spawnSync} from 'node:child_process'
2+import * as fs from 'node:fs'
3+import * as path from 'node:path'
4+
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.
19+
20+export 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+}
31+
32+interface AsrSegment {
33+ start: number
34+ end: number
35+ text: string
36+}
37+
38+interface ManifestSegment {
39+ file: string
40+ peerId: string
41+ name: string
42+ startedAt: string
43+ endedAt: string | null
44+ durationSec: number
45+}
46+
47+interface 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+}
55+
56+type 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}
60+
61+// ---- ASR engines ----------------------------------------------------------
62+
63+const FASTER_WHISPER_PY = `
64+import sys, json
65+from faster_whisper import WhisperModel
66+model_name = sys.argv[1]
67+language = None if sys.argv[2] == "-" else sys.argv[2]
68+files = sys.argv[3:]
69+model = WhisperModel(model_name, device="auto", compute_type="auto")
70+out = {}
71+for 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]
75+json.dump(out, sys.stdout)
76+`
77+
78+const hasCmd = (cmd: string, args: string[] = ['--version']): boolean =>
79+ spawnSync(cmd, args, {stdio: 'ignore'}).error === undefined
80+
81+const hasFasterWhisper = (): boolean =>
82+ spawnSync('python3', ['-c', 'import faster_whisper'], {stdio: 'ignore'})
83+ .status === 0
84+
85+const 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+}
99+
100+/** Run a command, streaming stderr lines to the log; resolve with stdout. */
101+const 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+ })
132+
133+/** Transcribe the given WAV paths; returns absolute path -> segments. */
134+const 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
141+
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+ }
154+
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+ }
182+
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+}
216+
217+// ---- timeline assembly ----------------------------------------------------
218+
219+/** Merge consecutive same-speaker utterances separated by < this many ms. */
220+const TURN_GAP_MS = 3000
221+
222+const 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+}
227+
228+const 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+}
233+
234+export 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
240+
241+ const engine = resolveEngine(opts.engine)
242+ opts.onLog(`engine: ${engine}${opts.model ? ` (model ${opts.model})` : ''}`)
243+
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')
249+
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+ }
279+
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+ }
299+
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+ }
329+
330+ items.sort((a, b) => a.timeMs - b.timeMs)
331+
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+ }
349+
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))]
354+
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)*', '')
378+
379+ const mdPath = path.join(opts.dir, 'transcript.md')
380+ fs.writeFileSync(mdPath, md.join('\n'))
381+
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+ )
403+
404+ opts.onLog(`wrote ${mdPath}`)
405+ opts.onLog(`wrote ${jsonPath}`)
406+}