@guava-ai/guava-sdk 0.38.0 → 0.40.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 +7 -5
- package/dist/guava-webrtc.esm.min.js +1 -1
- package/dist/guava-webrtc.esm.min.js.map +3 -3
- package/dist/guava-webrtc.min.js +1 -1
- package/dist/guava-webrtc.min.js.map +3 -3
- package/dist/src/socket/call-info.d.ts +2 -0
- package/dist/src/socket/call-info.js +1 -0
- package/dist/src/socket/call-info.js.map +1 -1
- package/dist/src/socket/listen-inbound.d.ts +2 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/webrtc/index.d.ts +56 -4
- package/dist/src/webrtc/index.js +153 -25
- package/dist/src/webrtc/index.js.map +1 -1
- package/dist/src/webrtc/tones.d.ts +7 -0
- package/dist/src/webrtc/tones.js +54 -2
- package/dist/src/webrtc/tones.js.map +1 -1
- package/package.json +3 -3
- package/src/socket/call-info.ts +1 -0
- package/src/version.ts +1 -1
- package/src/webrtc/index.ts +194 -26
- package/src/webrtc/tones.ts +85 -1
package/src/webrtc/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type ConnectingTone, JingleTone } from "./tones.ts";
|
|
1
|
+
import { type ConnectingTone, JingleTone, LeavingTone, type Tone } from "./tones.ts";
|
|
2
2
|
|
|
3
|
-
export { type ConnectingTone, JingleTone, RingbackTone } from "./tones.ts";
|
|
3
|
+
export { type ConnectingTone, JingleTone, RingbackTone, LeavingTone, type Tone } from "./tones.ts";
|
|
4
4
|
|
|
5
5
|
export type CallState =
|
|
6
6
|
| "not-started" // start() hasn't been called yet.
|
|
@@ -16,7 +16,8 @@ export type CallErrorCode =
|
|
|
16
16
|
| "AUTHENTICATION_FAILED"
|
|
17
17
|
| "PROTOCOL_ERROR" // The server sent an unexpected message.
|
|
18
18
|
| "SERVER_ERROR" // The server sent an error message.
|
|
19
|
-
| "REMOTE_HANGUP"
|
|
19
|
+
| "REMOTE_HANGUP" // The remote hung up before the call connected.
|
|
20
|
+
| "CONNECTION_TIMEOUT"; // The call didn't connect within connectionTimeoutMs.
|
|
20
21
|
|
|
21
22
|
export class CallError extends Error {
|
|
22
23
|
readonly code: CallErrorCode;
|
|
@@ -59,12 +60,29 @@ export class Call {
|
|
|
59
60
|
private _error?: CallError;
|
|
60
61
|
private readonly _stateHandlers = new Set<(state: CallState) => void>();
|
|
61
62
|
private readonly _remoteStreamHandlers = new Set<(stream: MediaStream) => void>();
|
|
63
|
+
private readonly _messageHandlers = new Set<(payload: Record<string, unknown>) => void>();
|
|
62
64
|
private readonly _webrtcCode: string;
|
|
63
65
|
private readonly _sockUrl: URL;
|
|
66
|
+
|
|
64
67
|
private readonly _remoteAudio: HTMLAudioElement;
|
|
65
68
|
private readonly _connectingTone?: ConnectingTone;
|
|
69
|
+
private readonly _leavingTone?: Tone;
|
|
70
|
+
private readonly _connectionTimeoutMs?: number;
|
|
71
|
+
private readonly _customPayload: Record<string, unknown>;
|
|
72
|
+
private readonly _customAudioTrack?: MediaStreamTrack;
|
|
73
|
+
private _connectionTimer?: ReturnType<typeof setTimeout>;
|
|
66
74
|
private _sock?: WebSocket;
|
|
75
|
+
|
|
76
|
+
private readonly _chatSockUrl: URL;
|
|
77
|
+
// Links the chat socket to this call's session on the server.
|
|
78
|
+
private readonly _chatSessionId = crypto.randomUUID();
|
|
79
|
+
private _chatSock?: WebSocket;
|
|
80
|
+
private _chatReady = false;
|
|
81
|
+
// Messages sent before the chat socket is ready.
|
|
82
|
+
private _pendingChatMessages: string[] = [];
|
|
83
|
+
|
|
67
84
|
private _localStream?: MediaStream;
|
|
85
|
+
private _micMuted = false;
|
|
68
86
|
private _peerConnection?: RTCPeerConnection;
|
|
69
87
|
private _remoteStream?: MediaStream;
|
|
70
88
|
private _iceServers?: RTCIceServer[];
|
|
@@ -75,16 +93,44 @@ export class Call {
|
|
|
75
93
|
webrtcCode,
|
|
76
94
|
baseUrl = "https://app.goguava.ai/",
|
|
77
95
|
connectingTone = new JingleTone(),
|
|
96
|
+
leavingTone = new LeavingTone(),
|
|
97
|
+
connectionTimeoutMs = 30_000,
|
|
98
|
+
customPayload = {},
|
|
99
|
+
audioTrack,
|
|
78
100
|
}: {
|
|
101
|
+
/** Code used to authenticate with the signaling server. */
|
|
79
102
|
webrtcCode: string;
|
|
103
|
+
/** Base URL of the Guava server. Defaults to `https://app.goguava.ai/`. */
|
|
80
104
|
baseUrl?: string;
|
|
81
|
-
|
|
105
|
+
/** Played while the call is in the "connecting" state. Defaults to {@link JingleTone}. */
|
|
82
106
|
connectingTone?: ConnectingTone | "disabled";
|
|
107
|
+
/** Played when the call ends. Defaults to {@link LeavingTone}. */
|
|
108
|
+
leavingTone?: Tone | "disabled";
|
|
109
|
+
/** Max time in the "connecting" state before the call fails with CONNECTION_TIMEOUT. Defaults to 30 seconds. */
|
|
110
|
+
connectionTimeoutMs?: number | "disabled";
|
|
111
|
+
/** A custom payload that is forwarded to the agent at the start of the call. */
|
|
112
|
+
customPayload?: Record<string, unknown>;
|
|
113
|
+
/**
|
|
114
|
+
* An audio track to send. Supplying this skips requesting the microphone with `getUserMedia()`.
|
|
115
|
+
* We use `track.clone()` to create a clone of this track. The caller is responsible for calling
|
|
116
|
+
* `track.stop()` on their original reference when it is no longer needed.
|
|
117
|
+
* Because we use a clone, use {@link Call.setMicrophoneMuted} to mute the call, rather than setting
|
|
118
|
+
* `enabled` on the original track.
|
|
119
|
+
*/
|
|
120
|
+
audioTrack?: MediaStreamTrack;
|
|
83
121
|
}) {
|
|
84
122
|
this._webrtcCode = webrtcCode;
|
|
123
|
+
this._customPayload = customPayload;
|
|
124
|
+
this._customAudioTrack = audioTrack;
|
|
85
125
|
if (connectingTone !== "disabled") {
|
|
86
126
|
this._connectingTone = connectingTone;
|
|
87
127
|
}
|
|
128
|
+
if (leavingTone !== "disabled") {
|
|
129
|
+
this._leavingTone = leavingTone;
|
|
130
|
+
}
|
|
131
|
+
if (connectionTimeoutMs !== "disabled") {
|
|
132
|
+
this._connectionTimeoutMs = connectionTimeoutMs;
|
|
133
|
+
}
|
|
88
134
|
|
|
89
135
|
this._sockUrl = new URL("/webrtc/", baseUrl);
|
|
90
136
|
const sockProtocol = SOCKET_PROTOCOLS[this._sockUrl.protocol];
|
|
@@ -92,6 +138,7 @@ export class Call {
|
|
|
92
138
|
throw new Error(`Unsupported baseUrl protocol: ${this._sockUrl.protocol}`);
|
|
93
139
|
}
|
|
94
140
|
this._sockUrl.protocol = sockProtocol;
|
|
141
|
+
this._chatSockUrl = new URL("/webrtc-chat/", this._sockUrl);
|
|
95
142
|
|
|
96
143
|
this._remoteAudio = document.createElement("audio");
|
|
97
144
|
this._remoteAudio.autoplay = true;
|
|
@@ -128,15 +175,40 @@ export class Call {
|
|
|
128
175
|
};
|
|
129
176
|
}
|
|
130
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Registers a handler for messages the agent sends with `call.send_webrtc_message()`.
|
|
180
|
+
* @param handler - Called with each message's payload.
|
|
181
|
+
* @returns A function that unregisters the handler.
|
|
182
|
+
*/
|
|
183
|
+
onMessage(handler: (payload: Record<string, unknown>) => void): () => void {
|
|
184
|
+
this._messageHandlers.add(handler);
|
|
185
|
+
return () => {
|
|
186
|
+
this._messageHandlers.delete(handler);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
private _setState(state: CallState) {
|
|
132
191
|
if (state === this._state) {
|
|
133
192
|
return;
|
|
134
193
|
}
|
|
194
|
+
|
|
195
|
+
if (state === "ended") {
|
|
196
|
+
this._leavingTone?.play();
|
|
197
|
+
}
|
|
198
|
+
|
|
135
199
|
this._state = state;
|
|
136
200
|
if (state === "connecting") {
|
|
137
201
|
this._connectingTone?.start();
|
|
202
|
+
if (this._connectionTimeoutMs !== undefined) {
|
|
203
|
+
this._connectionTimer = setTimeout(() => {
|
|
204
|
+
this._send({ type: "hangup" });
|
|
205
|
+
this._end(new CallError("CONNECTION_TIMEOUT", "Timed out connecting the call."));
|
|
206
|
+
}, this._connectionTimeoutMs);
|
|
207
|
+
}
|
|
138
208
|
} else {
|
|
139
209
|
this._connectingTone?.stop();
|
|
210
|
+
clearTimeout(this._connectionTimer);
|
|
211
|
+
this._connectionTimer = undefined;
|
|
140
212
|
}
|
|
141
213
|
for (const handler of this._stateHandlers) {
|
|
142
214
|
handler(state);
|
|
@@ -152,29 +224,40 @@ export class Call {
|
|
|
152
224
|
// Add the audio element.
|
|
153
225
|
document.body.appendChild(this._remoteAudio);
|
|
154
226
|
|
|
155
|
-
|
|
156
|
-
|
|
227
|
+
if (this._customAudioTrack !== undefined) {
|
|
228
|
+
this._localStream = new MediaStream([this._customAudioTrack.clone()]);
|
|
229
|
+
} else {
|
|
230
|
+
// We start by requesting device permissions first. The idea is that, after permission has been granted,
|
|
231
|
+
// it makes sence to play a ringback, but it doesn't make sense to do so during the permission prompt.
|
|
157
232
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
233
|
+
const permissionTimer = setTimeout(() => {
|
|
234
|
+
this._setState("waiting-for-permission");
|
|
235
|
+
}, 500);
|
|
161
236
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
237
|
+
try {
|
|
238
|
+
this._localStream = await navigator.mediaDevices.getUserMedia({
|
|
239
|
+
audio: true,
|
|
240
|
+
video: false,
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const callError = new CallError(
|
|
244
|
+
"MICROPHONE_ACCESS_FAILED",
|
|
245
|
+
"Failed to access microphone.",
|
|
246
|
+
{
|
|
247
|
+
cause: error,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
this._end(callError);
|
|
251
|
+
throw callError;
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(permissionTimer);
|
|
254
|
+
}
|
|
173
255
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
256
|
+
// hangup() was called during the permission prompt.
|
|
257
|
+
if (this.state === "ended") {
|
|
258
|
+
this._teardown();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
178
261
|
}
|
|
179
262
|
|
|
180
263
|
// Now open and authenticate the signaling socket.
|
|
@@ -185,7 +268,13 @@ export class Call {
|
|
|
185
268
|
|
|
186
269
|
this._sock = new WebSocket(this._sockUrl);
|
|
187
270
|
this._sock.onopen = () => {
|
|
188
|
-
this._send({
|
|
271
|
+
this._send({
|
|
272
|
+
type: "auth",
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
webrtc_code: this._webrtcCode,
|
|
275
|
+
chat_session_id: this._chatSessionId,
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
189
278
|
};
|
|
190
279
|
this._sock.onmessage = (event) => {
|
|
191
280
|
this._onMessage(JSON.parse(event.data) as SignalingMessage);
|
|
@@ -197,6 +286,70 @@ export class Call {
|
|
|
197
286
|
await started;
|
|
198
287
|
}
|
|
199
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Mutes or unmutes the microphone. Can be called before the call starts.
|
|
291
|
+
* @param muted - Whether the microphone should be muted.
|
|
292
|
+
*/
|
|
293
|
+
setMicrophoneMuted(muted: boolean) {
|
|
294
|
+
this._micMuted = muted;
|
|
295
|
+
for (const track of this._localStream?.getAudioTracks() ?? []) {
|
|
296
|
+
track.enabled = !muted;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Mutes or unmutes playback of the remote audio. Can be called before the call starts.
|
|
302
|
+
* @param muted - Whether the remote audio should be muted.
|
|
303
|
+
*/
|
|
304
|
+
setRemoteMuted(muted: boolean) {
|
|
305
|
+
this._remoteAudio.muted = muted;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Sends a message to the agent, delivered to its `on_webrtc_message` handler.
|
|
310
|
+
* Messages sent before the message channel is ready are queued.
|
|
311
|
+
* @param payload - A JSON-serializable object.
|
|
312
|
+
*/
|
|
313
|
+
sendMessage(payload: Record<string, unknown>) {
|
|
314
|
+
if (this._state === "not-started" || this._state === "ended") {
|
|
315
|
+
throw new Error(`sendMessage() called while the call is ${this._state}.`);
|
|
316
|
+
}
|
|
317
|
+
const message = JSON.stringify({ type: "webrtc-message", payload });
|
|
318
|
+
if (this._chatReady && this._chatSock !== undefined) {
|
|
319
|
+
this._chatSock.send(message);
|
|
320
|
+
} else {
|
|
321
|
+
this._pendingChatMessages.push(message);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private _openChatSocket() {
|
|
326
|
+
this._chatSock = new WebSocket(this._chatSockUrl);
|
|
327
|
+
this._chatSock.onopen = () => {
|
|
328
|
+
this._chatSock?.send(
|
|
329
|
+
JSON.stringify({ webrtc_code: this._webrtcCode, chat_session_id: this._chatSessionId }),
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
this._chatSock.onmessage = (event) => {
|
|
333
|
+
const message = JSON.parse(event.data) as {
|
|
334
|
+
type?: string;
|
|
335
|
+
command_type?: string;
|
|
336
|
+
payload?: Record<string, unknown>;
|
|
337
|
+
};
|
|
338
|
+
// The server sends "ready" once the call session is found.
|
|
339
|
+
if (message.type === "ready" && !this._chatReady) {
|
|
340
|
+
this._chatReady = true;
|
|
341
|
+
for (const pending of this._pendingChatMessages) {
|
|
342
|
+
this._chatSock?.send(pending);
|
|
343
|
+
}
|
|
344
|
+
this._pendingChatMessages = [];
|
|
345
|
+
} else if (message.command_type === "send-webrtc-message") {
|
|
346
|
+
for (const handler of this._messageHandlers) {
|
|
347
|
+
handler(message.payload ?? {});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
200
353
|
async hangup() {
|
|
201
354
|
if (this._state === "not-started") {
|
|
202
355
|
throw new Error(`hangup() called before start().`);
|
|
@@ -224,7 +377,8 @@ export class Call {
|
|
|
224
377
|
return;
|
|
225
378
|
}
|
|
226
379
|
this._iceServers = message.ice_servers;
|
|
227
|
-
this.
|
|
380
|
+
this._openChatSocket();
|
|
381
|
+
this._send({ type: "connect", body: { custom_payload: this._customPayload } });
|
|
228
382
|
void this._connectPeer();
|
|
229
383
|
break;
|
|
230
384
|
case "desc":
|
|
@@ -314,6 +468,7 @@ export class Call {
|
|
|
314
468
|
}
|
|
315
469
|
};
|
|
316
470
|
for (const track of this._localStream.getAudioTracks()) {
|
|
471
|
+
track.enabled = !this._micMuted;
|
|
317
472
|
this._peerConnection.addTrack(track);
|
|
318
473
|
}
|
|
319
474
|
|
|
@@ -351,12 +506,23 @@ export class Call {
|
|
|
351
506
|
}
|
|
352
507
|
|
|
353
508
|
private _teardown() {
|
|
509
|
+
// Close the signaling socket.
|
|
354
510
|
if (this._sock) {
|
|
355
511
|
// Don't fire close handlers for a socket we closed ourselves.
|
|
356
512
|
this._sock.onclose = null;
|
|
357
513
|
this._sock.close();
|
|
358
514
|
this._sock = undefined;
|
|
359
515
|
}
|
|
516
|
+
|
|
517
|
+
// Close the chat socket.
|
|
518
|
+
if (this._chatSock) {
|
|
519
|
+
this._chatSock.close();
|
|
520
|
+
this._chatSock = undefined;
|
|
521
|
+
}
|
|
522
|
+
this._chatReady = false;
|
|
523
|
+
this._pendingChatMessages = [];
|
|
524
|
+
|
|
525
|
+
// Close the peer connection.
|
|
360
526
|
if (this._peerConnection !== undefined) {
|
|
361
527
|
this._peerConnection.close();
|
|
362
528
|
this._peerConnection = undefined;
|
|
@@ -366,6 +532,8 @@ export class Call {
|
|
|
366
532
|
}
|
|
367
533
|
this._localStream = undefined;
|
|
368
534
|
this._remoteStream = undefined;
|
|
535
|
+
|
|
536
|
+
// Remove the audio element.
|
|
369
537
|
this._remoteAudio.srcObject = null;
|
|
370
538
|
this._remoteAudio.remove();
|
|
371
539
|
}
|
package/src/webrtc/tones.ts
CHANGED
|
@@ -68,7 +68,7 @@ export class JingleTone implements ConnectingTone {
|
|
|
68
68
|
private nextBarStart = 0;
|
|
69
69
|
|
|
70
70
|
// Configuration
|
|
71
|
-
private readonly volume = 0.
|
|
71
|
+
private readonly volume = 0.3;
|
|
72
72
|
private readonly bpm = 120;
|
|
73
73
|
private readonly beatsPerBar = 4;
|
|
74
74
|
// Delay before the first note, so near-instant connections stay silent.
|
|
@@ -198,3 +198,87 @@ export class JingleTone implements ConnectingTone {
|
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
export interface Tone {
|
|
203
|
+
play(): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class LeavingTone implements Tone {
|
|
207
|
+
public play(): void {
|
|
208
|
+
const ctx = new AudioContext();
|
|
209
|
+
|
|
210
|
+
if (ctx.state === "suspended") {
|
|
211
|
+
void ctx.resume();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const now = ctx.currentTime;
|
|
215
|
+
|
|
216
|
+
// First note: D4
|
|
217
|
+
const first = this.playNote(ctx, {
|
|
218
|
+
frequency: 293.665,
|
|
219
|
+
startTime: now,
|
|
220
|
+
attack: 0.065,
|
|
221
|
+
decay: 0.085,
|
|
222
|
+
volume: 0.2,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Second note: A3, starts 139ms later
|
|
226
|
+
const second = this.playNote(ctx, {
|
|
227
|
+
frequency: 220.0,
|
|
228
|
+
startTime: now + 0.139,
|
|
229
|
+
attack: 0.018,
|
|
230
|
+
decay: 0.075,
|
|
231
|
+
volume: 0.2,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
void Promise.all([first, second]).then(() => ctx.close());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Resolves once the note has finished playing.
|
|
238
|
+
private playNote(
|
|
239
|
+
ctx: AudioContext,
|
|
240
|
+
{
|
|
241
|
+
frequency,
|
|
242
|
+
startTime,
|
|
243
|
+
attack,
|
|
244
|
+
decay,
|
|
245
|
+
volume,
|
|
246
|
+
}: {
|
|
247
|
+
frequency: number;
|
|
248
|
+
startTime: number;
|
|
249
|
+
attack: number;
|
|
250
|
+
decay: number;
|
|
251
|
+
volume: number;
|
|
252
|
+
},
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const oscillator = ctx.createOscillator();
|
|
255
|
+
const gain = ctx.createGain();
|
|
256
|
+
|
|
257
|
+
oscillator.type = "sine";
|
|
258
|
+
oscillator.frequency.setValueAtTime(frequency, startTime);
|
|
259
|
+
|
|
260
|
+
// Smooth onset
|
|
261
|
+
gain.gain.setValueAtTime(0, startTime);
|
|
262
|
+
gain.gain.linearRampToValueAtTime(volume, startTime + attack);
|
|
263
|
+
|
|
264
|
+
// Exponential decay
|
|
265
|
+
const peakTime = startTime + attack;
|
|
266
|
+
const endTime = peakTime + decay * 5;
|
|
267
|
+
|
|
268
|
+
gain.gain.setTargetAtTime(0.00001, peakTime, decay);
|
|
269
|
+
|
|
270
|
+
oscillator.connect(gain);
|
|
271
|
+
gain.connect(ctx.destination);
|
|
272
|
+
|
|
273
|
+
oscillator.start(startTime);
|
|
274
|
+
oscillator.stop(endTime);
|
|
275
|
+
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
oscillator.onended = () => {
|
|
278
|
+
oscillator.disconnect();
|
|
279
|
+
gain.disconnect();
|
|
280
|
+
resolve();
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|