@originchats/voice
v0.1.0
Published
Framework-agnostic voice/video (WebRTC mesh) client for OriginChats servers.
Maintainers
Readme
@originchats/voice
Framework-agnostic voice & video (screen share + camera) client for OriginChats servers. It runs a WebRTC mesh over PeerJS and takes care of the fiddly parts — connection setup, glare avoidance, retries/reconnection, per-user volume, speaking detection, and encoder quality tuning — behind a small, UI-agnostic API.
It has no framework dependency. You give it a signaling transport, some
live settings getters, and a few optional hooks; it gives you a subscribe()
stream of immutable state snapshots you can render however you like.
npm install @originchats/voice peerjsWhy it exists
Every OriginChats client had to re-implement the same tricky WebRTC dance and kept hitting the same problems: black video tiles, one-way audio, blurry screen shares, and joins that silently failed for some users. This package centralizes that logic with those bugs fixed (see What it fixes).
Quick start
import { VoiceClient } from "@originchats/voice";
const voice = new VoiceClient({
// 1. How presence is announced to your OriginChats server.
signaling: {
join: (channel, peerId) => ws.send({ cmd: "voice_join", channel, peer_id: peerId }),
leave: () => ws.send({ cmd: "voice_leave" }),
setMuted: (muted) => ws.send({ cmd: muted ? "voice_mute" : "voice_unmute" }),
},
// 2. Live settings (read on demand, so changes apply mid-call).
settings: {
getMicThreshold: () => 30, // speaking-detection sensitivity, 0-100
getVideoHeight: () => 1080,
getVideoFps: () => 30,
getStartMuted: () => false,
},
// 3. (Recommended) your own STUN/TURN for reliability behind strict NATs.
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turn.example.com:3478", username: "user", credential: "pass" },
],
});
// Render state however you like.
const unsubscribe = voice.subscribe((state) => {
render(state.participants, state.screenStreams, state.cameraStreams /* … */);
});
// Feed your server's voice broadcasts back in.
ws.on("voice_join", (m) => voice.onJoined(m.channel, m.participants));
ws.on("voice_user_joined", (m) => voice.onUserJoined(m.channel, m.user));
ws.on("voice_user_left", (m) => voice.onUserLeft(m.channel, m.username));
ws.on("voice_user_updated", (m) => voice.onUserUpdated(m.channel, m.user));
// Drive it.
await voice.join("general", { username: "alice", serverId: ws.url });
voice.toggleMute();
voice.toggleCamera();
voice.toggleScreenShare();
voice.leave();How it works
- Signaling (who is in the channel) goes over your OriginChats WebSocket —
the package never talks to your server directly, it just calls your
signalingadapter and expects you to route the matching broadcasts back intoonJoined/onUserJoined/onUserLeft/onUserUpdated. - Media negotiation (SDP/ICE) goes through a PeerJS broker, keyed by the
peer_ideach client announces on join. By default this is the public PeerJS cloud, which is rate-limited — self-host one for production. - Each pair of participants shares one bidirectional audio connection; each active screen share / camera is a separate one-way connection.
What it fixes
Compared to a naive PeerJS mesh, this package addresses the common failure modes:
| Symptom | Cause | Fix |
| --- | --- | --- |
| "I can't hear someone / they can't hear me" | The call initiator dropped the answer stream, making audio one-way. | Both ends play the peer's stream; audio is fully duplex over one connection. |
| Black video tiles | Both peers called each other at once (glare), leaving connections stuck. | Deterministic initiator (lower peer_id calls; the other waits with a fallback). |
| Blurry / low-quality screen share | Default contentHint: "motion" trades resolution for framerate. | Screen tracks are tagged "detail" with maintain-resolution degradation. |
| Low quality in general | No sender bitrate ceiling; browser under-allocates on a mesh. | Per-kind maxBitrate derived from resolution, applied once media flows. |
| "It just doesn't connect" for some users | Single free TURN with expiring credentials. | TURN/STUN are configurable; supply your own. |
| Silent failures | No connection state surfaced. | Each participant carries a connection state (connecting/reconnecting/failed). |
Self-hosting the broker & TURN
The public PeerJS cloud and the bundled free TURN are best-effort fallbacks only. For anything real, run your own:
new VoiceClient({
// …
peerBroker: { host: "peer.example.com", port: 443, path: "/", secure: true },
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{ urls: "turn:turn.example.com:3478", username: "u", credential: "p" },
],
});- Broker:
peerjs-server(npx peerjs --port 9000). - TURN: coturn is the standard choice. TURN is what makes calls work behind symmetric NATs and restrictive firewalls — the usual reason "it works for me but not for them."
API
new VoiceClient(options)
| Option | Required | Description |
| --- | --- | --- |
| signaling | ✅ | { join, leave, setMuted } — announce presence to your server. |
| settings | ✅ | Live getters: getMicThreshold, getVideoHeight, getVideoFps, optional getStartMuted, getDefaultMicId, getDefaultCamId. |
| iceServers | – | STUN/TURN list. Defaults to public STUN + shared TURN. |
| peerBroker | – | PeerJS broker { host, port, path, key, secure }. Defaults to the PeerJS cloud. |
| quality | – | maxBitrate / contentHint / degradationPreference overrides. |
| hooks | – | Optional side-effects — see below. |
| storage | – | { getItem, setItem } for per-user prefs. Defaults to localStorage, then memory. |
| logger | – | (level, ...args) => void. Defaults to console.warn. |
Hooks (all optional): shouldBlockJoin(channel), onBlockedJoin(channel),
onMutedChange(muted), onCameraOnChange(on), onSelectedMicChange(id),
onSelectedCamChange(id), onViewVisibility(action).
Methods
subscribe(cb) => unsubscribe,getState()join(channel, { username?, serverId? }),leave()onJoined,onUserJoined,onUserLeft,onUserUpdated,setMyUsernametoggleMute(),toggleDeafen(),toggleCamera(),toggleScreenShare()switchMicrophone(id),switchCamera(id),flipCamera(),restartCamera(),restartScreenShare()setUserVolume/getUserVolume,setUserMuted/getUserMuted,setStreamVolume/getStreamVolume,setStreamMuted/getStreamMutedgetAudioInputDevices(),getVideoInputDevices(),setDefaultMicId(id),setDefaultCamId(id)- Getters:
currentChannel,serverId,isMuted,isDeafened,isSpeaking,callStartTime,cameraFacing,selectedMicId,selectedCamId,isInChannel(),getMyPeerId()
State snapshot
subscribe emits an immutable VoiceSnapshot: participants (each with
speaking, muted, locallyMuted, localVolume, connection),
screenStreams / cameraStreams (keyed by peer_id), the local streams,
mute/deafen flags, and the persisted per-user volume/mute maps.
License
MIT
