@playninja/voice-room
v0.2.0
Published
Tiny, transport-agnostic voice/audio rooms on top of the Cloudflare Realtime SFU. Bring your own signaling.
Downloads
287
Maintainers
Readme
@playninja/voice-room
Tiny, transport-agnostic voice/audio rooms on top of the Cloudflare Realtime SFU. Bring your own signaling — wire it to a game WebSocket, a Durable Object, anything.
- One peer connection per client (SFU topology) — you push your mic once, the SFU forwards it to everyone.
- No media servers to run. Cloudflare's SFU does the routing; TURN is built in and free alongside it.
- Zero runtime dependencies. ~1 small file of client code.
- Your secret stays server-side via a thin proxy helper.
- Built-in mute, speaking detection / levels, and reconnection.
Audio-first (perfect for party games & social deduction). Video is a future addition — the SFU and API already support it.
Why "bring your own signaling"?
WebRTC needs a side-channel to announce "I'm here, here's my session/track id." Most apps already have one (a game socket). This library rides on yours via a 2-method Transport, so it drops into an existing realtime app without standing up anything new. It never relays SDP/ICE peer-to-peer — only tiny roster messages.
Install
npm install @playninja/voice-roomQuick start
1. Server — proxy the SFU (keeps your secret off the client)
import { handleRealtimeProxy } from '@playninja/voice-room/server';
// in your Worker fetch handler:
if (url.pathname.startsWith('/voice')) {
return handleRealtimeProxy(request, '/voice', {
appId: env.REALTIME_APP_ID,
token: env.REALTIME_APP_SECRET, // wrangler secret put REALTIME_APP_SECRET
});
}2. Client — join the room
import { createVoiceRoom } from '@playninja/voice-room';
const voice = createVoiceRoom({
apiBase: '/voice', // your proxy route from step 1
localId: myPlayerId, // stable id
transport: { // wire to your existing socket
send: (msg) => socket.send(JSON.stringify({ type: 'voice', msg })),
onMessage: (handler) => {
const fn = (e) => { const d = JSON.parse(e.data); if (d.type === 'voice') handler(d.msg); };
socket.addEventListener('message', fn);
return () => socket.removeEventListener('message', fn);
},
},
});
voice.on('peers', (peers) => renderSpeakingIndicators(peers)); // [{id, speaking, level, muted, connected}]
voice.on('status', (s) => console.log('voice', s));
await voice.join(); // call from a click (mic permission + autoplay)
voice.setMuted(true);
await voice.leave();Your server must also relay the
voicemessages to the other players in the room (the same fan-out you already do for chat/game state). The SDK only produces/consumes the payloads.
API
| | |
|---|---|
| createVoiceRoom(options) | Create a room (doesn't join). |
| voice.join() | Mic permission → push track → pull peers. Call from a user gesture. |
| voice.setMuted(bool) | Enable/disable your mic (no renegotiation). |
| voice.resumeAudio() | Call from a gesture if needsgesture fired (autoplay blocked). |
| voice.leave() | Tear everything down. |
| voice.peers / voice.status / voice.muted | Current state. |
Events: status, peers, level ({id, level, speaking}, frequent), error, needsgesture.
Options: apiBase, transport, localId, iceServers?, audio?, speakingThreshold?.
Run the demo (your spike)
Two tabs on one machine hearing each other through real Cloudflare media:
- Create a Realtime app in the Cloudflare dashboard → copy the App ID + App Secret.
npm installnpm run build:demo- Create
demo/.dev.vars:REALTIME_APP_ID = "your-app-id" REALTIME_APP_SECRET = "your-app-secret" wrangler dev --config demo/wrangler.jsonc→ openhttp://localhost:8787in two tabs, same room code, Join in both.
(The demo uses BroadcastChannel for signaling, which works across tabs in one browser. Real cross-device use needs a real transport like your game socket.)
How it works
each client Cloudflare Realtime SFU
┌──────────┐ push mic (1 track) ┌──────────────────────┐
│ browser │ ─────────────────────▶ │ forwards to all │
│ (1 PC) │ ◀───────────────────── │ pullers │
└──────────┘ pull N peer tracks └──────────────────────┘
▲ roster (sessionId + trackName) over YOUR transport
▼
┌──────────┐
│ your app │ (game WebSocket / Durable Object)
└──────────┘License
MIT
