@guava-ai/guava-sdk 0.36.0 → 0.38.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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@guava-ai/guava-sdk",
3
3
  "description": "The official TypeScript SDK for building Guava voice agents.",
4
4
  "homepage": "https://docs.goguava.ai/typescript-sdk/",
5
- "version": "0.36.0",
5
+ "version": "0.38.0",
6
6
  "type": "commonjs",
7
7
  "main": "dist/src/index.js",
8
8
  "types": "dist/src/index.d.ts",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/src/helpers/openai.d.ts",
20
20
  "default": "./dist/src/helpers/openai.js"
21
21
  },
22
+ "./webrtc": {
23
+ "types": "./dist/src/webrtc/index.d.ts",
24
+ "default": "./dist/src/webrtc/index.js"
25
+ },
22
26
  "./example-data": {
23
27
  "types": "./dist/src/example-data.d.ts",
24
28
  "default": "./dist/src/example-data.js"
@@ -37,7 +41,9 @@
37
41
  "@types/jest": "^30.0.0",
38
42
  "@types/node": "^25.0.7",
39
43
  "@types/ws": "^8.18.1",
44
+ "esbuild": "^0.28.2",
40
45
  "jest": "^30.4.2",
46
+ "jest-environment-jsdom": "^30.5.2",
41
47
  "openai": "^6.16.0",
42
48
  "ts-jest": "^29.4.11",
43
49
  "tsx": "^4.21.0",
@@ -70,17 +76,27 @@
70
76
  "test:e2e": "jest --config jest.e2e.config.cjs",
71
77
  "check": "npm run typecheck && npm audit --omit=dev && npm run lint && npm run check-format",
72
78
  "clean": "rm -rf ./dist",
73
- "build": "tsx scripts/update-version.ts && tsc && chmod +x ./dist/bin/example-runner.js",
79
+ "build": "tsx scripts/update-version.ts && tsc && chmod +x ./dist/bin/example-runner.js && npm run build-webrtc && tsx scripts/update-readme.ts",
80
+ "build-webrtc": "npm run build-webrtc:esm && npm run build-webrtc:iife",
81
+ "build-webrtc:esm": "esbuild src/webrtc/index.ts --bundle --minify --sourcemap --target=es2020 --format=esm --outfile=dist/guava-webrtc.esm.min.js",
82
+ "build-webrtc:iife": "esbuild src/webrtc/index.ts --bundle --minify --sourcemap --target=es2020 --format=iife --global-name=Guava --outfile=dist/guava-webrtc.min.js",
74
83
  "prepare": "npm run clean && npm run build",
75
84
  "release": "npm publish --ignore-scripts=false"
76
85
  },
77
86
  "publishConfig": {
78
87
  "access": "public"
79
88
  },
89
+ "engines": {
90
+ "node": ">=20"
91
+ },
80
92
  "files": [
81
93
  "/src",
82
94
  "/dist",
83
95
  "/examples",
84
96
  "/bin"
85
- ]
97
+ ],
98
+ "repository": {
99
+ "type": "git",
100
+ "url": "git+https://github.com/goguava-ai/typescript-sdk.git"
101
+ }
86
102
  }
package/src/call.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  import { Say } from "./action-item.ts";
26
26
  import { telemetryClient } from "./telemetry.ts";
27
27
  import type { CallInfo } from "./socket/call-info.ts";
28
+ import type { SipUri } from "./sip.ts";
28
29
 
29
30
  export type TaskObjective =
30
31
  | { objective: string }
