concept-collection / commonroom-recorder
Record commonroom audio and chat from the command line
Joins a room as a visible, muted participant over the same nostr-signaled WebRTC mesh as the browser client, writes one WAV per participant (plus chat.txt, events.jsonl, manifest.json) for later transcription. Includes a browserless end-to-end loopback test (sine-tone speaker).
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 200df8a67ebd Browse files
14 changed files+2353−0
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+node_modules/
2+dist/
3+recordings/
4+*.tsbuildinfo
CLAUDE.mdadded+68−0View file
@@ -0,0 +1,68 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo — a Node CLI that joins a
4+[commonroom](https://github.com/concept-collection/commonroom) call as a
5+muted, visible participant and records every other participant's audio (one
6+WAV per participant) plus the room chat. Read commonroom's CLAUDE.md first:
7+this tool speaks its protocol verbatim, and the protocol is documented there.
8+
9+## Architecture
10+
11+```
12+src/
13+ identity.ts ported from commonroom; EPHEMERAL keypair (no localStorage)
14+ nostr.ts ported near-verbatim (Node >= 22 global WebSocket); close() added
15+ peer.ts ported, adapted to @roamhq/wrtc; receive-only media (see below)
16+ wav.ts incremental WAV writer (buffers ~1 s, re-patches header sizes)
17+ recorder.ts the heart: presence, mesh, control channel, audio sinks, files
18+ cli.ts arg parsing, signal handling, log lines
19+ test/
20+ speaker.ts synthetic participant: sine tone + one chat message
21+ loopback.js test: recorder + speaker in a random room, verify tone + chat
22+```
23+
24+## Key design decisions
25+
26+- **Protocol-identical participant.** Same announcements, per-peer signaling
27+ topics, deterministic initiator (smaller peer ID), control-channel messages
28+ (hello/mute/chat/bye), stalled-connection retry, room-full handling, and the
29+ soft cap of 8 — the recorder counts toward it. Don't invent protocol; if the
30+ browser client changes, port the change.
31+- **Receive-only media, but symmetric-looking.** Outgoing tracks are wrtc
32+ `RTCAudioSource`/`RTCVideoSource` placeholders that never produce data (=
33+ a fully muted participant). The VIDEO m-line is negotiated `sendonly` from
34+ our side (as initiator via `addTransceiver(track, {direction:'sendonly'})`;
35+ as answerer by flipping the transceiver direction after
36+ setRemoteDescription) so browsers never send us video — saves bandwidth and
37+ decode CPU. Audio stays sendrecv.
38+- **Recording gates on first non-zero frame.** Before the first RTP packet,
39+ `RTCAudioSink` delivers all-zero frames at a PROVISIONAL sample rate (16 kHz
40+ observed), then switches to the real one (48 kHz) — opening the file eagerly
41+ yields junk stub segments. A never-unmuting participant produces no file. A
42+ mid-stream format change (rare) closes the segment and starts a new one.
43+- **Wall-clock silence padding.** If the sink stalls > 1 s (network gap, DTX),
44+ silence is inserted so sample position keeps tracking elapsed time — the
45+ manifest's segment `startedAt` plus the file offset IS the meeting timeline.
46+- **connectionState flaps.** wrtc can pass through 'connected' several times
47+ while ICE settles; the connect handler must be idempotent or hello/notice
48+ get re-sent per flap.
49+- **Bye cooldown (3 s).** An announcement published just before a peer's bye
50+ can arrive just after it and would trigger an instant reconnect (and a stub
51+ recording); after a bye we ignore that peer's announcements briefly.
52+- **Every exit path MUST end in `process.exit()`.** @roamhq/wrtc segfaults in
53+ its static destructors on a natural process exit whenever nonstandard
54+ media sources exist. The CLI, the speaker, and any future script that
55+ touches wrtc must exit explicitly.
56+- **Crash-safe outputs.** events.jsonl and chat.txt are appended per event;
57+ WAVs flush (with header re-patch) about once a second; manifest.json is
58+ written atomically (tmp + rename) at segment boundaries and every 30 s.
59+
60+## Testing
61+
62+`npm run build && npm run test:loopback` — full end-to-end over the real
63+public relays (needs network): asserts the recorded WAV contains the 440 Hz
64+tone (RMS + zero-crossing rate) and the chat message landed exactly once.
65+Segfault-at-exit in a child process = some path bypassed `process.exit()`.
66+For manual testing against real browsers, record a room and join it at
67+https://concept-collection.github.io/commonroom/ — let the user do
68+multi-person tests; don't try to automate browser media.
README.mdadded+89−0View file
@@ -0,0 +1,89 @@
1+# commonroom-recorder
2+
3+Record a [commonroom](https://github.com/concept-collection/commonroom) call
4+from the command line, for transcribing the meeting afterwards.
5+
6+The recorder joins a room as an ordinary, **visible** participant (default
7+name "Recorder") that stays muted the whole time. It receives every other
8+participant's audio and writes one WAV file per participant — so a transcript
9+with speaker attribution needs no diarization — plus the room chat and a
10+machine-readable event log. By default it also posts a one-line chat notice to
11+each participant so everyone knows the meeting is being recorded.
12+
13+## Usage
14+
15+```
16+npm install
17+npm run build
18+node dist/cli.js <room> [options]
19+```
20+
21+Options:
22+
23+```
24+--name <name> Display name in the room (default: Recorder)
25+--out <dir> Output directory (default: ./recordings/<room>-<timestamp>)
26+--duration <sec> Stop automatically after this many seconds
27+--notice <text> Chat line sent to each participant on connect
28+ (default: "🔴 This meeting is being recorded.")
29+--no-notice Don't send any recording notice
30+```
31+
32+Stop with Ctrl-C. Requires Node >= 22 (built-in WebSocket). The WebRTC stack
33+is [`@roamhq/wrtc`](https://github.com/WonderInventions/node-webrtc), which
34+ships prebuilt binaries for Linux and macOS.
35+
36+## Output
37+
38+```
39+<out>/
40+ audio/<name>-<peer8>-segN.wav one file per participant per connection
41+ (48 kHz mono s16 PCM, typically)
42+ chat.txt human-readable chat + join/left log
43+ events.jsonl every event with ISO timestamps: join, left,
44+ chat, mute/unmute, segment start/end
45+ manifest.json session summary: room, participants,
46+ segments with start/end times and durations
47+```
48+
49+Everything is written incrementally (`tail -f chat.txt` works live; the
50+manifest is rewritten at every segment boundary and every 30 s), so a crash
51+loses at most about a second of audio. A file only starts when a participant's
52+first real audio arrives — someone who never unmutes produces no file. If a
53+participant disconnects and returns, they get a new numbered segment; the
54+manifest's per-segment start times let a transcript interleave speakers on one
55+timeline. During a segment, silence is padded by wall clock, so a sample's
56+position in the file always tracks elapsed time.
57+
58+To transcribe: run each `audio/*.wav` through your transcriber of choice
59+(e.g. whisper), offset each result by its segment's `startedAt` from
60+`manifest.json`, and merge.
61+
62+## How it works
63+
64+The p2p layer is commonroom's, ported to Node: the same nostr
65+presence/signaling topics (knowing the room name IS the key), the same
66+schnorr-signed events (with a fresh ephemeral keypair per run), the same
67+deterministic-initiator WebRTC mesh and control data channel (hello, mute
68+notices, chat, bye). To the browsers in the room the recorder is
69+indistinguishable from a participant whose mic and camera are muted — it
70+counts toward the room cap of 8 and appears in the participant list.
71+
72+Two deliberate deviations from the browser client:
73+
74+- **Receive-only media.** The video m-line is negotiated `sendonly` from the
75+ recorder's side (a placeholder track that never produces a frame), so no
76+ video is ever sent to the recorder — with up to 7 participants that saves
77+ several Mbit/s and all the decode CPU. Audio is symmetric (a silent
78+ placeholder goes out, like any muted mic).
79+- **Files instead of tiles.** Each remote audio track feeds an `RTCAudioSink`
80+ whose PCM goes straight to an incrementally-written WAV.
81+
82+## Testing
83+
84+`npm run test:loopback` runs an end-to-end test with no browser: it starts the
85+recorder and a synthetic participant that "speaks" a 440 Hz sine and sends a
86+chat message, then verifies the WAV really contains the tone and the chat made
87+it to disk. It uses the real public nostr relays, so it needs network access.
88+For a real-world test, run the recorder and join the same room at
89+https://concept-collection.github.io/commonroom/ from a browser.
package-lock.jsonadded+525−0View file
@@ -0,0 +1,525 @@
1+{
2+ "name": "commonroom-recorder",
3+ "version": "1.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "commonroom-recorder",
9+ "version": "1.0.0",
10+ "license": "ISC",
11+ "dependencies": {
12+ "@noble/secp256k1": "^3.1.0",
13+ "@roamhq/wrtc": "^0.10.0"
14+ },
15+ "devDependencies": {
16+ "@types/node": "^26.1.1",
17+ "typescript": "^7.0.2"
18+ }
19+ },
20+ "node_modules/@noble/secp256k1": {
21+ "version": "3.1.0",
22+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
23+ "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
24+ "license": "MIT",
25+ "funding": {
26+ "url": "https://paulmillr.com/funding/"
27+ }
28+ },
29+ "node_modules/@roamhq/wrtc": {
30+ "version": "0.10.0",
31+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc/-/wrtc-0.10.0.tgz",
32+ "integrity": "sha512-yFqQQ0EV1ZUHaphh3tmjoxPi2wzhW2vjmzoAVNRRLUjXYd2e1nvwi9TKfE2w4WNvNws/hBkouvOt23Xo9FkXkQ==",
33+ "license": "BSD-2-Clause",
34+ "optionalDependencies": {
35+ "@roamhq/wrtc-darwin-arm64": "0.10.0",
36+ "@roamhq/wrtc-darwin-x64": "0.10.0",
37+ "@roamhq/wrtc-linux-arm64": "0.10.0",
38+ "@roamhq/wrtc-linux-x64": "0.10.0",
39+ "@roamhq/wrtc-win32-x64": "0.10.0",
40+ "domexception": "^4.0.0"
41+ }
42+ },
43+ "node_modules/@roamhq/wrtc-darwin-arm64": {
44+ "version": "0.10.0",
45+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-arm64/-/wrtc-darwin-arm64-0.10.0.tgz",
46+ "integrity": "sha512-vFdi79jWuPHcnUcnuOjTvyKtmY/RI2xRQo9Y6RsIjIlYePN/7LTy00c+Ivrz4prYAPbp0oHscl7PDV64VUqGTQ==",
47+ "cpu": [
48+ "arm64"
49+ ],
50+ "license": "BSD-2-Clause",
51+ "optional": true,
52+ "os": [
53+ "darwin"
54+ ]
55+ },
56+ "node_modules/@roamhq/wrtc-darwin-x64": {
57+ "version": "0.10.0",
58+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-x64/-/wrtc-darwin-x64-0.10.0.tgz",
59+ "integrity": "sha512-H6852g2xYCuaR+/TrthpdMafs4bMfAUEpvRDhsIguzrK7Dz+MKpNI8MkwdqJN8W65J+7w7k+YqXIkTHe7Fz/cg==",
60+ "cpu": [
61+ "x64"
62+ ],
63+ "license": "BSD-2-Clause",
64+ "optional": true,
65+ "os": [
66+ "darwin"
67+ ]
68+ },
69+ "node_modules/@roamhq/wrtc-linux-arm64": {
70+ "version": "0.10.0",
71+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-arm64/-/wrtc-linux-arm64-0.10.0.tgz",
72+ "integrity": "sha512-fEuJbNjprxQG6QlFd2iqBW9x028RDSho6izVg7gyt8irdPiXWOxzOxNnYMs/B2fohBTd1wD4Qxfivl07/dCR8A==",
73+ "cpu": [
74+ "arm64"
75+ ],
76+ "license": "BSD-2-Clause",
77+ "optional": true,
78+ "os": [
79+ "linux"
80+ ]
81+ },
82+ "node_modules/@roamhq/wrtc-linux-x64": {
83+ "version": "0.10.0",
84+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-x64/-/wrtc-linux-x64-0.10.0.tgz",
85+ "integrity": "sha512-H32lK2eFg3sVb/9nkHIX5HIisxFoS82Gpesuea+zqAyRpRzSd5NpFXx28bVy9wQyRrNtj8k0bTUgEzWRzSbYCA==",
86+ "cpu": [
87+ "x64"
88+ ],
89+ "license": "BSD-2-Clause",
90+ "optional": true,
91+ "os": [
92+ "linux"
93+ ]
94+ },
95+ "node_modules/@roamhq/wrtc-win32-x64": {
96+ "version": "0.10.0",
97+ "resolved": "https://registry.npmjs.org/@roamhq/wrtc-win32-x64/-/wrtc-win32-x64-0.10.0.tgz",
98+ "integrity": "sha512-wEVXMvLrBizdLyrd+Zc7zb7zpwUuHUBXwrdIvI69e3i/AA8YsVYI2xo/sxk6GoQ+o8a14ONc4SStDS35TCjg+w==",
99+ "cpu": [
100+ "x64"
101+ ],
102+ "license": "BSD-2-Clause",
103+ "optional": true,
104+ "os": [
105+ "win32"
106+ ]
107+ },
108+ "node_modules/@types/node": {
109+ "version": "26.1.1",
110+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
111+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
112+ "dev": true,
113+ "license": "MIT",
114+ "dependencies": {
115+ "undici-types": "~8.3.0"
116+ }
117+ },
118+ "node_modules/@typescript/typescript-aix-ppc64": {
119+ "version": "7.0.2",
120+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
121+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
122+ "cpu": [
123+ "ppc64"
124+ ],
125+ "dev": true,
126+ "license": "Apache-2.0",
127+ "optional": true,
128+ "os": [
129+ "aix"
130+ ],
131+ "engines": {
132+ "node": ">=16.20.0"
133+ }
134+ },
135+ "node_modules/@typescript/typescript-darwin-arm64": {
136+ "version": "7.0.2",
137+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
138+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
139+ "cpu": [
140+ "arm64"
141+ ],
142+ "dev": true,
143+ "license": "Apache-2.0",
144+ "optional": true,
145+ "os": [
146+ "darwin"
147+ ],
148+ "engines": {
149+ "node": ">=16.20.0"
150+ }
151+ },
152+ "node_modules/@typescript/typescript-darwin-x64": {
153+ "version": "7.0.2",
154+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
155+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
156+ "cpu": [
157+ "x64"
158+ ],
159+ "dev": true,
160+ "license": "Apache-2.0",
161+ "optional": true,
162+ "os": [
163+ "darwin"
164+ ],
165+ "engines": {
166+ "node": ">=16.20.0"
167+ }
168+ },
169+ "node_modules/@typescript/typescript-freebsd-arm64": {
170+ "version": "7.0.2",
171+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
172+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
173+ "cpu": [
174+ "arm64"
175+ ],
176+ "dev": true,
177+ "license": "Apache-2.0",
178+ "optional": true,
179+ "os": [
180+ "freebsd"
181+ ],
182+ "engines": {
183+ "node": ">=16.20.0"
184+ }
185+ },
186+ "node_modules/@typescript/typescript-freebsd-x64": {
187+ "version": "7.0.2",
188+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
189+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
190+ "cpu": [
191+ "x64"
192+ ],
193+ "dev": true,
194+ "license": "Apache-2.0",
195+ "optional": true,
196+ "os": [
197+ "freebsd"
198+ ],
199+ "engines": {
200+ "node": ">=16.20.0"
201+ }
202+ },
203+ "node_modules/@typescript/typescript-linux-arm": {
204+ "version": "7.0.2",
205+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
206+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
207+ "cpu": [
208+ "arm"
209+ ],
210+ "dev": true,
211+ "license": "Apache-2.0",
212+ "optional": true,
213+ "os": [
214+ "linux"
215+ ],
216+ "engines": {
217+ "node": ">=16.20.0"
218+ }
219+ },
220+ "node_modules/@typescript/typescript-linux-arm64": {
221+ "version": "7.0.2",
222+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
223+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
224+ "cpu": [
225+ "arm64"
226+ ],
227+ "dev": true,
228+ "license": "Apache-2.0",
229+ "optional": true,
230+ "os": [
231+ "linux"
232+ ],
233+ "engines": {
234+ "node": ">=16.20.0"
235+ }
236+ },
237+ "node_modules/@typescript/typescript-linux-loong64": {
238+ "version": "7.0.2",
239+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
240+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
241+ "cpu": [
242+ "loong64"
243+ ],
244+ "dev": true,
245+ "license": "Apache-2.0",
246+ "optional": true,
247+ "os": [
248+ "linux"
249+ ],
250+ "engines": {
251+ "node": ">=16.20.0"
252+ }
253+ },
254+ "node_modules/@typescript/typescript-linux-mips64el": {
255+ "version": "7.0.2",
256+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
257+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
258+ "cpu": [
259+ "mips64el"
260+ ],
261+ "dev": true,
262+ "license": "Apache-2.0",
263+ "optional": true,
264+ "os": [
265+ "linux"
266+ ],
267+ "engines": {
268+ "node": ">=16.20.0"
269+ }
270+ },
271+ "node_modules/@typescript/typescript-linux-ppc64": {
272+ "version": "7.0.2",
273+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
274+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
275+ "cpu": [
276+ "ppc64"
277+ ],
278+ "dev": true,
279+ "license": "Apache-2.0",
280+ "optional": true,
281+ "os": [
282+ "linux"
283+ ],
284+ "engines": {
285+ "node": ">=16.20.0"
286+ }
287+ },
288+ "node_modules/@typescript/typescript-linux-riscv64": {
289+ "version": "7.0.2",
290+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
291+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
292+ "cpu": [
293+ "riscv64"
294+ ],
295+ "dev": true,
296+ "license": "Apache-2.0",
297+ "optional": true,
298+ "os": [
299+ "linux"
300+ ],
301+ "engines": {
302+ "node": ">=16.20.0"
303+ }
304+ },
305+ "node_modules/@typescript/typescript-linux-s390x": {
306+ "version": "7.0.2",
307+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
308+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
309+ "cpu": [
310+ "s390x"
311+ ],
312+ "dev": true,
313+ "license": "Apache-2.0",
314+ "optional": true,
315+ "os": [
316+ "linux"
317+ ],
318+ "engines": {
319+ "node": ">=16.20.0"
320+ }
321+ },
322+ "node_modules/@typescript/typescript-linux-x64": {
323+ "version": "7.0.2",
324+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
325+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
326+ "cpu": [
327+ "x64"
328+ ],
329+ "dev": true,
330+ "license": "Apache-2.0",
331+ "optional": true,
332+ "os": [
333+ "linux"
334+ ],
335+ "engines": {
336+ "node": ">=16.20.0"
337+ }
338+ },
339+ "node_modules/@typescript/typescript-netbsd-arm64": {
340+ "version": "7.0.2",
341+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
342+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
343+ "cpu": [
344+ "arm64"
345+ ],
346+ "dev": true,
347+ "license": "Apache-2.0",
348+ "optional": true,
349+ "os": [
350+ "netbsd"
351+ ],
352+ "engines": {
353+ "node": ">=16.20.0"
354+ }
355+ },
356+ "node_modules/@typescript/typescript-netbsd-x64": {
357+ "version": "7.0.2",
358+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
359+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
360+ "cpu": [
361+ "x64"
362+ ],
363+ "dev": true,
364+ "license": "Apache-2.0",
365+ "optional": true,
366+ "os": [
367+ "netbsd"
368+ ],
369+ "engines": {
370+ "node": ">=16.20.0"
371+ }
372+ },
373+ "node_modules/@typescript/typescript-openbsd-arm64": {
374+ "version": "7.0.2",
375+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
376+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
377+ "cpu": [
378+ "arm64"
379+ ],
380+ "dev": true,
381+ "license": "Apache-2.0",
382+ "optional": true,
383+ "os": [
384+ "openbsd"
385+ ],
386+ "engines": {
387+ "node": ">=16.20.0"
388+ }
389+ },
390+ "node_modules/@typescript/typescript-openbsd-x64": {
391+ "version": "7.0.2",
392+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
393+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
394+ "cpu": [
395+ "x64"
396+ ],
397+ "dev": true,
398+ "license": "Apache-2.0",
399+ "optional": true,
400+ "os": [
401+ "openbsd"
402+ ],
403+ "engines": {
404+ "node": ">=16.20.0"
405+ }
406+ },
407+ "node_modules/@typescript/typescript-sunos-x64": {
408+ "version": "7.0.2",
409+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
410+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
411+ "cpu": [
412+ "x64"
413+ ],
414+ "dev": true,
415+ "license": "Apache-2.0",
416+ "optional": true,
417+ "os": [
418+ "sunos"
419+ ],
420+ "engines": {
421+ "node": ">=16.20.0"
422+ }
423+ },
424+ "node_modules/@typescript/typescript-win32-arm64": {
425+ "version": "7.0.2",
426+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
427+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
428+ "cpu": [
429+ "arm64"
430+ ],
431+ "dev": true,
432+ "license": "Apache-2.0",
433+ "optional": true,
434+ "os": [
435+ "win32"
436+ ],
437+ "engines": {
438+ "node": ">=16.20.0"
439+ }
440+ },
441+ "node_modules/@typescript/typescript-win32-x64": {
442+ "version": "7.0.2",
443+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
444+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
445+ "cpu": [
446+ "x64"
447+ ],
448+ "dev": true,
449+ "license": "Apache-2.0",
450+ "optional": true,
451+ "os": [
452+ "win32"
453+ ],
454+ "engines": {
455+ "node": ">=16.20.0"
456+ }
457+ },
458+ "node_modules/domexception": {
459+ "version": "4.0.0",
460+ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
461+ "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
462+ "deprecated": "Use your platform's native DOMException instead",
463+ "license": "MIT",
464+ "optional": true,
465+ "dependencies": {
466+ "webidl-conversions": "^7.0.0"
467+ },
468+ "engines": {
469+ "node": ">=12"
470+ }
471+ },
472+ "node_modules/typescript": {
473+ "version": "7.0.2",
474+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
475+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
476+ "dev": true,
477+ "license": "Apache-2.0",
478+ "bin": {
479+ "tsc": "bin/tsc"
480+ },
481+ "engines": {
482+ "node": ">=16.20.0"
483+ },
484+ "optionalDependencies": {
485+ "@typescript/typescript-aix-ppc64": "7.0.2",
486+ "@typescript/typescript-darwin-arm64": "7.0.2",
487+ "@typescript/typescript-darwin-x64": "7.0.2",
488+ "@typescript/typescript-freebsd-arm64": "7.0.2",
489+ "@typescript/typescript-freebsd-x64": "7.0.2",
490+ "@typescript/typescript-linux-arm": "7.0.2",
491+ "@typescript/typescript-linux-arm64": "7.0.2",
492+ "@typescript/typescript-linux-loong64": "7.0.2",
493+ "@typescript/typescript-linux-mips64el": "7.0.2",
494+ "@typescript/typescript-linux-ppc64": "7.0.2",
495+ "@typescript/typescript-linux-riscv64": "7.0.2",
496+ "@typescript/typescript-linux-s390x": "7.0.2",
497+ "@typescript/typescript-linux-x64": "7.0.2",
498+ "@typescript/typescript-netbsd-arm64": "7.0.2",
499+ "@typescript/typescript-netbsd-x64": "7.0.2",
500+ "@typescript/typescript-openbsd-arm64": "7.0.2",
501+ "@typescript/typescript-openbsd-x64": "7.0.2",
502+ "@typescript/typescript-sunos-x64": "7.0.2",
503+ "@typescript/typescript-win32-arm64": "7.0.2",
504+ "@typescript/typescript-win32-x64": "7.0.2"
505+ }
506+ },
507+ "node_modules/undici-types": {
508+ "version": "8.3.0",
509+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
510+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
511+ "dev": true,
512+ "license": "MIT"
513+ },
514+ "node_modules/webidl-conversions": {
515+ "version": "7.0.0",
516+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
517+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
518+ "license": "BSD-2-Clause",
519+ "optional": true,
520+ "engines": {
521+ "node": ">=12"
522+ }
523+ }
524+ }
525+}
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "commonroom-recorder",
3+ "version": "0.1.0",
4+ "description": "CLI bot that joins a commonroom room and records every participant's audio (and the chat) to disk for transcription",
5+ "type": "module",
6+ "bin": {
7+ "commonroom-recorder": "dist/cli.js"
8+ },
9+ "scripts": {
10+ "build": "tsc -b",
11+ "record": "node dist/cli.js",
12+ "test:loopback": "node dist/test/loopback.js"
13+ },
14+ "engines": {
15+ "node": ">=22"
16+ },
17+ "license": "Apache-2.0",
18+ "dependencies": {
19+ "@noble/secp256k1": "^3.1.0",
20+ "@roamhq/wrtc": "^0.10.0"
21+ },
22+ "devDependencies": {
23+ "@types/node": "^26.1.1",
24+ "typescript": "^7.0.2"
25+ }
26+}
src/cli.tsadded+129−0View file
@@ -0,0 +1,129 @@
1+#!/usr/bin/env node
2+import * as path from 'node:path'
3+import {Recorder} from './recorder.js'
4+
5+const USAGE = `Usage: commonroom-recorder <room> [options]
6+
7+Joins the commonroom room as a visible, muted participant and records every
8+other participant's audio to per-speaker WAV files, plus the room chat.
9+Stop with Ctrl-C.
10+
11+Options:
12+ --name <name> Display name in the room (default: Recorder)
13+ --out <dir> Output directory (default: ./recordings/<room>-<timestamp>)
14+ --duration <sec> Stop automatically after this many seconds
15+ --notice <text> Chat line sent to each participant on connect
16+ (default: "🔴 This meeting is being recorded.")
17+ --no-notice Don't send any recording notice
18+`
19+
20+const DEFAULT_NOTICE = '🔴 This meeting is being recorded.'
21+
22+interface Args {
23+ room: string
24+ name: string
25+ out: string
26+ duration: number | null
27+ notice: string | null
28+}
29+
30+const parseArgs = (argv: string[]): Args => {
31+ let room: string | null = null
32+ let name = 'Recorder'
33+ let out: string | null = null
34+ let duration: number | null = null
35+ let notice: string | null = DEFAULT_NOTICE
36+ for (let i = 0; i < argv.length; i++) {
37+ const a = argv[i]!
38+ switch (a) {
39+ case '--help':
40+ case '-h':
41+ process.stdout.write(USAGE)
42+ process.exit(0)
43+ break
44+ case '--name':
45+ name = (argv[++i] ?? '').trim().slice(0, 40)
46+ break
47+ case '--out':
48+ out = argv[++i] ?? null
49+ break
50+ case '--duration': {
51+ const n = Number(argv[++i])
52+ if (!Number.isFinite(n) || n <= 0) fail('--duration needs a positive number of seconds')
53+ duration = n
54+ break
55+ }
56+ case '--notice':
57+ notice = (argv[++i] ?? '').slice(0, 2000)
58+ break
59+ case '--no-notice':
60+ notice = null
61+ break
62+ default:
63+ if (a.startsWith('-')) fail(`Unknown option: ${a}`)
64+ if (room !== null) fail('Only one room may be given')
65+ room = a.replace(/\s+/g, '').slice(0, 100)
66+ }
67+ }
68+ if (!room) fail('A room name is required')
69+ if (!name) fail('--name must not be empty')
70+ const ts = new Date()
71+ const p = (n: number) => String(n).padStart(2, '0')
72+ const defaultOut = path.join(
73+ 'recordings',
74+ `${room.replace(/[^a-zA-Z0-9_-]+/g, '_')}-` +
75+ `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-` +
76+ `${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`
77+ )
78+ return {room, name, out: out ?? defaultOut, duration, notice: notice || null}
79+}
80+
81+function fail(msg: string): never {
82+ process.stderr.write(`${msg}\n\n${USAGE}`)
83+ process.exit(1)
84+}
85+
86+const now = (): string => {
87+ const d = new Date()
88+ const p = (n: number) => String(n).padStart(2, '0')
89+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
90+}
91+
92+const main = async () => {
93+ const args = parseArgs(process.argv.slice(2))
94+ const recorder = new Recorder({
95+ room: args.room,
96+ name: args.name,
97+ outDir: args.out,
98+ notice: args.notice,
99+ onLog: line => process.stdout.write(`[${now()}] ${line}\n`),
100+ onFatal: message => {
101+ process.stderr.write(`[${now()}] ${message}\n`)
102+ recorder.stop()
103+ process.exit(1)
104+ }
105+ })
106+
107+ // NOTE: every exit path must go through process.exit(): @roamhq/wrtc
108+ // segfaults in its static destructors on a natural process exit when
109+ // nonstandard media sources exist.
110+ const shutdown = () => {
111+ process.stdout.write(`\n[${now()}] stopping...\n`)
112+ const summary = recorder.stop()
113+ process.stdout.write(
114+ `[${now()}] done: ${summary.segments} audio segment(s) from ` +
115+ `${summary.participants} participant(s) in ${args.out}\n`
116+ )
117+ process.exit(0)
118+ }
119+ process.on('SIGINT', shutdown)
120+ process.on('SIGTERM', shutdown)
121+ if (args.duration !== null) setTimeout(shutdown, args.duration * 1000)
122+
123+ await recorder.start()
124+}
125+
126+main().catch(err => {
127+ process.stderr.write(`fatal: ${err?.stack ?? err}\n`)
128+ process.exit(1)
129+})
src/identity.tsadded+66−0View file
@@ -0,0 +1,66 @@
1+import * as secp from '@noble/secp256k1'
2+
3+// Ported from commonroom's identity.ts. The peer identity is a secp256k1 /
4+// BIP340 (schnorr) keypair; the x-only public key (hex) IS the peer ID, and it
5+// signs every nostr event so nobody can speak on behalf of another peer.
6+//
7+// One deliberate change from the browser client: the key is EPHEMERAL — a
8+// fresh identity per run, nothing persisted. A recorder bot has no reason to
9+// keep a stable identity, and a fresh key sidesteps stale-presence clashes
10+// when a previous run died uncleanly.
11+
12+const toHex = (bytes: Uint8Array): string =>
13+ bytes.reduce((s, b) => s + b.toString(16).padStart(2, '0'), '')
14+
15+const fromHex = (hex: string): Uint8Array => {
16+ const out = new Uint8Array(hex.length / 2)
17+ for (let i = 0; i < out.length; i++) {
18+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
19+ }
20+ return out
21+}
22+
23+const {secretKey} = secp.schnorr.keygen()
24+const publicKey = secp.schnorr.getPublicKey(secretKey)
25+
26+/** This peer's ID = its x-only public key, as hex. */
27+export const selfId: string = toHex(publicKey)
28+
29+const sha256 = async (str: string): Promise<Uint8Array> =>
30+ new Uint8Array(
31+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
32+ )
33+
34+// ---- nostr event signing (schnorr over the nostr event id) ----
35+
36+export interface NostrEvent {
37+ id: string
38+ pubkey: string
39+ created_at: number
40+ kind: number
41+ tags: string[][]
42+ content: string
43+ sig: string
44+}
45+
46+/** Build and sign a nostr event with this peer's key. */
47+export const makeNostrEvent = async (
48+ kind: number,
49+ tags: string[][],
50+ content: string
51+): Promise<NostrEvent> => {
52+ const created_at = Math.floor(Date.now() / 1000)
53+ const serialized = JSON.stringify([
54+ 0,
55+ selfId,
56+ created_at,
57+ kind,
58+ tags,
59+ content
60+ ])
61+ const id = toHex(await sha256(serialized))
62+ const sig = toHex(await secp.schnorr.signAsync(fromHex(id), secretKey))
63+ return {id, pubkey: selfId, created_at, kind, tags, content, sig}
64+}
65+
66+export {toHex, fromHex}
src/nostr.tsadded+170−0View file
@@ -0,0 +1,170 @@
1+import {makeNostrEvent, type NostrEvent} from './identity.js'
2+
3+// Ported from commonroom's nostr.ts (which is modeled on trystero's nostr
4+// strategy): publish to a topic, subscribe to a topic. Topics are carried in
5+// an 'x' tag; each topic maps to an ephemeral event kind (20000+) so relays
6+// don't store the messages. Node >= 22 has the browser WebSocket API built in,
7+// so this is a near-verbatim port — the one addition is close(), which a CLI
8+// needs and a browser page does not.
9+
10+const RELAYS = [
11+ 'wss://relay.damus.io',
12+ 'wss://nos.lol',
13+ 'wss://relay.mostr.pub',
14+ 'wss://purplerelay.com'
15+]
16+
17+const TAG = 'x'
18+
19+const strToNum = (str: string, limit: number): number => {
20+ let sum = 0
21+ for (let i = 0; i < str.length; i++) sum += str.charCodeAt(i)
22+ return sum % limit
23+}
24+
25+const kindForTopic = (topic: string): number => strToNum(topic, 10000) + 20000
26+
27+const nowSec = (): number => Math.floor(Date.now() / 1000)
28+
29+const genSubId = (): string =>
30+ Array.from({length: 16}, () =>
31+ Math.floor(Math.random() * 16).toString(16)
32+ ).join('')
33+
34+type TopicHandler = (content: string, fromPubkey: string) => void
35+
36+// We publish every event to all relays and subscribe on all relays, so each
37+// event can arrive several times. Remember recently seen event ids and drop
38+// repeats so handlers fire exactly once per event.
39+const SEEN_CAP = 1000
40+
41+export class Nostr {
42+ private sockets: WebSocket[] = []
43+ private subs = new Map<string, {topic: string; handler: TopicHandler}>()
44+ private seen = new Set<string>()
45+ private closed = false
46+
47+ constructor() {
48+ for (const url of RELAYS) this.connect(url)
49+ }
50+
51+ private connect(url: string) {
52+ if (this.closed) return
53+ let ws: WebSocket
54+ try {
55+ ws = new WebSocket(url)
56+ } catch {
57+ return
58+ }
59+ this.sockets.push(ws)
60+
61+ ws.onopen = () => {
62+ // (re)send all active subscriptions on this socket
63+ for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
64+ }
65+
66+ ws.onmessage = ev => {
67+ let msg: unknown
68+ try {
69+ msg = JSON.parse(ev.data as string)
70+ } catch {
71+ return
72+ }
73+ if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
74+ const subId = msg[1] as string
75+ const event = msg[2] as NostrEvent
76+ const sub = this.subs.get(subId)
77+ if (!sub || !event || typeof event.content !== 'string') return
78+ if (event.id) {
79+ if (this.seen.has(event.id)) return
80+ this.seen.add(event.id)
81+ if (this.seen.size > SEEN_CAP) {
82+ for (const id of this.seen) {
83+ this.seen.delete(id)
84+ if (this.seen.size <= SEEN_CAP / 2) break
85+ }
86+ }
87+ }
88+ sub.handler(event.content, event.pubkey)
89+ }
90+
91+ ws.onclose = () => {
92+ this.sockets = this.sockets.filter(s => s !== ws)
93+ // reconnect after a short delay
94+ if (!this.closed) setTimeout(() => this.connect(url), 3000)
95+ }
96+
97+ ws.onerror = () => ws.close()
98+ }
99+
100+ private sendReq(ws: WebSocket, subId: string, topic: string) {
101+ if (ws.readyState !== WebSocket.OPEN) return
102+ ws.send(
103+ JSON.stringify([
104+ 'REQ',
105+ subId,
106+ {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
107+ ])
108+ )
109+ }
110+
111+ /** Subscribe to a topic. Handler fires once per incoming event. */
112+ subscribe(topic: string, handler: TopicHandler): () => void {
113+ const subId = genSubId()
114+ this.subs.set(subId, {topic, handler})
115+ for (const ws of this.sockets) this.sendReq(ws, subId, topic)
116+ return () => {
117+ this.subs.delete(subId)
118+ for (const ws of this.sockets) {
119+ if (ws.readyState === WebSocket.OPEN) {
120+ ws.send(JSON.stringify(['CLOSE', subId]))
121+ }
122+ }
123+ }
124+ }
125+
126+ /** Publish a signed event to a topic. */
127+ async publish(topic: string, content: string): Promise<void> {
128+ const event = await makeNostrEvent(
129+ kindForTopic(topic),
130+ [[TAG, topic]],
131+ content
132+ )
133+ const payload = JSON.stringify(['EVENT', event])
134+ for (const ws of this.sockets) {
135+ if (ws.readyState === WebSocket.OPEN) ws.send(payload)
136+ }
137+ }
138+
139+ /** Close every relay socket and stop reconnecting. */
140+ close() {
141+ this.closed = true
142+ this.subs.clear()
143+ for (const ws of this.sockets.splice(0)) {
144+ try {
145+ ws.close()
146+ } catch {
147+ /* ignore */
148+ }
149+ }
150+ }
151+}
152+
153+const sha256Hex = async (str: string): Promise<string> => {
154+ const buf = await crypto.subtle.digest(
155+ 'SHA-256',
156+ new TextEncoder().encode(str)
157+ )
158+ return Array.from(new Uint8Array(buf))
159+ .map(b => b.toString(16).padStart(2, '0'))
160+ .join('')
161+}
162+
163+/** Topic everyone in a room announces on / listens to for presence. The room
164+ * ID is any string (exact match — no normalization). */
165+export const roomTopic = (roomId: string): Promise<string> =>
166+ sha256Hex(`commonroom:${roomId}`)
167+
168+/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
169+export const peerTopic = (root: string, peerId: string): Promise<string> =>
170+ sha256Hex(`${root}:${peerId}`)
src/peer.tsadded+234−0View file
@@ -0,0 +1,234 @@
1+import wrtc from '@roamhq/wrtc'
2+
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.
22+
23+export type Signal =
24+ | {type: 'offer'; sdp: string}
25+ | {type: 'answer'; sdp: string}
26+ | {type: 'candidate'; candidate: RTCIceCandidateInit}
27+
28+export 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+}
38+
39+export 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+]
55+
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.
58+const DISCONNECT_GRACE_MS = 5000
59+
60+export 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
69+
70+ constructor(
71+ private initiator: boolean,
72+ audioTrack: MediaStreamTrack,
73+ videoTrack: MediaStreamTrack
74+ ) {
75+ this.pc = new wrtc.RTCPeerConnection({iceServers: ICE_SERVERS})
76+
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+ }
89+
90+ this.pc.ontrack = ({track}) => {
91+ this.handlers.track?.(track)
92+ }
93+
94+ this.pc.onicecandidate = ({candidate}) => {
95+ if (candidate) {
96+ this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
97+ }
98+ }
99+
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+ }
114+
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+ }
122+
123+ setHandlers(handlers: Partial<PeerHandlers>) {
124+ Object.assign(this.handlers, handlers)
125+ }
126+
127+ private clearDisconnectTimer() {
128+ if (this.disconnectTimer !== null) {
129+ clearTimeout(this.disconnectTimer)
130+ this.disconnectTimer = null
131+ }
132+ }
133+
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+ }
146+
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+ }
159+
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+ }
171+
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+ }
188+
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+ }
197+
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+ }
208+
209+ send(data: string) {
210+ if (this.channel?.readyState === 'open') this.channel.send(data)
211+ else if (!this.closed) this.outbox.push(data)
212+ }
213+
214+ get isConnected(): boolean {
215+ return this.pc.connectionState === 'connected'
216+ }
217+
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+}
src/recorder.tsadded+590−0View file
@@ -0,0 +1,590 @@
1+import * as fs from 'node:fs'
2+import * as path from 'node:path'
3+import wrtc from '@roamhq/wrtc'
4+import {selfId} from './identity.js'
5+import {Nostr, peerTopic, roomTopic} from './nostr.js'
6+import {Peer, type Signal} from './peer.js'
7+import {WavWriter} from './wav.js'
8+
9+// The recorder's network layer: commonroom's protocol (presence announcements,
10+// per-peer signaling topics, deterministic initiator, control data channel)
11+// with all the browser UI/media-capture machinery replaced by audio sinks and
12+// file writers. It joins a room as an ordinary — visible — participant that
13+// reports itself fully muted, receives every other participant's audio, and
14+// writes:
15+//
16+// audio/<name>-<peer8>-segN.wav one file per participant per connection
17+// events.jsonl every join/left/chat/mute/segment event
18+// chat.txt human-readable chat + join/left log
19+// manifest.json session summary: participants + segments
20+//
21+// All files are written incrementally (manifest every segment boundary and
22+// every 30 s), so a crash loses at most ~1 s of audio.
23+
24+export const MAX_PARTICIPANTS = 8
25+
26+const ANNOUNCE_INTERVAL_MS = 5000
27+const PRESENCE_TTL_MS = 15000
28+const CONNECT_RETRY_MS = 15000
29+const MANIFEST_INTERVAL_MS = 30000
30+
31+/** Pad with silence when the sink falls this far behind wall clock, so a
32+ * file's sample position always tracks elapsed time (within ~1 s). */
33+const PAD_THRESHOLD_FRAC = 1.0 // seconds
34+const PAD_MARGIN_FRAC = 0.1 // stay this far behind wall clock when padding
35+
36+interface Announcement {
37+ peerId: string
38+ name: string
39+}
40+
41+type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
42+
43+type ControlMsg =
44+ | {
45+ t: 'hello'
46+ name: string
47+ audioMuted: boolean
48+ videoMuted: boolean
49+ joinedAt: number
50+ settings: unknown[]
51+ }
52+ | {t: 'set'; key: string; value: unknown; rev: number; by: string}
53+ | {t: 'mute'; audio: boolean; video: boolean}
54+ | {t: 'chat'; text: string}
55+ | {t: 'bye'}
56+
57+interface AudioSinkData {
58+ samples: Int16Array
59+ sampleRate: number
60+ bitsPerSample?: number
61+ channelCount?: number
62+ numberOfFrames?: number
63+}
64+
65+interface Segment {
66+ file: string
67+ peerId: string
68+ name: string
69+ startedAt: string
70+ endedAt: string | null
71+ durationSec: number
72+ sampleRate: number
73+ channels: number
74+}
75+
76+interface Conn {
77+ peer: Peer
78+ createdAt: number
79+ name: string | null
80+ connected: boolean
81+ audioMuted: boolean
82+ videoMuted: boolean
83+ sink: InstanceType<typeof wrtc.nonstandard.RTCAudioSink> | null
84+ writer: WavWriter | null
85+ /** Wall-clock ms when the current segment's first audio arrived. */
86+ segStartMs: number
87+ segment: Segment | null
88+}
89+
90+export interface RecorderOptions {
91+ room: string
92+ name: string
93+ outDir: string
94+ /** Chat line sent to each participant when we connect to them (so everyone
95+ * in the room sees, once, that recording is happening). null = none. */
96+ notice: string | null
97+ onLog: (line: string) => void
98+ /** Unrecoverable situation (e.g. the room is full). */
99+ onFatal: (message: string) => void
100+}
101+
102+const sanitize = (name: string): string => {
103+ const s = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
104+ return (s || 'peer').slice(0, 24)
105+}
106+
107+const iso = (ms: number): string => new Date(ms).toISOString()
108+
109+const stamp = (ms: number): string => {
110+ const d = new Date(ms)
111+ const p = (n: number, w = 2) => String(n).padStart(w, '0')
112+ return (
113+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
114+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
115+ )
116+}
117+
118+export class Recorder {
119+ private nostr = new Nostr()
120+ private root = ''
121+ private presence = new Map<string, {name: string; lastSeen: number}>()
122+ private conns = new Map<string, Conn>()
123+ private unsubs: (() => void)[] = []
124+ private timers: ReturnType<typeof setInterval>[] = []
125+ private startedAtMs = 0
126+ private stopped = false
127+
128+ /** Last known display name per peer (for the manifest). */
129+ private names = new Map<string, string>()
130+ /** Peers currently counted present (got their hello, not yet left). */
131+ private present = new Set<string>()
132+ /** Peers we've ever logged join/left for (reconnects get a fresh line). */
133+ private seenEver = new Set<string>()
134+ /** peerId -> epoch ms until which we won't reconnect: an announcement
135+ * published just before a peer's bye can arrive just after it (relay
136+ * latency) and would otherwise trigger an instant, pointless reconnect. */
137+ private byeCooldown = new Map<string, number>()
138+ /** Per-peer segment counter, surviving reconnects. */
139+ private segCounts = new Map<string, number>()
140+ private segments: Segment[] = []
141+
142+ // Outgoing placeholder tracks, shared across all connections (like the
143+ // browser's single localStream): a silent mic and a camera that never
144+ // produces a frame — the shape of a fully muted participant.
145+ private audioSource = new wrtc.nonstandard.RTCAudioSource()
146+ private videoSource = new wrtc.nonstandard.RTCVideoSource()
147+ private audioTrack = this.audioSource.createTrack()
148+ private videoTrack = this.videoSource.createTrack()
149+
150+ private audioDir: string
151+ private eventsPath: string
152+ private chatPath: string
153+ private manifestPath: string
154+
155+ constructor(private opts: RecorderOptions) {
156+ this.audioDir = path.join(opts.outDir, 'audio')
157+ this.eventsPath = path.join(opts.outDir, 'events.jsonl')
158+ this.chatPath = path.join(opts.outDir, 'chat.txt')
159+ this.manifestPath = path.join(opts.outDir, 'manifest.json')
160+ }
161+
162+ async start() {
163+ fs.mkdirSync(this.audioDir, {recursive: true})
164+ this.startedAtMs = Date.now()
165+ this.event({type: 'start', room: this.opts.room, peerId: selfId, name: this.opts.name})
166+ this.chatLine(`* recording started (room: ${this.opts.room})`)
167+ this.opts.onLog(`joined room "${this.opts.room}" as "${this.opts.name}" (peer ${selfId.slice(0, 8)})`)
168+ this.opts.onLog(`writing to ${this.opts.outDir}`)
169+
170+ this.root = await roomTopic(this.opts.room)
171+ const selfTopic = await peerTopic(this.root, selfId)
172+
173+ this.unsubs.push(
174+ this.nostr.subscribe(selfTopic, (content, from) => {
175+ if (from === selfId || this.stopped) return
176+ let msg: PeerMsg
177+ try {
178+ msg = JSON.parse(content)
179+ } catch {
180+ return
181+ }
182+ this.handlePeerMsg(from, msg)
183+ })
184+ )
185+
186+ this.unsubs.push(
187+ this.nostr.subscribe(this.root, (content, from) => {
188+ if (from === selfId || this.stopped) return
189+ let ann: Partial<Announcement>
190+ try {
191+ ann = JSON.parse(content)
192+ } catch {
193+ return
194+ }
195+ if (ann.peerId !== from || typeof ann.name !== 'string') return
196+ const annName = ann.name.slice(0, 40)
197+ this.presence.set(from, {name: annName, lastSeen: Date.now()})
198+ this.names.set(from, annName)
199+ this.maybeConnect(from)
200+ })
201+ )
202+
203+ void this.announce()
204+ this.timers.push(setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS))
205+ this.timers.push(setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS))
206+ this.timers.push(setInterval(() => this.writeManifest(), MANIFEST_INTERVAL_MS))
207+ this.writeManifest()
208+ }
209+
210+ // ---- presence and the mesh ----------------------------------------------
211+
212+ private async announce() {
213+ if (this.stopped) return
214+ const ann: Announcement = {peerId: selfId, name: this.opts.name}
215+ void this.nostr.publish(this.root, JSON.stringify(ann))
216+ }
217+
218+ private sweepPresence() {
219+ const cutoff = Date.now() - PRESENCE_TTL_MS
220+ for (const [peerId, p] of this.presence) {
221+ if (p.lastSeen < cutoff) this.presence.delete(peerId)
222+ }
223+ }
224+
225+ private async sendToPeer(peerId: string, msg: PeerMsg) {
226+ const topic = await peerTopic(this.root, peerId)
227+ void this.nostr.publish(topic, JSON.stringify(msg))
228+ }
229+
230+ private atCapacity(): boolean {
231+ return this.conns.size >= MAX_PARTICIPANTS - 1
232+ }
233+
234+ private maybeConnect(peerId: string) {
235+ if (this.stopped || peerId === selfId) return
236+ const cooldown = this.byeCooldown.get(peerId)
237+ if (cooldown !== undefined) {
238+ if (Date.now() < cooldown) return
239+ this.byeCooldown.delete(peerId)
240+ }
241+ const existing = this.conns.get(peerId)
242+ if (existing) {
243+ const stalled =
244+ !existing.connected &&
245+ Date.now() - existing.createdAt > CONNECT_RETRY_MS
246+ if (!stalled) return
247+ this.conns.delete(peerId) // deleted first so the close handler no-ops
248+ this.closeConn(peerId, existing)
249+ }
250+ if (this.atCapacity()) {
251+ void this.sendToPeer(peerId, {t: 'room-full'})
252+ return
253+ }
254+ this.createPeer(peerId, selfId < peerId)
255+ }
256+
257+ private createPeer(peerId: string, initiator: boolean): Conn {
258+ const peer = new Peer(initiator, this.audioTrack, this.videoTrack)
259+ const conn: Conn = {
260+ peer,
261+ createdAt: Date.now(),
262+ name: null,
263+ connected: false,
264+ audioMuted: true,
265+ videoMuted: true,
266+ sink: null,
267+ writer: null,
268+ segStartMs: 0,
269+ segment: null
270+ }
271+ this.conns.set(peerId, conn)
272+
273+ peer.setHandlers({
274+ signal: signal => {
275+ void this.sendToPeer(peerId, {t: 'signal', signal})
276+ },
277+ track: track => {
278+ if (track.kind !== 'audio' || conn.sink) return
279+ this.attachSink(peerId, conn, track)
280+ },
281+ connect: () => {
282+ if (conn.connected) return // connectionState can flap during ICE settling
283+ conn.connected = true
284+ peer.send(
285+ JSON.stringify({
286+ t: 'hello',
287+ name: this.opts.name,
288+ audioMuted: true,
289+ videoMuted: true,
290+ joinedAt: this.startedAtMs,
291+ settings: []
292+ } satisfies ControlMsg)
293+ )
294+ if (this.opts.notice) {
295+ peer.send(
296+ JSON.stringify({t: 'chat', text: this.opts.notice} satisfies ControlMsg)
297+ )
298+ }
299+ },
300+ data: raw => this.handleControl(peerId, conn, raw),
301+ close: () => {
302+ if (this.conns.get(peerId) === conn) {
303+ this.conns.delete(peerId)
304+ this.closeConn(peerId, conn)
305+ if (this.present.delete(peerId)) {
306+ const name = this.displayName(peerId, conn)
307+ this.event({type: 'left', peerId, name})
308+ this.chatLine(`* ${name} left`)
309+ this.opts.onLog(`${name} left`)
310+ }
311+ }
312+ }
313+ })
314+
315+ return conn
316+ }
317+
318+ private handlePeerMsg(from: string, msg: PeerMsg) {
319+ switch (msg.t) {
320+ case 'signal': {
321+ let conn = this.conns.get(from)
322+ if (!conn) {
323+ // An offer can arrive before we've seen the peer's announcement.
324+ if (msg.signal?.type !== 'offer') return
325+ if (this.atCapacity()) {
326+ void this.sendToPeer(from, {t: 'room-full'})
327+ return
328+ }
329+ conn = this.createPeer(from, false)
330+ }
331+ void conn.peer.signal(msg.signal)
332+ return
333+ }
334+ case 'room-full': {
335+ // Only fatal while we have no foothold — once connected, we're in.
336+ if (this.conns.size === 0) {
337+ this.opts.onFatal(
338+ `The room is full (up to ${MAX_PARTICIPANTS} participants) — nothing recorded.`
339+ )
340+ }
341+ return
342+ }
343+ }
344+ }
345+
346+ // ---- control channel ----------------------------------------------------
347+
348+ private displayName(peerId: string, conn: Conn | null): string {
349+ return (
350+ this.presence.get(peerId)?.name ??
351+ conn?.name ??
352+ this.names.get(peerId) ??
353+ peerId.slice(0, 8)
354+ )
355+ }
356+
357+ private handleControl(peerId: string, conn: Conn, raw: string) {
358+ if (this.conns.get(peerId) !== conn) return
359+ let msg: ControlMsg
360+ try {
361+ msg = JSON.parse(raw)
362+ } catch {
363+ return
364+ }
365+ switch (msg.t) {
366+ case 'hello': {
367+ if (typeof msg.name === 'string' && msg.name) {
368+ conn.name = msg.name.slice(0, 40)
369+ this.names.set(peerId, conn.name)
370+ }
371+ conn.audioMuted = msg.audioMuted !== false
372+ conn.videoMuted = msg.videoMuted !== false
373+ if (!this.present.has(peerId)) {
374+ this.present.add(peerId)
375+ const name = this.displayName(peerId, conn)
376+ const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0
377+ const alreadyHere =
378+ joinedAt <= this.startedAtMs && !this.seenEver.has(peerId)
379+ this.seenEver.add(peerId)
380+ this.event({type: 'join', peerId, name, alreadyHere})
381+ this.chatLine(`* ${name} ${alreadyHere ? 'was already here' : 'joined'}`)
382+ this.opts.onLog(`${name} ${alreadyHere ? 'was already here' : 'joined'} (mic ${conn.audioMuted ? 'muted' : 'on'})`)
383+ }
384+ return
385+ }
386+ case 'mute': {
387+ if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
388+ return
389+ }
390+ if (conn.audioMuted !== msg.audio) {
391+ this.opts.onLog(
392+ `${this.displayName(peerId, conn)} ${msg.audio ? 'muted' : 'unmuted'} their mic`
393+ )
394+ }
395+ conn.audioMuted = msg.audio
396+ conn.videoMuted = msg.video
397+ this.event({
398+ type: 'mute',
399+ peerId,
400+ name: this.displayName(peerId, conn),
401+ audio: msg.audio,
402+ video: msg.video
403+ })
404+ return
405+ }
406+ case 'chat': {
407+ if (typeof msg.text !== 'string') return
408+ const text = msg.text.slice(0, 2000)
409+ if (!text.trim()) return
410+ const name = this.displayName(peerId, conn)
411+ this.event({type: 'chat', peerId, name, text})
412+ this.chatLine(`${name}: ${text}`)
413+ this.opts.onLog(`${name}: ${text}`)
414+ return
415+ }
416+ case 'set': // room settings don't matter to the recorder
417+ return
418+ case 'bye': {
419+ this.presence.delete(peerId)
420+ this.byeCooldown.set(peerId, Date.now() + 3000)
421+ conn.peer.destroy() // its close handler finalizes the segment
422+ return
423+ }
424+ }
425+ }
426+
427+ // ---- audio capture ------------------------------------------------------
428+
429+ private attachSink(peerId: string, conn: Conn, track: MediaStreamTrack) {
430+ const sink = new wrtc.nonstandard.RTCAudioSink(track)
431+ conn.sink = sink
432+ sink.ondata = (data: AudioSinkData) => {
433+ if (this.stopped || this.conns.get(peerId) !== conn) return
434+ const channels = data.channelCount ?? 1
435+ const rate = data.sampleRate
436+ if (!rate || !data.samples?.length) return
437+
438+ // A decoder format change (rare) starts a fresh segment.
439+ if (
440+ conn.writer &&
441+ (conn.writer.sampleRate !== rate || conn.writer.channels !== channels)
442+ ) {
443+ this.endSegment(peerId, conn)
444+ }
445+
446+ const now = Date.now()
447+ if (!conn.writer) {
448+ // Before the first RTP packet the sink delivers all-zero frames (at a
449+ // provisional sample rate, even) — don't open a file until there is
450+ // actual audio. A participant who never unmutes produces no file.
451+ if (!data.samples.some(s => s !== 0)) return
452+ const n = (this.segCounts.get(peerId) ?? 0) + 1
453+ this.segCounts.set(peerId, n)
454+ const name = this.displayName(peerId, conn)
455+ const file = path.join(
456+ 'audio',
457+ `${sanitize(name)}-${peerId.slice(0, 8)}-seg${n}.wav`
458+ )
459+ conn.writer = new WavWriter(
460+ path.join(this.opts.outDir, file),
461+ rate,
462+ channels
463+ )
464+ conn.segStartMs = now
465+ conn.segment = {
466+ file,
467+ peerId,
468+ name,
469+ startedAt: iso(now),
470+ endedAt: null,
471+ durationSec: 0,
472+ sampleRate: rate,
473+ channels
474+ }
475+ this.event({type: 'segment-start', peerId, name, file, sampleRate: rate, channels})
476+ this.opts.onLog(`recording ${name} -> ${file}`)
477+ } else {
478+ // If the sink stalled (network gap, DTX), pad with silence so sample
479+ // position keeps tracking wall-clock time.
480+ const expected = Math.floor(((now - conn.segStartMs) / 1000) * rate)
481+ const deficit = expected - conn.writer.framesWritten
482+ if (deficit > rate * PAD_THRESHOLD_FRAC) {
483+ conn.writer.appendSilence(deficit - Math.floor(rate * PAD_MARGIN_FRAC))
484+ }
485+ }
486+ conn.writer.append(data.samples)
487+ }
488+ }
489+
490+ private endSegment(peerId: string, conn: Conn) {
491+ if (!conn.writer || !conn.segment) return
492+ conn.writer.finalize()
493+ conn.segment.endedAt = iso(Date.now())
494+ conn.segment.durationSec = Math.round(conn.writer.durationSec * 100) / 100
495+ this.segments.push(conn.segment)
496+ this.event({
497+ type: 'segment-end',
498+ peerId,
499+ name: conn.segment.name,
500+ file: conn.segment.file,
501+ durationSec: conn.segment.durationSec
502+ })
503+ this.opts.onLog(
504+ `closed ${conn.segment.file} (${conn.segment.durationSec.toFixed(1)}s)`
505+ )
506+ conn.writer = null
507+ conn.segment = null
508+ this.writeManifest()
509+ }
510+
511+ /** Tear down a conn's media capture and finalize its segment. */
512+ private closeConn(peerId: string, conn: Conn) {
513+ try {
514+ conn.sink?.stop()
515+ } catch {
516+ /* ignore */
517+ }
518+ conn.sink = null
519+ this.endSegment(peerId, conn)
520+ conn.peer.destroy()
521+ }
522+
523+ // ---- output files -------------------------------------------------------
524+
525+ private event(ev: Record<string, unknown>) {
526+ const line = JSON.stringify({time: iso(Date.now()), ...ev})
527+ try {
528+ fs.appendFileSync(this.eventsPath, line + '\n')
529+ } catch {
530+ /* ignore */
531+ }
532+ }
533+
534+ private chatLine(text: string) {
535+ try {
536+ fs.appendFileSync(this.chatPath, `[${stamp(Date.now())}] ${text}\n`)
537+ } catch {
538+ /* ignore */
539+ }
540+ }
541+
542+ private writeManifest() {
543+ const active = [...this.conns.values()]
544+ .filter(c => c.segment && c.writer)
545+ .map(c => ({
546+ ...c.segment!,
547+ durationSec: Math.round(c.writer!.durationSec * 100) / 100
548+ }))
549+ const manifest = {
550+ room: this.opts.room,
551+ recorder: {peerId: selfId, name: this.opts.name},
552+ startedAt: iso(this.startedAtMs),
553+ endedAt: this.stopped ? iso(Date.now()) : null,
554+ participants: Object.fromEntries(this.names),
555+ segments: [...this.segments, ...active]
556+ }
557+ try {
558+ const tmp = this.manifestPath + '.tmp'
559+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n')
560+ fs.renameSync(tmp, this.manifestPath)
561+ } catch {
562+ /* ignore */
563+ }
564+ }
565+
566+ // ---- shutdown -----------------------------------------------------------
567+
568+ stop(): {segments: number; participants: number} {
569+ if (this.stopped) return {segments: this.segments.length, participants: this.names.size}
570+ this.stopped = true
571+ const bye = JSON.stringify({t: 'bye'} satisfies ControlMsg)
572+ for (const conn of this.conns.values()) conn.peer.send(bye)
573+ const conns = [...this.conns.entries()]
574+ this.conns.clear()
575+ for (const [peerId, conn] of conns) this.closeConn(peerId, conn)
576+ for (const u of this.unsubs.splice(0)) u()
577+ for (const t of this.timers.splice(0)) clearInterval(t)
578+ this.nostr.close()
579+ try {
580+ this.audioTrack.stop()
581+ this.videoTrack.stop()
582+ } catch {
583+ /* ignore */
584+ }
585+ this.event({type: 'stop'})
586+ this.chatLine('* recording stopped')
587+ this.writeManifest()
588+ return {segments: this.segments.length, participants: this.names.size}
589+ }
590+}
src/test/loopback.tsadded+166−0View file
@@ -0,0 +1,166 @@
1+// End-to-end loopback test, no browser required:
2+//
3+// 1. Start the recorder CLI in a random room.
4+// 2. Start a test speaker that plays a 440 Hz sine and sends a chat line.
5+// 3. After the speaker leaves, SIGINT the recorder.
6+// 4. Verify: the WAV exists, is long enough, actually contains a ~440 Hz
7+// tone (RMS + zero-crossing rate), and the chat made it into
8+// events.jsonl and chat.txt.
9+//
10+// Uses the real public nostr relays for signaling (same as dev-testing the
11+// browser client), so it needs network access.
12+
13+import {spawn, type ChildProcess} from 'node:child_process'
14+import * as fs from 'node:fs'
15+import * as os from 'node:os'
16+import * as path from 'node:path'
17+import {randomBytes} from 'node:crypto'
18+
19+const FREQ = 440
20+const SPEAK_SEC = 12
21+
22+const room = `looptest-${randomBytes(4).toString('hex')}`
23+const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'commonroom-recorder-test-'))
24+const dist = path.join(import.meta.dirname, '..')
25+
26+const failures: string[] = []
27+const check = (ok: boolean, what: string) => {
28+ process.stdout.write(`${ok ? 'PASS' : 'FAIL'}: ${what}\n`)
29+ if (!ok) failures.push(what)
30+}
31+
32+const run = (
33+ cmd: string[],
34+ label: string
35+): {proc: ChildProcess; output: () => string} => {
36+ const proc = spawn('node', cmd, {stdio: ['ignore', 'pipe', 'pipe']})
37+ let out = ''
38+ proc.stdout!.on('data', d => {
39+ out += d
40+ process.stdout.write(String(d).replace(/^/gm, ` ${label} | `))
41+ })
42+ proc.stderr!.on('data', d => {
43+ out += d
44+ process.stdout.write(String(d).replace(/^/gm, ` ${label} ! `))
45+ })
46+ return {proc, output: () => out}
47+}
48+
49+const wait = (ms: number) => new Promise(r => setTimeout(r, ms))
50+
51+const exited = (proc: ChildProcess, timeoutMs: number): Promise<boolean> =>
52+ new Promise(resolve => {
53+ const t = setTimeout(() => {
54+ proc.kill('SIGKILL')
55+ resolve(false)
56+ }, timeoutMs)
57+ proc.on('exit', () => {
58+ clearTimeout(t)
59+ resolve(true)
60+ })
61+ })
62+
63+const main = async () => {
64+ process.stdout.write(`room: ${room}\nout: ${outDir}\n\n`)
65+
66+ const recorder = run(
67+ [path.join(dist, 'cli.js'), room, '--out', outDir, '--notice', 'recording test'],
68+ 'rec'
69+ )
70+ await wait(3000)
71+ const speaker = run(
72+ [
73+ path.join(dist, 'test/speaker.js'),
74+ room,
75+ '--duration',
76+ String(SPEAK_SEC),
77+ '--freq',
78+ String(FREQ)
79+ ],
80+ 'spk'
81+ )
82+
83+ check(await exited(speaker.proc, (SPEAK_SEC + 45) * 1000), 'speaker ran and exited')
84+ await wait(1500)
85+ recorder.proc.kill('SIGINT')
86+ check(await exited(recorder.proc, 15000), 'recorder exited cleanly on SIGINT')
87+
88+ // ---- verify the outputs ----
89+ const manifestPath = path.join(outDir, 'manifest.json')
90+ check(fs.existsSync(manifestPath), 'manifest.json written')
91+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
92+ check(manifest.room === room, 'manifest has the room')
93+ check(manifest.endedAt !== null, 'manifest has endedAt')
94+ check(
95+ Object.values(manifest.participants ?? {}).includes('TestSpeaker'),
96+ 'manifest lists TestSpeaker'
97+ )
98+ const segs: {file: string; durationSec: number}[] = manifest.segments ?? []
99+ check(segs.length >= 1, `manifest has >= 1 segment (got ${segs.length})`)
100+
101+ const seg = [...segs].sort((a, b) => b.durationSec - a.durationSec)[0]
102+ if (seg) {
103+ const wavPath = path.join(outDir, seg.file)
104+ check(fs.existsSync(wavPath), `wav exists: ${seg.file}`)
105+ const wav = fs.readFileSync(wavPath)
106+ const sampleRate = wav.readUInt32LE(24)
107+ const channels = wav.readUInt16LE(22)
108+ const dataBytes = wav.readUInt32LE(40)
109+ const durationSec = dataBytes / (sampleRate * channels * 2)
110+ check(dataBytes + 44 === wav.length, 'wav header size matches file size')
111+ check(durationSec >= 6, `wav duration >= 6s (got ${durationSec.toFixed(1)}s)`)
112+
113+ // Analyze a middle stretch: real tone -> substantial RMS, and the
114+ // zero-crossing rate of a sine is 2f per second.
115+ const startFrame = Math.floor(sampleRate * 2)
116+ const endFrame = Math.min(Math.floor(sampleRate * 6), Math.floor(dataBytes / 2 / channels))
117+ let sumSq = 0
118+ let crossings = 0
119+ let prev = 0
120+ for (let f = startFrame; f < endFrame; f++) {
121+ const s = wav.readInt16LE(44 + f * channels * 2)
122+ sumSq += s * s
123+ if ((s > 0 && prev <= 0) || (s < 0 && prev >= 0)) crossings++
124+ prev = s
125+ }
126+ const n = endFrame - startFrame
127+ const rms = Math.sqrt(sumSq / n)
128+ const zcPerSec = crossings / (n / sampleRate)
129+ check(rms > 2000, `tone present: RMS > 2000 (got ${rms.toFixed(0)})`)
130+ check(
131+ Math.abs(zcPerSec - 2 * FREQ) < 2 * FREQ * 0.2,
132+ `tone is ~${FREQ} Hz: zero-crossings/sec ~ ${2 * FREQ} (got ${zcPerSec.toFixed(0)})`
133+ )
134+ }
135+
136+ const events = fs
137+ .readFileSync(path.join(outDir, 'events.jsonl'), 'utf8')
138+ .trim()
139+ .split('\n')
140+ .map(l => JSON.parse(l))
141+ const chatEvents = events.filter(
142+ e => e.type === 'chat' && e.text === 'hello from the loopback test'
143+ )
144+ check(
145+ chatEvents.length === 1,
146+ `chat message captured exactly once in events.jsonl (got ${chatEvents.length})`
147+ )
148+ check(
149+ events.some(e => e.type === 'join' && e.name === 'TestSpeaker'),
150+ 'join event captured'
151+ )
152+ const chatTxt = fs.readFileSync(path.join(outDir, 'chat.txt'), 'utf8')
153+ check(chatTxt.includes('hello from the loopback test'), 'chat message in chat.txt')
154+
155+ process.stdout.write(
156+ failures.length === 0
157+ ? `\nALL PASS (output kept in ${outDir})\n`
158+ : `\n${failures.length} FAILURE(S):\n${failures.map(f => ` - ${f}`).join('\n')}\n(output kept in ${outDir})\n`
159+ )
160+ process.exit(failures.length === 0 ? 0 : 1)
161+}
162+
163+main().catch(err => {
164+ process.stderr.write(`loopback fatal: ${err?.stack ?? err}\n`)
165+ process.exit(1)
166+})
src/test/speaker.tsadded+180−0View file
@@ -0,0 +1,180 @@
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]
7+
8+import wrtc from '@roamhq/wrtc'
9+import {selfId} from '../identity.js'
10+import {Nostr, peerTopic, roomTopic} from '../nostr.js'
11+import {Peer, type Signal} from '../peer.js'
12+
13+const argv = process.argv.slice(2)
14+let room: string | null = null
15+let durationSec = 12
16+let freq = 440
17+let name = 'TestSpeaker'
18+let chatText = 'hello from the loopback test'
19+for (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+}
27+if (!room) {
28+ process.stderr.write('usage: speaker.js <room> [--duration sec] [--freq hz]\n')
29+ process.exit(1)
30+}
31+
32+const log = (line: string) => process.stdout.write(`[speaker] ${line}\n`)
33+
34+// ---- outgoing audio: a continuous sine pushed in 10 ms frames ------------
35+
36+const RATE = 48000
37+const FRAME = 480 // 10 ms
38+const AMPLITUDE = 8000
39+
40+const audioSource = new wrtc.nonstandard.RTCAudioSource()
41+const audioTrack = audioSource.createTrack()
42+const videoTrack = new wrtc.nonstandard.RTCVideoSource().createTrack()
43+
44+let phase = 0
45+const 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).
62+let framesPushed = 0
63+const startMs = Date.now()
64+const 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)
73+
74+// ---- minimal mesh (commonroom protocol, one-shot) ------------------------
75+
76+type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
77+
78+const nostr = new Nostr()
79+const conns = new Map<string, {peer: Peer; connected: boolean}>()
80+const joinedAtMs = Date.now()
81+let chatSent = false
82+
83+const main = async () => {
84+ const root = await roomTopic(room!)
85+ const selfTopic = await peerTopic(root, selfId)
86+
87+ const sendToPeer = async (peerId: string, msg: PeerMsg) => {
88+ void nostr.publish(await peerTopic(root, peerId), JSON.stringify(msg))
89+ }
90+
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+ }
124+
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+ })
141+
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+ })
153+
154+ const announce = () =>
155+ void nostr.publish(root, JSON.stringify({peerId: selfId, name}))
156+ announce()
157+ const announceTimer = setInterval(announce, 5000)
158+
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)
173+
174+ log(`joined "${room}" as ${name} (peer ${selfId.slice(0, 8)}), ${freq} Hz for ${durationSec}s`)
175+}
176+
177+main().catch(err => {
178+ process.stderr.write(`speaker fatal: ${err?.stack ?? err}\n`)
179+ process.exit(1)
180+})
src/wav.tsadded+88−0View file
@@ -0,0 +1,88 @@
1+import * as fs from 'node:fs'
2+
3+// Incremental WAV (PCM s16le) writer. Audio arrives as 10 ms Int16 frames
4+// from an RTCAudioSink; we buffer ~1 s in memory, then append to disk and
5+// re-patch the RIFF/data sizes in the header, so a crash loses at most the
6+// last second and still leaves a playable file.
7+
8+const HEADER_BYTES = 44
9+
10+export class WavWriter {
11+ private fd: number
12+ private buffered: Buffer[] = []
13+ private bufferedBytes = 0
14+ private dataBytes = 0
15+ private finalized = false
16+
17+ /** Total frames (samples per channel) written or buffered so far. */
18+ framesWritten = 0
19+
20+ constructor(
21+ readonly path: string,
22+ readonly sampleRate: number,
23+ readonly channels: number
24+ ) {
25+ this.fd = fs.openSync(path, 'w')
26+ fs.writeSync(this.fd, this.header())
27+ }
28+
29+ private get flushThreshold(): number {
30+ return this.sampleRate * this.channels * 2 // one second of PCM
31+ }
32+
33+ private header(): Buffer {
34+ const h = Buffer.alloc(HEADER_BYTES)
35+ h.write('RIFF', 0, 'ascii')
36+ h.writeUInt32LE(36 + this.dataBytes, 4)
37+ h.write('WAVE', 8, 'ascii')
38+ h.write('fmt ', 12, 'ascii')
39+ h.writeUInt32LE(16, 16) // fmt chunk size
40+ h.writeUInt16LE(1, 20) // PCM
41+ h.writeUInt16LE(this.channels, 22)
42+ h.writeUInt32LE(this.sampleRate, 24)
43+ h.writeUInt32LE(this.sampleRate * this.channels * 2, 28) // byte rate
44+ h.writeUInt16LE(this.channels * 2, 32) // block align
45+ h.writeUInt16LE(16, 34) // bits per sample
46+ h.write('data', 36, 'ascii')
47+ h.writeUInt32LE(this.dataBytes, 40)
48+ return h
49+ }
50+
51+ /** Append interleaved s16 samples (copied — the sink reuses its buffer). */
52+ append(samples: Int16Array) {
53+ if (this.finalized) return
54+ this.buffered.push(Buffer.copyBytesFrom(samples))
55+ this.bufferedBytes += samples.byteLength
56+ this.framesWritten += samples.length / this.channels
57+ if (this.bufferedBytes >= this.flushThreshold) this.flush()
58+ }
59+
60+ /** Append silent frames (frames = samples per channel). */
61+ appendSilence(frames: number) {
62+ if (this.finalized || frames <= 0) return
63+ this.buffered.push(Buffer.alloc(frames * this.channels * 2))
64+ this.bufferedBytes += frames * this.channels * 2
65+ this.framesWritten += frames
66+ if (this.bufferedBytes >= this.flushThreshold) this.flush()
67+ }
68+
69+ get durationSec(): number {
70+ return this.framesWritten / this.sampleRate
71+ }
72+
73+ private flush() {
74+ if (this.buffered.length === 0) return
75+ const chunk = Buffer.concat(this.buffered.splice(0))
76+ this.bufferedBytes = 0
77+ fs.writeSync(this.fd, chunk, 0, chunk.length, HEADER_BYTES + this.dataBytes)
78+ this.dataBytes += chunk.length
79+ fs.writeSync(this.fd, this.header(), 0, HEADER_BYTES, 0)
80+ }
81+
82+ finalize() {
83+ if (this.finalized) return
84+ this.flush()
85+ this.finalized = true
86+ fs.closeSync(this.fd)
87+ }
88+}
tsconfig.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "NodeNext",
5+ "moduleResolution": "NodeNext",
6+ "lib": ["ES2022", "DOM"],
7+ "types": ["node"],
8+ "outDir": "dist",
9+ "rootDir": "src",
10+ "strict": true,
11+ "noUncheckedIndexedAccess": true,
12+ "noFallthroughCasesInSwitch": true,
13+ "skipLibCheck": true,
14+ "sourceMap": true,
15+ "declaration": false
16+ },
17+ "include": ["src"]
18+}