200df8aRecord commonroom audio and chat from the command lineJeremy Magland 1// Test participant: joins a room like a browser would and "talks" a sine
2// tone into it, sends one chat message, then says bye and leaves. Used by the
3// loopback test to exercise the whole path (nostr signaling -> WebRTC ->
4// Opus -> RTCAudioSink -> WAV) without a real browser.
5//
6// node dist/test/speaker.js <room> [--duration sec] [--freq hz] [--name X]
8import wrtc from '@roamhq/wrtc'
9import {selfId} from '../identity.js'
10import {Nostr, peerTopic, roomTopic} from '../nostr.js'
11import {Peer, type Signal} from '../peer.js'
13const argv = process.argv.slice(2)
14let room: string | null = null
15let durationSec = 12
16let freq = 440
17let name = 'TestSpeaker'
18let chatText = 'hello from the loopback test'
19for (let i = 0; i < argv.length; i++) {
20 const a = argv[i]!
21 if (a === '--duration') durationSec = Number(argv[++i])
22 else if (a === '--freq') freq = Number(argv[++i])
23 else if (a === '--name') name = argv[++i] ?? name
24 else if (a === '--chat') chatText = argv[++i] ?? chatText
25 else room = a
26}
27if (!room) {
28 process.stderr.write('usage: speaker.js <room> [--duration sec] [--freq hz]\n')
29 process.exit(1)
30}
32const log = (line: string) => process.stdout.write(`[speaker] ${line}\n`)
34// ---- outgoing audio: a continuous sine pushed in 10 ms frames ------------
36const RATE = 48000
37const FRAME = 480 // 10 ms
38const AMPLITUDE = 8000
40const audioSource = new wrtc.nonstandard.RTCAudioSource()
41const audioTrack = audioSource.createTrack()
42const videoTrack = new wrtc.nonstandard.RTCVideoSource().createTrack()
44let phase = 0
45const pushFrame = () => {
46 const samples = new Int16Array(FRAME)
47 for (let i = 0; i < FRAME; i++) {
48 samples[i] = Math.round(AMPLITUDE * Math.sin(phase))
49 phase += (2 * Math.PI * freq) / RATE
50 }
51 if (phase > 2 * Math.PI) phase -= 2 * Math.PI * Math.floor(phase / (2 * Math.PI))
52 audioSource.onData({
53 samples,
54 sampleRate: RATE,
55 bitsPerSample: 16,
56 channelCount: 1,
57 numberOfFrames: FRAME
58 })
59}
60// Wall-clock catch-up so timer jitter doesn't starve the source (bursts
61// capped — the source expects roughly real-time pacing).
62let framesPushed = 0
63const startMs = Date.now()
64const audioTimer = setInterval(() => {
65 const due = Math.floor(((Date.now() - startMs) / 1000) * RATE) / FRAME
66 let burst = 0
67 while (framesPushed < due && burst < 5) {
68 pushFrame()
69 framesPushed++
70 burst++
71 }
72}, 10)
74// ---- minimal mesh (commonroom protocol, one-shot) ------------------------
76type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
78const nostr = new Nostr()
79const conns = new Map<string, {peer: Peer; connected: boolean}>()
80const joinedAtMs = Date.now()
81let chatSent = false
83const main = async () => {
84 const root = await roomTopic(room!)
85 const selfTopic = await peerTopic(root, selfId)
87 const sendToPeer = async (peerId: string, msg: PeerMsg) => {
88 void nostr.publish(await peerTopic(root, peerId), JSON.stringify(msg))
89 }
91 const createPeer = (peerId: string, initiator: boolean) => {
92 const peer = new Peer(initiator, audioTrack, videoTrack)
93 const conn = {peer, connected: false}
94 conns.set(peerId, conn)
95 peer.setHandlers({
96 signal: signal => void sendToPeer(peerId, {t: 'signal', signal}),
97 connect: () => {
98 if (conn.connected) return // connectionState can flap during ICE settling
99 conn.connected = true
100 log(`connected to ${peerId.slice(0, 8)}`)
101 peer.send(
102 JSON.stringify({
103 t: 'hello',
104 name,
105 audioMuted: false,
106 videoMuted: true,
107 joinedAt: joinedAtMs,
108 settings: []
109 })
110 )
111 setTimeout(() => {
112 if (chatSent) return
113 chatSent = true
114 peer.send(JSON.stringify({t: 'chat', text: chatText}))
115 }, 2000)
116 },
117 data: () => undefined,
118 close: () => {
119 conns.delete(peerId)
120 }
121 })
122 return conn
123 }
125 nostr.subscribe(selfTopic, (content, from) => {
126 if (from === selfId) return
127 let msg: PeerMsg
128 try {
129 msg = JSON.parse(content)
130 } catch {
131 return
132 }
133 if (msg.t !== 'signal') return
134 let conn = conns.get(from)
135 if (!conn) {
136 if (msg.signal?.type !== 'offer') return
137 conn = createPeer(from, false)
138 }
139 void conn.peer.signal(msg.signal)
140 })
142 nostr.subscribe(root, (content, from) => {
143 if (from === selfId) return
144 let ann: {peerId?: string; name?: string}
145 try {
146 ann = JSON.parse(content)
147 } catch {
148 return
149 }
150 if (ann.peerId !== from) return
151 if (!conns.has(from)) createPeer(from, selfId < from)
152 })
154 const announce = () =>
155 void nostr.publish(root, JSON.stringify({peerId: selfId, name}))
156 announce()
157 const announceTimer = setInterval(announce, 5000)
159 setTimeout(() => {
160 log('leaving')
161 // Stop announcing and listening FIRST so nothing reconnects to us during
162 // the goodbye grace period, then say bye and tear down.
163 clearInterval(announceTimer)
164 nostr.close()
165 const bye = JSON.stringify({t: 'bye'})
166 for (const {peer} of conns.values()) peer.send(bye)
167 setTimeout(() => {
168 clearInterval(audioTimer)
169 for (const {peer} of conns.values()) peer.destroy()
170 process.exit(0) // wrtc segfaults on natural exit — always exit explicitly
171 }, 500)
172 }, durationSec * 1000)
174 log(`joined "${room}" as ${name} (peer ${selfId.slice(0, 8)}), ${freq} Hz for ${durationSec}s`)
175}
177main().catch(err => {
178 process.stderr.write(`speaker fatal: ${err?.stack ?? err}\n`)
179 process.exit(1)
180})