Audio & video calls
XChat calls are real end-to-end WebRTC, the same audio/video stack the official client uses (Periscope signaling plus a Janus SFU). emusks gives you a small, high-level surface: start a call, answer one, stream a media file into it, and record what comes back.
Prerequisite: a WebRTC engine
Calls need a WebRTC implementation. Node and Bun don't ship one, so install @roamhq/wrtc (a prebuilt native binding):
npm i @roamhq/wrtcemusks loads it automatically the first time you place or answer a call. To use a different engine, pass it once:
import wrtc from "@roamhq/wrtc";
client.xchat.useWebRTC(wrtc);ffmpeg is required only if you use the file helpers (sendAudioFile, sendVideoFile, recordIncoming); it must be on your PATH (or set FFMPEG_PATH).
client.xchat.call(recipient, opts?)
Place a 1:1 call. recipient is a numeric id, @handle, or { id }. Returns a call object.
const call = await client.xchat.call("elonmusk", { video: false });
call.on("connected", () => console.log("call is up"));
call.on("track", ({ kind }) => console.log("receiving", kind));
call.on("ended", ({ durationSeconds }) => console.log("call ended after", durationSeconds, "s"));Options:
| option | default | meaning |
|---|---|---|
video | false | send video as well as audio |
audioOnly | true | force audio-only (ignored when video is set) |
announce | true | also post the AVCallStarted event into the conversation (needs a loaded identity) |
Listening for incoming calls
const sub = client.xchat.listenForCalls((incoming) => {
console.log("call from", incoming.hostId);
incoming.accept({ video: false }); // returns a call object
// or: incoming.decline();
});
// later:
sub.close();Each incoming has { broadcastId, hostId, conversationId, audioOnly, accept(opts?), decline() }.
client.xchat.answerCall(incoming, opts?)
Answer a call you already know the details of (for example a broadcastId + hostId surfaced elsewhere). incoming.accept() above is a thin wrapper over this.
const call = await client.xchat.answerCall({ broadcastId, hostId }, { video: true });The call object
Both call() and answerCall() return the same object (an EventEmitter).
Events
connected: the peer connection is up, media is flowing.track{ kind, track }: a remote audio/video track arrived.mediastatus{ isCameraDeactivated, isMicrophoneDeactivated }: the other side toggled mic/camera.ended{ reason, durationSeconds }: the call finished.error: a non-fatal signaling error.
Sending media from a file
await call.sendAudioFile("./hello.mp3"); // any audio ffmpeg can read
await call.sendVideoFile("./clip.mp4"); // video + its audio trackFiles are transcoded and streamed in real time. The returned controller exposes done (a promise) and stop().
Recording what you receive
const rec = call.recordIncoming({ audioPath: "./in.wav", videoPath: "./in.mp4" });
// ...later
await rec.audio?.stop();
await rec.video?.stop();Prefer raw frames? Listen for track and attach your own sink from the WebRTC engine.
Hanging up
await call.hangup(); // tears down media + leaves the broadcast
call.durationSeconds; // seconds since connectGroup calls
Group calls run over a Janus SFU (the Twitter Spaces backend). The host starts a room and invites people; everyone else joins by its broadcast id. The host must invite a participant (invite) for them to be allowed to auto-join.
// host
const call = await client.xchat.startGroupCall({ audioOnly: true, invite: ["alice", "bob"] });
console.log("share this id:", call.broadcastId);
// participant
const call = await client.xchat.joinGroupCall(broadcastId, { audioOnly: true });startGroupCall options: video/audioOnly, invite (id/@handle/array), conversationId, and videoCodec (default "vp8", so video works with @roamhq/wrtc; pass "h264" only with an H.264-capable engine for interop with the official app). The returned object is the same as the 1:1 call object: sendAudioFile, sendVideoFile, recordIncoming, on("track"), hangup().
Codecs
@roamhq/wrtc supports VP8/VP9/AV1 but not H.264. The official X client uses H.264 for video. So:
- Between two emusks clients, leave the default VP8 and video works.
- To interop with the official app's video, supply a WebRTC engine that encodes/decodes H.264 (
client.xchat.useWebRTC(...)) and setvideoCodec: "h264".
Audio (Opus) is unaffected.
A note on what's verified
The full pipeline (signaling, ICE, perfect negotiation, media sources/sinks, ffmpeg file transcode) is implemented and verified end-to-end: two clients connect and exchange real audio and 640×480 video both directions, with files streamed in and recorded out. The Periscope bootstrap (token → login → guest token → TURN), broadcast creation, Janus connection, and go-live are all verified live.
Whether a given live call connects through X depends on X's own infrastructure:
- 1:1 calls are gated server-side by X's calling rollout. Creating the
p2p/broadcastreturns403until your account is enabled, regardless of settings, follows, or Premium. The implementation matches the official client byte-for-byte, so it connects the moment an account is in the rollout. - Group calls connect to the live SFU and the host goes live, but X's gateway is the Spaces audio-mixing backend (sessions are isolated VideoRoom views), so cross-participant media is brokered by that backend rather than exchanged as direct peer feeds.
If you have an account in the 1:1 rollout, the 1:1 path is the one that does true peer audio and video.