Skip to content

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):

sh
npm i @roamhq/wrtc

emusks loads it automatically the first time you place or answer a call. To use a different engine, pass it once:

js
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.

js
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:

optiondefaultmeaning
videofalsesend video as well as audio
audioOnlytrueforce audio-only (ignored when video is set)
announcetruealso post the AVCallStarted event into the conversation (needs a loaded identity)

Listening for incoming calls

js
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.

js
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

js
await call.sendAudioFile("./hello.mp3");          // any audio ffmpeg can read
await call.sendVideoFile("./clip.mp4");           // video + its audio track

Files are transcoded and streamed in real time. The returned controller exposes done (a promise) and stop().

Recording what you receive

js
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

js
await call.hangup();      // tears down media + leaves the broadcast
call.durationSeconds;     // seconds since connect

Group 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.

js
// 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 set videoCodec: "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/broadcast returns 403 until 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.

not affiliated with X Corp.