Voice and video
An OriginChats server tracks who is in each voice channel, but it never carries audio or video (see Voice). Your client has to capture media and connect directly to the other participants. @originchats/voice handles that part. It is the voice client the official web client uses.
The package runs a WebRTC mesh over PeerJS. It sets up connections, avoids both sides calling each other at once, and retries dropped connections. It also handles per-user volume, speaking detection, and encoder quality. It doesn’t depend on a UI framework. You pass it a way to send voice commands to your server, and you get back state snapshots to render.
It runs in the browser. It needs getUserMedia, RTCPeerConnection, and Web Audio.
Install
Section titled “Install”peerjs is a peer dependency, so install both:
npm install @originchats/voice peerjsThe package is published as TypeScript source, not compiled JavaScript. Vite, Bun, and esbuild compile it without extra setup. Other bundlers need a TypeScript loader that also covers node_modules.
How the pieces fit
Section titled “How the pieces fit”A call uses two separate channels:
- Presence goes over your existing OriginChats WebSocket. The package never talks to your server itself. It calls a
signalingadapter that you provide, and you pass the server’s voice events back to it. - Media negotiation goes through a PeerJS broker. Each client registers with the broker under its
peer_id, which is the value it announces invoice_join. By default this is the public PeerJS cloud.
Each pair of participants shares one two-way audio connection. Each screen share or camera stream that someone is watching gets its own one-way connection.
1. Create the client
Section titled “1. Create the client”Create one VoiceClient for your whole app. The examples below reuse socket and handleFrame from Build a client.
import { VoiceClient } from "@originchats/voice";
function send(frame) { if (socket.readyState !== WebSocket.OPEN) return false; socket.send(JSON.stringify(frame));}
const voice = new VoiceClient({ signaling: { join: (channel, peerId) => send({ cmd: "voice_join", channel, peer_id: peerId }), leave: () => send({ cmd: "voice_leave" }), setMuted: (muted) => send({ cmd: muted ? "voice_mute" : "voice_unmute" }), }, settings: { getMicThreshold: () => 5, getVideoHeight: () => 1080, getVideoFps: () => 30, },});signaling.join can return false to report that the socket is offline. The join then fails right away instead of waiting for a response that won’t come.
settings holds getter functions, not fixed values. The client reads them when it needs them, so you can back them with your own settings store. See Settings for the full list.
2. Pass voice events to it
Section titled “2. Pass voice events to it”The client needs every voice frame from the server the call is on. Add these cases to handleFrame:
case "voice_join": if (frame.peer_id === voice.getMyPeerId()) { voice.onJoined(frame.channel, frame.participants); } break;case "voice_user_joined": voice.onUserJoined(frame.channel, frame.user); break;case "voice_user_updated": voice.onUserUpdated(frame.channel, frame.user); break;case "voice_user_left": voice.onUserLeft(frame.channel, frame.username); break;Each method ignores events for channels other than the current call. If your client connects to several servers, only forward frames from the server whose URL matches voice.serverId.
Only forward a voice_join whose peer_id is yours. The voice_join response confirms the join. If the server doesn’t confirm within 10 seconds, the call fails.
3. Render the state
Section titled “3. Render the state”subscribe calls your function with an immutable VoiceSnapshot right away and again after every change. It returns a function that unsubscribes.
const unsubscribe = voice.subscribe((state) => { renderCallBar(state.status, state.currentChannel, state.isMuted, state.isDeafened); renderParticipants(state.participants); renderVideo(state.screenStreams, state.cameraStreams);});Use voice.getState() to read the latest snapshot without subscribing.
| Field | Meaning |
|---|---|
status |
idle, requesting-microphone, connecting, joining, connected, or failed. |
error |
{ code, message } for the last call-level error, or null. See Errors. |
currentChannel, serverId |
The call you’re in, or null. |
participants |
Everyone in the call. See below. |
isMuted, isDeafened, isSpeaking |
Your own audio state. |
micDenied |
The browser refused microphone access. You stay in the call as a listener. |
isScreenSharing, isCameraOn |
Whether you’re sending video. |
localScreenStream, localCameraStream |
Your own video streams, for a preview tile. |
screenStreams, cameraStreams |
Remote video streams you’re watching, keyed by peer_id. |
callStartTime |
When the call connected, in epoch milliseconds. |
userVolumes, userMutes, streamVolumes, streamMutes |
Saved per-user output preferences, keyed by peer_id. |
Each entry in participants has:
| Field | Meaning |
|---|---|
username, peer_id |
Who the participant is. |
muted |
The mute state the server reports. |
speaking |
They are producing audio above the speaking threshold right now. |
locallyMuted, localVolume |
Your local output settings for them. 1 is 100%. |
connection |
new, connecting, connected, reconnecting, disconnected, or failed. |
audioIssue |
A receive-side problem limited to this person, such as audio-packet-loss. |
videoAvailable |
screen or camera when they’re sharing video. |
watching, videoError |
Whether you’re watching their video, and why it failed to arrive. |
watchingMyStream |
They’re watching your video. |
4. Join and leave
Section titled “4. Join and leave”const joined = await voice.join("hangout", { username: me.username, serverId: serverUrl });
voice.leave();join asks for the microphone, connects to the broker, sends voice_join, and waits for the server to confirm. It resolves to true when the call connects. It resolves to false if the join was blocked or setup failed. On failure, state.error explains what went wrong.
Joining while you’re already in a call leaves that call first. Calling join again with the same channel and server returns the join that is already in progress.
Pass username so the client can recognize you in the participant list. Call voice.setMyUsername(name) if you only learn it later.
If the WebSocket reconnects during a call, the server has already removed you from the call. Join again. See Leaving and reconnecting.
5. Mute and deafen
Section titled “5. Mute and deafen”voice.toggleMute();voice.setMuted(true);
voice.toggleDeafen();voice.setDeafened(false);Muting stops sending your audio and tells the server through signaling.setMuted. Deafening silences everyone else and mutes you too. Unmuting also undeafens you. If the microphone was denied, toggleMute asks for it again.
6. Screen share and camera
Section titled “6. Screen share and camera”await voice.toggleScreenShare();await voice.toggleCamera();Remote video is opt-in, so a mesh doesn’t send every stream to every participant. When a participant’s videoAvailable is set, subscribe to their stream:
voice.watchStream(participant.peer_id);
// Later, attach it to a <video> element.video.srcObject = state.screenStreams[participant.peer_id] ?? state.cameraStreams[participant.peer_id];voice.stopWatching(peerId) releases one stream, and voice.stopWatching() releases them all. voice.focusStream(peerId) keeps only that stream and releases the others, which suits an enlarged view.
Use switchCamera(deviceId) to change cameras and flipCamera() to switch between the front and back cameras on phones. restartCamera() and restartScreenShare() start the capture again.
7. Per-user volume
Section titled “7. Per-user volume”These methods are keyed by peer_id and saved in storage. They only change what you hear. Volume ranges from 0 to 2, where 1 is 100%.
voice.setUserVolume(peerId, 1.5);voice.setUserMuted(peerId, true);
voice.setStreamVolume(peerId, 0.5);voice.setStreamMuted(peerId, false);The stream variants control the audio of someone’s screen share, separately from their voice.
Settings
Section titled “Settings”The client reads each getter on demand.
| Getter | Required | Meaning |
|---|---|---|
getMicThreshold() |
Yes | Microphone gate, 0 to 100. 0 turns the gate off. |
getVideoHeight() |
Yes | Preferred capture height in pixels, such as 720 or 1080. |
getVideoFps() |
Yes | Preferred capture frame rate. |
getNoiseSuppression() |
No | Browser noise suppression. Defaults to true. |
getStartMuted(), getStartDeafened() |
No | The initial state for new calls. |
getDefaultMicId(), getDefaultCamId() |
No | Saved deviceId preferences. |
Some changes need a call to take effect during a call:
| After changing | Call |
|---|---|
| The mic threshold | voice.applyMicThreshold() |
| Noise suppression | await voice.applyAudioSettings() |
| Video height or frame rate | await voice.applyVideoSettings() |
Use getAudioInputDevices() and getVideoInputDevices() to build device pickers. Switch devices with switchMicrophone(deviceId) and switchCamera(deviceId). Save a default with setDefaultMicId(deviceId) and setDefaultCamId(deviceId). supportsNoiseSuppression() reports whether the browser offers noise suppression.
Errors
Section titled “Errors”A call-level failure sets status to failed and fills error:
| Code | Cause |
|---|---|
microphone |
The microphone couldn’t be opened or switched. |
connection |
Setup failed, including when the server didn’t confirm voice_join. |
setup-timeout |
Setup took longer than 45 seconds. |
signaling, signaling-closed |
The PeerJS broker was lost and couldn’t be reconnected. |
playback |
The browser paused the call’s audio. |
Browsers can block audio that doesn’t start from a user action. On the playback code, show a button that calls voice.resumeAudio() from its click handler. Call voice.dismissError() to clear an error you’ve already shown.
Per-person problems don’t end the call. They appear on the participant as connection: "failed" or audioIssue. Offer a retry that calls voice.retryPeer(peerId).
The optional hooks option lets your app react to changes the client makes:
| Hook | Called when |
|---|---|
shouldBlockJoin(channel) |
Before a join. Return true to cancel it, for example when this account is already in a call in another tab. |
onBlockedJoin(channel) |
A join was cancelled by shouldBlockJoin. |
onMutedChange(muted), onDeafenedChange(deafened) |
Your mute or deafen state changed. Save it here if you back getStartMuted with it. |
onCameraOnChange(on) |
Your camera turned on or off. |
onSelectedMicChange(deviceId), onSelectedCamChange(deviceId) |
The active device changed. |
storage takes any { getItem, setItem } object for per-user preferences. It defaults to localStorage, and falls back to memory. logger takes (level, ...args) and defaults to the console.
Production setup
Section titled “Production setup”The defaults are public STUN servers and the public PeerJS cloud. They’re good enough for testing, but the PeerJS cloud is rate-limited. Without a TURN relay, some users can’t connect at all, such as those behind symmetric NATs or strict firewalls. For real deployments, run your own:
const voice = new VoiceClient({ signaling, settings, peerBroker: { host: "peer.example.com", port: 443, path: "/", secure: true }, iceServers: [ { urls: "stun:stun.example.com:3478" }, { urls: "turn:turn.example.com:3478", username: "user", credential: "pass" }, ],});- Broker:
peerjs-server, for examplenpx peerjs --port 9000. - TURN: coturn.
Every participant must use the same broker. Clients on different brokers can’t find each other, even in the same channel.
The quality option overrides encoder settings: cameraMaxBitrate, screenMaxBitrate, content hints, and degradation preferences. By default, screen shares keep their resolution and use the detail content hint so text stays sharp. Bitrate limits are derived from the capture size unless you set them.
Next steps
Section titled “Next steps”- Voice: what the server tracks and what it leaves to clients.
- Voice commands: the frames the adapter sends.
- Connection lifecycle: reconnects, which also end a call.