@@ -248,13 +249,13 @@ export class Call {
248
249
  * This method does not interfere with turn-taking — use this when
249
250
  * you want the agent to naturally transfer the call.
250
251
  *
251
- * @param destination - The phone number or SIP address to transfer the call to.
252
+ * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
252
253
  * @param instructions - Optional guidance for how the agent should handle the transfer.
253
254
  */
254
- async transfer(destination: string, instructions?: string) {
255
+ async transfer(destination: string | SipUri, instructions?: string) {
255
256
  await this.sendCommand(TransferCommand, {
256
257
  command_type: "transfer-call",
257
- to_number: destination,
258
+ to_number: destination.toString(),
258
259
  transfer_message:
259
260
  instructions ?? "Notify the caller that you will be transferring them, and then transfer.",
260
261
  soft_transfer: true,
@@ -267,14 +268,14 @@ export class Call {
267
268
  * Unlike transfer(), this does not respect turn-taking. The agent immediately
268
269
  * initiates the transfer, saying the script (if provided).
269
270
  *
270
- * @param destination - The phone number or SIP address to transfer the call to.
271
+ * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
271
272
  * @param finalScript - Optional message for the agent to deliver immediately
272
273
  * before transferring. Defaults to no message.
273
274
  */
274
- async immediateTransfer(destination: string, finalScript?: string) {
275
+ async immediateTransfer(destination: string | SipUri, finalScript?: string) {
275
276
  await this.sendCommand(TransferCommand, {
276
277
  command_type: "transfer-call",
277
- to_number: destination,
278
+ to_number: destination.toString(),
278
279
  transfer_message: finalScript ?? "",
279
280
  soft_transfer: false,
280
281
  });
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export type {
12
12
  export { Say, Field } from "./action-item.ts";
13
13
  export { Logger, getConsoleLogger, getDefaultLogger } from "./logging.ts";
14
14
  export { Agent, CallInfo } from "./agent.ts";
15
+ export { SIPCallInfo } from "./socket/call-info.ts";
16
+ export { SipUri } from "./sip.ts";
15
17
  export { Call, DEFAULT_REACH_PERSON_OUTCOMES } from "./call.ts";
16
18
  export type { ReachPersonOutcome } from "./call.ts";
17
19
  export type { SpeedPreset, Language } from "./commands.ts";
package/src/sip.ts ADDED
@@ -0,0 +1,62 @@
1
+ // Characters allowed unescaped in each component beyond RFC 3261's `unreserved`,
2
+ // which encodeURIComponent already leaves alone. See RFC 3261 section 25.1.
3
+ const USER_SAFE = "&=+$,;?/";
4
+ const PARAM_SAFE = "[]/:&+$";
5
+ const HEADER_SAFE = "[]/?:+$";
6
+
7
+ function sipEscape(value: string, safe: string): string {
8
+ return encodeURIComponent(value).replace(/%[0-9A-F]{2}/g, (escaped) => {
9
+ // Not decodeURIComponent, which throws on single bytes of multibyte UTF-8 sequences.
10
+ const char = String.fromCharCode(Number.parseInt(escaped.slice(1), 16));
11
+ return safe.includes(char) ? char : escaped;
12
+ });
13
+ }
14
+
15
+ export class SipUri {
16
+ host: string;
17
+ user?: string;
18
+ /** A `null` value renders as a valueless param, e.g. `;lr`. */
19
+ uriParams: Record<string, string | null>;
20
+ /** A `null` value renders as a valueless header, e.g. `?X-Foo=`. */
21
+ headers: Record<string, string | null>;
22
+
23
+ constructor(args: {
24
+ host: string;
25
+ user?: string;
26
+ uriParams?: Record<string, string | null>;
27
+ headers?: Record<string, string | null>;
28
+ }) {
29
+ this.host = args.host;
30
+ this.user = args.user;
31
+ this.uriParams = args.uriParams ?? {};
32
+ this.headers = args.headers ?? {};
33
+ }
34
+
35
+ toString(): string {
36
+ let url = "sip:";
37
+ if (this.user !== undefined) {
38
+ url += `${sipEscape(this.user, USER_SAFE)}@`;
39
+ }
40
+ url += this.host;
41
+
42
+ for (const [name, value] of Object.entries(this.uriParams)) {
43
+ url += `;${sipEscape(name, PARAM_SAFE)}`;
44
+ if (value !== null) {
45
+ url += `=${sipEscape(value, PARAM_SAFE)}`;
46
+ }
47
+ }
48
+
49
+ // Headers always need an "=", so a null value renders as "name=".
50
+ const headers = Object.entries(this.headers);
51
+ if (headers.length > 0) {
52
+ url += `?${headers
53
+ .map(
54
+ ([name, value]) =>
55
+ `${sipEscape(name, HEADER_SAFE)}=${sipEscape(value === null ? "" : String(value), HEADER_SAFE)}`,
56
+ )
57
+ .join("&")}`;
58
+ }
59
+
60
+ return url;
61
+ }
62
+ }
@@ -14,17 +14,59 @@ export const WebRTCCallInfo = z.object({
14
14
  });
15
15
  export type WebRTCCallInfo = z.infer<typeof WebRTCCallInfo>;
16
16
 
17
- export const SIPCallInfo = z.object({
18
- call_type: z.literal("sip"),
19
- from_aor: z.string(),
20
- sip_code: z.string().optional(),
21
- sip_headers: z.record(z.string(), z.string()).default({}),
22
- });
23
- export type SIPCallInfo = z.infer<typeof SIPCallInfo>;
17
+ const SIPCallInfoSchema = z
18
+ .object({
19
+ call_type: z.literal("sip"),
20
+ from_aor: z
21
+ .string()
22
+ .describe(
23
+ 'Despite the name, this field contains the entire SIP from-spec value, not just the AOR. Example: `"Display Name" <sip:user@example.com:5060>;tag=abc123`.',
24
+ ),
25
+ sip_code: z.string().optional(),
26
+ sip_headers: z.record(z.string(), z.string()).default({}),
27
+ })
28
+ .transform((data) => new SIPCallInfo(data.from_aor, data.sip_code, data.sip_headers));
29
+
30
+ export class SIPCallInfo {
31
+ readonly call_type = "sip" as const;
32
+
33
+ constructor(
34
+ readonly from_aor: string,
35
+ readonly sip_code?: string,
36
+ readonly sip_headers: Record<string, string> = {},
37
+ ) {}
38
+
39
+ /** The from URI. */
40
+ get fromUri(): string {
41
+ const value = this.from_aor.trim();
42
+
43
+ // Skip a quoted display name, which may contain '<', '>' or escaped quotes.
44
+ let i = 0;
45
+ if (value.startsWith('"')) {
46
+ i = 1;
47
+ while (i < value.length && value[i] !== '"') {
48
+ i += value[i] === "\\" ? 2 : 1;
49
+ }
50
+ i += 1;
51
+ }
52
+
53
+ // A '<' after the first ';' is inside a header param, not the URI's bracket.
54
+ const start = value.indexOf("<", i);
55
+ const semi = value.indexOf(";", i);
56
+ if (start !== -1 && (semi === -1 || start < semi)) {
57
+ const end = value.indexOf(">", start);
58
+ if (end !== -1) {
59
+ return value.slice(start + 1, end).trim();
60
+ }
61
+ }
62
+
63
+ return value.split(";", 1)[0].trim();
64
+ }
65
+ }
24
66
 
25
67
  export const CallInfo = z.discriminatedUnion("call_type", [
26
68
  PSTNCallInfo,
27
69
  WebRTCCallInfo,
28
- SIPCallInfo,
70
+ SIPCallInfoSchema,
29
71
  ]);
30
72
  export type CallInfo = z.infer<typeof CallInfo>;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.36.0";
1
+ export const SDK_VERSION = "0.38.0";
@@ -0,0 +1,372 @@
1
+ import { type ConnectingTone, JingleTone } from "./tones.ts";
2
+
3
+ export { type ConnectingTone, JingleTone, RingbackTone } from "./tones.ts";
4
+
5
+ export type CallState =
6
+ | "not-started" // start() hasn't been called yet.
7
+ | "starting" // Starting the call. Do not play a Ringback yet.
8
+ | "waiting-for-permission" // We're waiting for the Microphone permission prompt. You may show a reminder UI here.
9
+ | "connecting" // The call is now connection to the remote. This is where you can play a ringback or other connecting tone.
10
+ | "connected" // The call is now connected and the audio channel is established.
11
+ | "ended"; // The call has ended.
12
+
13
+ export type CallErrorCode =
14
+ | "MICROPHONE_ACCESS_FAILED"
15
+ | "CONNECTION_FAILED"
16
+ | "AUTHENTICATION_FAILED"
17
+ | "PROTOCOL_ERROR" // The server sent an unexpected message.
18
+ | "SERVER_ERROR" // The server sent an error message.
19
+ | "REMOTE_HANGUP"; // The remote hung up before the call connected.
20
+
21
+ export class CallError extends Error {
22
+ readonly code: CallErrorCode;
23
+
24
+ constructor(code: CallErrorCode, message: string, options?: ErrorOptions) {
25
+ super(message, options);
26
+ this.name = "CallError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ // Used if the server doesn't send ICE servers with the auth reply.
32
+ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
33
+ { urls: ["stun:stun.l.google.com:19302", "stun:stun.sipgate.net:10000"] },
34
+ ];
35
+
36
+ const SOCKET_PROTOCOLS: Record<string, string> = {
37
+ "http:": "ws:",
38
+ "https:": "wss:",
39
+ "ws:": "ws:",
40
+ "wss:": "wss:",
41
+ };
42
+
43
+ // Messages received on the signaling socket. See junkswitch/junkswitch/webrtcsocket.py.
44
+ type SignalingMessage =
45
+ | {
46
+ type: "auth";
47
+ body: "OK" | "FAIL";
48
+ reason?: string;
49
+ ice_servers?: RTCIceServer[];
50
+ }
51
+ | { type: "desc"; body: RTCSessionDescriptionInit }
52
+ | { type: "candidate"; body: RTCIceCandidateInit }
53
+ | { type: "hangup" }
54
+ | { type: "error"; body?: string }
55
+ | { type: "transfer" };
56
+
57
+ export class Call {
58
+ private _state: CallState = "not-started";
59
+ private _error?: CallError;
60
+ private readonly _stateHandlers = new Set<(state: CallState) => void>();
61
+ private readonly _remoteStreamHandlers = new Set<(stream: MediaStream) => void>();
62
+ private readonly _webrtcCode: string;
63
+ private readonly _sockUrl: URL;
64
+ private readonly _remoteAudio: HTMLAudioElement;
65
+ private readonly _connectingTone?: ConnectingTone;
66
+ private _sock?: WebSocket;
67
+ private _localStream?: MediaStream;
68
+ private _peerConnection?: RTCPeerConnection;
69
+ private _remoteStream?: MediaStream;
70
+ private _iceServers?: RTCIceServer[];
71
+ // Settles the promise returned by start().
72
+ private _pendingStart?: { resolve: () => void; reject: (error: CallError) => void };
73
+
74
+ constructor({
75
+ webrtcCode,
76
+ baseUrl = "https://app.goguava.ai/",
77
+ connectingTone = new JingleTone(),
78
+ }: {
79
+ webrtcCode: string;
80
+ baseUrl?: string;
81
+ // Played while the call is in the "connecting" state.
82
+ connectingTone?: ConnectingTone | "disabled";
83
+ }) {
84
+ this._webrtcCode = webrtcCode;
85
+ if (connectingTone !== "disabled") {
86
+ this._connectingTone = connectingTone;
87
+ }
88
+
89
+ this._sockUrl = new URL("/webrtc/", baseUrl);
90
+ const sockProtocol = SOCKET_PROTOCOLS[this._sockUrl.protocol];
91
+ if (sockProtocol === undefined) {
92
+ throw new Error(`Unsupported baseUrl protocol: ${this._sockUrl.protocol}`);
93
+ }
94
+ this._sockUrl.protocol = sockProtocol;
95
+
96
+ this._remoteAudio = document.createElement("audio");
97
+ this._remoteAudio.autoplay = true;
98
+ }
99
+
100
+ get state(): CallState {
101
+ return this._state;
102
+ }
103
+
104
+ // Set if the call ended due to a failure.
105
+ get error(): CallError | undefined {
106
+ return this._error;
107
+ }
108
+
109
+ // Set once the remote audio track arrives, which happens before the call reaches "connected".
110
+ get remoteStream(): MediaStream | undefined {
111
+ return this._remoteStream;
112
+ }
113
+
114
+ // Returns a function that unregisters the handler.
115
+ onStateChange(handler: (state: CallState) => void): () => void {
116
+ this._stateHandlers.add(handler);
117
+ return () => {
118
+ this._stateHandlers.delete(handler);
119
+ };
120
+ }
121
+
122
+ // Fires whenever the remote stream is replaced, including the first time it arrives.
123
+ // Returns a function that unregisters the handler.
124
+ onRemoteStream(handler: (stream: MediaStream) => void): () => void {
125
+ this._remoteStreamHandlers.add(handler);
126
+ return () => {
127
+ this._remoteStreamHandlers.delete(handler);
128
+ };
129
+ }
130
+
131
+ private _setState(state: CallState) {
132
+ if (state === this._state) {
133
+ return;
134
+ }
135
+ this._state = state;
136
+ if (state === "connecting") {
137
+ this._connectingTone?.start();
138
+ } else {
139
+ this._connectingTone?.stop();
140
+ }
141
+ for (const handler of this._stateHandlers) {
142
+ handler(state);
143
+ }
144
+ }
145
+
146
+ async start() {
147
+ if (this._state !== "not-started") {
148
+ throw new Error(`start() was already called.`);
149
+ }
150
+ this._setState("starting");
151
+
152
+ // Add the audio element.
153
+ document.body.appendChild(this._remoteAudio);
154
+
155
+ // We start by requesting device permissions first. The idea is that, after permission has been granted,
156
+ // it makes sence to play a ringback, but it doesn't make sense to do so during the permission prompt.
157
+
158
+ const permissionTimer = setTimeout(() => {
159
+ this._setState("waiting-for-permission");
160
+ }, 500);
161
+
162
+ try {
163
+ this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
164
+ } catch (error) {
165
+ const callError = new CallError("MICROPHONE_ACCESS_FAILED", "Failed to access microphone.", {
166
+ cause: error,
167
+ });
168
+ this._end(callError);
169
+ throw callError;
170
+ } finally {
171
+ clearTimeout(permissionTimer);
172
+ }
173
+
174
+ // hangup() was called during the permission prompt.
175
+ if (this.state === "ended") {
176
+ this._teardown();
177
+ return;
178
+ }
179
+
180
+ // Now open and authenticate the signaling socket.
181
+ this._setState("connecting"); // UI can start playing rinback tones here.
182
+ const started = new Promise<void>((resolve, reject) => {
183
+ this._pendingStart = { resolve, reject };
184
+ });
185
+
186
+ this._sock = new WebSocket(this._sockUrl);
187
+ this._sock.onopen = () => {
188
+ this._send({ type: "auth", body: this._webrtcCode });
189
+ };
190
+ this._sock.onmessage = (event) => {
191
+ this._onMessage(JSON.parse(event.data) as SignalingMessage);
192
+ };
193
+ this._sock.onclose = () => {
194
+ this._end(new CallError("CONNECTION_FAILED", "Signaling connection closed."));
195
+ };
196
+
197
+ await started;
198
+ }
199
+
200
+ async hangup() {
201
+ if (this._state === "not-started") {
202
+ throw new Error(`hangup() called before start().`);
203
+ }
204
+ if (this._state === "ended") {
205
+ return;
206
+ }
207
+ this._send({ type: "hangup" });
208
+ this._end();
209
+ }
210
+
211
+ private _send(message: { type: string; body?: unknown }) {
212
+ if (this._sock !== undefined && this._sock.readyState === WebSocket.OPEN) {
213
+ this._sock.send(JSON.stringify(message));
214
+ }
215
+ }
216
+
217
+ private _onMessage(message: SignalingMessage) {
218
+ switch (message.type) {
219
+ case "auth":
220
+ if (message.body !== "OK") {
221
+ this._end(
222
+ new CallError("AUTHENTICATION_FAILED", message.reason || "Authentication failed."),
223
+ );
224
+ return;
225
+ }
226
+ this._iceServers = message.ice_servers;
227
+ this._send({ type: "connect", body: {} });
228
+ void this._connectPeer();
229
+ break;
230
+ case "desc":
231
+ if (!this._peerConnection) {
232
+ this._end(
233
+ new CallError(
234
+ "PROTOCOL_ERROR",
235
+ "Received desc before the peer connection was created.",
236
+ ),
237
+ );
238
+ return;
239
+ }
240
+ this._peerConnection.setRemoteDescription(message.body).catch((error) => {
241
+ this._end(
242
+ new CallError("CONNECTION_FAILED", "Failed to set remote description.", {
243
+ cause: error,
244
+ }),
245
+ );
246
+ });
247
+ break;
248
+ case "candidate":
249
+ if (!this._peerConnection) {
250
+ this._end(
251
+ new CallError(
252
+ "PROTOCOL_ERROR",
253
+ "Received candidate before the peer connection was created.",
254
+ ),
255
+ );
256
+ return;
257
+ }
258
+ this._peerConnection.addIceCandidate(message.body).catch((error) => {
259
+ console.warn("Failed to add ICE candidate:", error);
260
+ });
261
+ break;
262
+ case "hangup":
263
+ if (this._state === "connected") {
264
+ this._end();
265
+ } else {
266
+ this._end(
267
+ new CallError("REMOTE_HANGUP", "The remote hung up before the call connected."),
268
+ );
269
+ }
270
+ break;
271
+ case "error":
272
+ this._end(new CallError("SERVER_ERROR", message.body || "Unknown server error."));
273
+ break;
274
+ case "transfer":
275
+ this._end(new CallError("PROTOCOL_ERROR", "Transfers are not supported."));
276
+ break;
277
+ default:
278
+ console.warn("Unknown signaling message:", message);
279
+ }
280
+ }
281
+
282
+ private async _connectPeer() {
283
+ if (!this._localStream) {
284
+ return;
285
+ }
286
+ this._peerConnection = new RTCPeerConnection({
287
+ iceServers: this._iceServers ?? DEFAULT_ICE_SERVERS,
288
+ });
289
+ this._peerConnection.onicecandidate = (event) => {
290
+ if (event.candidate) {
291
+ this._send({ type: "candidate", body: event.candidate });
292
+ }
293
+ };
294
+ this._peerConnection.ontrack = (event) => {
295
+ this._remoteStream = new MediaStream([event.track]);
296
+ this._remoteAudio.srcObject = this._remoteStream;
297
+ for (const handler of this._remoteStreamHandlers) {
298
+ handler(this._remoteStream);
299
+ }
300
+ };
301
+ this._peerConnection.onconnectionstatechange = () => {
302
+ if (this._peerConnection === undefined) {
303
+ return;
304
+ }
305
+ // "disconnected" is often transient, so wait for "failed" before ending the call.
306
+ if (this._peerConnection.connectionState === "connected") {
307
+ this._setState("connected");
308
+ if (this._pendingStart !== undefined) {
309
+ this._pendingStart.resolve();
310
+ this._pendingStart = undefined;
311
+ }
312
+ } else if (this._peerConnection.connectionState === "failed") {
313
+ this._end(new CallError("CONNECTION_FAILED", "Peer connection failed."));
314
+ }
315
+ };
316
+ for (const track of this._localStream.getAudioTracks()) {
317
+ this._peerConnection.addTrack(track);
318
+ }
319
+
320
+ try {
321
+ await this._peerConnection.setLocalDescription();
322
+ } catch (error) {
323
+ this._end(new CallError("CONNECTION_FAILED", "Failed to create offer.", { cause: error }));
324
+ return;
325
+ }
326
+
327
+ // The call may have ended while creating the offer.
328
+ if (this.state === "ended") {
329
+ return;
330
+ }
331
+ this._send({ type: "desc", body: this._peerConnection.localDescription });
332
+ }
333
+
334
+ private _end(error?: CallError) {
335
+ if (this._state === "ended") {
336
+ return;
337
+ }
338
+ this._error = error;
339
+ this._teardown();
340
+ this._setState("ended");
341
+
342
+ // If start() is still pending, it rejects on failure and resolves on hangup().
343
+ if (this._pendingStart !== undefined) {
344
+ if (error) {
345
+ this._pendingStart.reject(error);
346
+ } else {
347
+ this._pendingStart.resolve();
348
+ }
349
+ this._pendingStart = undefined;
350
+ }
351
+ }
352
+
353
+ private _teardown() {
354
+ if (this._sock) {
355
+ // Don't fire close handlers for a socket we closed ourselves.
356
+ this._sock.onclose = null;
357
+ this._sock.close();
358
+ this._sock = undefined;
359
+ }
360
+ if (this._peerConnection !== undefined) {
361
+ this._peerConnection.close();
362
+ this._peerConnection = undefined;
363
+ }
364
+ for (const track of this._localStream?.getTracks() ?? []) {
365
+ track.stop();
366
+ }
367
+ this._localStream = undefined;
368
+ this._remoteStream = undefined;
369
+ this._remoteAudio.srcObject = null;
370
+ this._remoteAudio.remove();
371
+ }
372
+ }