// Test participant: joins a room like a browser would and "talks" a sine // tone into it, sends one chat message, then says bye and leaves. Used by the // loopback test to exercise the whole path (nostr signaling -> WebRTC -> // Opus -> RTCAudioSink -> WAV) without a real browser. // // node dist/test/speaker.js [--duration sec] [--freq hz] [--name X] 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' 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 room = a } if (!room) { process.stderr.write('usage: speaker.js [--duration sec] [--freq hz]\n') process.exit(1) } const log = (line: string) => process.stdout.write(`[speaker] ${line}\n`) // ---- outgoing audio: a continuous sine pushed in 10 ms frames ------------ const RATE = 48000 const FRAME = 480 // 10 ms const AMPLITUDE = 8000 const audioSource = new wrtc.nonstandard.RTCAudioSource() const audioTrack = audioSource.createTrack() const videoTrack = new wrtc.nonstandard.RTCVideoSource().createTrack() let phase = 0 const pushFrame = () => { const samples = new Int16Array(FRAME) 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 framesPushed = 0 const startMs = Date.now() const 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)) } 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)}`) 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() const announceTimer = setInterval(announce, 5000) setTimeout(() => { log('leaving') // Stop announcing and listening FIRST so nothing reconnects to us during // the goodbye grace period, then say bye and tear down. clearInterval(announceTimer) nostr.close() const bye = JSON.stringify({t: 'bye'}) for (const {peer} of conns.values()) peer.send(bye) setTimeout(() => { clearInterval(audioTimer) for (const {peer} of conns.values()) peer.destroy() process.exit(0) // wrtc segfaults on natural exit — always exit explicitly }, 500) }, durationSec * 1000) log(`joined "${room}" as ${name} (peer ${selfId.slice(0, 8)}), ${freq} Hz for ${durationSec}s`) } main().catch(err => { process.stderr.write(`speaker fatal: ${err?.stack ?? err}\n`) process.exit(1) })