/ concept-collection / commonroom-recorder
concept-collection / commonroom-recorder
commonroom-recorder / src / test / loopback.ts
166 lines · 5.6 KBBlameHistoryRaw
1// End-to-end loopback test, no browser required:
2//
3// 1. Start the recorder CLI in a random room.
4// 2. Start a test speaker that plays a 440 Hz sine and sends a chat line.
5// 3. After the speaker leaves, SIGINT the recorder.
6// 4. Verify: the WAV exists, is long enough, actually contains a ~440 Hz
7// tone (RMS + zero-crossing rate), and the chat made it into
8// events.jsonl and chat.txt.
9//
10// Uses the real public nostr relays for signaling (same as dev-testing the
11// browser client), so it needs network access.
13import {spawn, type ChildProcess} from 'node:child_process'
14import * as fs from 'node:fs'
15import * as os from 'node:os'
16import * as path from 'node:path'
17import {randomBytes} from 'node:crypto'
19const FREQ = 440
20const SPEAK_SEC = 12
22const room = `looptest-${randomBytes(4).toString('hex')}`
23const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'commonroom-recorder-test-'))
24const dist = path.join(import.meta.dirname, '..')
26const failures: string[] = []
27const check = (ok: boolean, what: string) => {
28 process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`)
29 if (!ok) failures.push(what)
32const run = (
33 cmd: string[],
34 label: string
35): {proc: ChildProcess; output: () => string} => {
36 const proc = spawn('node', cmd, {stdio: ['ignore', 'pipe', 'pipe']})
37 let out = ''
38 proc.stdout!.on('data', d => {
39 out += d
40 process.stdout.write(String(d).replace(/^/gm, ` ${label} | `))
41 })
42 proc.stderr!.on('data', d => {
43 out += d
44 process.stdout.write(String(d).replace(/^/gm, ` ${label} ! `))
45 })
46 return {proc, output: () => out}
49const wait = (ms: number) => new Promise(r => setTimeout(r, ms))
51const exited = (proc: ChildProcess, timeoutMs: number): Promise<boolean> =>
52 new Promise(resolve => {
53 const t = setTimeout(() => {
54 proc.kill('SIGKILL')
55 resolve(false)
56 }, timeoutMs)
57 proc.on('exit', () => {
58 clearTimeout(t)
59 resolve(true)
60 })
61 })
63const main = async () => {
64 process.stdout.write(`room: ${room}\nout: ${outDir}\n\n`)
66 const recorder = run(
67 [path.join(dist, 'cli.js'), room, '--out', outDir, '--notice', 'recording test'],
68 'rec'
69 )
70 await wait(3000)
71 const speaker = run(
72 [
73 path.join(dist, 'test/speaker.js'),
74 room,
75 '--duration',
76 String(SPEAK_SEC),
77 '--freq',
78 String(FREQ)
79 ],
80 'spk'
81 )
83 check(await exited(speaker.proc, (SPEAK_SEC + 45) * 1000), 'speaker ran and exited')
84 await wait(1500)
85 recorder.proc.kill('SIGINT')
86 check(await exited(recorder.proc, 15000), 'recorder exited cleanly on SIGINT')
88 // ---- verify the outputs ----
89 const manifestPath = path.join(outDir, 'manifest.json')
90 check(fs.existsSync(manifestPath), 'manifest.json written')
91 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
92 check(manifest.room === room, 'manifest has the room')
93 check(manifest.endedAt !== null, 'manifest has endedAt')
94 check(
95 Object.values(manifest.participants ?? {}).includes('TestSpeaker'),
96 'manifest lists TestSpeaker'
97 )
98 const segs: {file: string; durationSec: number}[] = manifest.segments ?? []
99 check(segs.length >= 1, `manifest has >= 1 segment (got ${segs.length})`)
101 const seg = [...segs].sort((a, b) => b.durationSec - a.durationSec)[0]
102 if (seg) {
103 const wavPath = path.join(outDir, seg.file)
104 check(fs.existsSync(wavPath), `wav exists: ${seg.file}`)
105 const wav = fs.readFileSync(wavPath)
106 const sampleRate = wav.readUInt32LE(24)
107 const channels = wav.readUInt16LE(22)
108 const dataBytes = wav.readUInt32LE(40)
109 const durationSec = dataBytes / (sampleRate * channels * 2)
110 check(dataBytes + 44 === wav.length, 'wav header size matches file size')
111 check(durationSec >= 6, `wav duration >= 6s (got ${durationSec.toFixed(1)}s)`)
113 // Analyze a middle stretch: real tone -> substantial RMS, and the
114 // zero-crossing rate of a sine is 2f per second.
115 const startFrame = Math.floor(sampleRate * 2)
116 const endFrame = Math.min(Math.floor(sampleRate * 6), Math.floor(dataBytes / 2 / channels))
117 let sumSq = 0
118 let crossings = 0
119 let prev = 0
120 for (let f = startFrame; f < endFrame; f++) {
121 const s = wav.readInt16LE(44 + f * channels * 2)
122 sumSq += s * s
123 if ((s > 0 && prev <= 0) || (s < 0 && prev >= 0)) crossings++
124 prev = s
125 }
126 const n = endFrame - startFrame
127 const rms = Math.sqrt(sumSq / n)
128 const zcPerSec = crossings / (n / sampleRate)
129 check(rms > 2000, `tone present: RMS > 2000 (got ${rms.toFixed(0)})`)
130 check(
131 Math.abs(zcPerSec - 2 * FREQ) < 2 * FREQ * 0.2,
132 `tone is ~${FREQ} Hz: zero-crossings/sec ~ ${2 * FREQ} (got ${zcPerSec.toFixed(0)})`
133 )
134 }
136 const events = fs
137 .readFileSync(path.join(outDir, 'events.jsonl'), 'utf8')
138 .trim()
139 .split('\n')
140 .map(l => JSON.parse(l))
141 const chatEvents = events.filter(
142 e => e.type === 'chat' && e.text === 'hello from the loopback test'
143 )
144 check(
145 chatEvents.length === 1,
146 `chat message captured exactly once in events.jsonl (got ${chatEvents.length})`
147 )
148 check(
149 events.some(e => e.type === 'join' && e.name === 'TestSpeaker'),
150 'join event captured'
151 )
152 const chatTxt = fs.readFileSync(path.join(outDir, 'chat.txt'), 'utf8')
153 check(chatTxt.includes('hello from the loopback test'), 'chat message in chat.txt')
155 process.stdout.write(
156 failures.length === 0
157 ? `\nALL PASS (output kept in ${outDir})\n`
158 : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${outDir})\n`
159 )
160 process.exit(failures.length === 0 ? 0 : 1)
163main().catch(err => {
164 process.stderr.write(`loopback fatal: ${err?.stack ?? err}\n`)
165 process.exit(1)
166})