@playninja/voice-room 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/server/proxy.d.ts +16 -0
- package/dist/server/proxy.js +36 -0
- package/dist/src/emitter.d.ts +6 -0
- package/dist/src/emitter.js +31 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +6 -0
- package/dist/src/realtime.d.ts +13 -0
- package/dist/src/realtime.js +53 -0
- package/dist/src/types.d.ts +70 -0
- package/dist/src/types.js +1 -0
- package/dist/src/voice-room.d.ts +21 -0
- package/dist/src/voice-room.js +377 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PlayNinja (Ninja Tech Solutions)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @playninja/voice-room
|
|
2
|
+
|
|
3
|
+
Tiny, **transport-agnostic** voice/audio rooms on top of the [Cloudflare Realtime SFU](https://developers.cloudflare.com/realtime/). Bring your own signaling — wire it to a game WebSocket, a Durable Object, anything.
|
|
4
|
+
|
|
5
|
+
- **One peer connection per client** (SFU topology) — you push your mic once, the SFU forwards it to everyone.
|
|
6
|
+
- **No media servers to run.** Cloudflare's SFU does the routing; TURN is built in and free alongside it.
|
|
7
|
+
- **Zero runtime dependencies.** ~1 small file of client code.
|
|
8
|
+
- **Your secret stays server-side** via a thin proxy helper.
|
|
9
|
+
- Built-in **mute**, **speaking detection / levels**, and **reconnection**.
|
|
10
|
+
|
|
11
|
+
> Audio-first (perfect for party games & social deduction). Video is a future addition — the SFU and API already support it.
|
|
12
|
+
|
|
13
|
+
## Why "bring your own signaling"?
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
```bash
|
|
18
|
+
npm install @playninja/voice-room
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### 1. Server — proxy the SFU (keeps your secret off the client)
|
|
24
|
+
```ts
|
|
25
|
+
import { handleRealtimeProxy } from '@playninja/voice-room/server';
|
|
26
|
+
|
|
27
|
+
// in your Worker fetch handler:
|
|
28
|
+
if (url.pathname.startsWith('/voice')) {
|
|
29
|
+
return handleRealtimeProxy(request, '/voice', {
|
|
30
|
+
appId: env.REALTIME_APP_ID,
|
|
31
|
+
token: env.REALTIME_APP_SECRET, // wrangler secret put REALTIME_APP_SECRET
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Client — join the room
|
|
37
|
+
```ts
|
|
38
|
+
import { createVoiceRoom } from '@playninja/voice-room';
|
|
39
|
+
|
|
40
|
+
const voice = createVoiceRoom({
|
|
41
|
+
apiBase: '/voice', // your proxy route from step 1
|
|
42
|
+
localId: myPlayerId, // stable id
|
|
43
|
+
transport: { // wire to your existing socket
|
|
44
|
+
send: (msg) => socket.send(JSON.stringify({ type: 'voice', msg })),
|
|
45
|
+
onMessage: (handler) => {
|
|
46
|
+
const fn = (e) => { const d = JSON.parse(e.data); if (d.type === 'voice') handler(d.msg); };
|
|
47
|
+
socket.addEventListener('message', fn);
|
|
48
|
+
return () => socket.removeEventListener('message', fn);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
voice.on('peers', (peers) => renderSpeakingIndicators(peers)); // [{id, speaking, level, muted, connected}]
|
|
54
|
+
voice.on('status', (s) => console.log('voice', s));
|
|
55
|
+
|
|
56
|
+
await voice.join(); // call from a click (mic permission + autoplay)
|
|
57
|
+
voice.setMuted(true);
|
|
58
|
+
await voice.leave();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> Your server must also **relay** the `voice` messages 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.
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
| | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `createVoiceRoom(options)` | Create a room (doesn't join). |
|
|
67
|
+
| `voice.join()` | Mic permission → push track → pull peers. Call from a user gesture. |
|
|
68
|
+
| `voice.setMuted(bool)` | Enable/disable your mic (no renegotiation). |
|
|
69
|
+
| `voice.resumeAudio()` | Call from a gesture if `needsgesture` fired (autoplay blocked). |
|
|
70
|
+
| `voice.leave()` | Tear everything down. |
|
|
71
|
+
| `voice.peers` / `voice.status` / `voice.muted` | Current state. |
|
|
72
|
+
|
|
73
|
+
**Events:** `status`, `peers`, `level` (`{id, level, speaking}`, frequent), `error`, `needsgesture`.
|
|
74
|
+
|
|
75
|
+
**Options:** `apiBase`, `transport`, `localId`, `iceServers?`, `audio?`, `speakingThreshold?`.
|
|
76
|
+
|
|
77
|
+
## Run the demo (your spike)
|
|
78
|
+
Two tabs on one machine hearing each other through real Cloudflare media:
|
|
79
|
+
|
|
80
|
+
1. Create a **Realtime app** in the Cloudflare dashboard → copy the **App ID** + **App Secret**.
|
|
81
|
+
2. `npm install`
|
|
82
|
+
3. `npm run build:demo`
|
|
83
|
+
4. Create `demo/.dev.vars`:
|
|
84
|
+
```
|
|
85
|
+
REALTIME_APP_ID = "your-app-id"
|
|
86
|
+
REALTIME_APP_SECRET = "your-app-secret"
|
|
87
|
+
```
|
|
88
|
+
5. `wrangler dev --config demo/wrangler.jsonc` → open `http://localhost:8787` in **two tabs**, same room code, Join in both.
|
|
89
|
+
|
|
90
|
+
(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.)
|
|
91
|
+
|
|
92
|
+
## How it works
|
|
93
|
+
```
|
|
94
|
+
each client Cloudflare Realtime SFU
|
|
95
|
+
┌──────────┐ push mic (1 track) ┌──────────────────────┐
|
|
96
|
+
│ browser │ ─────────────────────▶ │ forwards to all │
|
|
97
|
+
│ (1 PC) │ ◀───────────────────── │ pullers │
|
|
98
|
+
└──────────┘ pull N peer tracks └──────────────────────┘
|
|
99
|
+
▲ roster (sessionId + trackName) over YOUR transport
|
|
100
|
+
▼
|
|
101
|
+
┌──────────┐
|
|
102
|
+
│ your app │ (game WebSocket / Durable Object)
|
|
103
|
+
└──────────┘
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RealtimeProxyConfig {
|
|
2
|
+
appId: string;
|
|
3
|
+
token: string;
|
|
4
|
+
/** Override for testing; defaults to the public SFU base. */
|
|
5
|
+
sfuBase?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Forward a single SFU subpath (e.g. "/sessions/new"). */
|
|
8
|
+
export declare function proxyRealtime(subpath: string, init: {
|
|
9
|
+
method: string;
|
|
10
|
+
body?: string | null;
|
|
11
|
+
}, cfg: RealtimeProxyConfig): Promise<Response>;
|
|
12
|
+
/**
|
|
13
|
+
* Handle a Worker `Request` whose path starts with `apiBasePath`. Only
|
|
14
|
+
* `/sessions...` subpaths are forwarded; everything else 404s.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleRealtimeProxy(request: Request, apiBasePath: string, cfg: RealtimeProxyConfig): Promise<Response>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Framework-agnostic proxy for the Cloudflare Realtime SFU.
|
|
2
|
+
//
|
|
3
|
+
// The browser SDK POSTs to `${apiBase}/sessions/...` on YOUR origin. This helper
|
|
4
|
+
// forwards those to the real SFU API, injecting `apps/{appId}` and the bearer
|
|
5
|
+
// secret so the secret never reaches the client. Drop it into a Worker:
|
|
6
|
+
//
|
|
7
|
+
// import { handleRealtimeProxy } from '@playninja/voice-room/server';
|
|
8
|
+
// if (url.pathname.startsWith('/voice')) {
|
|
9
|
+
// return handleRealtimeProxy(request, '/voice', { appId: env.REALTIME_APP_ID, token: env.REALTIME_APP_SECRET });
|
|
10
|
+
// }
|
|
11
|
+
const SFU_BASE = 'https://rtc.live.cloudflare.com/v1';
|
|
12
|
+
/** Forward a single SFU subpath (e.g. "/sessions/new"). */
|
|
13
|
+
export async function proxyRealtime(subpath, init, cfg) {
|
|
14
|
+
const base = cfg.sfuBase ?? SFU_BASE;
|
|
15
|
+
const res = await fetch(`${base}/apps/${cfg.appId}${subpath}`, {
|
|
16
|
+
method: init.method,
|
|
17
|
+
headers: { authorization: `Bearer ${cfg.token}`, 'content-type': 'application/json' },
|
|
18
|
+
body: init.body ?? undefined,
|
|
19
|
+
});
|
|
20
|
+
return new Response(res.body, {
|
|
21
|
+
status: res.status,
|
|
22
|
+
headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Handle a Worker `Request` whose path starts with `apiBasePath`. Only
|
|
27
|
+
* `/sessions...` subpaths are forwarded; everything else 404s.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleRealtimeProxy(request, apiBasePath, cfg) {
|
|
30
|
+
const sub = new URL(request.url).pathname.slice(apiBasePath.replace(/\/$/, '').length);
|
|
31
|
+
if (!sub.startsWith('/sessions'))
|
|
32
|
+
return new Response('Not found', { status: 404 });
|
|
33
|
+
const method = request.method.toUpperCase();
|
|
34
|
+
const body = method === 'GET' || method === 'HEAD' ? null : await request.text();
|
|
35
|
+
return proxyRealtime(sub, { method, body }, cfg);
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare class Emitter<Events extends Record<string, any>> {
|
|
2
|
+
#private;
|
|
3
|
+
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): () => void;
|
|
4
|
+
off<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void;
|
|
5
|
+
emit<K extends keyof Events>(event: K, payload: Events[K]): void;
|
|
6
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var _Emitter_handlers;
|
|
7
|
+
// Minimal typed event emitter — zero deps.
|
|
8
|
+
export class Emitter {
|
|
9
|
+
constructor() {
|
|
10
|
+
_Emitter_handlers.set(this, {});
|
|
11
|
+
}
|
|
12
|
+
on(event, handler) {
|
|
13
|
+
var _a;
|
|
14
|
+
((_a = __classPrivateFieldGet(this, _Emitter_handlers, "f"))[event] ?? (_a[event] = new Set())).add(handler);
|
|
15
|
+
return () => this.off(event, handler);
|
|
16
|
+
}
|
|
17
|
+
off(event, handler) {
|
|
18
|
+
__classPrivateFieldGet(this, _Emitter_handlers, "f")[event]?.delete(handler);
|
|
19
|
+
}
|
|
20
|
+
emit(event, payload) {
|
|
21
|
+
__classPrivateFieldGet(this, _Emitter_handlers, "f")[event]?.forEach((h) => {
|
|
22
|
+
try {
|
|
23
|
+
h(payload);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error('[voice-room] listener error', err);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
_Emitter_handlers = new WeakMap();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { VoiceRoom } from './voice-room.js';
|
|
2
|
+
import type { VoiceRoomOptions } from './types.js';
|
|
3
|
+
export { VoiceRoom } from './voice-room.js';
|
|
4
|
+
export type { Transport, VoiceRoomOptions, VoicePeer, VoiceStatus, VoiceRoomEvents, } from './types.js';
|
|
5
|
+
/** Create (but don't join) a voice room. Call `.join()` from a user gesture. */
|
|
6
|
+
export declare function createVoiceRoom(options: VoiceRoomOptions): VoiceRoom;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NewSessionResponse, SessionDescription, TrackObject, TracksResponse } from './types.js';
|
|
2
|
+
export declare class RealtimeApi {
|
|
3
|
+
#private;
|
|
4
|
+
private readonly apiBase;
|
|
5
|
+
constructor(apiBase: string);
|
|
6
|
+
newSession(): Promise<NewSessionResponse>;
|
|
7
|
+
newTracks(sessionId: string, body: {
|
|
8
|
+
tracks: TrackObject[];
|
|
9
|
+
sessionDescription?: SessionDescription;
|
|
10
|
+
}): Promise<TracksResponse>;
|
|
11
|
+
renegotiate(sessionId: string, sessionDescription: SessionDescription): Promise<unknown>;
|
|
12
|
+
closeTracks(sessionId: string, mids: string[], sessionDescription: SessionDescription): Promise<unknown>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
3
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
4
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var _RealtimeApi_instances, _RealtimeApi_req;
|
|
7
|
+
// Thin client for the Cloudflare Realtime SFU REST API, talking to YOUR proxy
|
|
8
|
+
// (which injects `apps/{appId}` + the bearer secret server-side). Paths mirror
|
|
9
|
+
// the SFU API: POST /sessions/new, POST /sessions/{id}/tracks/new,
|
|
10
|
+
// PUT /sessions/{id}/renegotiate, PUT /sessions/{id}/tracks/close.
|
|
11
|
+
export class RealtimeApi {
|
|
12
|
+
constructor(apiBase) {
|
|
13
|
+
_RealtimeApi_instances.add(this);
|
|
14
|
+
this.apiBase = apiBase;
|
|
15
|
+
}
|
|
16
|
+
newSession() {
|
|
17
|
+
return __classPrivateFieldGet(this, _RealtimeApi_instances, "m", _RealtimeApi_req).call(this, '/sessions/new', 'POST');
|
|
18
|
+
}
|
|
19
|
+
newTracks(sessionId, body) {
|
|
20
|
+
return __classPrivateFieldGet(this, _RealtimeApi_instances, "m", _RealtimeApi_req).call(this, `/sessions/${sessionId}/tracks/new`, 'POST', body);
|
|
21
|
+
}
|
|
22
|
+
renegotiate(sessionId, sessionDescription) {
|
|
23
|
+
return __classPrivateFieldGet(this, _RealtimeApi_instances, "m", _RealtimeApi_req).call(this, `/sessions/${sessionId}/renegotiate`, 'PUT', { sessionDescription });
|
|
24
|
+
}
|
|
25
|
+
closeTracks(sessionId, mids, sessionDescription) {
|
|
26
|
+
return __classPrivateFieldGet(this, _RealtimeApi_instances, "m", _RealtimeApi_req).call(this, `/sessions/${sessionId}/tracks/close`, 'PUT', {
|
|
27
|
+
tracks: mids.map((mid) => ({ mid })),
|
|
28
|
+
sessionDescription,
|
|
29
|
+
force: false,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
_RealtimeApi_instances = new WeakSet(), _RealtimeApi_req = async function _RealtimeApi_req(path, method, body) {
|
|
34
|
+
const res = await fetch(this.apiBase + path, {
|
|
35
|
+
method,
|
|
36
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
37
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(`Realtime ${method} ${path} → ${res.status} ${await safeText(res)}`);
|
|
41
|
+
const json = (res.status === 204 ? {} : await res.json());
|
|
42
|
+
if (json.errorCode)
|
|
43
|
+
throw new Error(`Realtime ${path}: ${json.errorCode} ${json.errorDescription ?? ''}`);
|
|
44
|
+
return json;
|
|
45
|
+
};
|
|
46
|
+
async function safeText(res) {
|
|
47
|
+
try {
|
|
48
|
+
return await res.text();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface SessionDescription {
|
|
2
|
+
type: 'offer' | 'answer';
|
|
3
|
+
sdp: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TrackObject {
|
|
6
|
+
location?: 'local' | 'remote';
|
|
7
|
+
trackName?: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
mid?: string | null;
|
|
10
|
+
errorCode?: string;
|
|
11
|
+
errorDescription?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface NewSessionResponse {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
sessionDescription?: SessionDescription;
|
|
16
|
+
errorCode?: string;
|
|
17
|
+
errorDescription?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface TracksResponse {
|
|
20
|
+
sessionDescription: SessionDescription;
|
|
21
|
+
requiresImmediateRenegotiation?: boolean;
|
|
22
|
+
tracks?: TrackObject[];
|
|
23
|
+
errorCode?: string;
|
|
24
|
+
errorDescription?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Transport {
|
|
27
|
+
send(message: unknown): void;
|
|
28
|
+
/** Register a handler for incoming messages. Returns an unsubscribe fn. */
|
|
29
|
+
onMessage(handler: (message: any) => void): () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface VoiceRoomOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Base URL of YOUR media proxy (a Worker route that forwards to the Realtime
|
|
34
|
+
* SFU with your app secret). The SDK POSTs `${apiBase}/sessions/new` etc.
|
|
35
|
+
* e.g. "/undermine/play/voice".
|
|
36
|
+
*/
|
|
37
|
+
apiBase: string;
|
|
38
|
+
/** Your signaling channel (game WebSocket, BroadcastChannel for local demos, …). */
|
|
39
|
+
transport: Transport;
|
|
40
|
+
/** Stable identity for this participant (e.g. the player id). */
|
|
41
|
+
localId: string;
|
|
42
|
+
/** Optional ICE servers (TURN). Cloudflare's SFU usually connects without TURN. */
|
|
43
|
+
iceServers?: RTCIceServer[];
|
|
44
|
+
/** Mic constraints. Defaults to echo cancellation + noise suppression on. */
|
|
45
|
+
audio?: MediaTrackConstraints | boolean;
|
|
46
|
+
/** Speaking-detection threshold on a 0..1 RMS scale (default 0.02). */
|
|
47
|
+
speakingThreshold?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface VoicePeer {
|
|
50
|
+
id: string;
|
|
51
|
+
/** True once their audio is flowing. */
|
|
52
|
+
connected: boolean;
|
|
53
|
+
speaking: boolean;
|
|
54
|
+
level: number;
|
|
55
|
+
muted: boolean;
|
|
56
|
+
}
|
|
57
|
+
export type VoiceStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
|
58
|
+
export interface VoiceRoomEvents {
|
|
59
|
+
status: VoiceStatus;
|
|
60
|
+
peers: VoicePeer[];
|
|
61
|
+
/** Frequent: per-participant audio level (includes your own id). */
|
|
62
|
+
level: {
|
|
63
|
+
id: string;
|
|
64
|
+
level: number;
|
|
65
|
+
speaking: boolean;
|
|
66
|
+
};
|
|
67
|
+
error: Error;
|
|
68
|
+
/** Browser blocked audio autoplay — call resumeAudio() from a user gesture. */
|
|
69
|
+
needsgesture: void;
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Emitter } from './emitter.js';
|
|
2
|
+
import type { VoicePeer, VoiceRoomEvents, VoiceRoomOptions, VoiceStatus } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A voice room over the Cloudflare Realtime SFU. One peer connection per client;
|
|
5
|
+
* you push your mic once and pull everyone else. Signaling (who's here + their
|
|
6
|
+
* track ids) rides on YOUR transport — this class never relays SDP between peers.
|
|
7
|
+
*/
|
|
8
|
+
export declare class VoiceRoom extends Emitter<VoiceRoomEvents> {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(opts: VoiceRoomOptions);
|
|
11
|
+
get status(): VoiceStatus;
|
|
12
|
+
get muted(): boolean;
|
|
13
|
+
get peers(): VoicePeer[];
|
|
14
|
+
join(): Promise<void>;
|
|
15
|
+
setMuted(muted: boolean): void;
|
|
16
|
+
/** Drop a peer (e.g. they left/disconnected from your app). Safe if unknown. */
|
|
17
|
+
removePeer(id: string): void;
|
|
18
|
+
/** Call from a user gesture if `needsgesture` fired (autoplay was blocked). */
|
|
19
|
+
resumeAudio(): void;
|
|
20
|
+
leave(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var _VoiceRoom_instances, _VoiceRoom_api, _VoiceRoom_opts, _VoiceRoom_peers, _VoiceRoom_midToPeer, _VoiceRoom_transport, _VoiceRoom_unsub, _VoiceRoom_pc, _VoiceRoom_sessionId, _VoiceRoom_localStream, _VoiceRoom_localTrack, _VoiceRoom_audioCtx, _VoiceRoom_localAnalyser, _VoiceRoom_levelTimer, _VoiceRoom_disconnectTimer, _VoiceRoom_chain, _VoiceRoom_status, _VoiceRoom_muted, _VoiceRoom_destroyed, _VoiceRoom_reconnecting, _VoiceRoom_pendingAutoplay, _VoiceRoom_connect, _VoiceRoom_reconnect, _VoiceRoom_maybePull, _VoiceRoom_pull, _VoiceRoom_queue, _VoiceRoom_onMessage, _VoiceRoom_send, _VoiceRoom_attachRemote, _VoiceRoom_detachRemote, _VoiceRoom_setupLevels, _VoiceRoom_makeAnalyser, _VoiceRoom_sampleLevels, _VoiceRoom_peer, _VoiceRoom_snapshot, _VoiceRoom_emitPeers, _VoiceRoom_setStatus, _VoiceRoom_armDisconnectTimer, _VoiceRoom_clearDisconnectTimer, _VoiceRoom_teardown;
|
|
13
|
+
import { Emitter } from './emitter.js';
|
|
14
|
+
import { RealtimeApi } from './realtime.js';
|
|
15
|
+
const TAG = '__vr'; // namespaces our roster messages on the shared transport
|
|
16
|
+
const LOCAL_TRACK = 'mic';
|
|
17
|
+
/**
|
|
18
|
+
* A voice room over the Cloudflare Realtime SFU. One peer connection per client;
|
|
19
|
+
* you push your mic once and pull everyone else. Signaling (who's here + their
|
|
20
|
+
* track ids) rides on YOUR transport — this class never relays SDP between peers.
|
|
21
|
+
*/
|
|
22
|
+
export class VoiceRoom extends Emitter {
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
super();
|
|
25
|
+
_VoiceRoom_instances.add(this);
|
|
26
|
+
_VoiceRoom_api.set(this, void 0);
|
|
27
|
+
_VoiceRoom_opts.set(this, void 0);
|
|
28
|
+
_VoiceRoom_peers.set(this, new Map());
|
|
29
|
+
_VoiceRoom_midToPeer.set(this, new Map());
|
|
30
|
+
_VoiceRoom_transport.set(this, void 0);
|
|
31
|
+
_VoiceRoom_unsub.set(this, void 0);
|
|
32
|
+
_VoiceRoom_pc.set(this, void 0);
|
|
33
|
+
_VoiceRoom_sessionId.set(this, void 0);
|
|
34
|
+
_VoiceRoom_localStream.set(this, void 0);
|
|
35
|
+
_VoiceRoom_localTrack.set(this, void 0);
|
|
36
|
+
_VoiceRoom_audioCtx.set(this, void 0);
|
|
37
|
+
_VoiceRoom_localAnalyser.set(this, void 0);
|
|
38
|
+
_VoiceRoom_levelTimer.set(this, void 0);
|
|
39
|
+
_VoiceRoom_disconnectTimer.set(this, void 0);
|
|
40
|
+
_VoiceRoom_chain.set(this, Promise.resolve());
|
|
41
|
+
_VoiceRoom_status.set(this, 'idle');
|
|
42
|
+
_VoiceRoom_muted.set(this, false);
|
|
43
|
+
_VoiceRoom_destroyed.set(this, false);
|
|
44
|
+
_VoiceRoom_reconnecting.set(this, false);
|
|
45
|
+
_VoiceRoom_pendingAutoplay.set(this, []);
|
|
46
|
+
__classPrivateFieldSet(this, _VoiceRoom_opts, { speakingThreshold: 0.02, ...opts }, "f");
|
|
47
|
+
__classPrivateFieldSet(this, _VoiceRoom_api, new RealtimeApi(opts.apiBase.replace(/\/$/, '')), "f");
|
|
48
|
+
__classPrivateFieldSet(this, _VoiceRoom_transport, opts.transport, "f");
|
|
49
|
+
}
|
|
50
|
+
get status() { return __classPrivateFieldGet(this, _VoiceRoom_status, "f"); }
|
|
51
|
+
get muted() { return __classPrivateFieldGet(this, _VoiceRoom_muted, "f"); }
|
|
52
|
+
get peers() { return __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_snapshot).call(this); }
|
|
53
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
54
|
+
async join() {
|
|
55
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_status, "f") !== 'idle' && __classPrivateFieldGet(this, _VoiceRoom_status, "f") !== 'error')
|
|
56
|
+
return;
|
|
57
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'connecting');
|
|
58
|
+
__classPrivateFieldSet(this, _VoiceRoom_unsub, __classPrivateFieldGet(this, _VoiceRoom_transport, "f").onMessage((m) => __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_onMessage).call(this, m)), "f");
|
|
59
|
+
try {
|
|
60
|
+
__classPrivateFieldSet(this, _VoiceRoom_localStream, await navigator.mediaDevices.getUserMedia({ audio: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").audio ?? defaultAudio() }), "f");
|
|
61
|
+
__classPrivateFieldSet(this, _VoiceRoom_localTrack, __classPrivateFieldGet(this, _VoiceRoom_localStream, "f").getAudioTracks()[0], "f");
|
|
62
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f"))
|
|
63
|
+
__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f").enabled = !__classPrivateFieldGet(this, _VoiceRoom_muted, "f");
|
|
64
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setupLevels).call(this);
|
|
65
|
+
await __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_connect).call(this);
|
|
66
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'connected');
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'error');
|
|
70
|
+
this.emit('error', err);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
setMuted(muted) {
|
|
75
|
+
__classPrivateFieldSet(this, _VoiceRoom_muted, muted, "f");
|
|
76
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f"))
|
|
77
|
+
__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f").enabled = !muted;
|
|
78
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_send).call(this, { [TAG]: 1, kind: 'mute', id: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId, muted });
|
|
79
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
80
|
+
}
|
|
81
|
+
/** Drop a peer (e.g. they left/disconnected from your app). Safe if unknown. */
|
|
82
|
+
removePeer(id) {
|
|
83
|
+
const p = __classPrivateFieldGet(this, _VoiceRoom_peers, "f").get(id);
|
|
84
|
+
if (!p)
|
|
85
|
+
return;
|
|
86
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_detachRemote).call(this, p);
|
|
87
|
+
__classPrivateFieldGet(this, _VoiceRoom_peers, "f").delete(id);
|
|
88
|
+
for (const [mid, pid] of __classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f"))
|
|
89
|
+
if (pid === id)
|
|
90
|
+
__classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f").delete(mid);
|
|
91
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
92
|
+
}
|
|
93
|
+
/** Call from a user gesture if `needsgesture` fired (autoplay was blocked). */
|
|
94
|
+
resumeAudio() {
|
|
95
|
+
__classPrivateFieldGet(this, _VoiceRoom_audioCtx, "f")?.resume().catch(() => { });
|
|
96
|
+
const pending = __classPrivateFieldGet(this, _VoiceRoom_pendingAutoplay, "f");
|
|
97
|
+
__classPrivateFieldSet(this, _VoiceRoom_pendingAutoplay, [], "f");
|
|
98
|
+
for (const el of pending)
|
|
99
|
+
el.play().catch(() => __classPrivateFieldGet(this, _VoiceRoom_pendingAutoplay, "f").push(el));
|
|
100
|
+
}
|
|
101
|
+
async leave() {
|
|
102
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_destroyed, "f"))
|
|
103
|
+
return;
|
|
104
|
+
__classPrivateFieldSet(this, _VoiceRoom_destroyed, true, "f");
|
|
105
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_send).call(this, { [TAG]: 1, kind: 'leave', id: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId });
|
|
106
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_teardown).call(this);
|
|
107
|
+
__classPrivateFieldGet(this, _VoiceRoom_unsub, "f")?.call(this);
|
|
108
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'idle');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
_VoiceRoom_api = new WeakMap(), _VoiceRoom_opts = new WeakMap(), _VoiceRoom_peers = new WeakMap(), _VoiceRoom_midToPeer = new WeakMap(), _VoiceRoom_transport = new WeakMap(), _VoiceRoom_unsub = new WeakMap(), _VoiceRoom_pc = new WeakMap(), _VoiceRoom_sessionId = new WeakMap(), _VoiceRoom_localStream = new WeakMap(), _VoiceRoom_localTrack = new WeakMap(), _VoiceRoom_audioCtx = new WeakMap(), _VoiceRoom_localAnalyser = new WeakMap(), _VoiceRoom_levelTimer = new WeakMap(), _VoiceRoom_disconnectTimer = new WeakMap(), _VoiceRoom_chain = new WeakMap(), _VoiceRoom_status = new WeakMap(), _VoiceRoom_muted = new WeakMap(), _VoiceRoom_destroyed = new WeakMap(), _VoiceRoom_reconnecting = new WeakMap(), _VoiceRoom_pendingAutoplay = new WeakMap(), _VoiceRoom_instances = new WeakSet(), _VoiceRoom_connect =
|
|
112
|
+
// ── Connection lifecycle ──────────────────────────────────────────────────────
|
|
113
|
+
async function _VoiceRoom_connect() {
|
|
114
|
+
const pc = new RTCPeerConnection({ iceServers: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").iceServers ?? [] });
|
|
115
|
+
__classPrivateFieldSet(this, _VoiceRoom_pc, pc, "f");
|
|
116
|
+
pc.ontrack = (e) => {
|
|
117
|
+
const peerId = e.transceiver.mid ? __classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f").get(e.transceiver.mid) : undefined;
|
|
118
|
+
if (peerId)
|
|
119
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_attachRemote).call(this, peerId, e.track);
|
|
120
|
+
};
|
|
121
|
+
pc.onconnectionstatechange = () => {
|
|
122
|
+
const s = pc.connectionState;
|
|
123
|
+
if (s === 'connected') {
|
|
124
|
+
__classPrivateFieldSet(this, _VoiceRoom_reconnecting, false, "f");
|
|
125
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'connected');
|
|
126
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_clearDisconnectTimer).call(this);
|
|
127
|
+
}
|
|
128
|
+
else if (s === 'failed') {
|
|
129
|
+
void __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_reconnect).call(this);
|
|
130
|
+
}
|
|
131
|
+
else if (s === 'disconnected') {
|
|
132
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_armDisconnectTimer).call(this);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const { sessionId } = await __classPrivateFieldGet(this, _VoiceRoom_api, "f").newSession();
|
|
136
|
+
__classPrivateFieldSet(this, _VoiceRoom_sessionId, sessionId, "f");
|
|
137
|
+
// push our mic (one sendonly transceiver)
|
|
138
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f")) {
|
|
139
|
+
const tx = pc.addTransceiver(__classPrivateFieldGet(this, _VoiceRoom_localTrack, "f"), { direction: 'sendonly' });
|
|
140
|
+
const offer = await pc.createOffer();
|
|
141
|
+
await pc.setLocalDescription(offer);
|
|
142
|
+
const res = await __classPrivateFieldGet(this, _VoiceRoom_api, "f").newTracks(sessionId, {
|
|
143
|
+
sessionDescription: { type: 'offer', sdp: offer.sdp },
|
|
144
|
+
tracks: [{ location: 'local', mid: tx.mid, trackName: LOCAL_TRACK }],
|
|
145
|
+
});
|
|
146
|
+
await pc.setRemoteDescription(res.sessionDescription);
|
|
147
|
+
}
|
|
148
|
+
// announce ourselves and ask existing peers to (re-)announce so we can pull them
|
|
149
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_send).call(this, { [TAG]: 1, kind: 'join', id: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId, sessionId, trackName: LOCAL_TRACK, muted: __classPrivateFieldGet(this, _VoiceRoom_muted, "f") });
|
|
150
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_send).call(this, { [TAG]: 1, kind: 'hello', id: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId });
|
|
151
|
+
// pull anyone we already learned about
|
|
152
|
+
for (const p of __classPrivateFieldGet(this, _VoiceRoom_peers, "f").values())
|
|
153
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_maybePull).call(this, p);
|
|
154
|
+
}, _VoiceRoom_reconnect = async function _VoiceRoom_reconnect() {
|
|
155
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_destroyed, "f") || __classPrivateFieldGet(this, _VoiceRoom_reconnecting, "f"))
|
|
156
|
+
return;
|
|
157
|
+
__classPrivateFieldSet(this, _VoiceRoom_reconnecting, true, "f");
|
|
158
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_setStatus).call(this, 'reconnecting');
|
|
159
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_clearDisconnectTimer).call(this);
|
|
160
|
+
try {
|
|
161
|
+
__classPrivateFieldGet(this, _VoiceRoom_pc, "f")?.close();
|
|
162
|
+
}
|
|
163
|
+
catch { /* noop */ }
|
|
164
|
+
__classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f").clear();
|
|
165
|
+
for (const p of __classPrivateFieldGet(this, _VoiceRoom_peers, "f").values()) {
|
|
166
|
+
p.connected = false;
|
|
167
|
+
p.pulledFrom = undefined;
|
|
168
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_detachRemote).call(this, p);
|
|
169
|
+
}
|
|
170
|
+
// small backoff before re-establishing
|
|
171
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
172
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_destroyed, "f"))
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
await __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_connect).call(this);
|
|
176
|
+
__classPrivateFieldSet(this, _VoiceRoom_reconnecting, false, "f");
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
this.emit('error', err);
|
|
180
|
+
// try again shortly
|
|
181
|
+
setTimeout(() => { __classPrivateFieldSet(this, _VoiceRoom_reconnecting, false, "f"); void __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_reconnect).call(this); }, 2000);
|
|
182
|
+
}
|
|
183
|
+
}, _VoiceRoom_maybePull = function _VoiceRoom_maybePull(p) {
|
|
184
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_destroyed, "f") || !__classPrivateFieldGet(this, _VoiceRoom_pc, "f") || !__classPrivateFieldGet(this, _VoiceRoom_sessionId, "f"))
|
|
185
|
+
return;
|
|
186
|
+
if (p.id === __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId)
|
|
187
|
+
return;
|
|
188
|
+
if (!p.sessionId || !p.trackName)
|
|
189
|
+
return;
|
|
190
|
+
if (p.pulledFrom === p.sessionId)
|
|
191
|
+
return; // already pulling this session
|
|
192
|
+
p.pulledFrom = p.sessionId;
|
|
193
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_queue).call(this, () => __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_pull).call(this, p)).catch((err) => {
|
|
194
|
+
p.pulledFrom = undefined; // allow retry
|
|
195
|
+
this.emit('error', err);
|
|
196
|
+
});
|
|
197
|
+
}, _VoiceRoom_pull = async function _VoiceRoom_pull(p) {
|
|
198
|
+
const pc = __classPrivateFieldGet(this, _VoiceRoom_pc, "f");
|
|
199
|
+
if (!pc || !__classPrivateFieldGet(this, _VoiceRoom_sessionId, "f") || !p.sessionId || !p.trackName)
|
|
200
|
+
return;
|
|
201
|
+
const res = await __classPrivateFieldGet(this, _VoiceRoom_api, "f").newTracks(__classPrivateFieldGet(this, _VoiceRoom_sessionId, "f"), {
|
|
202
|
+
tracks: [{ location: 'remote', sessionId: p.sessionId, trackName: p.trackName }],
|
|
203
|
+
});
|
|
204
|
+
const mid = res.tracks?.[0]?.mid;
|
|
205
|
+
if (mid)
|
|
206
|
+
__classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f").set(mid, p.id);
|
|
207
|
+
if (res.requiresImmediateRenegotiation) {
|
|
208
|
+
await pc.setRemoteDescription(res.sessionDescription); // server offer
|
|
209
|
+
const answer = await pc.createAnswer();
|
|
210
|
+
await pc.setLocalDescription(answer);
|
|
211
|
+
await __classPrivateFieldGet(this, _VoiceRoom_api, "f").renegotiate(__classPrivateFieldGet(this, _VoiceRoom_sessionId, "f"), { type: 'answer', sdp: answer.sdp });
|
|
212
|
+
}
|
|
213
|
+
}, _VoiceRoom_queue = function _VoiceRoom_queue(task) {
|
|
214
|
+
const run = __classPrivateFieldGet(this, _VoiceRoom_chain, "f").then(task, task);
|
|
215
|
+
__classPrivateFieldSet(this, _VoiceRoom_chain, run.then(() => { }, () => { }), "f");
|
|
216
|
+
return run;
|
|
217
|
+
}, _VoiceRoom_onMessage = function _VoiceRoom_onMessage(m) {
|
|
218
|
+
if (!m || m[TAG] !== 1 || __classPrivateFieldGet(this, _VoiceRoom_destroyed, "f"))
|
|
219
|
+
return;
|
|
220
|
+
const msg = m;
|
|
221
|
+
if (msg.kind !== 'hello' && msg.id === __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId)
|
|
222
|
+
return; // ignore our own echoes
|
|
223
|
+
switch (msg.kind) {
|
|
224
|
+
case 'join': {
|
|
225
|
+
const p = __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_peer).call(this, msg.id);
|
|
226
|
+
p.sessionId = msg.sessionId;
|
|
227
|
+
p.trackName = msg.trackName;
|
|
228
|
+
p.muted = msg.muted;
|
|
229
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_maybePull).call(this, p);
|
|
230
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'hello': {
|
|
234
|
+
if (msg.id === __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId)
|
|
235
|
+
break;
|
|
236
|
+
// a newcomer asked everyone to announce — re-broadcast our info
|
|
237
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_sessionId, "f")) {
|
|
238
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_send).call(this, { [TAG]: 1, kind: 'join', id: __classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId, sessionId: __classPrivateFieldGet(this, _VoiceRoom_sessionId, "f"), trackName: LOCAL_TRACK, muted: __classPrivateFieldGet(this, _VoiceRoom_muted, "f") });
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'mute': {
|
|
243
|
+
const p = __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_peer).call(this, msg.id);
|
|
244
|
+
p.muted = msg.muted;
|
|
245
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'leave': {
|
|
249
|
+
const p = __classPrivateFieldGet(this, _VoiceRoom_peers, "f").get(msg.id);
|
|
250
|
+
if (p) {
|
|
251
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_detachRemote).call(this, p);
|
|
252
|
+
__classPrivateFieldGet(this, _VoiceRoom_peers, "f").delete(msg.id);
|
|
253
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}, _VoiceRoom_send = function _VoiceRoom_send(msg) { try {
|
|
259
|
+
__classPrivateFieldGet(this, _VoiceRoom_transport, "f").send(msg);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error('[voice-room] transport send failed', err);
|
|
263
|
+
} }, _VoiceRoom_attachRemote = function _VoiceRoom_attachRemote(peerId, track) {
|
|
264
|
+
const p = __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_peer).call(this, peerId);
|
|
265
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_detachRemote).call(this, p);
|
|
266
|
+
const stream = new MediaStream([track]);
|
|
267
|
+
const el = document.createElement('audio');
|
|
268
|
+
el.autoplay = true;
|
|
269
|
+
el.srcObject = stream;
|
|
270
|
+
el.playsInline = true;
|
|
271
|
+
el.style.display = 'none';
|
|
272
|
+
document.body.appendChild(el);
|
|
273
|
+
el.play().catch(() => { __classPrivateFieldGet(this, _VoiceRoom_pendingAutoplay, "f").push(el); this.emit('needsgesture', undefined); });
|
|
274
|
+
p.audio = el;
|
|
275
|
+
p.connected = true;
|
|
276
|
+
p.analyser = __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_makeAnalyser).call(this, stream);
|
|
277
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
278
|
+
}, _VoiceRoom_detachRemote = function _VoiceRoom_detachRemote(p) {
|
|
279
|
+
if (p.audio) {
|
|
280
|
+
try {
|
|
281
|
+
p.audio.srcObject = null;
|
|
282
|
+
p.audio.remove();
|
|
283
|
+
}
|
|
284
|
+
catch { /* noop */ }
|
|
285
|
+
p.audio = undefined;
|
|
286
|
+
}
|
|
287
|
+
p.analyser = undefined;
|
|
288
|
+
}, _VoiceRoom_setupLevels = function _VoiceRoom_setupLevels() {
|
|
289
|
+
try {
|
|
290
|
+
__classPrivateFieldSet(this, _VoiceRoom_audioCtx, new (window.AudioContext || window.webkitAudioContext)(), "f");
|
|
291
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_localStream, "f"))
|
|
292
|
+
__classPrivateFieldSet(this, _VoiceRoom_localAnalyser, __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_makeAnalyser).call(this, __classPrivateFieldGet(this, _VoiceRoom_localStream, "f")), "f");
|
|
293
|
+
const buf = new Uint8Array(1024);
|
|
294
|
+
__classPrivateFieldSet(this, _VoiceRoom_levelTimer, setInterval(() => __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_sampleLevels).call(this, buf), 120), "f");
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
console.warn('[voice-room] level metering unavailable', err);
|
|
298
|
+
}
|
|
299
|
+
}, _VoiceRoom_makeAnalyser = function _VoiceRoom_makeAnalyser(stream) {
|
|
300
|
+
if (!__classPrivateFieldGet(this, _VoiceRoom_audioCtx, "f"))
|
|
301
|
+
return undefined;
|
|
302
|
+
try {
|
|
303
|
+
const src = __classPrivateFieldGet(this, _VoiceRoom_audioCtx, "f").createMediaStreamSource(stream);
|
|
304
|
+
const an = __classPrivateFieldGet(this, _VoiceRoom_audioCtx, "f").createAnalyser();
|
|
305
|
+
an.fftSize = 256;
|
|
306
|
+
src.connect(an);
|
|
307
|
+
return an;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}, _VoiceRoom_sampleLevels = function _VoiceRoom_sampleLevels(buf) {
|
|
313
|
+
const report = (id, an, rec) => {
|
|
314
|
+
if (!an)
|
|
315
|
+
return;
|
|
316
|
+
an.getByteTimeDomainData(buf);
|
|
317
|
+
let sum = 0;
|
|
318
|
+
for (let i = 0; i < an.fftSize; i++) {
|
|
319
|
+
const v = (buf[i] - 128) / 128;
|
|
320
|
+
sum += v * v;
|
|
321
|
+
}
|
|
322
|
+
const level = Math.min(1, Math.sqrt(sum / an.fftSize) * 3);
|
|
323
|
+
const speaking = level >= __classPrivateFieldGet(this, _VoiceRoom_opts, "f").speakingThreshold && !(rec ? rec.muted : __classPrivateFieldGet(this, _VoiceRoom_muted, "f"));
|
|
324
|
+
if (rec) {
|
|
325
|
+
const changed = rec.speaking !== speaking;
|
|
326
|
+
rec.level = level;
|
|
327
|
+
rec.speaking = speaking;
|
|
328
|
+
if (changed)
|
|
329
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_emitPeers).call(this);
|
|
330
|
+
}
|
|
331
|
+
this.emit('level', { id, level, speaking });
|
|
332
|
+
};
|
|
333
|
+
report(__classPrivateFieldGet(this, _VoiceRoom_opts, "f").localId, __classPrivateFieldGet(this, _VoiceRoom_localAnalyser, "f"));
|
|
334
|
+
for (const p of __classPrivateFieldGet(this, _VoiceRoom_peers, "f").values())
|
|
335
|
+
report(p.id, p.analyser, p);
|
|
336
|
+
}, _VoiceRoom_peer = function _VoiceRoom_peer(id) {
|
|
337
|
+
let p = __classPrivateFieldGet(this, _VoiceRoom_peers, "f").get(id);
|
|
338
|
+
if (!p) {
|
|
339
|
+
p = { id, connected: false, speaking: false, level: 0, muted: false };
|
|
340
|
+
__classPrivateFieldGet(this, _VoiceRoom_peers, "f").set(id, p);
|
|
341
|
+
}
|
|
342
|
+
return p;
|
|
343
|
+
}, _VoiceRoom_snapshot = function _VoiceRoom_snapshot() {
|
|
344
|
+
return [...__classPrivateFieldGet(this, _VoiceRoom_peers, "f").values()].map((p) => ({ id: p.id, connected: p.connected, speaking: p.speaking, level: p.level, muted: p.muted }));
|
|
345
|
+
}, _VoiceRoom_emitPeers = function _VoiceRoom_emitPeers() { this.emit('peers', __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_snapshot).call(this)); }, _VoiceRoom_setStatus = function _VoiceRoom_setStatus(s) { if (__classPrivateFieldGet(this, _VoiceRoom_status, "f") !== s) {
|
|
346
|
+
__classPrivateFieldSet(this, _VoiceRoom_status, s, "f");
|
|
347
|
+
this.emit('status', s);
|
|
348
|
+
} }, _VoiceRoom_armDisconnectTimer = function _VoiceRoom_armDisconnectTimer() {
|
|
349
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_clearDisconnectTimer).call(this);
|
|
350
|
+
__classPrivateFieldSet(this, _VoiceRoom_disconnectTimer, setTimeout(() => { if (__classPrivateFieldGet(this, _VoiceRoom_pc, "f")?.connectionState !== 'connected')
|
|
351
|
+
void __classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_reconnect).call(this); }, 6000), "f");
|
|
352
|
+
}, _VoiceRoom_clearDisconnectTimer = function _VoiceRoom_clearDisconnectTimer() { if (__classPrivateFieldGet(this, _VoiceRoom_disconnectTimer, "f")) {
|
|
353
|
+
clearTimeout(__classPrivateFieldGet(this, _VoiceRoom_disconnectTimer, "f"));
|
|
354
|
+
__classPrivateFieldSet(this, _VoiceRoom_disconnectTimer, undefined, "f");
|
|
355
|
+
} }, _VoiceRoom_teardown = function _VoiceRoom_teardown() {
|
|
356
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_clearDisconnectTimer).call(this);
|
|
357
|
+
if (__classPrivateFieldGet(this, _VoiceRoom_levelTimer, "f"))
|
|
358
|
+
clearInterval(__classPrivateFieldGet(this, _VoiceRoom_levelTimer, "f"));
|
|
359
|
+
for (const p of __classPrivateFieldGet(this, _VoiceRoom_peers, "f").values())
|
|
360
|
+
__classPrivateFieldGet(this, _VoiceRoom_instances, "m", _VoiceRoom_detachRemote).call(this, p);
|
|
361
|
+
__classPrivateFieldGet(this, _VoiceRoom_peers, "f").clear();
|
|
362
|
+
__classPrivateFieldGet(this, _VoiceRoom_midToPeer, "f").clear();
|
|
363
|
+
try {
|
|
364
|
+
__classPrivateFieldGet(this, _VoiceRoom_pc, "f")?.close();
|
|
365
|
+
}
|
|
366
|
+
catch { /* noop */ }
|
|
367
|
+
__classPrivateFieldGet(this, _VoiceRoom_localStream, "f")?.getTracks().forEach((t) => t.stop());
|
|
368
|
+
__classPrivateFieldGet(this, _VoiceRoom_audioCtx, "f")?.close().catch(() => { });
|
|
369
|
+
__classPrivateFieldSet(this, _VoiceRoom_pc, undefined, "f");
|
|
370
|
+
__classPrivateFieldSet(this, _VoiceRoom_localStream, undefined, "f");
|
|
371
|
+
__classPrivateFieldSet(this, _VoiceRoom_localTrack, undefined, "f");
|
|
372
|
+
__classPrivateFieldSet(this, _VoiceRoom_audioCtx, undefined, "f");
|
|
373
|
+
__classPrivateFieldSet(this, _VoiceRoom_localAnalyser, undefined, "f");
|
|
374
|
+
};
|
|
375
|
+
function defaultAudio() {
|
|
376
|
+
return { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
|
377
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@playninja/voice-room",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny, transport-agnostic voice/audio rooms on top of the Cloudflare Realtime SFU. Bring your own signaling.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/src/index.js",
|
|
7
|
+
"module": "./dist/src/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" },
|
|
11
|
+
"./server": { "types": "./dist/server/proxy.d.ts", "import": "./dist/server/proxy.js" }
|
|
12
|
+
},
|
|
13
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
14
|
+
"publishConfig": { "access": "public" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"build:demo": "esbuild src/index.ts --bundle --format=esm --outfile=demo/public/voice-room.js",
|
|
19
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
20
|
+
"demo": "npm run build:demo && wrangler dev --config demo/wrangler.jsonc"
|
|
21
|
+
},
|
|
22
|
+
"keywords": ["webrtc", "voice", "audio", "cloudflare", "realtime", "sfu", "calls", "voice-chat", "game"],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "PlayNinja",
|
|
25
|
+
"repository": { "type": "git", "url": "https://github.com/sanjeev0291/voice-room" },
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"esbuild": "^0.23.0",
|
|
28
|
+
"typescript": "^5.5.0"
|
|
29
|
+
}
|
|
30
|
+
}
|