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)
30}
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}
47}
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'), 'record', 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 // Once the speaker is in, drop a file into the inbox: its content must be
84 // sent to the room as a chat message from the recorder.
85 const speakerDone = exited(speaker.proc, (SPEAK_SEC + 45) * 1000)
86 let speakerExited = false
87 void speakerDone.then(() => (speakerExited = true))
88 const INBOX_MSG = 'interjection from the inbox'
89 let inboxWritten = false
90 while (!speakerExited) {
91 if (!inboxWritten && recorder.output().includes('TestSpeaker joined')) {
92 fs.writeFileSync(path.join(outDir, 'inbox', 'msg-1.txt'), INBOX_MSG + '\n')
93 inboxWritten = true
94 }
95 await wait(500)
96 }
97 check(await speakerDone, 'speaker ran and exited')
98 check(inboxWritten, 'inbox message was written during the call')
99 check(
100 speaker.output().includes(`chat received: ${INBOX_MSG}`),
101 'speaker received the inbox chat over the data channel'
102 )
103 await wait(1500)
104 recorder.proc.kill('SIGINT')
105 check(await exited(recorder.proc, 15000), 'recorder exited cleanly on SIGINT')
107 // ---- verify the outputs ----
108 const manifestPath = path.join(outDir, 'manifest.json')
109 check(fs.existsSync(manifestPath), 'manifest.json written')
110 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
111 check(manifest.room === room, 'manifest has the room')
112 check(manifest.endedAt !== null, 'manifest has endedAt')
113 check(
114 Object.values(manifest.participants ?? {}).includes('TestSpeaker'),
115 'manifest lists TestSpeaker'
116 )
117 const segs: {file: string; durationSec: number}[] = manifest.segments ?? []
118 check(segs.length >= 1, `manifest has >= 1 segment (got ${segs.length})`)
120 const seg = [...segs].sort((a, b) => b.durationSec - a.durationSec)[0]
121 if (seg) {
122 const wavPath = path.join(outDir, seg.file)
123 check(fs.existsSync(wavPath), `wav exists: ${seg.file}`)
124 const wav = fs.readFileSync(wavPath)
125 const sampleRate = wav.readUInt32LE(24)
126 const channels = wav.readUInt16LE(22)
127 const dataBytes = wav.readUInt32LE(40)
128 const durationSec = dataBytes / (sampleRate * channels * 2)
129 check(dataBytes + 44 === wav.length, 'wav header size matches file size')
130 check(durationSec >= 6, `wav duration >= 6s (got ${durationSec.toFixed(1)}s)`)
132 // Analyze a middle stretch: real tone -> substantial RMS, and the
133 // zero-crossing rate of a sine is 2f per second.
134 const startFrame = Math.floor(sampleRate * 2)
135 const endFrame = Math.min(Math.floor(sampleRate * 6), Math.floor(dataBytes / 2 / channels))
136 let sumSq = 0
137 let crossings = 0
138 let prev = 0
139 for (let f = startFrame; f < endFrame; f++) {
140 const s = wav.readInt16LE(44 + f * channels * 2)
141 sumSq += s * s
142 if ((s > 0 && prev <= 0) || (s < 0 && prev >= 0)) crossings++
143 prev = s
144 }
145 const n = endFrame - startFrame
146 const rms = Math.sqrt(sumSq / n)
147 const zcPerSec = crossings / (n / sampleRate)
148 check(rms > 2000, `tone present: RMS > 2000 (got ${rms.toFixed(0)})`)
149 check(
150 Math.abs(zcPerSec - 2 * FREQ) < 2 * FREQ * 0.2,
151 `tone is ~${FREQ} Hz: zero-crossings/sec ~ ${2 * FREQ} (got ${zcPerSec.toFixed(0)})`
152 )
153 }
155 const events = fs
156 .readFileSync(path.join(outDir, 'events.jsonl'), 'utf8')
157 .trim()
158 .split('\n')
159 .map(l => JSON.parse(l))
160 const chatEvents = events.filter(
161 e => e.type === 'chat' && e.text === 'hello from the loopback test'
162 )
163 check(
164 chatEvents.length === 1,
165 `chat message captured exactly once in events.jsonl (got ${chatEvents.length})`
166 )
167 check(
168 events.some(e => e.type === 'join' && e.name === 'TestSpeaker'),
169 'join event captured'
170 )
171 const chatTxt = fs.readFileSync(path.join(outDir, 'chat.txt'), 'utf8')
172 check(chatTxt.includes('hello from the loopback test'), 'chat message in chat.txt')
173 check(
174 events.some(
175 e => e.type === 'chat' && e.name === 'Recorder' && e.text === 'interjection from the inbox'
176 ),
177 'inbox chat logged in events.jsonl as the recorder'
178 )
179 check(chatTxt.includes('Recorder: interjection from the inbox'), 'inbox chat in chat.txt')
180 check(
181 fs.readdirSync(path.join(outDir, 'inbox')).length === 0,
182 'inbox file deleted after sending'
183 )
185 process.stdout.write(
186 failures.length === 0
187 ? `\nALL PASS (output kept in ${outDir})\n`
188 : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${outDir})\n`
189 )
190 process.exit(failures.length === 0 ? 0 : 1)
191}
193main().catch(err => {
194 process.stderr.write(`loopback fatal: ${err?.stack ?? err}\n`)
195 process.exit(1)
196})