concept-collection / commonroom-recorder
commonroom-recorder / src / test / live-loopback.ts
153 lines · 5.0 KBBlameHistoryRaw
1// End-to-end test of LIVE transcription (record --transcribe), no browser:
2//
3// 1. Start the recorder with --transcribe --model tiny in a random room.
4// 2. Start a test speaker that plays the 11 s JFK sample into the room.
5// 3. Check transcript.md appears (and has content) WHILE still recording.
6// 4. SIGINT the recorder; verify the final transcript has the speaker, the
7// right words, the chat line, and that the asr/ cache was written.
8//
9// Needs network (relays; sample + model download on first run).
11import {spawn, type ChildProcess} from 'node:child_process'
12import * as fs from 'node:fs'
13import * as os from 'node:os'
14import * as path from 'node:path'
15import {randomBytes} from 'node:crypto'
17const JFK_URL =
18 'https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/samples/jfk.wav'
19const SPEAK_SEC = 25 // after first connect: 11 s of speech, then silence
21const room = `livetest-${randomBytes(4).toString('hex')}`
22const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'commonroom-live-test-'))
23const dist = path.join(import.meta.dirname, '..')
25const failures: string[] = []
26const check = (ok: boolean, what: string) => {
27 process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`)
28 if (!ok) failures.push(what)
31const run = (cmd: string[], label: string): ChildProcess => {
32 const proc = spawn('node', cmd, {stdio: ['ignore', 'pipe', 'pipe']})
33 proc.stdout!.on('data', d =>
34 process.stdout.write(String(d).replace(/^/gm, ` ${label} | `))
35 )
36 proc.stderr!.on('data', d =>
37 process.stdout.write(String(d).replace(/^/gm, ` ${label} ! `))
38 )
39 return proc
42const wait = (ms: number) => new Promise(r => setTimeout(r, ms))
44const exited = (proc: ChildProcess, timeoutMs: number): Promise<boolean> =>
45 new Promise(resolve => {
46 if (proc.exitCode !== null) return resolve(true)
47 const t = setTimeout(() => {
48 proc.kill('SIGKILL')
49 resolve(false)
50 }, timeoutMs)
51 proc.on('exit', () => {
52 clearTimeout(t)
53 resolve(true)
54 })
55 })
57const main = async () => {
58 const jfkPath = path.join(os.tmpdir(), 'commonroom-recorder-jfk-sample.wav')
59 if (!fs.existsSync(jfkPath)) {
60 process.stdout.write(`downloading ${JFK_URL}\n`)
61 const res = await fetch(JFK_URL)
62 if (!res.ok) throw new Error(`sample download failed: ${res.status}`)
63 fs.writeFileSync(jfkPath, Buffer.from(await res.arrayBuffer()))
64 }
66 process.stdout.write(`room: ${room}\nout: ${outDir}\n\n`)
67 const recorder = run(
68 [
69 path.join(dist, 'cli.js'),
70 'record',
71 room,
72 '--out',
73 outDir,
74 '--transcribe',
75 '--model',
76 'tiny',
77 '--notice',
78 'recording test'
79 ],
80 'rec'
81 )
82 await wait(3000)
83 const speaker = run(
84 [
85 path.join(dist, 'test/speaker.js'),
86 room,
87 '--wav',
88 jfkPath,
89 '--duration',
90 String(SPEAK_SEC),
91 '--chat',
92 'live transcription test chat'
93 ],
94 'spk'
95 )
97 // The speaker exits SPEAK_SEC after its first connection (plus connect
98 // time); poll for the live transcript while it runs.
99 const mdPath = path.join(outDir, 'transcript.md')
100 let liveSeen = false
101 const speakerDone = exited(speaker, 180000)
102 let done = false
103 void speakerDone.then(() => (done = true))
104 while (!done) {
105 if (!liveSeen && fs.existsSync(mdPath)) {
106 const md = fs.readFileSync(mdPath, 'utf8')
107 // A SPEECH turn (bold "**[hh:mm:ss] Name:**"), not the chat line.
108 if (/^\*\*\[\d\d:\d\d:\d\d\] TestSpeaker:\*\*/m.test(md)) {
109 liveSeen = true
110 process.stdout.write(' (live transcript has speech — still recording)\n')
111 }
112 }
113 await wait(1000)
114 }
115 check(await speakerDone, 'speaker ran and exited')
116 check(liveSeen, 'transcript.md grew DURING the recording')
118 await wait(1500)
119 recorder.kill('SIGINT')
120 check(await exited(recorder, 120000), 'recorder exited cleanly on SIGINT')
122 check(fs.existsSync(mdPath), 'transcript.md written')
123 const md = fs.readFileSync(mdPath, 'utf8')
124 check(
125 /^\*\*\[\d\d:\d\d:\d\d\] TestSpeaker:\*\*/m.test(md),
126 'speaker has a speech turn in transcript'
127 )
128 check(/your country/i.test(md), 'JFK words transcribed')
129 check(md.includes('live transcription test chat'), 'chat line in transcript')
130 check(!md.includes('(in progress)'), 'final render has an end time')
132 const json = JSON.parse(fs.readFileSync(path.join(outDir, 'transcript.json'), 'utf8'))
133 check(
134 Array.isArray(json.items) && json.items.some((i: {type: string}) => i.type === 'speech'),
135 'transcript.json has speech items'
136 )
137 const asrFiles = fs.existsSync(path.join(outDir, 'asr'))
138 ? fs.readdirSync(path.join(outDir, 'asr'))
139 : []
140 check(asrFiles.length >= 1, `asr/ cache written (${asrFiles.length} file(s))`)
142 process.stdout.write(
143 failures.length === 0
144 ? `\nALL PASS (output kept in ${outDir})\n`
145 : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${outDir})\n`
146 )
147 process.exit(failures.length === 0 ? 0 : 1)
150main().catch(err => {
151 process.stderr.write(`live-loopback fatal: ${err?.stack ?? err}\n`)
152 process.exit(1)
153})