realtime-avatar 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 +176 -0
- package/dist/browser.d.ts +203 -0
- package/dist/browser.js +172 -0
- package/dist/express.d.ts +28 -0
- package/dist/express.js +510 -0
- package/dist/hono.d.ts +19 -0
- package/dist/hono.js +498 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +453 -0
- package/dist/nextjs.d.ts +18 -0
- package/dist/nextjs.js +498 -0
- package/dist/proxy-client-cZyX50-O.d.ts +1508 -0
- package/dist/react-native.d.ts +103 -0
- package/dist/react-native.js +2305 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +2610 -0
- package/dist/server-only-guard.d.ts +2 -0
- package/dist/server-only-guard.js +4 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +453 -0
- package/dist/tanstack-start.d.ts +24 -0
- package/dist/tanstack-start.js +498 -0
- package/dist/tools.d.ts +111 -0
- package/dist/tools.js +121 -0
- package/dist/types-C_EMPwN7.d.ts +831 -0
- package/dist/types-E8SrD6sv.d.ts +48 -0
- package/package.json +150 -0
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# realtime-avatar
|
|
2
|
+
|
|
3
|
+
A live character your users can talk to — voice, or voice and video. She listens while she
|
|
4
|
+
speaks, so you can interrupt her mid-sentence and she stops, the way a person stops.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install realtime-avatar
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { RealtimeAvatar, isQueued } from "realtime-avatar";
|
|
12
|
+
|
|
13
|
+
const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
|
|
14
|
+
|
|
15
|
+
// On your server. The client picks WHO to call; you decide everything about the call.
|
|
16
|
+
const call = await rta.startCall({
|
|
17
|
+
avatarId: "ava_…",
|
|
18
|
+
instructions: "You are Rin. Short, warm, specific sentences.",
|
|
19
|
+
maxSeconds: 600,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (isQueued(call)) return { queued: true, position: call.position };
|
|
23
|
+
return call.raw; // relay to the browser byte-for-byte
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That is the whole server half. The client joins with the payload and renders her.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## What you get
|
|
31
|
+
|
|
32
|
+
| | |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| **Voice or video** | The same call, the same code. `mode: "voice"` is audio-only and cheaper. |
|
|
35
|
+
| **Full-duplex** | Interrupt her and she stops. A cough does not derail her. A pause is not the end of your turn. |
|
|
36
|
+
| **Your character** | Your footage, your voice, your persona — not a stock presenter. |
|
|
37
|
+
| **Your tools** | You run them; you feed the result back into a turn. Nothing calls your API on your behalf. |
|
|
38
|
+
| **Priced to leave on** | Under $5/hour of live conversation, billed by the second. |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## The shape of an integration
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
your client ──▶ your backend ──▶ Realtime Avatar
|
|
46
|
+
(holds the key, (capacity, listening,
|
|
47
|
+
decides the call) thinking, speaking, rendering)
|
|
48
|
+
◀────────── live audio + video, and she is listening ──────────
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Two facts follow from that picture and drive everything else:
|
|
52
|
+
|
|
53
|
+
1. **The key is server-only.** A browser holding it can start unlimited calls on your
|
|
54
|
+
account. The constructor throws in a browser runtime so that fails loudly, not silently.
|
|
55
|
+
2. **The connection payload is opaque.** Relay `call.raw` untouched — the browser client
|
|
56
|
+
validates it strictly.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
Everything is on one class. The full types are in
|
|
63
|
+
[`libs/http-client/src/types.ts`](../http-client/src/types.ts) — one file, no import chasing.
|
|
64
|
+
Almost every shape in it is **derived** from the published OpenAPI contract rather than declared
|
|
65
|
+
beside it, so a field that changes upstream fails the typecheck here. The exceptions are named at
|
|
66
|
+
the top of that file with the reason for each: two are gaps in the contract itself (it declares no
|
|
67
|
+
query parameters for `GET /v1/usage/sessions`, and the transcript webhook body is not in it at
|
|
68
|
+
all), and the `video` policy types are deliberately not one-to-one with the wire.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// calls
|
|
72
|
+
rta.startCall({ avatarId, mode?, instructions?, context?, maxSeconds?, video?, transcript?, metadata? })
|
|
73
|
+
rta.endCall(sessionId, { reason? }) // free an abandoned call's slot now; idempotent, never throws
|
|
74
|
+
|
|
75
|
+
// avatars
|
|
76
|
+
rta.createAvatarFromVideo({ displayName, videoUrl, voice? })
|
|
77
|
+
rta.listAvatars()
|
|
78
|
+
rta.getAvatar(avatarId)
|
|
79
|
+
rta.syncClips(avatarId, clipUrls) // after ANY clip change
|
|
80
|
+
|
|
81
|
+
// assets
|
|
82
|
+
rta.createRemoteAsset({ kind, remoteUrl })
|
|
83
|
+
rta.uploadAsset(file, { kind })
|
|
84
|
+
|
|
85
|
+
// billing
|
|
86
|
+
rta.creditBalance()
|
|
87
|
+
|
|
88
|
+
// webhooks
|
|
89
|
+
verifyTranscript(rawBytes, headers, secret)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Subpaths
|
|
95
|
+
|
|
96
|
+
**One install.** tsup treeshakes per entry, so a server-only app importing `realtime-avatar/server`
|
|
97
|
+
gets 18.8 KB with no React and no LiveKit in it, even though they sit in the same tarball.
|
|
98
|
+
|
|
99
|
+
Server — these hold your API key:
|
|
100
|
+
|
|
101
|
+
| Import | What it is |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `realtime-avatar` | `RealtimeAvatar`, `isQueued`, `verifyTranscript`, the two error classes |
|
|
104
|
+
| `realtime-avatar/server` | The same client with no route adapters — 18.8 KB |
|
|
105
|
+
| `realtime-avatar/nextjs` | `createRealtimeAvatarRoute` — App Router `{ GET, POST }` |
|
|
106
|
+
| `realtime-avatar/hono` | `realtimeAvatarHono` — Hono, Workers, Bun, Deno |
|
|
107
|
+
| `realtime-avatar/express` | `realtimeAvatarExpress` |
|
|
108
|
+
| `realtime-avatar/tanstack-start` | TanStack Start server route |
|
|
109
|
+
|
|
110
|
+
Browser — these never can:
|
|
111
|
+
|
|
112
|
+
| Import | What it is |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `realtime-avatar/react` | `AvatarCall`, `useAvatarCall`, `useRealtimeSession`, `useSessionLifecycle` |
|
|
115
|
+
| `realtime-avatar/react-native` | The same surface for Expo / React Native |
|
|
116
|
+
| `realtime-avatar/browser` | `enableMicrophone`, `attachRemoteAudio` — no React |
|
|
117
|
+
| `realtime-avatar/tools` | `attachAvatarTools` — the browser tool plane |
|
|
118
|
+
|
|
119
|
+
Every adapter takes the same two hooks: `authorize` gates the request, `session` decides the
|
|
120
|
+
call. Policy — `instructions`, `maxSeconds`, `voice`, `video` — is decided in `session`, on
|
|
121
|
+
your server. A route that spreads the request body into `startCall` hands your caller your
|
|
122
|
+
system prompt and your bill.
|
|
123
|
+
|
|
124
|
+
### Importing a server entry into a browser build throws
|
|
125
|
+
|
|
126
|
+
Not a lint rule and not a naming convention — the six server subpaths carry `browser` and
|
|
127
|
+
`react-native` export conditions pointing at a module whose only statement is a `throw`, so the
|
|
128
|
+
key-holding code never enters a client module graph. Measured: a browser bundle that imports both
|
|
129
|
+
halves contains **0** occurrences of `Bearer` or `apiKey`.
|
|
130
|
+
|
|
131
|
+
This lived under a second npm name (`realtime-avatar-react`) until 2026-08-26, on the theory that a
|
|
132
|
+
condition "chooses which file is bundled, never whether the package is". That was tested and is
|
|
133
|
+
false. Two things worth knowing if you copy the pattern: do **not** use `"browser": null` — Vite 8
|
|
134
|
+
and rolldown ignore it and bundle the server file *with* the secret — and do not leave
|
|
135
|
+
`"sideEffects": false` in place, which lets a bundler treeshake a throw-only module away and
|
|
136
|
+
silently disarms the whole guard.
|
|
137
|
+
|
|
138
|
+
## The two subpaths that are not React
|
|
139
|
+
|
|
140
|
+
`enableMicrophone` returns the cause as a value instead of throwing, because "the mic won't
|
|
141
|
+
start" is one sentence covering six causes with different fixes — and one of them, a macOS
|
|
142
|
+
system denial, cannot be fixed from the address bar and needs the browser restarted.
|
|
143
|
+
`attachRemoteAudio` attaches into the DOM *before* `connect`, which is what stops a track
|
|
144
|
+
arriving mid-connect from being lost on a fast connection.
|
|
145
|
+
|
|
146
|
+
`attachAvatarTools` runs your functions in the page. Nothing is executed on the platform, and
|
|
147
|
+
a tool has **2.5 seconds** to answer before the call gives up on it and tells her it failed.
|
|
148
|
+
|
|
149
|
+
## What `/react` exports, and what it deliberately does not
|
|
150
|
+
|
|
151
|
+
31 names, down from 82 on 2026-08-26. Two groups came out and are not coming back:
|
|
152
|
+
|
|
153
|
+
**LiveKit symbols.** `Room`, `RoomEvent`, `Track`, `useRoomContext` and 20 others were
|
|
154
|
+
re-exported from here. `livekit-client` and `@livekit/components-react` are peer dependencies, so
|
|
155
|
+
import them from LiveKit directly and you get the version you installed — re-exporting put their
|
|
156
|
+
types in this package's public surface, which meant a LiveKit major could break ours without a
|
|
157
|
+
line of our code changing.
|
|
158
|
+
|
|
159
|
+
**State-machine internals.** `acquireMicLease`, `stepQualityGovernor`, `retryStep`,
|
|
160
|
+
`resolveWarnBeforeMs` and 27 more were the individual steps the hooks drive. None was callable in
|
|
161
|
+
a useful order from outside, and every one was a name we would have had to keep working forever.
|
|
162
|
+
|
|
163
|
+
What stayed: the components and hooks, the `DEFAULT_*` constants (so you can read the timings
|
|
164
|
+
rather than guess them), the two capacity mappers `capacityErrorFromBusy` / `capacityStateFromGrant`
|
|
165
|
+
for building your own queue UI, and the zod schemas `sessionBehaviorSchema` / `sessionClipSchema`.
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Docs and support
|
|
171
|
+
|
|
172
|
+
- Full documentation: <https://realtimeavatar.ai/docs>
|
|
173
|
+
- Issues and feature requests: this repo
|
|
174
|
+
- The API is versioned at `/api/v1`; breaking changes get a new version, not a silent edit.
|
|
175
|
+
|
|
176
|
+
MIT licensed.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning the microphone on, and saying what went wrong when it does not.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `room.localParticipant.setMicrophoneEnabled(true)` is one line, so every
|
|
5
|
+
* integration writes it inline and none of them handle its rejection — the reject path is
|
|
6
|
+
* invisible until a real user is on a real machine. When it fails there is nothing to see:
|
|
7
|
+
* the call is already connected, the page is mid-`await`, and the rejection becomes an
|
|
8
|
+
* unhandled promise nobody is looking at. The reported symptom is always the same sentence —
|
|
9
|
+
* "the mic won't start" — and it covers at least six unrelated causes with different fixes,
|
|
10
|
+
* one of which is not in the browser at all.
|
|
11
|
+
*
|
|
12
|
+
* So this returns a RESULT rather than throwing: the cause becomes a value the caller has to
|
|
13
|
+
* handle, and each one carries the sentence that names its actual fix. `message` is what the
|
|
14
|
+
* browser said; `hint` is what the person should do.
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately structural — it never imports `livekit-client`, so it pins no version
|
|
17
|
+
* and adds no bytes. Anything with a `localParticipant.setMicrophoneEnabled` satisfies it.
|
|
18
|
+
*/
|
|
19
|
+
/** The sliver of livekit-client's `Room` this needs. Structural on purpose — see above. */
|
|
20
|
+
interface MicrophoneCapableRoom {
|
|
21
|
+
localParticipant: {
|
|
22
|
+
setMicrophoneEnabled(enabled: boolean): Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why the microphone did not start. Every variant has a DIFFERENT fix, which is the whole
|
|
27
|
+
* reason this is an enum and not a boolean.
|
|
28
|
+
*/
|
|
29
|
+
type MicrophoneFailureReason =
|
|
30
|
+
/** No `navigator.mediaDevices` — the page is not a secure context. */
|
|
31
|
+
"insecure-origin"
|
|
32
|
+
/** The permission prompt was never answered. getUserMedia may never settle; we time out. */
|
|
33
|
+
| "no-answer"
|
|
34
|
+
/** The BROWSER denied it — the site's own permission, resettable from the address bar. */
|
|
35
|
+
| "denied-by-browser"
|
|
36
|
+
/** The OS denied it — macOS System Settings, nothing the page or the site can change. */
|
|
37
|
+
| "denied-by-os"
|
|
38
|
+
/** No input device is connected, or none matches the constraints. */
|
|
39
|
+
| "no-device"
|
|
40
|
+
/** A device exists but something else holds it — another tab, app, or a stale call. */
|
|
41
|
+
| "device-in-use"
|
|
42
|
+
/** Anything unrecognised. `message` still carries what the browser said. */
|
|
43
|
+
| "unknown";
|
|
44
|
+
type MicrophoneResult = {
|
|
45
|
+
ok: true;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
reason: MicrophoneFailureReason;
|
|
49
|
+
/** What the browser reported, verbatim. Show it — it is often exact. */
|
|
50
|
+
message: string;
|
|
51
|
+
/** What the person should actually do about it. */
|
|
52
|
+
hint: string;
|
|
53
|
+
};
|
|
54
|
+
interface EnableMicrophoneOptions {
|
|
55
|
+
/**
|
|
56
|
+
* How long to wait before calling it a no-answer. Default 15s.
|
|
57
|
+
*
|
|
58
|
+
* This is not belt-and-braces. Per the getUserMedia specification a prompt the user never
|
|
59
|
+
* dismisses may leave the promise permanently pending — "neither resolve nor reject" — and
|
|
60
|
+
* a page that awaits it in its connect path hangs on a state that has no error and no end.
|
|
61
|
+
* A deadline turns that into a reportable outcome.
|
|
62
|
+
*/
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Publish the caller's microphone, and report WHY if it does not start.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* const mic = await enableMicrophone(room);
|
|
70
|
+
* if (!mic.ok) {
|
|
71
|
+
* status.textContent = `Microphone: ${mic.message}`;
|
|
72
|
+
* help.textContent = mic.hint; // the sentence that names the fix
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* Never throws. A call that cannot start the microphone is an ordinary outcome of asking a
|
|
77
|
+
* person for a device, not a programmer error, so it belongs in the return type.
|
|
78
|
+
*
|
|
79
|
+
* Note it does NOT hang up on failure — the call is still live and still billing, and only
|
|
80
|
+
* the caller knows whether a text-only session is useful to them. If it is not, disconnect
|
|
81
|
+
* in the `!ok` branch; leaving a room connected against a device the user never granted is
|
|
82
|
+
* how the retry ends up contending with the call that is still holding it.
|
|
83
|
+
*/
|
|
84
|
+
declare function enableMicrophone(room: MicrophoneCapableRoom, options?: EnableMicrophoneOptions): Promise<MicrophoneResult>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Making the character AUDIBLE — the half of "audio works" that has nothing to do with the
|
|
88
|
+
* microphone.
|
|
89
|
+
*
|
|
90
|
+
* WHY THIS EXISTS. Two failures here look identical to a developer ("she never speaks") and
|
|
91
|
+
* neither raises anything:
|
|
92
|
+
*
|
|
93
|
+
* 1. The audio element is created but never inserted into the document. `track.attach()`
|
|
94
|
+
* hands back a detached `<audio>`; a detached element is not reliably played by every
|
|
95
|
+
* engine, and there is no node on the page to fall back to. Nothing errors — the call is
|
|
96
|
+
* connected, the track is subscribed, the meter moves, and it is silent.
|
|
97
|
+
* 2. Autoplay is blocked. A page that has not yet had a user gesture may not start audio,
|
|
98
|
+
* so the first call a visitor makes is mute. `room.startAudio()` fixes it, but it must be
|
|
99
|
+
* called FROM a gesture — which means the page needs a button, which means the page has
|
|
100
|
+
* to know it is blocked. That signal is an event nobody subscribes to.
|
|
101
|
+
*
|
|
102
|
+
* So this owns both: it attaches into the DOM, and it tells you when a gesture is required
|
|
103
|
+
* and hands you the closure that spends it.
|
|
104
|
+
*
|
|
105
|
+
* Structural like its sibling — no `livekit-client` import, no version pin.
|
|
106
|
+
*/
|
|
107
|
+
/** The sliver of a livekit-client `Track` this needs. */
|
|
108
|
+
interface AttachableTrack {
|
|
109
|
+
kind: string;
|
|
110
|
+
attach(): HTMLMediaElement;
|
|
111
|
+
detach(): HTMLMediaElement[];
|
|
112
|
+
}
|
|
113
|
+
/** The sliver of a livekit-client `Room` this needs. */
|
|
114
|
+
interface AudioCapableRoom {
|
|
115
|
+
canPlaybackAudio: boolean;
|
|
116
|
+
startAudio(): Promise<void>;
|
|
117
|
+
on(event: string, listener: (...args: never[]) => void): unknown;
|
|
118
|
+
off(event: string, listener: (...args: never[]) => void): unknown;
|
|
119
|
+
}
|
|
120
|
+
interface AttachRemoteAudioOptions {
|
|
121
|
+
/**
|
|
122
|
+
* Where to park the audio elements. Defaults to `document.body`.
|
|
123
|
+
*
|
|
124
|
+
* They are not `display:none` — a hidden media element is still subject to the same
|
|
125
|
+
* autoplay rules, and hiding it only removes the browser's own affordance.
|
|
126
|
+
*/
|
|
127
|
+
container?: HTMLElement;
|
|
128
|
+
/**
|
|
129
|
+
* Called when the browser refuses to start audio without a gesture, and again with `null`
|
|
130
|
+
* once audio is playing. Render a button from it and call `unblock` in the click handler:
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* onPlaybackBlocked: (unblock) => {
|
|
134
|
+
* button.hidden = unblock === null;
|
|
135
|
+
* button.onclick = () => unblock?.();
|
|
136
|
+
* }
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
onPlaybackBlocked?: (unblock: (() => Promise<void>) | null) => void;
|
|
140
|
+
}
|
|
141
|
+
interface RemoteAudioAttachment {
|
|
142
|
+
/** Stop listening and remove every element this created. Safe to call twice. */
|
|
143
|
+
detach(): void;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Play every remote audio track the room subscribes to, and surface an autoplay block.
|
|
147
|
+
*
|
|
148
|
+
* Call it BEFORE `room.connect()` — a track subscribed during connect is missed otherwise,
|
|
149
|
+
* and that race is the version of this bug that only reproduces on a fast connection.
|
|
150
|
+
*
|
|
151
|
+
* ```ts
|
|
152
|
+
* const audio = attachRemoteAudio(room, {
|
|
153
|
+
* onPlaybackBlocked: (unblock) => {
|
|
154
|
+
* enableSound.hidden = unblock === null;
|
|
155
|
+
* enableSound.onclick = () => unblock?.();
|
|
156
|
+
* },
|
|
157
|
+
* });
|
|
158
|
+
* await room.connect(url, token);
|
|
159
|
+
* // …later
|
|
160
|
+
* audio.detach();
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
declare function attachRemoteAudio(room: AudioCapableRoom, options?: AttachRemoteAudioOptions): RemoteAudioAttachment;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ping-pong (boomerang) playback for idle/ambient clips.
|
|
167
|
+
*
|
|
168
|
+
* Idle videos are plain image-to-video generations (start frame != end
|
|
169
|
+
* frame), so a hard `loop` visibly jumps at the wrap. Playing forward, then
|
|
170
|
+
* scrubbing back to the start, makes ANY clip loop seamlessly — no seamless
|
|
171
|
+
* A==B generation required. Reverse is done by stepping `currentTime` on a
|
|
172
|
+
* requestAnimationFrame clock because `playbackRate < 0` is unsupported in
|
|
173
|
+
* most browsers.
|
|
174
|
+
*/
|
|
175
|
+
type BoomerangPlaybackOptions = {
|
|
176
|
+
/** Reverse-phase speed relative to real time. Default 1 (same pace as forward). */
|
|
177
|
+
reverseRate?: number;
|
|
178
|
+
/** Seconds from each end at which the direction flips. Default 0.05. */
|
|
179
|
+
edgeEpsilonSeconds?: number;
|
|
180
|
+
};
|
|
181
|
+
type BoomerangPlayback = {
|
|
182
|
+
/** Stop driving the element (leaves the video itself untouched). */
|
|
183
|
+
stop(): void;
|
|
184
|
+
/** Current loop state, e.g. to hand playback position to a live renderer. */
|
|
185
|
+
position(): {
|
|
186
|
+
timeSeconds: number;
|
|
187
|
+
direction: "forward" | "reverse";
|
|
188
|
+
};
|
|
189
|
+
/** Jump the loop to a position+direction (seamless handback from a live render). */
|
|
190
|
+
alignTo(timeSeconds: number, direction: "forward" | "reverse"): void;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Drive a `<video>` element in a forward-then-reverse loop. The element should
|
|
194
|
+
* be muted + playsInline (autoplay policies). Idempotent per call; returns a
|
|
195
|
+
* handle whose `stop()` cancels the drive loop. Safe with elements whose
|
|
196
|
+
* metadata has not loaded yet.
|
|
197
|
+
*
|
|
198
|
+
* const playback = attachBoomerangPlayback(videoEl);
|
|
199
|
+
* // later: playback.stop();
|
|
200
|
+
*/
|
|
201
|
+
declare function attachBoomerangPlayback(video: HTMLVideoElement, options?: BoomerangPlaybackOptions): BoomerangPlayback;
|
|
202
|
+
|
|
203
|
+
export { type AttachRemoteAudioOptions, type AttachableTrack, type AudioCapableRoom, type BoomerangPlayback, type BoomerangPlaybackOptions, type EnableMicrophoneOptions, type MicrophoneCapableRoom, type MicrophoneFailureReason, type MicrophoneResult, type RemoteAudioAttachment, attachBoomerangPlayback, attachRemoteAudio, enableMicrophone };
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// ../browser/src/microphone.ts
|
|
2
|
+
var HINTS = {
|
|
3
|
+
"insecure-origin": "The browser only exposes microphones on a secure origin. Serve the page over https, or open it on http://localhost \u2014 a LAN address like http://192.168.1.5 will not do.",
|
|
4
|
+
"no-answer": "The permission prompt is probably still open, or was dismissed without an answer. Look for it in the address bar, answer it, and try again.",
|
|
5
|
+
"denied-by-browser": "This site is blocked from using the microphone. Click the icon at the left of the address bar, set Microphone to Allow, and reload.",
|
|
6
|
+
"denied-by-os": "The operating system is blocking the browser itself, so no site can record. On macOS: System Settings > Privacy & Security > Microphone, enable your browser, then RESTART it \u2014 the change does not apply to a running browser.",
|
|
7
|
+
"no-device": "No microphone is available. Connect one, and if you are on Bluetooth headphones check they are in a mode that has a microphone.",
|
|
8
|
+
"device-in-use": "Another application or browser tab is holding the microphone. Close it \u2014 including any call from this page you did not hang up \u2014 and try again.",
|
|
9
|
+
unknown: "Try again, and if it persists include the message above in your report."
|
|
10
|
+
};
|
|
11
|
+
function isOperatingSystemDenial(message) {
|
|
12
|
+
return /\bby system\b|system permission/i.test(message);
|
|
13
|
+
}
|
|
14
|
+
function classify(error) {
|
|
15
|
+
if (error instanceof TypeError && /mediaDevices|undefined/i.test(error.message)) {
|
|
16
|
+
return { reason: "insecure-origin", message: error.message };
|
|
17
|
+
}
|
|
18
|
+
const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
|
|
19
|
+
const message = typeof error === "object" && error !== null && "message" in error && error.message ? String(error.message) : String(error);
|
|
20
|
+
switch (name) {
|
|
21
|
+
case "NotAllowedError":
|
|
22
|
+
case "PermissionDeniedError":
|
|
23
|
+
return {
|
|
24
|
+
reason: isOperatingSystemDenial(message) ? "denied-by-os" : "denied-by-browser",
|
|
25
|
+
message
|
|
26
|
+
};
|
|
27
|
+
case "NotFoundError":
|
|
28
|
+
case "DevicesNotFoundError":
|
|
29
|
+
case "OverconstrainedError":
|
|
30
|
+
case "ConstraintNotSatisfiedError":
|
|
31
|
+
return { reason: "no-device", message };
|
|
32
|
+
case "NotReadableError":
|
|
33
|
+
case "TrackStartError":
|
|
34
|
+
case "AbortError":
|
|
35
|
+
return { reason: "device-in-use", message };
|
|
36
|
+
case "SecurityError":
|
|
37
|
+
return { reason: "insecure-origin", message };
|
|
38
|
+
default:
|
|
39
|
+
return { reason: "unknown", message };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function fail(reason, message) {
|
|
43
|
+
return { ok: false, reason, message, hint: HINTS[reason] };
|
|
44
|
+
}
|
|
45
|
+
async function enableMicrophone(room, options = {}) {
|
|
46
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
47
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
48
|
+
return fail(
|
|
49
|
+
"insecure-origin",
|
|
50
|
+
"navigator.mediaDevices is undefined \u2014 this page is not a secure context."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const NO_ANSWER = /* @__PURE__ */ Symbol("no-answer");
|
|
54
|
+
let timer;
|
|
55
|
+
try {
|
|
56
|
+
const deadline = new Promise((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve(NO_ANSWER), timeoutMs);
|
|
58
|
+
});
|
|
59
|
+
const outcome = await Promise.race([
|
|
60
|
+
room.localParticipant.setMicrophoneEnabled(true).then(() => "granted"),
|
|
61
|
+
deadline
|
|
62
|
+
]);
|
|
63
|
+
if (outcome === NO_ANSWER) {
|
|
64
|
+
return fail(
|
|
65
|
+
"no-answer",
|
|
66
|
+
`The microphone permission prompt was not answered within ${Math.round(timeoutMs / 1e3)}s.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const { reason, message } = classify(error);
|
|
72
|
+
return fail(reason, message);
|
|
73
|
+
} finally {
|
|
74
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ../browser/src/remote-audio.ts
|
|
79
|
+
var AUDIO_PLAYBACK_CHANGED = "audioPlaybackChanged";
|
|
80
|
+
var TRACK_SUBSCRIBED = "trackSubscribed";
|
|
81
|
+
var AUDIO_KIND = "audio";
|
|
82
|
+
function attachRemoteAudio(room, options = {}) {
|
|
83
|
+
const container = options.container ?? document.body;
|
|
84
|
+
const elements = /* @__PURE__ */ new Set();
|
|
85
|
+
let detached = false;
|
|
86
|
+
const publishPlaybackState = () => {
|
|
87
|
+
if (detached) return;
|
|
88
|
+
options.onPlaybackBlocked?.(room.canPlaybackAudio ? null : () => room.startAudio());
|
|
89
|
+
};
|
|
90
|
+
const onTrackSubscribed = (track) => {
|
|
91
|
+
if (detached || track.kind !== AUDIO_KIND) return;
|
|
92
|
+
const element = track.attach();
|
|
93
|
+
element.autoplay = true;
|
|
94
|
+
container.appendChild(element);
|
|
95
|
+
elements.add(element);
|
|
96
|
+
publishPlaybackState();
|
|
97
|
+
};
|
|
98
|
+
room.on(TRACK_SUBSCRIBED, onTrackSubscribed);
|
|
99
|
+
room.on(AUDIO_PLAYBACK_CHANGED, publishPlaybackState);
|
|
100
|
+
publishPlaybackState();
|
|
101
|
+
return {
|
|
102
|
+
detach() {
|
|
103
|
+
if (detached) return;
|
|
104
|
+
detached = true;
|
|
105
|
+
room.off(TRACK_SUBSCRIBED, onTrackSubscribed);
|
|
106
|
+
room.off(AUDIO_PLAYBACK_CHANGED, publishPlaybackState);
|
|
107
|
+
for (const element of elements) element.remove();
|
|
108
|
+
elements.clear();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../client/src/browser/boomerang.ts
|
|
114
|
+
function attachBoomerangPlayback(video, options = {}) {
|
|
115
|
+
const reverseRate = options.reverseRate ?? 1;
|
|
116
|
+
const edge = options.edgeEpsilonSeconds ?? 0.05;
|
|
117
|
+
let raf = null;
|
|
118
|
+
let direction = 1;
|
|
119
|
+
let last = now();
|
|
120
|
+
const step = (nowMs) => {
|
|
121
|
+
raf = requestAnimationFrame(step);
|
|
122
|
+
const duration = video.duration;
|
|
123
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
124
|
+
last = nowMs;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (direction === 1) {
|
|
128
|
+
if (video.paused) void video.play().catch(() => {
|
|
129
|
+
});
|
|
130
|
+
if (video.currentTime >= duration - edge) {
|
|
131
|
+
direction = -1;
|
|
132
|
+
video.pause();
|
|
133
|
+
last = nowMs;
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const dt = Math.min(0.1, (nowMs - last) / 1e3) * reverseRate;
|
|
138
|
+
last = nowMs;
|
|
139
|
+
const next = video.currentTime - dt;
|
|
140
|
+
if (next <= edge) {
|
|
141
|
+
video.currentTime = 0;
|
|
142
|
+
direction = 1;
|
|
143
|
+
} else {
|
|
144
|
+
video.currentTime = next;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
raf = requestAnimationFrame(step);
|
|
148
|
+
return {
|
|
149
|
+
stop() {
|
|
150
|
+
if (raf !== null) {
|
|
151
|
+
cancelAnimationFrame(raf);
|
|
152
|
+
raf = null;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
position() {
|
|
156
|
+
return { timeSeconds: video.currentTime, direction: direction === 1 ? "forward" : "reverse" };
|
|
157
|
+
},
|
|
158
|
+
alignTo(timeSeconds, nextDirection) {
|
|
159
|
+
const duration = video.duration;
|
|
160
|
+
const clamped = Number.isFinite(duration) && duration > 0 ? Math.min(Math.max(timeSeconds, 0), duration - edge) : Math.max(timeSeconds, 0);
|
|
161
|
+
video.currentTime = clamped;
|
|
162
|
+
direction = nextDirection === "reverse" ? -1 : 1;
|
|
163
|
+
last = now();
|
|
164
|
+
if (direction === -1) video.pause();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function now() {
|
|
169
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { attachBoomerangPlayback, attachRemoteAudio, enableMicrophone };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { P as ProxyConfig } from './types-E8SrD6sv.js';
|
|
2
|
+
import './types-C_EMPwN7.js';
|
|
3
|
+
|
|
4
|
+
type Expressish = {
|
|
5
|
+
method: string;
|
|
6
|
+
originalUrl: string;
|
|
7
|
+
headers: Record<string, string | string[] | undefined>;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
};
|
|
10
|
+
type ResponseLike = {
|
|
11
|
+
status(code: number): ResponseLike;
|
|
12
|
+
set(field: string, value: string): ResponseLike;
|
|
13
|
+
send(body: string): void;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Express 4/5.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* app.use("/api/realtime-avatar", express.json(), realtimeAvatarExpress({ apiKey, session }));
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Express hands us a parsed body, so it is re-serialized here rather than streamed. That is
|
|
23
|
+
* fine for this route — the payloads are small — but it is why the Fetch adapters are the
|
|
24
|
+
* better path if you have a choice.
|
|
25
|
+
*/
|
|
26
|
+
declare function realtimeAvatarExpress(config: ProxyConfig): (req: Expressish, res: ResponseLike) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
export { realtimeAvatarExpress };
|