200df8aRecord commonroom audio and chat from the command lineJeremy Magland 1import wrtc from '@roamhq/wrtc'
3// A thin WebRTC wrapper, ported from commonroom's peer.ts onto @roamhq/wrtc
4// (node-webrtc). One instance per remote participant: it carries our
5// placeholder outgoing tracks plus the small control data channel (hello,
6// mute notices, chat, settings sync). As in the browser client we avoid
7// "perfect negotiation" glare handling by ensuring only ONE side (the
8// deterministically chosen initiator = smaller peer ID) ever creates the
9// offer.
10//
11// Changes from the browser version:
12// - The VIDEO m-line is negotiated 'sendonly' from our side (our placeholder
13// track, which never produces a frame). The browser answers/offers the
14// complement (recvonly), so NO video RTP ever flows to the recorder — with
15// up to 7 participants that saves several Mbit/s of download plus the
16// decode CPU, and to the browsers we look exactly like a camera-muted
17// participant.
18// - The track handler hands over the remote MediaStreamTrack (we attach an
19// RTCAudioSink per audio track) instead of the MediaStream.
20// - replaceTrack/setVideoParameters are gone: the recorder never upgrades or
21// caps media.
23export type Signal =
24 | {type: 'offer'; sdp: string}
25 | {type: 'answer'; sdp: string}
26 | {type: 'candidate'; candidate: RTCIceCandidateInit}
28export interface PeerHandlers {
29 signal: (signal: Signal) => void
30 /** Connection reached the 'connected' state. */
31 connect: () => void
32 /** A remote media track became available (we only consume audio). */
33 track: (track: MediaStreamTrack) => void
34 /** A string message arrived on the control channel. */
35 data: (data: string) => void
36 close: () => void
37}
39export const ICE_SERVERS: RTCIceServer[] = [
40 {urls: 'stun:stun.l.google.com:19302'},
41 {urls: 'stun:stun1.l.google.com:19302'},
42 {urls: 'stun:stun.cloudflare.com:3478'},
43 // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
44 // fails (symmetric NAT, hairpinning, host-candidate blocking).
45 {
46 urls: [
47 'turn:openrelay.metered.ca:80',
48 'turn:openrelay.metered.ca:443',
49 'turns:openrelay.metered.ca:443'
50 ],
51 username: 'openrelayproject',
52 credential: 'openrelayproject'
53 }
54]
56// A media connection can survive a brief network blip: 'disconnected' often
57// recovers on its own, so only tear down if it persists this long.
58const DISCONNECT_GRACE_MS = 5000
60export class Peer {
61 private pc: RTCPeerConnection
62 private channel: RTCDataChannel | null = null
63 /** Control messages sent before the channel opens; flushed on open. */
64 private outbox: string[] = []
65 private handlers: Partial<PeerHandlers> = {}
66 private pendingCandidates: RTCIceCandidateInit[] = []
67 private disconnectTimer: ReturnType<typeof setTimeout> | null = null
68 private closed = false
70 constructor(
71 private initiator: boolean,
72 audioTrack: MediaStreamTrack,
73 videoTrack: MediaStreamTrack
74 ) {
75 this.pc = new wrtc.RTCPeerConnection({iceServers: ICE_SERVERS})
77 // Both sides add media up front so the initiator's single offer covers
78 // everything. Audio is sendrecv (our track is a silent placeholder — a
79 // muted mic); video is sendonly so the other side never sends us any.
80 this.pc.addTrack(audioTrack)
81 if (initiator) {
82 this.pc.addTransceiver(videoTrack, {direction: 'sendonly'})
83 } else {
84 // As answerer the transceivers come from the remote offer;
85 // setRemoteDescription associates this track with the video m-line and
86 // signal() flips its direction to sendonly before answering.
87 this.pc.addTrack(videoTrack)
88 }
90 this.pc.ontrack = ({track}) => {
91 this.handlers.track?.(track)
92 }
94 this.pc.onicecandidate = ({candidate}) => {
95 if (candidate) {
96 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
97 }
98 }
100 this.pc.onconnectionstatechange = () => {
101 const s = this.pc.connectionState
102 if (s === 'connected') {
103 this.clearDisconnectTimer()
104 this.handlers.connect?.()
105 } else if (s === 'failed' || s === 'closed') {
106 this.destroy()
107 } else if (s === 'disconnected') {
108 this.clearDisconnectTimer()
109 this.disconnectTimer = setTimeout(() => {
110 if (this.pc.connectionState !== 'connected') this.destroy()
111 }, DISCONNECT_GRACE_MS)
112 }
113 }
115 if (initiator) {
116 this.setupChannel(this.pc.createDataChannel('control'))
117 this.pc.onnegotiationneeded = () => void this.makeOffer()
118 } else {
119 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
120 }
121 }
123 setHandlers(handlers: Partial<PeerHandlers>) {
124 Object.assign(this.handlers, handlers)
125 }
127 private clearDisconnectTimer() {
128 if (this.disconnectTimer !== null) {
129 clearTimeout(this.disconnectTimer)
130 this.disconnectTimer = null
131 }
132 }
134 private setupChannel(channel: RTCDataChannel) {
135 this.channel = channel
136 const flush = () => {
137 for (const data of this.outbox.splice(0)) channel.send(data)
138 }
139 if (channel.readyState === 'open') flush()
140 else channel.onopen = flush
141 channel.onclose = () => this.destroy()
142 channel.onmessage = e => {
143 if (typeof e.data === 'string') this.handlers.data?.(e.data)
144 }
145 }
147 private async makeOffer() {
148 if (this.closed) return
149 try {
150 await this.pc.setLocalDescription(await this.pc.createOffer())
151 this.handlers.signal?.({
152 type: 'offer',
153 sdp: this.pc.localDescription!.sdp
154 })
155 } catch {
156 /* ignore */
157 }
158 }
160 async signal(signal: Signal) {
161 if (this.closed) return
162 try {
163 if (signal.type === 'candidate') {
164 if (this.pc.remoteDescription) {
165 await this.pc.addIceCandidate(signal.candidate)
166 } else {
167 this.pendingCandidates.push(signal.candidate)
168 }
169 return
170 }
172 if (signal.type === 'offer') {
173 if (this.initiator) return // initiators never accept remote offers
174 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
175 // Refuse incoming video: answer that m-line sendonly (our
176 // never-producing placeholder) so the browser doesn't send us any.
177 for (const t of this.pc.getTransceivers()) {
178 if (t.receiver.track?.kind === 'video') t.direction = 'sendonly'
179 }
180 await this.flushCandidates()
181 await this.pc.setLocalDescription(await this.pc.createAnswer())
182 this.handlers.signal?.({
183 type: 'answer',
184 sdp: this.pc.localDescription!.sdp
185 })
186 return
187 }
189 if (signal.type === 'answer') {
190 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
191 await this.flushCandidates()
192 }
193 } catch {
194 /* ignore transient signaling errors */
195 }
196 }
198 private async flushCandidates() {
199 const queued = this.pendingCandidates.splice(0)
200 for (const c of queued) {
201 try {
202 await this.pc.addIceCandidate(c)
203 } catch {
204 /* ignore */
205 }
206 }
207 }
209 send(data: string) {
210 if (this.channel?.readyState === 'open') this.channel.send(data)
211 else if (!this.closed) this.outbox.push(data)
212 }
214 get isConnected(): boolean {
215 return this.pc.connectionState === 'connected'
216 }
218 destroy() {
219 if (this.closed) return
220 this.closed = true
221 this.clearDisconnectTimer()
222 try {
223 this.channel?.close()
224 } catch {
225 /* ignore */
226 }
227 try {
228 this.pc.close()
229 } catch {
230 /* ignore */
231 }
232 this.handlers.close?.()
233 }
234}