@loop-voice-agent/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loop Methods
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # @loop-voice-agent/web
2
+
3
+ Browser SDK for the Loop Voice Agent platform. Start a voice call from a
4
+ **publishable key** and an **agent id** — the prompt, model and voice never
5
+ reach the browser.
6
+
7
+ > **Temporary package name.** This is published under `@loop-voice-agent/web`
8
+ > while the product name is being decided. When it changes, a new package will
9
+ > be published and consumers update their import line; the API will not change
10
+ > as part of that rename.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @loop-voice-agent/web
16
+ ```
17
+
18
+ ESM only. Modern bundlers (Vite, Next, Rollup, webpack 5) consume it directly.
19
+
20
+ ## Use
21
+
22
+ ```ts
23
+ import { VoiceAgent } from "@loop-voice-agent/web";
24
+
25
+ const client = new VoiceAgent(import.meta.env.VITE_VOICE_PUBLIC_KEY, {
26
+ baseUrl: "https://api.example.com",
27
+ });
28
+
29
+ client.on("call-start", () => setStatus("live"));
30
+ client.on("call-end", ({ endedReason }) => setStatus(`ended: ${endedReason}`));
31
+ client.on("message", (m) => {
32
+ if (m.type === "transcript" && m.transcriptType === "final") append(m.role, m.transcript);
33
+ });
34
+ client.on("error", ({ code, message }) => showError(code, message));
35
+
36
+ await client.start(agentId, {
37
+ variables: { candidate_name: "Asha", role: "SDE" },
38
+ language: "hi",
39
+ channel: "audio",
40
+ metadata: { applicant_id: "A-123" },
41
+ });
42
+
43
+ // …later
44
+ client.setMuted(true);
45
+ client.stop();
46
+ ```
47
+
48
+ Attach the agent's audio to play it:
49
+
50
+ ```ts
51
+ audioEl.srcObject = client.getRemoteStream();
52
+ ```
53
+
54
+ ## Credentials
55
+
56
+ Two key classes exist, and only one belongs in a browser:
57
+
58
+ | Key | Where it belongs | Authority |
59
+ | ------- | ---------------- | ----------------------------------------------- |
60
+ | `vpk_…` | Browser bundle | Start a call — and only from registered origins |
61
+ | `vak_…` | Your server only | Create agents, place PSTN calls, read history |
62
+
63
+ A publishable key is safe to ship because its authority is capped and it only
64
+ works from origins registered against it. **Register your app's origin** on the
65
+ key, or every call fails with a 403 — that specific failure means the origin is
66
+ missing, not that the key is wrong.
67
+
68
+ ## Events
69
+
70
+ | Event | Payload | When |
71
+ | -------------- | --------------------------- | ----------------------------------------- |
72
+ | `call-start` | — | Media is flowing |
73
+ | `call-end` | `{ endedReason, callId? }` | The call ended, cleanly or otherwise |
74
+ | `speech-start` | — | The agent started speaking |
75
+ | `speech-end` | — | The agent stopped speaking |
76
+ | `message` | `TranscriptMessage \| …` | A message from the server, usually a turn |
77
+ | `error` | `{ code, message, cause? }` | The call could not start |
78
+
79
+ `error` and `call-end` are mutually exclusive for a single failure: a call that
80
+ never started emits `error`, and one that started and then ended emits
81
+ `call-end`. You never have to de-duplicate the two.
82
+
83
+ Classify an ending with the exported helper rather than by matching strings
84
+ yourself:
85
+
86
+ ```ts
87
+ import { isAbnormalEndedReason } from "@loop-voice-agent/web";
88
+
89
+ client.on("call-end", ({ endedReason }) => {
90
+ if (isAbnormalEndedReason(endedReason)) showRetry();
91
+ else showComplete();
92
+ });
93
+ ```
94
+
95
+ ## Screen sharing
96
+
97
+ Pass an **already-captured** stream — the SDK never calls `getDisplayMedia`
98
+ itself, because browsers only grant it from a user gesture and capturing inside
99
+ the SDK would raise a second picker mid-call:
100
+
101
+ ```ts
102
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
103
+ await client.start(agentId, { screenStream });
104
+ ```
105
+
106
+ Prefer passing it to `start()` when you have it up front: the track goes into the
107
+ initial offer and skips a renegotiation round trip while the user waits. When the
108
+ stream only arrives mid-call:
109
+
110
+ ```ts
111
+ await client.startScreenShare(screenStream);
112
+ ```
113
+
114
+ ## Networking
115
+
116
+ `start()` performs both hops for you:
117
+
118
+ 1. `POST {baseUrl}/v1/calls/web` with the publishable key → session bundle
119
+ 2. WebRTC offer → `POST {voiceWorkerUrl}/v1/calls/web/sessions` → SDP answer
120
+
121
+ Behind symmetric NAT you need ICE servers; without them only host candidates are
122
+ tried and the call fails with `pipeline-error-connection-failed`:
123
+
124
+ ```ts
125
+ new VoiceAgent(key, {
126
+ baseUrl,
127
+ iceServers: [{ urls: "stun:stun.example.com:3478" }],
128
+ });
129
+ ```
130
+
131
+ ## API
132
+
133
+ - `new VoiceAgent(publicKey, options?)` — `baseUrl`, `iceServers`,
134
+ `connectTimeoutMs`, `fetch`, `getUserMedia`
135
+ - `start(agentId, context?)` — `variables`, `language`, `channel`, `metadata`
136
+ - `stop()` · `setMuted(bool)` · `isMuted()` · `destroy()`
137
+ - `startScreenShare(stream)` · `isScreenSharing()`
138
+ - `getStatus()` · `getCallId()` · `getLocalStream()` · `getRemoteStream()`
139
+ - `on(event, handler)` · `once(...)` · `off(...)` — `on` returns an unsubscribe
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,143 @@
1
+ import { type CallContext, type FetchLike } from "./controlPlane.js";
2
+ import type { VoiceAgentErrorCode, VoiceAgentEventMap, VoiceAgentEventName } from "./events.js";
3
+ /** Where the call is in its lifecycle. */
4
+ export type CallStatus = "idle" | "connecting" | "active" | "ended" | "error";
5
+ export interface VoiceAgentOptions {
6
+ /**
7
+ * Platform base URL — the Kong gateway. Defaults to same-origin, which is
8
+ * right when the app is served behind the same gateway and wrong otherwise,
9
+ * so most integrations pass this explicitly.
10
+ */
11
+ readonly baseUrl?: string;
12
+ /**
13
+ * ICE servers. Empty means host candidates only, which works on a LAN and
14
+ * fails behind symmetric NAT — a deployment serving real users over the
15
+ * internet needs at least a STUN server here, and realistically a TURN one.
16
+ */
17
+ readonly iceServers?: RTCIceServer[];
18
+ /**
19
+ * How long to wait for media after the server accepts the call, in ms.
20
+ *
21
+ * This exists because accepting a call is not connecting one. Without a
22
+ * bound, a user whose UDP is blocked sits on "Connecting…" indefinitely with
23
+ * nothing ever arriving to tell them otherwise.
24
+ */
25
+ readonly connectTimeoutMs?: number;
26
+ /** Injectable fetch, for tests and for consumers with an instrumented client. */
27
+ readonly fetch?: FetchLike;
28
+ /** Injectable media capture, for tests. Defaults to `navigator.mediaDevices`. */
29
+ readonly getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
30
+ }
31
+ /**
32
+ * A browser voice-agent client.
33
+ *
34
+ * ```ts
35
+ * const client = new VoiceAgent(publishableKey, { baseUrl });
36
+ * client.on("message", (m) => { if (m.type === "transcript") render(m); });
37
+ * await client.start(agentId, { variables: { candidate_name: "Asha" } });
38
+ * ```
39
+ *
40
+ * `start()` performs both hops — asking the platform for a session, then
41
+ * negotiating media with the assigned worker — so a caller supplies only a
42
+ * publishable key and an agent id, and never handles a session token or an SDP
43
+ * offer itself.
44
+ *
45
+ * One instance drives one call at a time. Calling `start()` twice without an
46
+ * intervening `stop()` is an error rather than a silent takeover, because the
47
+ * second call would otherwise orphan the first one's media with no event to
48
+ * say so.
49
+ */
50
+ export declare class VoiceAgent {
51
+ private readonly publicKey;
52
+ private readonly emitter;
53
+ private readonly baseUrl;
54
+ private readonly iceServers;
55
+ private readonly connectTimeoutMs;
56
+ private readonly fetchImpl;
57
+ private readonly getUserMediaImpl;
58
+ private session;
59
+ private localStream;
60
+ private remoteStream;
61
+ private callId;
62
+ private status;
63
+ private muted;
64
+ private connectTimer;
65
+ /**
66
+ * Rejects the in-flight `start()`, if there is one.
67
+ *
68
+ * Without this, stopping a call mid-connect left `start()` pending forever:
69
+ * every asynchronous continuation correctly declined to act on a superseded
70
+ * attempt, and so nothing was left to settle the promise the caller was
71
+ * awaiting.
72
+ */
73
+ private pendingReject;
74
+ /**
75
+ * Monotonic attempt counter. Every asynchronous continuation checks it before
76
+ * acting, so a superseded attempt — one whose call was stopped, or replaced
77
+ * by a retry — can never emit an event or tear down the attempt that
78
+ * replaced it. Without this a slow first attempt's timeout fires into a
79
+ * healthy second call.
80
+ */
81
+ private attempt;
82
+ constructor(publicKey: string, options?: VoiceAgentOptions);
83
+ on<E extends VoiceAgentEventName>(event: E, handler: VoiceAgentEventMap[E]): () => void;
84
+ once<E extends VoiceAgentEventName>(event: E, handler: VoiceAgentEventMap[E]): () => void;
85
+ off<E extends VoiceAgentEventName>(event: E, handler: VoiceAgentEventMap[E]): void;
86
+ getStatus(): CallStatus;
87
+ getCallId(): string | undefined;
88
+ isMuted(): boolean;
89
+ /** The local microphone stream, for a level meter or a self-view. */
90
+ getLocalStream(): MediaStream | null;
91
+ /** The agent's stream — its audio, plus avatar video on a video call. */
92
+ getRemoteStream(): MediaStream | null;
93
+ /**
94
+ * Start a call with `agentId`.
95
+ *
96
+ * Resolves once media is flowing. Rejects if the call could not be started,
97
+ * having already emitted `error` — so a caller may either await this or bind
98
+ * `error`, and does not have to do both.
99
+ */
100
+ start(agentId: string, context?: CallContext): Promise<void>;
101
+ /** End the call. Idempotent, and safe to call before `start()`. */
102
+ stop(): void;
103
+ /**
104
+ * Attach a screen share to a call that is already running.
105
+ *
106
+ * Takes an ALREADY-CAPTURED stream rather than calling `getDisplayMedia`
107
+ * itself. The caller has to own the capture anyway — browsers only grant it
108
+ * from a user gesture — and capturing here would raise a second picker in the
109
+ * middle of a call the user is already on.
110
+ *
111
+ * Prefer passing `screenStream` to `start()` when the stream is available up
112
+ * front: it goes into the initial offer and avoids a renegotiation round trip
113
+ * while the user is waiting to be connected.
114
+ */
115
+ startScreenShare(stream: MediaStream): Promise<void>;
116
+ /** Whether a screen share is currently attached to the call. */
117
+ isScreenSharing(): boolean;
118
+ /** Mute or unmute the microphone. */
119
+ setMuted(muted: boolean): void;
120
+ /** Release everything and drop all handlers. The instance is done after this. */
121
+ destroy(): void;
122
+ private captureMicrophone;
123
+ private openMedia;
124
+ /**
125
+ * Settle a pending `start()` as cancelled. Emits nothing: the caller asked
126
+ * for this, so an `error` event would be an unexplained failure toast on a
127
+ * deliberate action.
128
+ */
129
+ private releasePending;
130
+ /** True when this attempt has been superseded by a stop() or a newer start(). */
131
+ private isSuperseded;
132
+ private clearConnectTimer;
133
+ private teardown;
134
+ private emitCallEnd;
135
+ /** Emit `error` and return the matching exception, so callers can `throw` it. */
136
+ private fail;
137
+ }
138
+ /** The error `start()` rejects with. The matching `error` event has already fired. */
139
+ export declare class VoiceAgentError extends Error {
140
+ readonly code: VoiceAgentErrorCode;
141
+ readonly cause?: unknown;
142
+ constructor(code: VoiceAgentErrorCode, message: string, cause?: unknown);
143
+ }
package/dist/client.js ADDED
@@ -0,0 +1,364 @@
1
+ import { startWebCall, } from "./controlPlane.js";
2
+ import { Emitter } from "./emitter.js";
3
+ import { CLEAN_ENDED_REASONS, FAILED_ENDED_REASONS } from "./endedReason.js";
4
+ import { decodeMessage } from "./messages.js";
5
+ import { MediaSession } from "./session.js";
6
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
7
+ const AUDIO_CONSTRAINTS = {
8
+ echoCancellation: true,
9
+ noiseSuppression: true,
10
+ autoGainControl: true,
11
+ };
12
+ const VIDEO_CONSTRAINTS = {
13
+ width: { ideal: 640 },
14
+ height: { ideal: 480 },
15
+ facingMode: "user",
16
+ };
17
+ /**
18
+ * A browser voice-agent client.
19
+ *
20
+ * ```ts
21
+ * const client = new VoiceAgent(publishableKey, { baseUrl });
22
+ * client.on("message", (m) => { if (m.type === "transcript") render(m); });
23
+ * await client.start(agentId, { variables: { candidate_name: "Asha" } });
24
+ * ```
25
+ *
26
+ * `start()` performs both hops — asking the platform for a session, then
27
+ * negotiating media with the assigned worker — so a caller supplies only a
28
+ * publishable key and an agent id, and never handles a session token or an SDP
29
+ * offer itself.
30
+ *
31
+ * One instance drives one call at a time. Calling `start()` twice without an
32
+ * intervening `stop()` is an error rather than a silent takeover, because the
33
+ * second call would otherwise orphan the first one's media with no event to
34
+ * say so.
35
+ */
36
+ export class VoiceAgent {
37
+ publicKey;
38
+ emitter = new Emitter();
39
+ baseUrl;
40
+ iceServers;
41
+ connectTimeoutMs;
42
+ fetchImpl;
43
+ getUserMediaImpl;
44
+ session = null;
45
+ localStream = null;
46
+ remoteStream = null;
47
+ callId;
48
+ status = "idle";
49
+ muted = false;
50
+ connectTimer = null;
51
+ /**
52
+ * Rejects the in-flight `start()`, if there is one.
53
+ *
54
+ * Without this, stopping a call mid-connect left `start()` pending forever:
55
+ * every asynchronous continuation correctly declined to act on a superseded
56
+ * attempt, and so nothing was left to settle the promise the caller was
57
+ * awaiting.
58
+ */
59
+ pendingReject = null;
60
+ /**
61
+ * Monotonic attempt counter. Every asynchronous continuation checks it before
62
+ * acting, so a superseded attempt — one whose call was stopped, or replaced
63
+ * by a retry — can never emit an event or tear down the attempt that
64
+ * replaced it. Without this a slow first attempt's timeout fires into a
65
+ * healthy second call.
66
+ */
67
+ attempt = 0;
68
+ constructor(publicKey, options = {}) {
69
+ this.publicKey = publicKey;
70
+ this.baseUrl = options.baseUrl ?? "";
71
+ this.iceServers = options.iceServers ?? [];
72
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
73
+ this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
74
+ this.getUserMediaImpl =
75
+ options.getUserMedia ?? ((c) => globalThis.navigator.mediaDevices.getUserMedia(c));
76
+ }
77
+ // ---------------------------------------------------------------- events --
78
+ on(event, handler) {
79
+ return this.emitter.on(event, handler);
80
+ }
81
+ once(event, handler) {
82
+ return this.emitter.once(event, handler);
83
+ }
84
+ off(event, handler) {
85
+ this.emitter.off(event, handler);
86
+ }
87
+ // ----------------------------------------------------------------- state --
88
+ getStatus() {
89
+ return this.status;
90
+ }
91
+ getCallId() {
92
+ return this.callId;
93
+ }
94
+ isMuted() {
95
+ return this.muted;
96
+ }
97
+ /** The local microphone stream, for a level meter or a self-view. */
98
+ getLocalStream() {
99
+ return this.localStream;
100
+ }
101
+ /** The agent's stream — its audio, plus avatar video on a video call. */
102
+ getRemoteStream() {
103
+ return this.remoteStream;
104
+ }
105
+ // ------------------------------------------------------------------ call --
106
+ /**
107
+ * Start a call with `agentId`.
108
+ *
109
+ * Resolves once media is flowing. Rejects if the call could not be started,
110
+ * having already emitted `error` — so a caller may either await this or bind
111
+ * `error`, and does not have to do both.
112
+ */
113
+ async start(agentId, context = {}) {
114
+ if (!this.publicKey) {
115
+ throw this.fail("missing-public-key", "No publishable key was supplied to VoiceAgent.");
116
+ }
117
+ if (!agentId) {
118
+ throw this.fail("missing-agent-id", "start() requires an agent id.");
119
+ }
120
+ if (this.status === "connecting" || this.status === "active") {
121
+ throw this.fail("already-started", "A call is already in progress — call stop() before starting another.");
122
+ }
123
+ const attempt = ++this.attempt;
124
+ this.status = "connecting";
125
+ this.callId = undefined;
126
+ this.muted = false;
127
+ const wantVideo = context.channel === "video";
128
+ const screenStream = context.screenStream;
129
+ try {
130
+ const session = await startWebCall(this.baseUrl, this.publicKey, agentId, context, this.fetchImpl);
131
+ if (this.isSuperseded(attempt))
132
+ throw cancelled();
133
+ this.callId = session.callId;
134
+ const stream = await this.captureMicrophone(wantVideo);
135
+ if (this.isSuperseded(attempt)) {
136
+ for (const track of stream.getTracks())
137
+ track.stop();
138
+ throw cancelled();
139
+ }
140
+ this.localStream = stream;
141
+ await this.openMedia(attempt, session, stream, wantVideo, screenStream);
142
+ }
143
+ catch (err) {
144
+ // A cancellation is the caller's own stop() coming back to them; it is
145
+ // already fully handled and must not be reported as a failure.
146
+ if (err instanceof VoiceAgentError && err.code === "cancelled")
147
+ throw err;
148
+ if (this.isSuperseded(attempt))
149
+ throw cancelled();
150
+ this.teardown();
151
+ this.status = "error";
152
+ if (err instanceof VoiceAgentError)
153
+ throw err;
154
+ throw this.fail("media-plane-failed", describe(err), err);
155
+ }
156
+ finally {
157
+ this.pendingReject = null;
158
+ }
159
+ }
160
+ /** End the call. Idempotent, and safe to call before `start()`. */
161
+ stop() {
162
+ if (this.status !== "connecting" && this.status !== "active") {
163
+ this.teardown();
164
+ return;
165
+ }
166
+ const wasActive = this.status === "active";
167
+ this.attempt += 1; // supersede any in-flight attempt
168
+ this.releasePending();
169
+ this.teardown();
170
+ this.status = "ended";
171
+ // A call that never connected has no end to report — the caller either
172
+ // awaited a rejected start() or received `error`. Emitting `call-end` here
173
+ // too would make a single failure look like two events.
174
+ if (wasActive)
175
+ this.emitCallEnd(CLEAN_ENDED_REASONS.CUSTOMER_ENDED_CALL);
176
+ }
177
+ /**
178
+ * Attach a screen share to a call that is already running.
179
+ *
180
+ * Takes an ALREADY-CAPTURED stream rather than calling `getDisplayMedia`
181
+ * itself. The caller has to own the capture anyway — browsers only grant it
182
+ * from a user gesture — and capturing here would raise a second picker in the
183
+ * middle of a call the user is already on.
184
+ *
185
+ * Prefer passing `screenStream` to `start()` when the stream is available up
186
+ * front: it goes into the initial offer and avoids a renegotiation round trip
187
+ * while the user is waiting to be connected.
188
+ */
189
+ async startScreenShare(stream) {
190
+ if (this.status !== "active" || !this.session) {
191
+ throw this.fail("no-active-call", "startScreenShare() needs a live call — pass `screenStream` to start() instead.");
192
+ }
193
+ try {
194
+ await this.session.addScreenShare(stream);
195
+ }
196
+ catch (err) {
197
+ throw this.fail("screen-share-failed", describe(err), err);
198
+ }
199
+ }
200
+ /** Whether a screen share is currently attached to the call. */
201
+ isScreenSharing() {
202
+ return this.session?.hasScreenShare() ?? false;
203
+ }
204
+ /** Mute or unmute the microphone. */
205
+ setMuted(muted) {
206
+ this.muted = muted;
207
+ if (this.session && this.localStream)
208
+ this.session.setMuted(this.localStream, muted);
209
+ }
210
+ /** Release everything and drop all handlers. The instance is done after this. */
211
+ destroy() {
212
+ this.stop();
213
+ this.emitter.removeAll();
214
+ }
215
+ // -------------------------------------------------------------- internals --
216
+ async captureMicrophone(wantVideo) {
217
+ try {
218
+ return await this.getUserMediaImpl({
219
+ audio: AUDIO_CONSTRAINTS,
220
+ video: wantVideo ? VIDEO_CONSTRAINTS : false,
221
+ });
222
+ }
223
+ catch (err) {
224
+ throw this.fail("microphone-denied", "Microphone access was denied. Allow the microphone and try again.", err);
225
+ }
226
+ }
227
+ async openMedia(attempt, session, stream, wantVideo, screenStream) {
228
+ const connected = new Promise((resolve, reject) => {
229
+ const media = new MediaSession({ iceServers: this.iceServers, fetchImpl: this.fetchImpl }, {
230
+ onMessage: (raw) => {
231
+ if (this.isSuperseded(attempt))
232
+ return;
233
+ const message = decodeMessage(raw);
234
+ if (!message)
235
+ return;
236
+ // Speech frames are lifecycle, not content: they get their own
237
+ // events rather than being handed to `message` handlers, which are
238
+ // written to expect transcript turns.
239
+ if (message.type === "speech-start")
240
+ this.emitter.emit("speech-start");
241
+ else if (message.type === "speech-end")
242
+ this.emitter.emit("speech-end");
243
+ else
244
+ this.emitter.emit("message", message);
245
+ },
246
+ onTrack: (event) => {
247
+ if (this.isSuperseded(attempt))
248
+ return;
249
+ const [remote] = event.streams;
250
+ if (remote)
251
+ this.remoteStream = remote;
252
+ },
253
+ onConnected: () => {
254
+ if (this.isSuperseded(attempt) || this.status === "active")
255
+ return;
256
+ this.clearConnectTimer();
257
+ this.status = "active";
258
+ this.emitter.emit("call-start");
259
+ resolve();
260
+ },
261
+ onFailed: (reason) => {
262
+ if (this.isSuperseded(attempt))
263
+ return;
264
+ this.clearConnectTimer();
265
+ const started = this.status === "active";
266
+ this.teardown();
267
+ this.status = started ? "ended" : "error";
268
+ if (started) {
269
+ this.emitCallEnd(FAILED_ENDED_REASONS.CONNECTION_FAILED);
270
+ }
271
+ else {
272
+ reject(this.fail("connection-failed", `Could not establish a live audio path: ${reason}. A VPN or firewall blocking UDP is the usual cause.`));
273
+ }
274
+ },
275
+ onDisconnected: () => {
276
+ if (this.isSuperseded(attempt) || this.status !== "active")
277
+ return;
278
+ this.teardown();
279
+ this.status = "ended";
280
+ // The transport went away while the call was live. From here it is
281
+ // indistinguishable from the agent hanging up, which is the far
282
+ // more common cause, so it is reported as a clean end rather than
283
+ // sending the user to a retry screen for a call that finished.
284
+ this.emitCallEnd(CLEAN_ENDED_REASONS.ASSISTANT_ENDED_CALL);
285
+ },
286
+ });
287
+ this.session = media;
288
+ this.pendingReject = reject;
289
+ this.connectTimer = setTimeout(() => {
290
+ if (this.isSuperseded(attempt) || this.status === "active")
291
+ return;
292
+ this.teardown();
293
+ this.status = "error";
294
+ reject(this.fail("connection-timeout", "Could not connect to the voice call. Please check your connection and try again."));
295
+ }, this.connectTimeoutMs);
296
+ media.connect(session, stream, wantVideo, screenStream).catch((err) => {
297
+ if (this.isSuperseded(attempt))
298
+ return;
299
+ this.clearConnectTimer();
300
+ this.teardown();
301
+ this.status = "error";
302
+ reject(this.fail("media-plane-failed", describe(err), err));
303
+ });
304
+ });
305
+ await connected;
306
+ }
307
+ /**
308
+ * Settle a pending `start()` as cancelled. Emits nothing: the caller asked
309
+ * for this, so an `error` event would be an unexplained failure toast on a
310
+ * deliberate action.
311
+ */
312
+ releasePending() {
313
+ const reject = this.pendingReject;
314
+ this.pendingReject = null;
315
+ reject?.(cancelled());
316
+ }
317
+ /** True when this attempt has been superseded by a stop() or a newer start(). */
318
+ isSuperseded(attempt) {
319
+ return attempt !== this.attempt;
320
+ }
321
+ clearConnectTimer() {
322
+ if (this.connectTimer !== null) {
323
+ clearTimeout(this.connectTimer);
324
+ this.connectTimer = null;
325
+ }
326
+ }
327
+ teardown() {
328
+ this.clearConnectTimer();
329
+ this.session?.close();
330
+ this.session = null;
331
+ if (this.localStream) {
332
+ for (const track of this.localStream.getTracks())
333
+ track.stop();
334
+ this.localStream = null;
335
+ }
336
+ this.remoteStream = null;
337
+ }
338
+ emitCallEnd(endedReason) {
339
+ this.emitter.emit("call-end", {
340
+ endedReason,
341
+ ...(this.callId ? { callId: this.callId } : {}),
342
+ });
343
+ }
344
+ /** Emit `error` and return the matching exception, so callers can `throw` it. */
345
+ fail(code, message, cause) {
346
+ this.emitter.emit("error", { code, message, ...(cause === undefined ? {} : { cause }) });
347
+ return new VoiceAgentError(code, message, cause);
348
+ }
349
+ }
350
+ /** The error `start()` rejects with. The matching `error` event has already fired. */
351
+ export class VoiceAgentError extends Error {
352
+ code;
353
+ cause;
354
+ constructor(code, message, cause) {
355
+ super(message);
356
+ this.name = "VoiceAgentError";
357
+ this.code = code;
358
+ this.cause = cause;
359
+ }
360
+ }
361
+ /** The rejection a cancelled `start()` produces. */
362
+ const cancelled = () => new VoiceAgentError("cancelled", "The call was stopped before it connected.");
363
+ const describe = (err) => err instanceof Error ? err.message : typeof err === "string" ? err : "Unknown error";
364
+ //# sourceMappingURL=client.js.map