// Test participant: joins a room like a browser would and "talks" into it — // either a sine tone or a WAV file (--wav) — sends one chat message, then // says bye and leaves. Used by the loopback tests to exercise the whole path // (nostr signaling -> WebRTC -> Opus -> RTCAudioSink -> WAV) without a real // browser. Audio playback and the leave countdown start at the FIRST // connection, so slow signaling can't eat into the clip. // // node dist/test/speaker.js [--duration sec] [--freq hz] [--wav f.wav] import * as fs from 'node:fs' import wrtc from '@roamhq/wrtc' import {selfId} from '../identity.js' import {Nostr, peerTopic, roomTopic} from '../nostr.js' import {Peer, type Signal} from '../peer.js' const argv = process.argv.slice(2) let room: string | null = null let durationSec = 12 let freq = 440 let name = 'TestSpeaker' let chatText = 'hello from the loopback test' let wavPath: string | null = null for (let i = 0; i < argv.length; i++) { const a = argv[i]! if (a === '--duration') durationSec = Number(argv[++i]) else if (a === '--freq') freq = Number(argv[++i]) else if (a === '--name') name = argv[++i] ?? name else if (a === '--chat') chatText = argv[++i] ?? chatText else if (a === '--wav') wavPath = argv[++i] ?? null else room = a } if (!room) { process.stderr.write( 'usage: speaker.js [--duration sec] [--freq hz] [--wav f.wav]\n' ) process.exit(1) } const log = (line: string) => process.stdout.write(`[speaker] ${line}\n`) // ---- outgoing audio: sine tone or WAV, pushed in 10 ms frames ------------ const AMPLITUDE = 8000 let rate = 48000 let wavSamples: Int16Array | null = null if (wavPath) { const buf = fs.readFileSync(wavPath) rate = buf.readUInt32LE(24) const channels = buf.readUInt16LE(22) if (rate % 100 !== 0) { process.stderr.write(`--wav needs a sample rate divisible by 100 (got ${rate})\n`) process.exit(1) } const dataIdx = buf.indexOf('data') const pcm = new Int16Array( buf.buffer, buf.byteOffset + dataIdx + 8, (buf.length - dataIdx - 8) >> 1 ) if (channels === 1) { wavSamples = pcm } else { wavSamples = new Int16Array(Math.floor(pcm.length / channels)) for (let i = 0; i < wavSamples.length; i++) { let sum = 0 for (let c = 0; c < channels; c++) sum += pcm[i * channels + c]! wavSamples[i] = Math.round(sum / channels) } } } const FRAME = rate / 100 // 10 ms const audioSource = new wrtc.nonstandard.RTCAudioSource() const audioTrack = audioSource.createTrack() const videoTrack = new wrtc.nonstandard.RTCVideoSource().createTrack() let phase = 0 let wavOffset = 0 const pushFrame = () => { const samples = new Int16Array(FRAME) if (wavSamples) { // Play the file once, then silence. for (let i = 0; i < FRAME && wavOffset < wavSamples.length; i++) { samples[i] = wavSamples[wavOffset++]! } } else { for (let i = 0; i < FRAME; i++) { samples[i] = Math.round(AMPLITUDE * Math.sin(phase)) phase += (2 * Math.PI * freq) / rate } if (phase > 2 * Math.PI) { phase -= 2 * Math.PI * Math.floor(phase / (2 * Math.PI)) } } audioSource.onData({ samples, sampleRate: rate, bitsPerSample: 16, channelCount: 1, numberOfFrames: FRAME }) } // Wall-clock catch-up so timer jitter doesn't starve the source (bursts // capped — the source expects roughly real-time pacing). let audioTimer: ReturnType | null = null const startAudio = () => { if (audioTimer !== null) return let framesPushed = 0 const startMs = Date.now() audioTimer = setInterval(() => { const due = Math.floor(((Date.now() - startMs) / 1000) * rate) / FRAME let burst = 0 while (framesPushed < due && burst < 5) { pushFrame() framesPushed++ burst++ } }, 10) } // ---- minimal mesh (commonroom protocol, one-shot) ------------------------ type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'} const nostr = new Nostr() const conns = new Map() const joinedAtMs = Date.now() let chatSent = false const main = async () => { const root = await roomTopic(room!) const selfTopic = await peerTopic(root, selfId) const sendToPeer = async (peerId: string, msg: PeerMsg) => { void nostr.publish(await peerTopic(root, peerId), JSON.stringify(msg)) } let announceTimer: ReturnType | null = null let leaving = false const leave = () => { if (leaving) return leaving = true log('leaving') // Stop announcing and listening FIRST so nothing reconnects to us during // the goodbye grace period, then say bye and tear down. if (announceTimer !== null) clearInterval(announceTimer) nostr.close() const bye = JSON.stringify({t: 'bye'}) for (const {peer} of conns.values()) peer.send(bye) setTimeout(() => { if (audioTimer !== null) clearInterval(audioTimer) for (const {peer} of conns.values()) peer.destroy() process.exit(0) // wrtc segfaults on natural exit — always exit explicitly }, 500) } const createPeer = (peerId: string, initiator: boolean) => { const peer = new Peer(initiator, audioTrack, videoTrack) const conn = {peer, connected: false} conns.set(peerId, conn) peer.setHandlers({ signal: signal => void sendToPeer(peerId, {t: 'signal', signal}), connect: () => { if (conn.connected) return // connectionState can flap during ICE settling conn.connected = true log(`connected to ${peerId.slice(0, 8)}`) startAudio() // playback + leave countdown start at the first connect setTimeout(leave, durationSec * 1000) peer.send( JSON.stringify({ t: 'hello', name, audioMuted: false, videoMuted: true, joinedAt: joinedAtMs, settings: [] }) ) setTimeout(() => { if (chatSent) return chatSent = true peer.send(JSON.stringify({t: 'chat', text: chatText})) }, 2000) }, data: () => undefined, close: () => { conns.delete(peerId) } }) return conn } nostr.subscribe(selfTopic, (content, from) => { if (from === selfId) return let msg: PeerMsg try { msg = JSON.parse(content) } catch { return } if (msg.t !== 'signal') return let conn = conns.get(from) if (!conn) { if (msg.signal?.type !== 'offer') return conn = createPeer(from, false) } void conn.peer.signal(msg.signal) }) nostr.subscribe(root, (content, from) => { if (from === selfId) return let ann: {peerId?: string; name?: string} try { ann = JSON.parse(content) } catch { return } if (ann.peerId !== from) return if (!conns.has(from)) createPeer(from, selfId < from) }) const announce = () => void nostr.publish(root, JSON.stringify({peerId: selfId, name})) announce() announceTimer = setInterval(announce, 5000) // Safety net: give up if nobody ever connects. setTimeout(() => { if (audioTimer === null) { log('no connection after 120s, giving up') process.exit(1) } }, 120000) log( `joined "${room}" as ${name} (peer ${selfId.slice(0, 8)}), ` + `${wavPath ? `playing ${wavPath}` : `${freq} Hz`} for ${durationSec}s after connect` ) } main().catch(err => { process.stderr.write(`speaker fatal: ${err?.stack ?? err}\n`) process.exit(1) })