@broberg/call 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @broberg/call
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Voice calls for 2–10 people, like a Slack huddle: the app decides who may enter a room, the audio goes through a self-hosted LiveKit media server (SFU) in the EU, and the browser gets mute, output mute, device choice, per-person volume, who-is-speaking and the participant list. Audio only in 0.1.0. Epic F104.
4
+
5
+ ```bash
6
+ npm i @broberg/call
7
+ ```
8
+
9
+ > **Renamed from `@broberg/voice` (F104.6).** `@broberg/call` 0.1.0 is `@broberg/voice` 0.2.0 under a new name — same API, same server. Moving over means changing the import path only: `@broberg/voice/server` → `@broberg/call/server`, `@broberg/voice/client` → `@broberg/call/client`. Camera and screen share come in 0.2.0 (F104.7); recording is planned (F104.9).
10
+ >
11
+ > **Server:** the shared LiveKit media server `broberg-voice` (Fly `arn`, voice.broberg.ai, F104.2) with TURN over TLS on 443 in `broberg-voice-turn` (F104.5); both owned by components.
12
+
13
+ ## Server — `@broberg/call/server`
14
+
15
+ ```ts
16
+ import { createRoomToken, verifyWebhook, VoiceError } from "@broberg/call/server";
17
+
18
+ // POST /jam/:id/voice-token — check the person's access FIRST, then:
19
+ const token = await createRoomToken({
20
+ apiKey: process.env.LIVEKIT_API_KEY!,
21
+ apiSecret: process.env.LIVEKIT_API_SECRET!,
22
+ room: `jam:${id}`, // one room per ticket
23
+ identity: user.id, // the same person joining twice replaces the first connection
24
+ name: user.name,
25
+ // ttl: 600 // seconds the ticket can be used to ENTER (default 600, max 3600)
26
+ });
27
+
28
+ // POST /voice/webhook — join/leave from the media server. Pass the RAW body:
29
+ const event = await verifyWebhook(await req.text(), req.headers.get("authorization"), { apiKey, apiSecret });
30
+ if (event.event === "participant_joined") { /* … */ }
31
+ ```
32
+
33
+ - **Listen-only (0.2.0):** `createRoomToken({ …, canPublish: false })` gives a ticket that can join and hear everyone but publish nothing — pair it with `joinVoice({ url, token, publish: false })`, which never opens the microphone (`call.muted` is `true`; `unmute()` would be refused by the server).
34
+ - The ticket admits to **one room**, may **only publish the microphone** (no camera, screen or data channel), and may listen to everyone in the room.
35
+ - Missing `apiKey`/`apiSecret` is refused (`VoiceError` `missing_credentials`) — LiveKit's SDK would otherwise silently fall back to `LIVEKIT_*` from the environment.
36
+ - Every rejection of a webhook (changed body, wrong secret, missing header) is `VoiceError` `invalid_webhook`.
37
+
38
+ ## Browser — `@broberg/call/client`
39
+
40
+ No `node:` imports. Call `joinVoice` from a click: browsers only let audio play after a user gesture.
41
+
42
+ ```ts
43
+ import { joinVoice } from "@broberg/call/client";
44
+
45
+ const call = await joinVoice({ url: "wss://voice.broberg.ai", token });
46
+
47
+ call.onParticipants((people) => render(people)); // [{ identity, name, isLocal }], at once and on every join/leave
48
+ call.onSpeaking((ids) => highlight(ids)); // who is speaking now, loudest first
49
+ await call.mute(); await call.unmute(); call.muted; // your microphone
50
+ call.setOutputMuted(true); // silence everyone else …
51
+ call.setVolume(identity, 0.5); // … each person's own volume is kept and comes back on unmute
52
+ await call.setInputDevice(deviceId); // from navigator.mediaDevices.enumerateDevices()
53
+ await call.setOutputDevice(deviceId);
54
+ await call.leave(); // disconnects; no callback fires after this
55
+ ```
56
+
57
+ - The microphone opens with **noise suppression, echo cancellation and auto gain** on.
58
+ - If joining fails — the microphone is refused, the ticket is rejected — `joinVoice` rejects AND disconnects first, so nobody is left in the room listening without a way to leave.
59
+ - Other people's audio is attached to hidden `<audio>` elements and removed when they leave.
60
+ - Reconnection after a network drop (ICE restart, resume) is LiveKit's own.
61
+ - The package draws no UI. If you build one, give every control a `data-testid`.
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ var livekitClient = require('livekit-client');
4
+
5
+ // src/client.ts
6
+ var AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true };
7
+ async function joinVoice({ url, token, publish = true }) {
8
+ const room = new livekitClient.Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });
9
+ const speakingCbs = /* @__PURE__ */ new Set();
10
+ const participantCbs = /* @__PURE__ */ new Set();
11
+ const volumes = /* @__PURE__ */ new Map();
12
+ const audioEls = /* @__PURE__ */ new Map();
13
+ let outputMuted = false;
14
+ let muted = !publish;
15
+ let left = false;
16
+ const participants = () => [
17
+ toVoiceParticipant(room.localParticipant, true),
18
+ ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false))
19
+ ];
20
+ const emitParticipants = () => {
21
+ if (left) return;
22
+ const list = participants();
23
+ participantCbs.forEach((cb) => cb(list));
24
+ };
25
+ const applyVolume = (p) => p.setVolume(outputMuted ? 0 : volumes.get(p.identity) ?? 1);
26
+ const handlers = [
27
+ [livekitClient.RoomEvent.ParticipantConnected, (p) => {
28
+ applyVolume(p);
29
+ emitParticipants();
30
+ }],
31
+ [livekitClient.RoomEvent.ParticipantDisconnected, () => emitParticipants()],
32
+ [livekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
33
+ if (left) return;
34
+ const ids = speakers.map((s) => s.identity);
35
+ speakingCbs.forEach((cb) => cb(ids));
36
+ }],
37
+ [livekitClient.RoomEvent.TrackSubscribed, (track, _pub, p) => {
38
+ if (track.kind !== livekitClient.Track.Kind.Audio) return;
39
+ const el = track.attach();
40
+ document.body.appendChild(el);
41
+ audioEls.set(track, [el]);
42
+ applyVolume(p);
43
+ }],
44
+ [livekitClient.RoomEvent.TrackUnsubscribed, (track) => {
45
+ track.detach().forEach((el) => el.remove());
46
+ audioEls.delete(track);
47
+ }]
48
+ ];
49
+ for (const [event, fn] of handlers) room.on(event, fn);
50
+ try {
51
+ await room.connect(url, token);
52
+ if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });
53
+ await room.startAudio();
54
+ } catch (e) {
55
+ left = true;
56
+ for (const [event, fn] of handlers) room.off(event, fn);
57
+ await room.disconnect();
58
+ throw e;
59
+ }
60
+ return {
61
+ get muted() {
62
+ return muted;
63
+ },
64
+ async mute() {
65
+ await room.localParticipant.setMicrophoneEnabled(false);
66
+ muted = true;
67
+ },
68
+ async unmute() {
69
+ await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });
70
+ muted = false;
71
+ },
72
+ setOutputMuted(m) {
73
+ outputMuted = m;
74
+ room.remoteParticipants.forEach(applyVolume);
75
+ },
76
+ async setInputDevice(deviceId) {
77
+ await room.switchActiveDevice("audioinput", deviceId);
78
+ },
79
+ async setOutputDevice(deviceId) {
80
+ await room.switchActiveDevice("audiooutput", deviceId);
81
+ },
82
+ setVolume(identity, volume) {
83
+ volumes.set(identity, Math.min(1, Math.max(0, volume)));
84
+ const p = room.remoteParticipants.get(identity);
85
+ if (p) applyVolume(p);
86
+ },
87
+ onSpeaking(cb) {
88
+ speakingCbs.add(cb);
89
+ return () => speakingCbs.delete(cb);
90
+ },
91
+ onParticipants(cb) {
92
+ participantCbs.add(cb);
93
+ if (!left) cb(participants());
94
+ return () => participantCbs.delete(cb);
95
+ },
96
+ async leave() {
97
+ left = true;
98
+ for (const [event, fn] of handlers) room.off(event, fn);
99
+ speakingCbs.clear();
100
+ participantCbs.clear();
101
+ audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
102
+ audioEls.clear();
103
+ await room.disconnect();
104
+ }
105
+ };
106
+ }
107
+ function toVoiceParticipant(p, isLocal) {
108
+ return { identity: p.identity, name: p.name || p.identity, isLocal };
109
+ }
110
+
111
+ exports.AUDIO_CAPTURE = AUDIO_CAPTURE;
112
+ exports.joinVoice = joinVoice;
113
+ //# sourceMappingURL=client.cjs.map
114
+ //# sourceMappingURL=client.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"names":["Room","RoomEvent","Track"],"mappings":"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","file":"client.cjs","sourcesContent":["// @broberg/call/client — the browser's half of a voice call (F104.3).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { Participant, RemoteParticipant, RemoteTrack } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n leave(): Promise<void>;\n}\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
@@ -0,0 +1,40 @@
1
+ interface VoiceParticipant {
2
+ identity: string;
3
+ name: string;
4
+ isLocal: boolean;
5
+ }
6
+ interface JoinOptions {
7
+ /** The media server, e.g. `wss://voice.broberg.ai`. */
8
+ url: string;
9
+ /** A ticket from `createRoomToken` on the app's server. */
10
+ token: string;
11
+ /**
12
+ * F104.4 — `false` joins as a listener: the microphone is never opened
13
+ * (a listen-only ticket would be refused if it tried). Default `true`.
14
+ */
15
+ publish?: boolean;
16
+ }
17
+ interface VoiceCall {
18
+ mute(): Promise<void>;
19
+ unmute(): Promise<void>;
20
+ readonly muted: boolean;
21
+ /** Silence everyone else (true) or bring back each person's own volume (false). */
22
+ setOutputMuted(muted: boolean): void;
23
+ setInputDevice(deviceId: string): Promise<void>;
24
+ setOutputDevice(deviceId: string): Promise<void>;
25
+ /** 0–1 for one person. Kept while output is muted and applied on unmute. */
26
+ setVolume(identity: string, volume: number): void;
27
+ /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */
28
+ onSpeaking(cb: (identities: string[]) => void): () => void;
29
+ /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */
30
+ onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;
31
+ leave(): Promise<void>;
32
+ }
33
+ declare const AUDIO_CAPTURE: {
34
+ readonly noiseSuppression: true;
35
+ readonly echoCancellation: true;
36
+ readonly autoGainControl: true;
37
+ };
38
+ declare function joinVoice({ url, token, publish }: JoinOptions): Promise<VoiceCall>;
39
+
40
+ export { AUDIO_CAPTURE, type JoinOptions, type VoiceCall, type VoiceParticipant, joinVoice };
@@ -0,0 +1,40 @@
1
+ interface VoiceParticipant {
2
+ identity: string;
3
+ name: string;
4
+ isLocal: boolean;
5
+ }
6
+ interface JoinOptions {
7
+ /** The media server, e.g. `wss://voice.broberg.ai`. */
8
+ url: string;
9
+ /** A ticket from `createRoomToken` on the app's server. */
10
+ token: string;
11
+ /**
12
+ * F104.4 — `false` joins as a listener: the microphone is never opened
13
+ * (a listen-only ticket would be refused if it tried). Default `true`.
14
+ */
15
+ publish?: boolean;
16
+ }
17
+ interface VoiceCall {
18
+ mute(): Promise<void>;
19
+ unmute(): Promise<void>;
20
+ readonly muted: boolean;
21
+ /** Silence everyone else (true) or bring back each person's own volume (false). */
22
+ setOutputMuted(muted: boolean): void;
23
+ setInputDevice(deviceId: string): Promise<void>;
24
+ setOutputDevice(deviceId: string): Promise<void>;
25
+ /** 0–1 for one person. Kept while output is muted and applied on unmute. */
26
+ setVolume(identity: string, volume: number): void;
27
+ /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */
28
+ onSpeaking(cb: (identities: string[]) => void): () => void;
29
+ /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */
30
+ onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;
31
+ leave(): Promise<void>;
32
+ }
33
+ declare const AUDIO_CAPTURE: {
34
+ readonly noiseSuppression: true;
35
+ readonly echoCancellation: true;
36
+ readonly autoGainControl: true;
37
+ };
38
+ declare function joinVoice({ url, token, publish }: JoinOptions): Promise<VoiceCall>;
39
+
40
+ export { AUDIO_CAPTURE, type JoinOptions, type VoiceCall, type VoiceParticipant, joinVoice };
package/dist/client.js ADDED
@@ -0,0 +1,111 @@
1
+ import { Room, RoomEvent, Track } from 'livekit-client';
2
+
3
+ // src/client.ts
4
+ var AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true };
5
+ async function joinVoice({ url, token, publish = true }) {
6
+ const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });
7
+ const speakingCbs = /* @__PURE__ */ new Set();
8
+ const participantCbs = /* @__PURE__ */ new Set();
9
+ const volumes = /* @__PURE__ */ new Map();
10
+ const audioEls = /* @__PURE__ */ new Map();
11
+ let outputMuted = false;
12
+ let muted = !publish;
13
+ let left = false;
14
+ const participants = () => [
15
+ toVoiceParticipant(room.localParticipant, true),
16
+ ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false))
17
+ ];
18
+ const emitParticipants = () => {
19
+ if (left) return;
20
+ const list = participants();
21
+ participantCbs.forEach((cb) => cb(list));
22
+ };
23
+ const applyVolume = (p) => p.setVolume(outputMuted ? 0 : volumes.get(p.identity) ?? 1);
24
+ const handlers = [
25
+ [RoomEvent.ParticipantConnected, (p) => {
26
+ applyVolume(p);
27
+ emitParticipants();
28
+ }],
29
+ [RoomEvent.ParticipantDisconnected, () => emitParticipants()],
30
+ [RoomEvent.ActiveSpeakersChanged, (speakers) => {
31
+ if (left) return;
32
+ const ids = speakers.map((s) => s.identity);
33
+ speakingCbs.forEach((cb) => cb(ids));
34
+ }],
35
+ [RoomEvent.TrackSubscribed, (track, _pub, p) => {
36
+ if (track.kind !== Track.Kind.Audio) return;
37
+ const el = track.attach();
38
+ document.body.appendChild(el);
39
+ audioEls.set(track, [el]);
40
+ applyVolume(p);
41
+ }],
42
+ [RoomEvent.TrackUnsubscribed, (track) => {
43
+ track.detach().forEach((el) => el.remove());
44
+ audioEls.delete(track);
45
+ }]
46
+ ];
47
+ for (const [event, fn] of handlers) room.on(event, fn);
48
+ try {
49
+ await room.connect(url, token);
50
+ if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });
51
+ await room.startAudio();
52
+ } catch (e) {
53
+ left = true;
54
+ for (const [event, fn] of handlers) room.off(event, fn);
55
+ await room.disconnect();
56
+ throw e;
57
+ }
58
+ return {
59
+ get muted() {
60
+ return muted;
61
+ },
62
+ async mute() {
63
+ await room.localParticipant.setMicrophoneEnabled(false);
64
+ muted = true;
65
+ },
66
+ async unmute() {
67
+ await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });
68
+ muted = false;
69
+ },
70
+ setOutputMuted(m) {
71
+ outputMuted = m;
72
+ room.remoteParticipants.forEach(applyVolume);
73
+ },
74
+ async setInputDevice(deviceId) {
75
+ await room.switchActiveDevice("audioinput", deviceId);
76
+ },
77
+ async setOutputDevice(deviceId) {
78
+ await room.switchActiveDevice("audiooutput", deviceId);
79
+ },
80
+ setVolume(identity, volume) {
81
+ volumes.set(identity, Math.min(1, Math.max(0, volume)));
82
+ const p = room.remoteParticipants.get(identity);
83
+ if (p) applyVolume(p);
84
+ },
85
+ onSpeaking(cb) {
86
+ speakingCbs.add(cb);
87
+ return () => speakingCbs.delete(cb);
88
+ },
89
+ onParticipants(cb) {
90
+ participantCbs.add(cb);
91
+ if (!left) cb(participants());
92
+ return () => participantCbs.delete(cb);
93
+ },
94
+ async leave() {
95
+ left = true;
96
+ for (const [event, fn] of handlers) room.off(event, fn);
97
+ speakingCbs.clear();
98
+ participantCbs.clear();
99
+ audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
100
+ audioEls.clear();
101
+ await room.disconnect();
102
+ }
103
+ };
104
+ }
105
+ function toVoiceParticipant(p, isLocal) {
106
+ return { identity: p.identity, name: p.name || p.identity, isLocal };
107
+ }
108
+
109
+ export { AUDIO_CAPTURE, joinVoice };
110
+ //# sourceMappingURL=client.js.map
111
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["// @broberg/call/client — the browser's half of a voice call (F104.3).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { Participant, RemoteParticipant, RemoteTrack } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n leave(): Promise<void>;\n}\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ var livekitServerSdk = require('livekit-server-sdk');
4
+
5
+ // src/server.ts
6
+ var VoiceError = class extends Error {
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "VoiceError";
11
+ }
12
+ code;
13
+ };
14
+ var DEFAULT_TTL_SECONDS = 600;
15
+ var MAX_TTL_SECONDS = 3600;
16
+ function requireCredentials({ apiKey, apiSecret }) {
17
+ if (!apiKey || !apiSecret) {
18
+ throw new VoiceError("missing_credentials", "apiKey and apiSecret are required");
19
+ }
20
+ }
21
+ async function createRoomToken(opts) {
22
+ requireCredentials(opts);
23
+ if (!opts.room?.trim()) throw new VoiceError("invalid_room", "room is required");
24
+ if (!opts.identity?.trim()) throw new VoiceError("invalid_identity", "identity is required");
25
+ const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;
26
+ if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {
27
+ throw new VoiceError("invalid_ttl", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);
28
+ }
29
+ const token = new livekitServerSdk.AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
30
+ const speak = opts.canPublish !== false;
31
+ token.addGrant({
32
+ room: opts.room,
33
+ roomJoin: true,
34
+ canSubscribe: true,
35
+ canPublish: speak,
36
+ ...speak ? { canPublishSources: [livekitServerSdk.TrackSource.MICROPHONE] } : {},
37
+ canPublishData: false
38
+ });
39
+ return token.toJwt();
40
+ }
41
+ async function verifyWebhook(body, authHeader, credentials) {
42
+ requireCredentials(credentials);
43
+ try {
44
+ return await new livekitServerSdk.WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? void 0);
45
+ } catch (e) {
46
+ throw new VoiceError("invalid_webhook", `webhook rejected: ${e.message}`);
47
+ }
48
+ }
49
+
50
+ exports.DEFAULT_TTL_SECONDS = DEFAULT_TTL_SECONDS;
51
+ exports.MAX_TTL_SECONDS = MAX_TTL_SECONDS;
52
+ exports.VoiceError = VoiceError;
53
+ exports.createRoomToken = createRoomToken;
54
+ exports.verifyWebhook = verifyWebhook;
55
+ //# sourceMappingURL=server.cjs.map
56
+ //# sourceMappingURL=server.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.ts"],"names":["AccessToken","TrackSource","WebhookReceiver"],"mappings":";;;;;AAmBO,IAAM,UAAA,GAAN,cAAyB,KAAA,CAAM;AAAA,EACpC,WAAA,CACW,MACT,OAAA,EACA;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAHJ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAIT,IAAA,IAAA,CAAK,IAAA,GAAO,YAAA;AAAA,EACd;AAAA,EALW,IAAA;AAMb;AAwBO,IAAM,mBAAA,GAAsB;AAC5B,IAAM,eAAA,GAAkB;AAE/B,SAAS,kBAAA,CAAmB,EAAE,MAAA,EAAQ,SAAA,EAAU,EAA+B;AAI7E,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,SAAA,EAAW;AACzB,IAAA,MAAM,IAAI,UAAA,CAAW,qBAAA,EAAuB,mCAAmC,CAAA;AAAA,EACjF;AACF;AAGA,eAAsB,gBAAgB,IAAA,EAAyC;AAC7E,EAAA,kBAAA,CAAmB,IAAI,CAAA;AACvB,EAAA,IAAI,CAAC,KAAK,IAAA,EAAM,IAAA,IAAQ,MAAM,IAAI,UAAA,CAAW,cAAA,EAAgB,kBAAkB,CAAA;AAC/E,EAAA,IAAI,CAAC,KAAK,QAAA,EAAU,IAAA,IAAQ,MAAM,IAAI,UAAA,CAAW,kBAAA,EAAoB,sBAAsB,CAAA;AAC3F,EAAA,MAAM,GAAA,GAAM,KAAK,GAAA,IAAO,mBAAA;AACxB,EAAA,IAAI,CAAC,OAAO,SAAA,CAAU,GAAG,KAAK,GAAA,IAAO,CAAA,IAAK,MAAM,eAAA,EAAiB;AAC/D,IAAA,MAAM,IAAI,UAAA,CAAW,aAAA,EAAe,CAAA,oDAAA,EAAuD,eAAe,CAAA,CAAE,CAAA;AAAA,EAC9G;AAEA,EAAA,MAAM,KAAA,GAAQ,IAAIA,4BAAA,CAAY,IAAA,CAAK,QAAQ,IAAA,CAAK,SAAA,EAAW,EAAE,QAAA,EAAU,KAAK,QAAA,EAAU,IAAA,EAAM,IAAA,CAAK,IAAA,EAAM,KAAK,CAAA;AAC5G,EAAA,MAAM,KAAA,GAAQ,KAAK,UAAA,KAAe,KAAA;AAClC,EAAA,KAAA,CAAM,QAAA,CAAS;AAAA,IACb,MAAM,IAAA,CAAK,IAAA;AAAA,IACX,QAAA,EAAU,IAAA;AAAA,IACV,YAAA,EAAc,IAAA;AAAA,IACd,UAAA,EAAY,KAAA;AAAA,IACZ,GAAI,QAAQ,EAAE,iBAAA,EAAmB,CAACC,4BAAA,CAAY,UAAU,CAAA,EAAE,GAAI,EAAC;AAAA,IAC/D,cAAA,EAAgB;AAAA,GACjB,CAAA;AACD,EAAA,OAAO,MAAM,KAAA,EAAM;AACrB;AAMA,eAAsB,aAAA,CACpB,IAAA,EACA,UAAA,EACA,WAAA,EACuB;AACvB,EAAA,kBAAA,CAAmB,WAAW,CAAA;AAC9B,EAAA,IAAI;AAEF,IAAA,OAAO,MAAM,IAAIC,gCAAA,CAAgB,WAAA,CAAY,MAAA,EAAQ,WAAA,CAAY,SAAS,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,UAAA,IAAc,KAAA,CAAS,CAAA;AAAA,EACnH,SAAS,CAAA,EAAG;AACV,IAAA,MAAM,IAAI,UAAA,CAAW,iBAAA,EAAmB,CAAA,kBAAA,EAAsB,CAAA,CAAY,OAAO,CAAA,CAAE,CAAA;AAAA,EACrF;AACF","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a voice call (F104.3).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_webhook\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n}\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/** Sign a microphone-only ticket for one room. Check the person's access BEFORE calling this. */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n const speak = opts.canPublish !== false;\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: [TrackSource.MICROPHONE] } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import { WebhookEvent } from 'livekit-server-sdk';
2
+ export { WebhookEvent } from 'livekit-server-sdk';
3
+
4
+ type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_webhook";
5
+ declare class VoiceError extends Error {
6
+ readonly code: VoiceErrorCode;
7
+ constructor(code: VoiceErrorCode, message: string);
8
+ }
9
+ interface Credentials {
10
+ apiKey: string;
11
+ apiSecret: string;
12
+ }
13
+ interface RoomTokenOptions extends Credentials {
14
+ /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */
15
+ room: string;
16
+ /** Stable id of the person — the same person joining twice replaces the first connection. */
17
+ identity: string;
18
+ /** Display name other participants see. */
19
+ name?: string;
20
+ /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */
21
+ ttl?: number;
22
+ /**
23
+ * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear
24
+ * everyone but publish nothing (no microphone, no data). Default `true`.
25
+ * Pair it with `joinVoice({ publish: false })` in the browser.
26
+ */
27
+ canPublish?: boolean;
28
+ }
29
+ declare const DEFAULT_TTL_SECONDS = 600;
30
+ declare const MAX_TTL_SECONDS = 3600;
31
+ /** Sign a microphone-only ticket for one room. Check the person's access BEFORE calling this. */
32
+ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
33
+ /**
34
+ * Verify a webhook from the media server and return its event.
35
+ * `body` must be the RAW request body — re-serialised JSON changes the hash.
36
+ */
37
+ declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
38
+
39
+ export { type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
@@ -0,0 +1,39 @@
1
+ import { WebhookEvent } from 'livekit-server-sdk';
2
+ export { WebhookEvent } from 'livekit-server-sdk';
3
+
4
+ type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_webhook";
5
+ declare class VoiceError extends Error {
6
+ readonly code: VoiceErrorCode;
7
+ constructor(code: VoiceErrorCode, message: string);
8
+ }
9
+ interface Credentials {
10
+ apiKey: string;
11
+ apiSecret: string;
12
+ }
13
+ interface RoomTokenOptions extends Credentials {
14
+ /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */
15
+ room: string;
16
+ /** Stable id of the person — the same person joining twice replaces the first connection. */
17
+ identity: string;
18
+ /** Display name other participants see. */
19
+ name?: string;
20
+ /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */
21
+ ttl?: number;
22
+ /**
23
+ * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear
24
+ * everyone but publish nothing (no microphone, no data). Default `true`.
25
+ * Pair it with `joinVoice({ publish: false })` in the browser.
26
+ */
27
+ canPublish?: boolean;
28
+ }
29
+ declare const DEFAULT_TTL_SECONDS = 600;
30
+ declare const MAX_TTL_SECONDS = 3600;
31
+ /** Sign a microphone-only ticket for one room. Check the person's access BEFORE calling this. */
32
+ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
33
+ /**
34
+ * Verify a webhook from the media server and return its event.
35
+ * `body` must be the RAW request body — re-serialised JSON changes the hash.
36
+ */
37
+ declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
38
+
39
+ export { type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
package/dist/server.js ADDED
@@ -0,0 +1,50 @@
1
+ import { AccessToken, TrackSource, WebhookReceiver } from 'livekit-server-sdk';
2
+
3
+ // src/server.ts
4
+ var VoiceError = class extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "VoiceError";
9
+ }
10
+ code;
11
+ };
12
+ var DEFAULT_TTL_SECONDS = 600;
13
+ var MAX_TTL_SECONDS = 3600;
14
+ function requireCredentials({ apiKey, apiSecret }) {
15
+ if (!apiKey || !apiSecret) {
16
+ throw new VoiceError("missing_credentials", "apiKey and apiSecret are required");
17
+ }
18
+ }
19
+ async function createRoomToken(opts) {
20
+ requireCredentials(opts);
21
+ if (!opts.room?.trim()) throw new VoiceError("invalid_room", "room is required");
22
+ if (!opts.identity?.trim()) throw new VoiceError("invalid_identity", "identity is required");
23
+ const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;
24
+ if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {
25
+ throw new VoiceError("invalid_ttl", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);
26
+ }
27
+ const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
28
+ const speak = opts.canPublish !== false;
29
+ token.addGrant({
30
+ room: opts.room,
31
+ roomJoin: true,
32
+ canSubscribe: true,
33
+ canPublish: speak,
34
+ ...speak ? { canPublishSources: [TrackSource.MICROPHONE] } : {},
35
+ canPublishData: false
36
+ });
37
+ return token.toJwt();
38
+ }
39
+ async function verifyWebhook(body, authHeader, credentials) {
40
+ requireCredentials(credentials);
41
+ try {
42
+ return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? void 0);
43
+ } catch (e) {
44
+ throw new VoiceError("invalid_webhook", `webhook rejected: ${e.message}`);
45
+ }
46
+ }
47
+
48
+ export { DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, VoiceError, createRoomToken, verifyWebhook };
49
+ //# sourceMappingURL=server.js.map
50
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.ts"],"names":[],"mappings":";;;AAmBO,IAAM,UAAA,GAAN,cAAyB,KAAA,CAAM;AAAA,EACpC,WAAA,CACW,MACT,OAAA,EACA;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAHJ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAIT,IAAA,IAAA,CAAK,IAAA,GAAO,YAAA;AAAA,EACd;AAAA,EALW,IAAA;AAMb;AAwBO,IAAM,mBAAA,GAAsB;AAC5B,IAAM,eAAA,GAAkB;AAE/B,SAAS,kBAAA,CAAmB,EAAE,MAAA,EAAQ,SAAA,EAAU,EAA+B;AAI7E,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,SAAA,EAAW;AACzB,IAAA,MAAM,IAAI,UAAA,CAAW,qBAAA,EAAuB,mCAAmC,CAAA;AAAA,EACjF;AACF;AAGA,eAAsB,gBAAgB,IAAA,EAAyC;AAC7E,EAAA,kBAAA,CAAmB,IAAI,CAAA;AACvB,EAAA,IAAI,CAAC,KAAK,IAAA,EAAM,IAAA,IAAQ,MAAM,IAAI,UAAA,CAAW,cAAA,EAAgB,kBAAkB,CAAA;AAC/E,EAAA,IAAI,CAAC,KAAK,QAAA,EAAU,IAAA,IAAQ,MAAM,IAAI,UAAA,CAAW,kBAAA,EAAoB,sBAAsB,CAAA;AAC3F,EAAA,MAAM,GAAA,GAAM,KAAK,GAAA,IAAO,mBAAA;AACxB,EAAA,IAAI,CAAC,OAAO,SAAA,CAAU,GAAG,KAAK,GAAA,IAAO,CAAA,IAAK,MAAM,eAAA,EAAiB;AAC/D,IAAA,MAAM,IAAI,UAAA,CAAW,aAAA,EAAe,CAAA,oDAAA,EAAuD,eAAe,CAAA,CAAE,CAAA;AAAA,EAC9G;AAEA,EAAA,MAAM,KAAA,GAAQ,IAAI,WAAA,CAAY,IAAA,CAAK,QAAQ,IAAA,CAAK,SAAA,EAAW,EAAE,QAAA,EAAU,KAAK,QAAA,EAAU,IAAA,EAAM,IAAA,CAAK,IAAA,EAAM,KAAK,CAAA;AAC5G,EAAA,MAAM,KAAA,GAAQ,KAAK,UAAA,KAAe,KAAA;AAClC,EAAA,KAAA,CAAM,QAAA,CAAS;AAAA,IACb,MAAM,IAAA,CAAK,IAAA;AAAA,IACX,QAAA,EAAU,IAAA;AAAA,IACV,YAAA,EAAc,IAAA;AAAA,IACd,UAAA,EAAY,KAAA;AAAA,IACZ,GAAI,QAAQ,EAAE,iBAAA,EAAmB,CAAC,WAAA,CAAY,UAAU,CAAA,EAAE,GAAI,EAAC;AAAA,IAC/D,cAAA,EAAgB;AAAA,GACjB,CAAA;AACD,EAAA,OAAO,MAAM,KAAA,EAAM;AACrB;AAMA,eAAsB,aAAA,CACpB,IAAA,EACA,UAAA,EACA,WAAA,EACuB;AACvB,EAAA,kBAAA,CAAmB,WAAW,CAAA;AAC9B,EAAA,IAAI;AAEF,IAAA,OAAO,MAAM,IAAI,eAAA,CAAgB,WAAA,CAAY,MAAA,EAAQ,WAAA,CAAY,SAAS,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,UAAA,IAAc,KAAA,CAAS,CAAA;AAAA,EACnH,SAAS,CAAA,EAAG;AACV,IAAA,MAAM,IAAI,UAAA,CAAW,iBAAA,EAAmB,CAAA,kBAAA,EAAsB,CAAA,CAAY,OAAO,CAAA,CAAE,CAAA;AAAA,EACrF;AACF","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a voice call (F104.3).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_webhook\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n}\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/** Sign a microphone-only ticket for one room. Check the person's access BEFORE calling this. */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n const speak = opts.canPublish !== false;\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: [TrackSource.MICROPHONE] } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@broberg/call",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Calls (audio today; video + screen share next) for 2–10 people on a self-hosted LiveKit SFU: the app's server mints a short-lived, room-bound, microphone-only ticket and verifies LiveKit's signed webhooks; the browser joins with noise suppression, echo cancellation and auto gain, and gets mute, output mute, device choice, per-person volume, who-is-speaking and the participant list.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "exports": {
13
+ "./server": {
14
+ "types": "./dist/server.d.ts",
15
+ "import": "./dist/server.js",
16
+ "require": "./dist/server.cjs"
17
+ },
18
+ "./client": {
19
+ "types": "./dist/client.d.ts",
20
+ "import": "./dist/client.js",
21
+ "require": "./dist/client.cjs"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "test": "vitest run --reporter=default --reporter=json --outputFile.json=.vitest-report/results.json && node ../../scripts/mutations-if-changed.mjs test/mutations.mjs",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "livekit-client": "2.22.3",
32
+ "livekit-server-sdk": "2.19.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^26.2.0",
36
+ "tsup": "^8.3.0",
37
+ "typescript": "^5.6.0",
38
+ "vitest": "^2.1.0"
39
+ },
40
+ "keywords": [
41
+ "voice",
42
+ "webrtc",
43
+ "livekit",
44
+ "sfu",
45
+ "audio-call",
46
+ "huddle",
47
+ "broberg"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/broberg-ai/components",
52
+ "directory": "packages/voice"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }