import * as fs from 'node:fs' import * as path from 'node:path' import wrtc from '@roamhq/wrtc' import {selfId} from './identity.js' import {Nostr, peerTopic, roomTopic} from './nostr.js' import {Peer, type Signal} from './peer.js' import {WavWriter} from './wav.js' // The recorder's network layer: commonroom's protocol (presence announcements, // per-peer signaling topics, deterministic initiator, control data channel) // with all the browser UI/media-capture machinery replaced by audio sinks and // file writers. It joins a room as an ordinary — visible — participant that // reports itself fully muted, receives every other participant's audio, and // writes: // // audio/--segN.wav one file per participant per connection // events.jsonl every join/left/chat/mute/segment event // chat.txt human-readable chat + join/left log // manifest.json session summary: participants + segments // // All files are written incrementally (manifest every segment boundary and // every 30 s), so a crash loses at most ~1 s of audio. export const MAX_PARTICIPANTS = 8 const ANNOUNCE_INTERVAL_MS = 5000 const PRESENCE_TTL_MS = 15000 const CONNECT_RETRY_MS = 15000 const MANIFEST_INTERVAL_MS = 30000 /** Pad with silence when the sink falls this far behind wall clock, so a * file's sample position always tracks elapsed time (within ~1 s). */ const PAD_THRESHOLD_FRAC = 1.0 // seconds const PAD_MARGIN_FRAC = 0.1 // stay this far behind wall clock when padding interface Announcement { peerId: string name: string } type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'} type ControlMsg = | { t: 'hello' name: string audioMuted: boolean videoMuted: boolean joinedAt: number settings: unknown[] } | {t: 'set'; key: string; value: unknown; rev: number; by: string} | {t: 'mute'; audio: boolean; video: boolean} | {t: 'chat'; text: string} | {t: 'bye'} interface AudioSinkData { samples: Int16Array sampleRate: number bitsPerSample?: number channelCount?: number numberOfFrames?: number } interface Segment { file: string peerId: string name: string startedAt: string endedAt: string | null durationSec: number sampleRate: number channels: number } interface Conn { peer: Peer createdAt: number name: string | null connected: boolean audioMuted: boolean videoMuted: boolean sink: InstanceType | null writer: WavWriter | null /** Wall-clock ms when the current segment's first audio arrived. */ segStartMs: number segment: Segment | null } export interface RecorderOptions { room: string name: string outDir: string /** Chat line sent to each participant when we connect to them (so everyone * in the room sees, once, that recording is happening). null = none. */ notice: string | null onLog: (line: string) => void /** Unrecoverable situation (e.g. the room is full). */ onFatal: (message: string) => void } const sanitize = (name: string): string => { const s = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') return (s || 'peer').slice(0, 24) } const iso = (ms: number): string => new Date(ms).toISOString() const stamp = (ms: number): string => { const d = new Date(ms) const p = (n: number, w = 2) => String(n).padStart(w, '0') return ( `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` ) } export class Recorder { private nostr = new Nostr() private root = '' private presence = new Map() private conns = new Map() private unsubs: (() => void)[] = [] private timers: ReturnType[] = [] private startedAtMs = 0 private stopped = false /** Last known display name per peer (for the manifest). */ private names = new Map() /** Peers currently counted present (got their hello, not yet left). */ private present = new Set() /** Peers we've ever logged join/left for (reconnects get a fresh line). */ private seenEver = new Set() /** peerId -> epoch ms until which we won't reconnect: an announcement * published just before a peer's bye can arrive just after it (relay * latency) and would otherwise trigger an instant, pointless reconnect. */ private byeCooldown = new Map() /** Per-peer segment counter, surviving reconnects. */ private segCounts = new Map() private segments: Segment[] = [] // Outgoing placeholder tracks, shared across all connections (like the // browser's single localStream): a silent mic and a camera that never // produces a frame — the shape of a fully muted participant. private audioSource = new wrtc.nonstandard.RTCAudioSource() private videoSource = new wrtc.nonstandard.RTCVideoSource() private audioTrack = this.audioSource.createTrack() private videoTrack = this.videoSource.createTrack() private audioDir: string private eventsPath: string private chatPath: string private manifestPath: string constructor(private opts: RecorderOptions) { this.audioDir = path.join(opts.outDir, 'audio') this.eventsPath = path.join(opts.outDir, 'events.jsonl') this.chatPath = path.join(opts.outDir, 'chat.txt') this.manifestPath = path.join(opts.outDir, 'manifest.json') } async start() { fs.mkdirSync(this.audioDir, {recursive: true}) this.startedAtMs = Date.now() this.event({type: 'start', room: this.opts.room, peerId: selfId, name: this.opts.name}) this.chatLine(`* recording started (room: ${this.opts.room})`) this.opts.onLog(`joined room "${this.opts.room}" as "${this.opts.name}" (peer ${selfId.slice(0, 8)})`) this.opts.onLog(`writing to ${this.opts.outDir}`) this.root = await roomTopic(this.opts.room) const selfTopic = await peerTopic(this.root, selfId) this.unsubs.push( this.nostr.subscribe(selfTopic, (content, from) => { if (from === selfId || this.stopped) return let msg: PeerMsg try { msg = JSON.parse(content) } catch { return } this.handlePeerMsg(from, msg) }) ) this.unsubs.push( this.nostr.subscribe(this.root, (content, from) => { if (from === selfId || this.stopped) return let ann: Partial try { ann = JSON.parse(content) } catch { return } if (ann.peerId !== from || typeof ann.name !== 'string') return const annName = ann.name.slice(0, 40) this.presence.set(from, {name: annName, lastSeen: Date.now()}) this.names.set(from, annName) this.maybeConnect(from) }) ) void this.announce() this.timers.push(setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)) this.timers.push(setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)) this.timers.push(setInterval(() => this.writeManifest(), MANIFEST_INTERVAL_MS)) this.writeManifest() } // ---- presence and the mesh ---------------------------------------------- private async announce() { if (this.stopped) return const ann: Announcement = {peerId: selfId, name: this.opts.name} void this.nostr.publish(this.root, JSON.stringify(ann)) } private sweepPresence() { const cutoff = Date.now() - PRESENCE_TTL_MS for (const [peerId, p] of this.presence) { if (p.lastSeen < cutoff) this.presence.delete(peerId) } } private async sendToPeer(peerId: string, msg: PeerMsg) { const topic = await peerTopic(this.root, peerId) void this.nostr.publish(topic, JSON.stringify(msg)) } private atCapacity(): boolean { return this.conns.size >= MAX_PARTICIPANTS - 1 } private maybeConnect(peerId: string) { if (this.stopped || peerId === selfId) return const cooldown = this.byeCooldown.get(peerId) if (cooldown !== undefined) { if (Date.now() < cooldown) return this.byeCooldown.delete(peerId) } const existing = this.conns.get(peerId) if (existing) { const stalled = !existing.connected && Date.now() - existing.createdAt > CONNECT_RETRY_MS if (!stalled) return this.conns.delete(peerId) // deleted first so the close handler no-ops this.closeConn(peerId, existing) } if (this.atCapacity()) { void this.sendToPeer(peerId, {t: 'room-full'}) return } this.createPeer(peerId, selfId < peerId) } private createPeer(peerId: string, initiator: boolean): Conn { const peer = new Peer(initiator, this.audioTrack, this.videoTrack) const conn: Conn = { peer, createdAt: Date.now(), name: null, connected: false, audioMuted: true, videoMuted: true, sink: null, writer: null, segStartMs: 0, segment: null } this.conns.set(peerId, conn) peer.setHandlers({ signal: signal => { void this.sendToPeer(peerId, {t: 'signal', signal}) }, track: track => { if (track.kind !== 'audio' || conn.sink) return this.attachSink(peerId, conn, track) }, connect: () => { if (conn.connected) return // connectionState can flap during ICE settling conn.connected = true peer.send( JSON.stringify({ t: 'hello', name: this.opts.name, audioMuted: true, videoMuted: true, joinedAt: this.startedAtMs, settings: [] } satisfies ControlMsg) ) if (this.opts.notice) { peer.send( JSON.stringify({t: 'chat', text: this.opts.notice} satisfies ControlMsg) ) } }, data: raw => this.handleControl(peerId, conn, raw), close: () => { if (this.conns.get(peerId) === conn) { this.conns.delete(peerId) this.closeConn(peerId, conn) if (this.present.delete(peerId)) { const name = this.displayName(peerId, conn) this.event({type: 'left', peerId, name}) this.chatLine(`* ${name} left`) this.opts.onLog(`${name} left`) } } } }) return conn } private handlePeerMsg(from: string, msg: PeerMsg) { switch (msg.t) { case 'signal': { let conn = this.conns.get(from) if (!conn) { // An offer can arrive before we've seen the peer's announcement. if (msg.signal?.type !== 'offer') return if (this.atCapacity()) { void this.sendToPeer(from, {t: 'room-full'}) return } conn = this.createPeer(from, false) } void conn.peer.signal(msg.signal) return } case 'room-full': { // Only fatal while we have no foothold — once connected, we're in. if (this.conns.size === 0) { this.opts.onFatal( `The room is full (up to ${MAX_PARTICIPANTS} participants) — nothing recorded.` ) } return } } } // ---- control channel ---------------------------------------------------- private displayName(peerId: string, conn: Conn | null): string { return ( this.presence.get(peerId)?.name ?? conn?.name ?? this.names.get(peerId) ?? peerId.slice(0, 8) ) } private handleControl(peerId: string, conn: Conn, raw: string) { if (this.conns.get(peerId) !== conn) return let msg: ControlMsg try { msg = JSON.parse(raw) } catch { return } switch (msg.t) { case 'hello': { if (typeof msg.name === 'string' && msg.name) { conn.name = msg.name.slice(0, 40) this.names.set(peerId, conn.name) } conn.audioMuted = msg.audioMuted !== false conn.videoMuted = msg.videoMuted !== false if (!this.present.has(peerId)) { this.present.add(peerId) const name = this.displayName(peerId, conn) const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0 const alreadyHere = joinedAt <= this.startedAtMs && !this.seenEver.has(peerId) this.seenEver.add(peerId) this.event({type: 'join', peerId, name, alreadyHere}) this.chatLine(`* ${name} ${alreadyHere ? 'was already here' : 'joined'}`) this.opts.onLog(`${name} ${alreadyHere ? 'was already here' : 'joined'} (mic ${conn.audioMuted ? 'muted' : 'on'})`) } return } case 'mute': { if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') { return } if (conn.audioMuted !== msg.audio) { this.opts.onLog( `${this.displayName(peerId, conn)} ${msg.audio ? 'muted' : 'unmuted'} their mic` ) } conn.audioMuted = msg.audio conn.videoMuted = msg.video this.event({ type: 'mute', peerId, name: this.displayName(peerId, conn), audio: msg.audio, video: msg.video }) return } case 'chat': { if (typeof msg.text !== 'string') return const text = msg.text.slice(0, 2000) if (!text.trim()) return const name = this.displayName(peerId, conn) this.event({type: 'chat', peerId, name, text}) this.chatLine(`${name}: ${text}`) this.opts.onLog(`${name}: ${text}`) return } case 'set': // room settings don't matter to the recorder return case 'bye': { this.presence.delete(peerId) this.byeCooldown.set(peerId, Date.now() + 3000) conn.peer.destroy() // its close handler finalizes the segment return } } } // ---- audio capture ------------------------------------------------------ private attachSink(peerId: string, conn: Conn, track: MediaStreamTrack) { const sink = new wrtc.nonstandard.RTCAudioSink(track) conn.sink = sink sink.ondata = (data: AudioSinkData) => { if (this.stopped || this.conns.get(peerId) !== conn) return const channels = data.channelCount ?? 1 const rate = data.sampleRate if (!rate || !data.samples?.length) return // A decoder format change (rare) starts a fresh segment. if ( conn.writer && (conn.writer.sampleRate !== rate || conn.writer.channels !== channels) ) { this.endSegment(peerId, conn) } const now = Date.now() if (!conn.writer) { // Before the first RTP packet the sink delivers all-zero frames (at a // provisional sample rate, even) — don't open a file until there is // actual audio. A participant who never unmutes produces no file. if (!data.samples.some(s => s !== 0)) return const n = (this.segCounts.get(peerId) ?? 0) + 1 this.segCounts.set(peerId, n) const name = this.displayName(peerId, conn) const file = path.join( 'audio', `${sanitize(name)}-${peerId.slice(0, 8)}-seg${n}.wav` ) conn.writer = new WavWriter( path.join(this.opts.outDir, file), rate, channels ) conn.segStartMs = now conn.segment = { file, peerId, name, startedAt: iso(now), endedAt: null, durationSec: 0, sampleRate: rate, channels } this.event({type: 'segment-start', peerId, name, file, sampleRate: rate, channels}) this.opts.onLog(`recording ${name} -> ${file}`) } else { // If the sink stalled (network gap, DTX), pad with silence so sample // position keeps tracking wall-clock time. const expected = Math.floor(((now - conn.segStartMs) / 1000) * rate) const deficit = expected - conn.writer.framesWritten if (deficit > rate * PAD_THRESHOLD_FRAC) { conn.writer.appendSilence(deficit - Math.floor(rate * PAD_MARGIN_FRAC)) } } conn.writer.append(data.samples) } } private endSegment(peerId: string, conn: Conn) { if (!conn.writer || !conn.segment) return conn.writer.finalize() conn.segment.endedAt = iso(Date.now()) conn.segment.durationSec = Math.round(conn.writer.durationSec * 100) / 100 this.segments.push(conn.segment) this.event({ type: 'segment-end', peerId, name: conn.segment.name, file: conn.segment.file, durationSec: conn.segment.durationSec }) this.opts.onLog( `closed ${conn.segment.file} (${conn.segment.durationSec.toFixed(1)}s)` ) conn.writer = null conn.segment = null this.writeManifest() } /** Tear down a conn's media capture and finalize its segment. */ private closeConn(peerId: string, conn: Conn) { try { conn.sink?.stop() } catch { /* ignore */ } conn.sink = null this.endSegment(peerId, conn) conn.peer.destroy() } // ---- output files ------------------------------------------------------- private event(ev: Record) { const line = JSON.stringify({time: iso(Date.now()), ...ev}) try { fs.appendFileSync(this.eventsPath, line + '\n') } catch { /* ignore */ } } private chatLine(text: string) { try { fs.appendFileSync(this.chatPath, `[${stamp(Date.now())}] ${text}\n`) } catch { /* ignore */ } } private writeManifest() { const active = [...this.conns.values()] .filter(c => c.segment && c.writer) .map(c => ({ ...c.segment!, durationSec: Math.round(c.writer!.durationSec * 100) / 100 })) const manifest = { room: this.opts.room, recorder: {peerId: selfId, name: this.opts.name}, startedAt: iso(this.startedAtMs), endedAt: this.stopped ? iso(Date.now()) : null, participants: Object.fromEntries(this.names), segments: [...this.segments, ...active] } try { const tmp = this.manifestPath + '.tmp' fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n') fs.renameSync(tmp, this.manifestPath) } catch { /* ignore */ } } // ---- shutdown ----------------------------------------------------------- stop(): {segments: number; participants: number} { if (this.stopped) return {segments: this.segments.length, participants: this.names.size} this.stopped = true const bye = JSON.stringify({t: 'bye'} satisfies ControlMsg) for (const conn of this.conns.values()) conn.peer.send(bye) const conns = [...this.conns.entries()] this.conns.clear() for (const [peerId, conn] of conns) this.closeConn(peerId, conn) for (const u of this.unsubs.splice(0)) u() for (const t of this.timers.splice(0)) clearInterval(t) this.nostr.close() try { this.audioTrack.stop() this.videoTrack.stop() } catch { /* ignore */ } this.event({type: 'stop'}) this.chatLine('* recording stopped') this.writeManifest() return {segments: this.segments.length, participants: this.names.size} } }