@broberg/call 0.2.1 → 0.3.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 +28 -2
- package/dist/client.cjs +15 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +7 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +37 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +35 -2
- package/dist/server.d.ts +35 -2
- package/dist/server.js +35 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @broberg/call
|
|
2
2
|
|
|
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. Camera and screen share since 0.2.0 (opt-in per ticket). Epic F104.
|
|
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. Camera and screen share since 0.2.0 (opt-in per ticket). Recording of the whole call to EU storage since 0.3.0. Epic F104.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm i @broberg/call
|
|
7
7
|
```
|
|
8
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 arrived in 0.2.0 (F104.7); recording
|
|
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 arrived in 0.2.0 (F104.7); recording arrived in 0.3.0 (F104.9).
|
|
10
10
|
>
|
|
11
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
12
|
|
|
@@ -62,6 +62,10 @@ call.onLocalMedia((m) => setButtons(m)); // at once + on every chang
|
|
|
62
62
|
call.onVideos((videos) => renderTiles(videos)); // [{ id, identity, name, isLocal, source: "camera"|"screen_share", attach(el), detach(el) }] — a turned-off (muted) camera is NOT in the list (0.2.1)
|
|
63
63
|
videos[0].attach(videoEl); // sets autoplay + playsinline; your own picture is muted
|
|
64
64
|
call.setAudioOnly(true); // weak connection: stop receiving others' video, keep sound
|
|
65
|
+
|
|
66
|
+
// 0.3.0 — is the room being recorded? Comes from the media server, so EVERYONE sees it
|
|
67
|
+
call.recording; // true / false
|
|
68
|
+
call.onRecording((on) => showRecordingBadge(on)); // at once + on start/stop — show «Optager nu» while true
|
|
65
69
|
```
|
|
66
70
|
|
|
67
71
|
- The microphone opens with **noise suppression, echo cancellation and auto gain** on.
|
|
@@ -70,3 +74,25 @@ call.setAudioOnly(true); // weak connection: stop re
|
|
|
70
74
|
- Reconnection after a network drop (ICE restart, resume) is LiveKit's own.
|
|
71
75
|
- **2–10 people with video:** the room runs with simulcast (each camera is sent in several sizes), adaptive stream (you only receive the size your tile shows) and dynacast (sizes nobody watches are paused) — `VIDEO_ROOM_OPTIONS`. Video is never added to the page by the package; the app places each picture with `attach`.
|
|
72
76
|
- The package draws no UI. If you build one, give every control a `data-testid`.
|
|
77
|
+
|
|
78
|
+
## Recording — 0.3.0 (F104.9)
|
|
79
|
+
|
|
80
|
+
The whole call — everyone, one video, grid layout — is recorded to EU storage (R2, EU jurisdiction) and deleted after 365 days. A recording machine runs only while something is being recorded, so recording costs nothing while nobody records.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { startRecording, stopRecording, getRecording } from "@broberg/call/server";
|
|
84
|
+
|
|
85
|
+
const opts = { apiKey, apiSecret, controlUrl: process.env.CALL_RECORDING_URL! }; // the recording service, from YOUR config
|
|
86
|
+
const rec = await startRecording("jam:42", opts); // { recordingId, room, state: "recording", … }
|
|
87
|
+
await stopRecording(rec.recordingId, "jam:42", opts); // state: "stopped"
|
|
88
|
+
const done = await getRecording(rec.recordingId, "jam:42", opts); // poll until done.files is there (or state "failed")
|
|
89
|
+
done.files; // [{ name, size, durationSec }] — name is the key in the bucket
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Consent first.** It records every participant. Get each person's consent (BID purpose `call-recording`) BEFORE calling `startRecording`.
|
|
93
|
+
- **Cold start:** when the recording machine is asleep, the picture joins after ~20 s; when it is warm, after under 2 s. Tell people recording is starting, not that it has.
|
|
94
|
+
- The request carries a 5-minute ticket signed with YOUR app's LiveKit key and bound to ONE room — an app can only start, stop or read recordings of the room it names.
|
|
95
|
+
- A refusal from the service is `VoiceError` `recording_failed` (status + reason in the message), never a recording. A `controlUrl` that is not http(s) is `invalid_control_url`.
|
|
96
|
+
- States: `recording` · `stopped` (after `stopRecording`) · `complete` (the room emptied by itself) · `failed` (with `error`). After a stop, `files` is absent until the file is written.
|
|
97
|
+
- Webhooks: `egress_started` and `egress_ended` arrive through `verifyWebhook` like every other event; `event.egressInfo.fileResults` names the file.
|
|
98
|
+
- In the browser, `call.recording` / `call.onRecording` tell EVERY participant that the room is being recorded — show it.
|
package/dist/client.cjs
CHANGED
|
@@ -13,6 +13,7 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
13
13
|
let audioOnly = false;
|
|
14
14
|
const speakingCbs = /* @__PURE__ */ new Set();
|
|
15
15
|
const participantCbs = /* @__PURE__ */ new Set();
|
|
16
|
+
const recordingCbs = /* @__PURE__ */ new Set();
|
|
16
17
|
const volumes = /* @__PURE__ */ new Map();
|
|
17
18
|
const audioEls = /* @__PURE__ */ new Map();
|
|
18
19
|
let outputMuted = false;
|
|
@@ -64,6 +65,11 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
64
65
|
emitParticipants();
|
|
65
66
|
}],
|
|
66
67
|
[livekitClient.RoomEvent.ParticipantDisconnected, () => emitParticipants()],
|
|
68
|
+
// F104.9.5 — LiveKit marks the room as recorded while an egress runs on it.
|
|
69
|
+
[livekitClient.RoomEvent.RecordingStatusChanged, (on) => {
|
|
70
|
+
if (left) return;
|
|
71
|
+
recordingCbs.forEach((cb) => cb(on));
|
|
72
|
+
}],
|
|
67
73
|
[livekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
68
74
|
if (left) return;
|
|
69
75
|
const ids = speakers.map((s) => s.identity);
|
|
@@ -176,6 +182,14 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
176
182
|
get audioOnly() {
|
|
177
183
|
return audioOnly;
|
|
178
184
|
},
|
|
185
|
+
get recording() {
|
|
186
|
+
return room.isRecording;
|
|
187
|
+
},
|
|
188
|
+
onRecording(cb) {
|
|
189
|
+
recordingCbs.add(cb);
|
|
190
|
+
if (!left) cb(room.isRecording);
|
|
191
|
+
return () => recordingCbs.delete(cb);
|
|
192
|
+
},
|
|
179
193
|
setAudioOnly(on) {
|
|
180
194
|
audioOnly = on;
|
|
181
195
|
room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));
|
|
@@ -187,6 +201,7 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
187
201
|
participantCbs.clear();
|
|
188
202
|
localMediaCbs.clear();
|
|
189
203
|
videoCbs.clear();
|
|
204
|
+
recordingCbs.clear();
|
|
190
205
|
remoteVideos.forEach((_v, track) => track.detach());
|
|
191
206
|
remoteVideos.clear();
|
|
192
207
|
audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
|
package/dist/client.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client.ts"],"names":["Room","Track","RoomEvent"],"mappings":"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","file":"client.cjs","sourcesContent":["// @broberg/call/client — the browser's half of a call (F104.3; camera + screen share F104.7).\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// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: 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 /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\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 }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n // F104.10 — kept WITH its publication: a muted camera stays subscribed, so\n // only the publication can say whether there is a picture right now.\n const remoteVideos = new Map<RemoteTrack, { video: CallVideo; pub: RemoteTrackPublication }>();\n let audioOnly = false;\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 localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n const remote = [...remoteVideos.values()].filter(({ pub }) => !pub.isMuted).map(({ video }) => video);\n return [...local, ...remote];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\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: RemoteTrackPublication, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, { video: videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track), pub });\n emitVideos();\n return;\n }\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 if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n // F104.10 — LiveKit turns a camera off by MUTING it, for you and for\n // everyone else, so a mute is a picture going away (and unmute coming back).\n [RoomEvent.TrackMuted, onLocalChange],\n [RoomEvent.TrackUnmuted, onLocalChange],\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 setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\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 localMediaCbs.clear();\n videoCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source || pub.isMuted) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":["Room","Track","RoomEvent"],"mappings":"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","file":"client.cjs","sourcesContent":["// @broberg/call/client — the browser's half of a call (F104.3; camera + screen share F104.7).\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// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: 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 /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n /**\n * F104.9.5 — `true` while the room is being recorded. It comes from the media\n * server, so EVERY participant sees it — not only the one whose app started it.\n */\n readonly recording: boolean;\n /** Called at once and whenever a recording of the room starts or stops. Returns an unsubscribe. */\n onRecording(cb: (recording: boolean) => void): () => void;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\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 }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n // F104.10 — kept WITH its publication: a muted camera stays subscribed, so\n // only the publication can say whether there is a picture right now.\n const remoteVideos = new Map<RemoteTrack, { video: CallVideo; pub: RemoteTrackPublication }>();\n let audioOnly = false;\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const recordingCbs = new Set<(on: boolean) => 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 localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n const remote = [...remoteVideos.values()].filter(({ pub }) => !pub.isMuted).map(({ video }) => video);\n return [...local, ...remote];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n // F104.9.5 — LiveKit marks the room as recorded while an egress runs on it.\n [RoomEvent.RecordingStatusChanged, (on: boolean) => {\n if (left) return;\n recordingCbs.forEach((cb) => cb(on));\n }],\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: RemoteTrackPublication, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, { video: videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track), pub });\n emitVideos();\n return;\n }\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 if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n // F104.10 — LiveKit turns a camera off by MUTING it, for you and for\n // everyone else, so a mute is a picture going away (and unmute coming back).\n [RoomEvent.TrackMuted, onLocalChange],\n [RoomEvent.TrackUnmuted, onLocalChange],\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 setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n get recording() { return room.isRecording; },\n onRecording(cb) {\n recordingCbs.add(cb);\n if (!left) cb(room.isRecording);\n return () => recordingCbs.delete(cb);\n },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\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 localMediaCbs.clear();\n videoCbs.clear();\n recordingCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source || pub.isMuted) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
package/dist/client.d.cts
CHANGED
|
@@ -65,6 +65,13 @@ interface VoiceCall {
|
|
|
65
65
|
/** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */
|
|
66
66
|
setAudioOnly(on: boolean): void;
|
|
67
67
|
readonly audioOnly: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* F104.9.5 — `true` while the room is being recorded. It comes from the media
|
|
70
|
+
* server, so EVERY participant sees it — not only the one whose app started it.
|
|
71
|
+
*/
|
|
72
|
+
readonly recording: boolean;
|
|
73
|
+
/** Called at once and whenever a recording of the room starts or stops. Returns an unsubscribe. */
|
|
74
|
+
onRecording(cb: (recording: boolean) => void): () => void;
|
|
68
75
|
leave(): Promise<void>;
|
|
69
76
|
}
|
|
70
77
|
/** F104.7 — the room settings that keep 2–10 people with video usable. */
|
package/dist/client.d.ts
CHANGED
|
@@ -65,6 +65,13 @@ interface VoiceCall {
|
|
|
65
65
|
/** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */
|
|
66
66
|
setAudioOnly(on: boolean): void;
|
|
67
67
|
readonly audioOnly: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* F104.9.5 — `true` while the room is being recorded. It comes from the media
|
|
70
|
+
* server, so EVERY participant sees it — not only the one whose app started it.
|
|
71
|
+
*/
|
|
72
|
+
readonly recording: boolean;
|
|
73
|
+
/** Called at once and whenever a recording of the room starts or stops. Returns an unsubscribe. */
|
|
74
|
+
onRecording(cb: (recording: boolean) => void): () => void;
|
|
68
75
|
leave(): Promise<void>;
|
|
69
76
|
}
|
|
70
77
|
/** F104.7 — the room settings that keep 2–10 people with video usable. */
|
package/dist/client.js
CHANGED
|
@@ -11,6 +11,7 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
11
11
|
let audioOnly = false;
|
|
12
12
|
const speakingCbs = /* @__PURE__ */ new Set();
|
|
13
13
|
const participantCbs = /* @__PURE__ */ new Set();
|
|
14
|
+
const recordingCbs = /* @__PURE__ */ new Set();
|
|
14
15
|
const volumes = /* @__PURE__ */ new Map();
|
|
15
16
|
const audioEls = /* @__PURE__ */ new Map();
|
|
16
17
|
let outputMuted = false;
|
|
@@ -62,6 +63,11 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
62
63
|
emitParticipants();
|
|
63
64
|
}],
|
|
64
65
|
[RoomEvent.ParticipantDisconnected, () => emitParticipants()],
|
|
66
|
+
// F104.9.5 — LiveKit marks the room as recorded while an egress runs on it.
|
|
67
|
+
[RoomEvent.RecordingStatusChanged, (on) => {
|
|
68
|
+
if (left) return;
|
|
69
|
+
recordingCbs.forEach((cb) => cb(on));
|
|
70
|
+
}],
|
|
65
71
|
[RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
66
72
|
if (left) return;
|
|
67
73
|
const ids = speakers.map((s) => s.identity);
|
|
@@ -174,6 +180,14 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
174
180
|
get audioOnly() {
|
|
175
181
|
return audioOnly;
|
|
176
182
|
},
|
|
183
|
+
get recording() {
|
|
184
|
+
return room.isRecording;
|
|
185
|
+
},
|
|
186
|
+
onRecording(cb) {
|
|
187
|
+
recordingCbs.add(cb);
|
|
188
|
+
if (!left) cb(room.isRecording);
|
|
189
|
+
return () => recordingCbs.delete(cb);
|
|
190
|
+
},
|
|
177
191
|
setAudioOnly(on) {
|
|
178
192
|
audioOnly = on;
|
|
179
193
|
room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));
|
|
@@ -185,6 +199,7 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
185
199
|
participantCbs.clear();
|
|
186
200
|
localMediaCbs.clear();
|
|
187
201
|
videoCbs.clear();
|
|
202
|
+
recordingCbs.clear();
|
|
188
203
|
remoteVideos.forEach((_v, track) => track.detach());
|
|
189
204
|
remoteVideos.clear();
|
|
190
205
|
audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
|
package/dist/client.js.map
CHANGED
|
@@ -1 +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 call (F104.3; camera + screen share F104.7).\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// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: 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 /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\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 }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n // F104.10 — kept WITH its publication: a muted camera stays subscribed, so\n // only the publication can say whether there is a picture right now.\n const remoteVideos = new Map<RemoteTrack, { video: CallVideo; pub: RemoteTrackPublication }>();\n let audioOnly = false;\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 localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n const remote = [...remoteVideos.values()].filter(({ pub }) => !pub.isMuted).map(({ video }) => video);\n return [...local, ...remote];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\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: RemoteTrackPublication, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, { video: videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track), pub });\n emitVideos();\n return;\n }\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 if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n // F104.10 — LiveKit turns a camera off by MUTING it, for you and for\n // everyone else, so a mute is a picture going away (and unmute coming back).\n [RoomEvent.TrackMuted, onLocalChange],\n [RoomEvent.TrackUnmuted, onLocalChange],\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 setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\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 localMediaCbs.clear();\n videoCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source || pub.isMuted) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
|
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 call (F104.3; camera + screen share F104.7).\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// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: 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 /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n /**\n * F104.9.5 — `true` while the room is being recorded. It comes from the media\n * server, so EVERY participant sees it — not only the one whose app started it.\n */\n readonly recording: boolean;\n /** Called at once and whenever a recording of the room starts or stops. Returns an unsubscribe. */\n onRecording(cb: (recording: boolean) => void): () => void;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\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 }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n // F104.10 — kept WITH its publication: a muted camera stays subscribed, so\n // only the publication can say whether there is a picture right now.\n const remoteVideos = new Map<RemoteTrack, { video: CallVideo; pub: RemoteTrackPublication }>();\n let audioOnly = false;\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const recordingCbs = new Set<(on: boolean) => 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 localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n const remote = [...remoteVideos.values()].filter(({ pub }) => !pub.isMuted).map(({ video }) => video);\n return [...local, ...remote];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n // F104.9.5 — LiveKit marks the room as recorded while an egress runs on it.\n [RoomEvent.RecordingStatusChanged, (on: boolean) => {\n if (left) return;\n recordingCbs.forEach((cb) => cb(on));\n }],\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: RemoteTrackPublication, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, { video: videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track), pub });\n emitVideos();\n return;\n }\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 if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n // F104.10 — LiveKit turns a camera off by MUTING it, for you and for\n // everyone else, so a mute is a picture going away (and unmute coming back).\n [RoomEvent.TrackMuted, onLocalChange],\n [RoomEvent.TrackUnmuted, onLocalChange],\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 setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n get recording() { return room.isRecording; },\n onRecording(cb) {\n recordingCbs.add(cb);\n if (!left) cb(room.isRecording);\n return () => recordingCbs.delete(cb);\n },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\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 localMediaCbs.clear();\n videoCbs.clear();\n recordingCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source || pub.isMuted) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
package/dist/server.cjs
CHANGED
|
@@ -63,11 +63,48 @@ async function verifyWebhook(body, authHeader, credentials) {
|
|
|
63
63
|
throw new VoiceError("invalid_webhook", `webhook rejected: ${e.message}`);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
async function recordingTicket(room, credentials) {
|
|
67
|
+
requireCredentials(credentials);
|
|
68
|
+
if (!room?.trim()) throw new VoiceError("invalid_room", "room is required");
|
|
69
|
+
const token = new livekitServerSdk.AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });
|
|
70
|
+
token.addGrant({ room, roomRecord: true });
|
|
71
|
+
return token.toJwt();
|
|
72
|
+
}
|
|
73
|
+
async function control(path, room, opts, init) {
|
|
74
|
+
if (!/^https?:\/\//.test(opts.controlUrl ?? "")) throw new VoiceError("invalid_control_url", "controlUrl must be an http(s) URL");
|
|
75
|
+
const res = await fetch(opts.controlUrl.replace(/\/+$/, "") + path, {
|
|
76
|
+
...init,
|
|
77
|
+
headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, "content-type": "application/json" },
|
|
78
|
+
signal: AbortSignal.timeout(9e4)
|
|
79
|
+
// a cold recording machine takes ~20 s to start
|
|
80
|
+
});
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
let body;
|
|
83
|
+
try {
|
|
84
|
+
body = text ? JSON.parse(text) : {};
|
|
85
|
+
} catch {
|
|
86
|
+
body = { error: text.slice(0, 200) };
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) throw new VoiceError("recording_failed", `recording service answered ${res.status}: ${body?.error ?? "unknown"}`);
|
|
89
|
+
return body;
|
|
90
|
+
}
|
|
91
|
+
function startRecording(room, opts) {
|
|
92
|
+
return control("/v1/recordings", room, opts, { method: "POST", body: JSON.stringify({ room }) });
|
|
93
|
+
}
|
|
94
|
+
function stopRecording(recordingId, room, opts) {
|
|
95
|
+
return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: "POST" });
|
|
96
|
+
}
|
|
97
|
+
function getRecording(recordingId, room, opts) {
|
|
98
|
+
return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: "GET" });
|
|
99
|
+
}
|
|
66
100
|
|
|
67
101
|
exports.DEFAULT_TTL_SECONDS = DEFAULT_TTL_SECONDS;
|
|
68
102
|
exports.MAX_TTL_SECONDS = MAX_TTL_SECONDS;
|
|
69
103
|
exports.VoiceError = VoiceError;
|
|
70
104
|
exports.createRoomToken = createRoomToken;
|
|
105
|
+
exports.getRecording = getRecording;
|
|
106
|
+
exports.startRecording = startRecording;
|
|
107
|
+
exports.stopRecording = stopRecording;
|
|
71
108
|
exports.verifyWebhook = verifyWebhook;
|
|
72
109
|
//# sourceMappingURL=server.cjs.map
|
|
73
110
|
//# sourceMappingURL=server.cjs.map
|
package/dist/server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":["TrackSource","AccessToken","WebhookReceiver"],"mappings":"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","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\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_sources\"\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 * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\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/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\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 speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\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"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":["TrackSource","AccessToken","WebhookReceiver"],"mappings":"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","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\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_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\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 * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\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/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\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 speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\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\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n if (!/^https?:\\/\\//.test(opts.controlUrl ?? \"\")) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an http(s) URL\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
package/dist/server.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebhookEvent } from 'livekit-server-sdk';
|
|
2
2
|
export { WebhookEvent } from 'livekit-server-sdk';
|
|
3
3
|
|
|
4
|
-
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook";
|
|
4
|
+
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook" | "invalid_control_url" | "recording_failed";
|
|
5
5
|
declare class VoiceError extends Error {
|
|
6
6
|
readonly code: VoiceErrorCode;
|
|
7
7
|
constructor(code: VoiceErrorCode, message: string);
|
|
@@ -47,5 +47,38 @@ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
|
47
47
|
* `body` must be the RAW request body — re-serialised JSON changes the hash.
|
|
48
48
|
*/
|
|
49
49
|
declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
|
|
50
|
+
interface RecordingOptions extends Credentials {
|
|
51
|
+
/** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */
|
|
52
|
+
controlUrl: string;
|
|
53
|
+
}
|
|
54
|
+
interface RecordingFile {
|
|
55
|
+
name: string;
|
|
56
|
+
size: number;
|
|
57
|
+
durationSec: number;
|
|
58
|
+
}
|
|
59
|
+
interface Recording {
|
|
60
|
+
recordingId: string;
|
|
61
|
+
egressId?: string;
|
|
62
|
+
room: string;
|
|
63
|
+
/**
|
|
64
|
+
* `recording` while it runs; `stopped` after stopRecording; `complete` if the
|
|
65
|
+
* room emptied by itself; `failed` (with `error`) if it could not be made.
|
|
66
|
+
*/
|
|
67
|
+
state: "recording" | "stopped" | "complete" | "failed";
|
|
68
|
+
attempts?: number;
|
|
69
|
+
error?: string;
|
|
70
|
+
/** Absent until the file is written — after a stop, poll getRecording until it is there. */
|
|
71
|
+
files?: RecordingFile[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Start recording the whole room (everyone, one video, grid layout).
|
|
75
|
+
* Ask for every participant's consent BEFORE calling this — it records all of them.
|
|
76
|
+
* On a cold recording machine the picture joins after ~20 s.
|
|
77
|
+
*/
|
|
78
|
+
declare function startRecording(room: string, opts: RecordingOptions): Promise<Recording>;
|
|
79
|
+
/** Stop a recording. `room` must be the room it was started for. */
|
|
80
|
+
declare function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording>;
|
|
81
|
+
/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */
|
|
82
|
+
declare function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording>;
|
|
50
83
|
|
|
51
|
-
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
|
84
|
+
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type Recording, type RecordingFile, type RecordingOptions, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, getRecording, startRecording, stopRecording, verifyWebhook };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebhookEvent } from 'livekit-server-sdk';
|
|
2
2
|
export { WebhookEvent } from 'livekit-server-sdk';
|
|
3
3
|
|
|
4
|
-
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook";
|
|
4
|
+
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook" | "invalid_control_url" | "recording_failed";
|
|
5
5
|
declare class VoiceError extends Error {
|
|
6
6
|
readonly code: VoiceErrorCode;
|
|
7
7
|
constructor(code: VoiceErrorCode, message: string);
|
|
@@ -47,5 +47,38 @@ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
|
47
47
|
* `body` must be the RAW request body — re-serialised JSON changes the hash.
|
|
48
48
|
*/
|
|
49
49
|
declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
|
|
50
|
+
interface RecordingOptions extends Credentials {
|
|
51
|
+
/** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */
|
|
52
|
+
controlUrl: string;
|
|
53
|
+
}
|
|
54
|
+
interface RecordingFile {
|
|
55
|
+
name: string;
|
|
56
|
+
size: number;
|
|
57
|
+
durationSec: number;
|
|
58
|
+
}
|
|
59
|
+
interface Recording {
|
|
60
|
+
recordingId: string;
|
|
61
|
+
egressId?: string;
|
|
62
|
+
room: string;
|
|
63
|
+
/**
|
|
64
|
+
* `recording` while it runs; `stopped` after stopRecording; `complete` if the
|
|
65
|
+
* room emptied by itself; `failed` (with `error`) if it could not be made.
|
|
66
|
+
*/
|
|
67
|
+
state: "recording" | "stopped" | "complete" | "failed";
|
|
68
|
+
attempts?: number;
|
|
69
|
+
error?: string;
|
|
70
|
+
/** Absent until the file is written — after a stop, poll getRecording until it is there. */
|
|
71
|
+
files?: RecordingFile[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Start recording the whole room (everyone, one video, grid layout).
|
|
75
|
+
* Ask for every participant's consent BEFORE calling this — it records all of them.
|
|
76
|
+
* On a cold recording machine the picture joins after ~20 s.
|
|
77
|
+
*/
|
|
78
|
+
declare function startRecording(room: string, opts: RecordingOptions): Promise<Recording>;
|
|
79
|
+
/** Stop a recording. `room` must be the room it was started for. */
|
|
80
|
+
declare function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording>;
|
|
81
|
+
/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */
|
|
82
|
+
declare function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording>;
|
|
50
83
|
|
|
51
|
-
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
|
84
|
+
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type Recording, type RecordingFile, type RecordingOptions, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, getRecording, startRecording, stopRecording, verifyWebhook };
|
package/dist/server.js
CHANGED
|
@@ -61,7 +61,41 @@ async function verifyWebhook(body, authHeader, credentials) {
|
|
|
61
61
|
throw new VoiceError("invalid_webhook", `webhook rejected: ${e.message}`);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
async function recordingTicket(room, credentials) {
|
|
65
|
+
requireCredentials(credentials);
|
|
66
|
+
if (!room?.trim()) throw new VoiceError("invalid_room", "room is required");
|
|
67
|
+
const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });
|
|
68
|
+
token.addGrant({ room, roomRecord: true });
|
|
69
|
+
return token.toJwt();
|
|
70
|
+
}
|
|
71
|
+
async function control(path, room, opts, init) {
|
|
72
|
+
if (!/^https?:\/\//.test(opts.controlUrl ?? "")) throw new VoiceError("invalid_control_url", "controlUrl must be an http(s) URL");
|
|
73
|
+
const res = await fetch(opts.controlUrl.replace(/\/+$/, "") + path, {
|
|
74
|
+
...init,
|
|
75
|
+
headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, "content-type": "application/json" },
|
|
76
|
+
signal: AbortSignal.timeout(9e4)
|
|
77
|
+
// a cold recording machine takes ~20 s to start
|
|
78
|
+
});
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = text ? JSON.parse(text) : {};
|
|
83
|
+
} catch {
|
|
84
|
+
body = { error: text.slice(0, 200) };
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok) throw new VoiceError("recording_failed", `recording service answered ${res.status}: ${body?.error ?? "unknown"}`);
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
function startRecording(room, opts) {
|
|
90
|
+
return control("/v1/recordings", room, opts, { method: "POST", body: JSON.stringify({ room }) });
|
|
91
|
+
}
|
|
92
|
+
function stopRecording(recordingId, room, opts) {
|
|
93
|
+
return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: "POST" });
|
|
94
|
+
}
|
|
95
|
+
function getRecording(recordingId, room, opts) {
|
|
96
|
+
return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: "GET" });
|
|
97
|
+
}
|
|
64
98
|
|
|
65
|
-
export { DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, VoiceError, createRoomToken, verifyWebhook };
|
|
99
|
+
export { DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, VoiceError, createRoomToken, getRecording, startRecording, stopRecording, verifyWebhook };
|
|
66
100
|
//# sourceMappingURL=server.js.map
|
|
67
101
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\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_sources\"\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 * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\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/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\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 speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\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"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\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_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\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 * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\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/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\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 speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\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\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n if (!/^https?:\\/\\//.test(opts.controlUrl ?? \"\")) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an http(s) URL\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/call",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Calls for 2–10 people on a self-hosted LiveKit SFU in the EU — audio, camera and screen share: the app's server mints a short-lived, room-bound ticket that allows exactly the sources it names (microphone only by default) and verifies LiveKit's signed webhooks; the browser joins with noise suppression, echo cancellation and auto gain, and gets mute, devices, per-person volume, who-is-speaking, the participant list, camera and screen share on/off (incl. the browser's own stop button), every video picture to attach, and an audio-only mode — with simulcast, adaptive stream and dynacast.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Calls for 2–10 people on a self-hosted LiveKit SFU in the EU — audio, camera and screen share: the app's server mints a short-lived, room-bound ticket that allows exactly the sources it names (microphone only by default) and verifies LiveKit's signed webhooks; the browser joins with noise suppression, echo cancellation and auto gain, and gets mute, devices, per-person volume, who-is-speaking, the participant list, camera and screen share on/off (incl. the browser's own stop button), every video picture to attach, and an audio-only mode — with simulcast, adaptive stream and dynacast. 0.3.0 records the whole call to EU storage (startRecording/stopRecording/getRecording on the server, a recording state every participant sees in the browser).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|