/ concept-collection / commonroom-recorder
Sign in
concept-collection / commonroom-recorder
commonroom-recorder / src / test / speaker.ts
245 lines · 7.4 KBBlameHistoryRaw
1// Test participant: joins a room like a browser would and "talks" into it —
2// either a sine tone or a WAV file (--wav) — sends one chat message, then
3// says bye and leaves. Used by the loopback tests to exercise the whole path
4// (nostr signaling -> WebRTC -> Opus -> RTCAudioSink -> WAV) without a real
5// browser. Audio playback and the leave countdown start at the FIRST
6// connection, so slow signaling can't eat into the clip.
7//
8// node dist/test/speaker.js <room> [--duration sec] [--freq hz] [--wav f.wav]
10import * as fs from 'node:fs'
11import wrtc from '@roamhq/wrtc'
12import {selfId} from '../identity.js'
13import {Nostr, peerTopic, roomTopic} from '../nostr.js'
14import {Peer, type Signal} from '../peer.js'
16const argv = process.argv.slice(2)
17let room: string | null = null
18let durationSec = 12
19let freq = 440
20let name = 'TestSpeaker'
21let chatText = 'hello from the loopback test'
22let wavPath: string | null = null
23for (let i = 0; i < argv.length; i++) {
24 const a = argv[i]!
25 if (a === '--duration') durationSec = Number(argv[++i])
26 else if (a === '--freq') freq = Number(argv[++i])
27 else if (a === '--name') name = argv[++i] ?? name
28 else if (a === '--chat') chatText = argv[++i] ?? chatText
29 else if (a === '--wav') wavPath = argv[++i] ?? null
30 else room = a
32if (!room) {
33 process.stderr.write(
34 'usage: speaker.js <room> [--duration sec] [--freq hz] [--wav f.wav]\n'
35 )
36 process.exit(1)
39const log = (line: string) => process.stdout.write(`[speaker] ${line}\n`)
41// ---- outgoing audio: sine tone or WAV, pushed in 10 ms frames ------------
43const AMPLITUDE = 8000
45let rate = 48000
46let wavSamples: Int16Array | null = null
47if (wavPath) {
48 const buf = fs.readFileSync(wavPath)
49 rate = buf.readUInt32LE(24)
50 const channels = buf.readUInt16LE(22)
51 if (rate % 100 !== 0) {
52 process.stderr.write(`--wav needs a sample rate divisible by 100 (got ${rate})\n`)
53 process.exit(1)
54 }
55 const dataIdx = buf.indexOf('data')
56 const pcm = new Int16Array(
57 buf.buffer,
58 buf.byteOffset + dataIdx + 8,
59 (buf.length - dataIdx - 8) >> 1
60 )
61 if (channels === 1) {
62 wavSamples = pcm
63 } else {
64 wavSamples = new Int16Array(Math.floor(pcm.length / channels))
65 for (let i = 0; i < wavSamples.length; i++) {
66 let sum = 0
67 for (let c = 0; c < channels; c++) sum += pcm[i * channels + c]!
68 wavSamples[i] = Math.round(sum / channels)
69 }
70 }
72const FRAME = rate / 100 // 10 ms
74const audioSource = new wrtc.nonstandard.RTCAudioSource()
75const audioTrack = audioSource.createTrack()
76const videoTrack = new wrtc.nonstandard.RTCVideoSource().createTrack()
78let phase = 0
79let wavOffset = 0
80const pushFrame = () => {
81 const samples = new Int16Array(FRAME)
82 if (wavSamples) {
83 // Play the file once, then silence.
84 for (let i = 0; i < FRAME && wavOffset < wavSamples.length; i++) {
85 samples[i] = wavSamples[wavOffset++]!
86 }
87 } else {
88 for (let i = 0; i < FRAME; i++) {
89 samples[i] = Math.round(AMPLITUDE * Math.sin(phase))
90 phase += (2 * Math.PI * freq) / rate
91 }
92 if (phase > 2 * Math.PI) {
93 phase -= 2 * Math.PI * Math.floor(phase / (2 * Math.PI))
94 }
95 }
96 audioSource.onData({
97 samples,
98 sampleRate: rate,
99 bitsPerSample: 16,
100 channelCount: 1,
101 numberOfFrames: FRAME
102 })
104// Wall-clock catch-up so timer jitter doesn't starve the source (bursts
105// capped — the source expects roughly real-time pacing).
106let audioTimer: ReturnType<typeof setInterval> | null = null
107const startAudio = () => {
108 if (audioTimer !== null) return
109 let framesPushed = 0
110 const startMs = Date.now()
111 audioTimer = setInterval(() => {
112 const due = Math.floor(((Date.now() - startMs) / 1000) * rate) / FRAME
113 let burst = 0
114 while (framesPushed < due && burst < 5) {
115 pushFrame()
116 framesPushed++
117 burst++
118 }
119 }, 10)
122// ---- minimal mesh (commonroom protocol, one-shot) ------------------------
124type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
126const nostr = new Nostr()
127const conns = new Map<string, {peer: Peer; connected: boolean}>()
128const joinedAtMs = Date.now()
129let chatSent = false
131const main = async () => {
132 const root = await roomTopic(room!)
133 const selfTopic = await peerTopic(root, selfId)
135 const sendToPeer = async (peerId: string, msg: PeerMsg) => {
136 void nostr.publish(await peerTopic(root, peerId), JSON.stringify(msg))
137 }
139 let announceTimer: ReturnType<typeof setInterval> | null = null
140 let leaving = false
141 const leave = () => {
142 if (leaving) return
143 leaving = true
144 log('leaving')
145 // Stop announcing and listening FIRST so nothing reconnects to us during
146 // the goodbye grace period, then say bye and tear down.
147 if (announceTimer !== null) clearInterval(announceTimer)
148 nostr.close()
149 const bye = JSON.stringify({t: 'bye'})
150 for (const {peer} of conns.values()) peer.send(bye)
151 setTimeout(() => {
152 if (audioTimer !== null) clearInterval(audioTimer)
153 for (const {peer} of conns.values()) peer.destroy()
154 process.exit(0) // wrtc segfaults on natural exit — always exit explicitly
155 }, 500)
156 }
158 const createPeer = (peerId: string, initiator: boolean) => {
159 const peer = new Peer(initiator, audioTrack, videoTrack)
160 const conn = {peer, connected: false}
161 conns.set(peerId, conn)
162 peer.setHandlers({
163 signal: signal => void sendToPeer(peerId, {t: 'signal', signal}),
164 connect: () => {
165 if (conn.connected) return // connectionState can flap during ICE settling
166 conn.connected = true
167 log(`connected to ${peerId.slice(0, 8)}`)
168 startAudio() // playback + leave countdown start at the first connect
169 setTimeout(leave, durationSec * 1000)
170 peer.send(
171 JSON.stringify({
172 t: 'hello',
173 name,
174 audioMuted: false,
175 videoMuted: true,
176 joinedAt: joinedAtMs,
177 settings: []
178 })
179 )
180 setTimeout(() => {
181 if (chatSent) return
182 chatSent = true
183 peer.send(JSON.stringify({t: 'chat', text: chatText}))
184 }, 2000)
185 },
186 data: () => undefined,
187 close: () => {
188 conns.delete(peerId)
189 }
190 })
191 return conn
192 }
194 nostr.subscribe(selfTopic, (content, from) => {
195 if (from === selfId) return
196 let msg: PeerMsg
197 try {
198 msg = JSON.parse(content)
199 } catch {
200 return
201 }
202 if (msg.t !== 'signal') return
203 let conn = conns.get(from)
204 if (!conn) {
205 if (msg.signal?.type !== 'offer') return
206 conn = createPeer(from, false)
207 }
208 void conn.peer.signal(msg.signal)
209 })
211 nostr.subscribe(root, (content, from) => {
212 if (from === selfId) return
213 let ann: {peerId?: string; name?: string}
214 try {
215 ann = JSON.parse(content)
216 } catch {
217 return
218 }
219 if (ann.peerId !== from) return
220 if (!conns.has(from)) createPeer(from, selfId < from)
221 })
223 const announce = () =>
224 void nostr.publish(root, JSON.stringify({peerId: selfId, name}))
225 announce()
226 announceTimer = setInterval(announce, 5000)
228 // Safety net: give up if nobody ever connects.
229 setTimeout(() => {
230 if (audioTimer === null) {
231 log('no connection after 120s, giving up')
232 process.exit(1)
233 }
234 }, 120000)
236 log(
237 `joined "${room}" as ${name} (peer ${selfId.slice(0, 8)}), ` +
238 `${wavPath ? `playing ${wavPath}` : `${freq} Hz`} for ${durationSec}s after connect`
239 )
242main().catch(err => {
243 process.stderr.write(`speaker fatal: ${err?.stack ?? err}\n`)
244 process.exit(1)
245})
moveopenescclose