// End-to-end loopback test, no browser required: // // 1. Start the recorder CLI in a random room. // 2. Start a test speaker that plays a 440 Hz sine and sends a chat line. // 3. After the speaker leaves, SIGINT the recorder. // 4. Verify: the WAV exists, is long enough, actually contains a ~440 Hz // tone (RMS + zero-crossing rate), and the chat made it into // events.jsonl and chat.txt. // // Uses the real public nostr relays for signaling (same as dev-testing the // browser client), so it needs network access. import {spawn, type ChildProcess} from 'node:child_process' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import {randomBytes} from 'node:crypto' const FREQ = 440 const SPEAK_SEC = 12 const room = `looptest-${randomBytes(4).toString('hex')}` const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'commonroom-recorder-test-')) const dist = path.join(import.meta.dirname, '..') const failures: string[] = [] const check = (ok: boolean, what: string) => { process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`) if (!ok) failures.push(what) } const run = ( cmd: string[], label: string ): {proc: ChildProcess; output: () => string} => { const proc = spawn('node', cmd, {stdio: ['ignore', 'pipe', 'pipe']}) let out = '' proc.stdout!.on('data', d => { out += d process.stdout.write(String(d).replace(/^/gm, ` ${label} | `)) }) proc.stderr!.on('data', d => { out += d process.stdout.write(String(d).replace(/^/gm, ` ${label} ! `)) }) return {proc, output: () => out} } const wait = (ms: number) => new Promise(r => setTimeout(r, ms)) const exited = (proc: ChildProcess, timeoutMs: number): Promise => new Promise(resolve => { const t = setTimeout(() => { proc.kill('SIGKILL') resolve(false) }, timeoutMs) proc.on('exit', () => { clearTimeout(t) resolve(true) }) }) const main = async () => { process.stdout.write(`room: ${room}\nout: ${outDir}\n\n`) const recorder = run( [path.join(dist, 'cli.js'), room, '--out', outDir, '--notice', 'recording test'], 'rec' ) await wait(3000) const speaker = run( [ path.join(dist, 'test/speaker.js'), room, '--duration', String(SPEAK_SEC), '--freq', String(FREQ) ], 'spk' ) check(await exited(speaker.proc, (SPEAK_SEC + 45) * 1000), 'speaker ran and exited') await wait(1500) recorder.proc.kill('SIGINT') check(await exited(recorder.proc, 15000), 'recorder exited cleanly on SIGINT') // ---- verify the outputs ---- const manifestPath = path.join(outDir, 'manifest.json') check(fs.existsSync(manifestPath), 'manifest.json written') const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) check(manifest.room === room, 'manifest has the room') check(manifest.endedAt !== null, 'manifest has endedAt') check( Object.values(manifest.participants ?? {}).includes('TestSpeaker'), 'manifest lists TestSpeaker' ) const segs: {file: string; durationSec: number}[] = manifest.segments ?? [] check(segs.length >= 1, `manifest has >= 1 segment (got ${segs.length})`) const seg = [...segs].sort((a, b) => b.durationSec - a.durationSec)[0] if (seg) { const wavPath = path.join(outDir, seg.file) check(fs.existsSync(wavPath), `wav exists: ${seg.file}`) const wav = fs.readFileSync(wavPath) const sampleRate = wav.readUInt32LE(24) const channels = wav.readUInt16LE(22) const dataBytes = wav.readUInt32LE(40) const durationSec = dataBytes / (sampleRate * channels * 2) check(dataBytes + 44 === wav.length, 'wav header size matches file size') check(durationSec >= 6, `wav duration >= 6s (got ${durationSec.toFixed(1)}s)`) // Analyze a middle stretch: real tone -> substantial RMS, and the // zero-crossing rate of a sine is 2f per second. const startFrame = Math.floor(sampleRate * 2) const endFrame = Math.min(Math.floor(sampleRate * 6), Math.floor(dataBytes / 2 / channels)) let sumSq = 0 let crossings = 0 let prev = 0 for (let f = startFrame; f < endFrame; f++) { const s = wav.readInt16LE(44 + f * channels * 2) sumSq += s * s if ((s > 0 && prev <= 0) || (s < 0 && prev >= 0)) crossings++ prev = s } const n = endFrame - startFrame const rms = Math.sqrt(sumSq / n) const zcPerSec = crossings / (n / sampleRate) check(rms > 2000, `tone present: RMS > 2000 (got ${rms.toFixed(0)})`) check( Math.abs(zcPerSec - 2 * FREQ) < 2 * FREQ * 0.2, `tone is ~${FREQ} Hz: zero-crossings/sec ~ ${2 * FREQ} (got ${zcPerSec.toFixed(0)})` ) } const events = fs .readFileSync(path.join(outDir, 'events.jsonl'), 'utf8') .trim() .split('\n') .map(l => JSON.parse(l)) const chatEvents = events.filter( e => e.type === 'chat' && e.text === 'hello from the loopback test' ) check( chatEvents.length === 1, `chat message captured exactly once in events.jsonl (got ${chatEvents.length})` ) check( events.some(e => e.type === 'join' && e.name === 'TestSpeaker'), 'join event captured' ) const chatTxt = fs.readFileSync(path.join(outDir, 'chat.txt'), 'utf8') check(chatTxt.includes('hello from the loopback test'), 'chat message in chat.txt') process.stdout.write( failures.length === 0 ? `\nALL PASS (output kept in ${outDir})\n` : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${outDir})\n` ) process.exit(failures.length === 0 ? 0 : 1) } main().catch(err => { process.stderr.write(`loopback fatal: ${err?.stack ?? err}\n`) process.exit(1) })