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.
10import {spawnSync} from 'node:child_process'
11import * as fs from 'node:fs'
12import * as os from 'node:os'
13import * as path from 'node:path'
15const JFK_URL =
16 'https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/samples/jfk.wav'
18const failures: string[] = []
19const check = (ok: boolean, what: string) => {
20 process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`)
21 if (!ok) failures.push(what)
22}
24const 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 }
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'))
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 )
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')
91 const mdPath = path.join(dir, 'transcript.md')
92 check(fs.existsSync(mdPath), 'transcript.md written')
93 const md = fs.readFileSync(mdPath, 'utf8')
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')
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 )
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 )
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}
136main().catch(err => {
137 process.stderr.write(`transcribe-test fatal: ${err?.stack ?? err}\n`)
138 process.exit(1)
139})