CLAUDE.md#
Tips for future agents working in this repo — a Node CLI that joins a commonroom call as a muted, visible participant and records every other participant's audio (one WAV per participant) plus the room chat. Read commonroom's CLAUDE.md first: this tool speaks its protocol verbatim, and the protocol is documented there.
Architecture#
src/
identity.ts ported from commonroom; EPHEMERAL keypair (no localStorage)
nostr.ts ported near-verbatim (Node >= 22 global WebSocket); close() added
peer.ts ported, adapted to @roamhq/wrtc; receive-only media (see below)
wav.ts incremental WAV writer (buffers ~1 s, re-patches header sizes)
recorder.ts the heart: presence, mesh, control channel, audio sinks, files
render.ts shared transcript timeline: turn merging, md/json rendering
transcribe.ts `transcribe` subcommand: per-WAV ASR + merged transcript.md
livetranscribe.ts record --transcribe: persistent faster-whisper helper,
silence-cut chunking, live transcript re-rendering
cli.ts record/transcribe subcommands, signal handling, log lines
test/
speaker.ts synthetic participant: sine tone + one chat message
loopback.ts test: recorder + speaker in a random room -> tone + chat
transcribe-test.ts test: fabricated 2-speaker recording dir (JFK sample)
-> transcript ordering + words; uses --model tiny
Key design decisions#
- Protocol-identical participant. Same announcements, per-peer signaling topics, deterministic initiator (smaller peer ID), control-channel messages (hello/mute/chat/bye), stalled-connection retry, room-full handling, and the soft cap of 8 — the recorder counts toward it. Don't invent protocol; if the browser client changes, port the change.
- Receive-only media, but symmetric-looking. Outgoing tracks are wrtc
RTCAudioSource/RTCVideoSourceplaceholders that never produce data (= a fully muted participant). The VIDEO m-line is negotiatedsendonlyfrom our side (as initiator viaaddTransceiver(track, {direction:'sendonly'}); as answerer by flipping the transceiver direction after setRemoteDescription) so browsers never send us video — saves bandwidth and decode CPU. Audio stays sendrecv. - Recording gates on first non-zero frame. Before the first RTP packet,
RTCAudioSinkdelivers all-zero frames at a PROVISIONAL sample rate (16 kHz observed), then switches to the real one (48 kHz) — opening the file eagerly yields junk stub segments. A never-unmuting participant produces no file. A mid-stream format change (rare) closes the segment and starts a new one. - Wall-clock silence padding. If the sink stalls > 1 s (network gap, DTX),
silence is inserted so sample position keeps tracking elapsed time — the
manifest's segment
startedAtplus the file offset IS the meeting timeline. - connectionState flaps. wrtc can pass through 'connected' several times while ICE settles; the connect handler must be idempotent or hello/notice get re-sent per flap.
- Bye cooldown (3 s). An announcement published just before a peer's bye can arrive just after it and would trigger an instant reconnect (and a stub recording); after a bye we ignore that peer's announcements briefly.
- The recording dir is also the agent interface.
inbox/is polled every 500 ms; a file's content is broadcast as a chat message from the recorder (then the file is deleted; dotfiles/*.tmp skipped, 300 ms mtime settle, 2000-char cap) and logged everywhere a received chat would be.AGENT.md(template at the bottom of recorder.ts) is written at start with instructions for a monitoring AI agent. - Every exit path MUST end in
process.exit(). @roamhq/wrtc segfaults in its static destructors on a natural process exit whenever nonstandard media sources exist. The CLI, the speaker, and any future script that touches wrtc must exit explicitly. - Crash-safe outputs. events.jsonl and chat.txt are appended per event; WAVs flush (with header re-patch) about once a second; manifest.json is written atomically (tmp + rename) at segment boundaries and every 30 s.
- Transcription needs no alignment step. The silence padding means an ASR
timestamp within a segment plus the manifest
startedAtis the wall-clock time; transcribe.ts just merges utterances with chat/join/left events and groups adjacent same-speaker utterances (< 3 s gap) into turns. ASR engines are probed (faster-whisper via an embedded python3 stdin script — VAD on, which also skips the padded silence — then whisper-cli, then whisper); raw ASR is cached per WAV in<dir>/asr/. cli.ts imports recorder.js LAZILY so transcribe works where the wrtc native module doesn't load. - Live transcription (record --transcribe) never blocks recording. One
persistent python process (faster-whisper only) serves requests over
line-JSON stdio; it reads raw flushed frame ranges straight from the
growing WAVs (header bypassed). Chunks are cut at >= 300 ms of quiet
(RMS < 300) tracked from the live sample stream — never mid-word — with a
30 s force-cut for unbroken speech; all-quiet chunks skip ASR entirely.
Constants are tuned for ~8 s typical end-of-utterance-to-text latency
(3 s tick, 2 s min chunk, 0.5 s render debounce).
Results re-render transcript.md live and are persisted to asr/*.json in the
offline format, so
transcribecan re-render or upgrade models later. If the helper dies, it logs once and recording continues. - Spawn the persistent helper with
python3 -c <script>, neverpython3 -+ script on stdin:-reads stdin to EOF before executing, so a process that keeps stdin open for requests never starts. (The one-shot offline helper uses stdin+end() and is fine.) In the helper, read requests withsys.stdin.readline(), notfor line in sys.stdin(read-ahead buffering sits on complete lines).
Testing#
npm run build && npm run test:loopback — full end-to-end over the real
public relays (needs network): asserts the recorded WAV contains the 440 Hz
tone (RMS + zero-crossing rate) and the chat message landed exactly once.
npm run test:transcribe — real-speech transcription test (downloads the
whisper.cpp JFK sample + the tiny model on first run). npm run test:live —
end-to-end LIVE transcription: a speaker streams the JFK WAV into a room
(speaker.js --wav; playback and leave countdown start at first connect) and
the test asserts the transcript grew while still recording.
Segfault-at-exit in a child process = some path bypassed process.exit().
For manual testing against real browsers, record a room and join it at
https://concept-collection.github.io/commonroom/ — let the user do
multi-person tests; don't try to automate browser media.