@guava-ai/guava-sdk 0.38.0 → 0.39.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 +4 -4
- 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 +36 -4
- package/dist/src/webrtc/index.js +103 -4
- 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 +1 -1
- package/src/socket/call-info.ts +1 -0
- package/src/version.ts +1 -1
- package/src/webrtc/index.ts +131 -6
- 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,11 +60,26 @@ 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 _connectionTimer?: ReturnType<typeof setTimeout>;
|
|
66
73
|
private _sock?: WebSocket;
|
|
74
|
+
|
|
75
|
+
private readonly _chatSockUrl: URL;
|
|
76
|
+
// Links the chat socket to this call's session on the server.
|
|
77
|
+
private readonly _chatSessionId = crypto.randomUUID();
|
|
78
|
+
private _chatSock?: WebSocket;
|
|
79
|
+
private _chatReady = false;
|
|
80
|
+
// Messages sent before the chat socket is ready.
|
|
81
|
+
private _pendingChatMessages: string[] = [];
|
|
82
|
+
|
|
67
83
|
private _localStream?: MediaStream;
|
|
68
84
|
private _peerConnection?: RTCPeerConnection;
|
|
69
85
|
private _remoteStream?: MediaStream;
|
|
@@ -75,16 +91,34 @@ export class Call {
|
|
|
75
91
|
webrtcCode,
|
|
76
92
|
baseUrl = "https://app.goguava.ai/",
|
|
77
93
|
connectingTone = new JingleTone(),
|
|
94
|
+
leavingTone = new LeavingTone(),
|
|
95
|
+
connectionTimeoutMs = 30_000,
|
|
96
|
+
customPayload = {},
|
|
78
97
|
}: {
|
|
98
|
+
/** Code used to authenticate with the signaling server. */
|
|
79
99
|
webrtcCode: string;
|
|
100
|
+
/** Base URL of the Guava server. Defaults to `https://app.goguava.ai/`. */
|
|
80
101
|
baseUrl?: string;
|
|
81
|
-
|
|
102
|
+
/** Played while the call is in the "connecting" state. Defaults to {@link JingleTone}. */
|
|
82
103
|
connectingTone?: ConnectingTone | "disabled";
|
|
104
|
+
/** Played when the call ends. Defaults to {@link LeavingTone}. */
|
|
105
|
+
leavingTone?: Tone | "disabled";
|
|
106
|
+
/** Max time in the "connecting" state before the call fails with CONNECTION_TIMEOUT. Defaults to 30 seconds. */
|
|
107
|
+
connectionTimeoutMs?: number | "disabled";
|
|
108
|
+
/** A custom payload that is forwarded to the agent at the start of the call. */
|
|
109
|
+
customPayload?: Record<string, unknown>;
|
|
83
110
|
}) {
|
|
84
111
|
this._webrtcCode = webrtcCode;
|
|
112
|
+
this._customPayload = customPayload;
|
|
85
113
|
if (connectingTone !== "disabled") {
|
|
86
114
|
this._connectingTone = connectingTone;
|
|
87
115
|
}
|
|
116
|
+
if (leavingTone !== "disabled") {
|
|
117
|
+
this._leavingTone = leavingTone;
|
|
118
|
+
}
|
|
119
|
+
if (connectionTimeoutMs !== "disabled") {
|
|
120
|
+
this._connectionTimeoutMs = connectionTimeoutMs;
|
|
121
|
+
}
|
|
88
122
|
|
|
89
123
|
this._sockUrl = new URL("/webrtc/", baseUrl);
|
|
90
124
|
const sockProtocol = SOCKET_PROTOCOLS[this._sockUrl.protocol];
|
|
@@ -92,6 +126,7 @@ export class Call {
|
|
|
92
126
|
throw new Error(`Unsupported baseUrl protocol: ${this._sockUrl.protocol}`);
|
|
93
127
|
}
|
|
94
128
|
this._sockUrl.protocol = sockProtocol;
|
|
129
|
+
this._chatSockUrl = new URL("/webrtc-chat/", this._sockUrl);
|
|
95
130
|
|
|
96
131
|
this._remoteAudio = document.createElement("audio");
|
|
97
132
|
this._remoteAudio.autoplay = true;
|
|
@@ -128,15 +163,40 @@ export class Call {
|
|
|
128
163
|
};
|
|
129
164
|
}
|
|
130
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Registers a handler for messages the agent sends with `call.send_webrtc_message()`.
|
|
168
|
+
* @param handler - Called with each message's payload.
|
|
169
|
+
* @returns A function that unregisters the handler.
|
|
170
|
+
*/
|
|
171
|
+
onMessage(handler: (payload: Record<string, unknown>) => void): () => void {
|
|
172
|
+
this._messageHandlers.add(handler);
|
|
173
|
+
return () => {
|
|
174
|
+
this._messageHandlers.delete(handler);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
131
178
|
private _setState(state: CallState) {
|
|
132
179
|
if (state === this._state) {
|
|
133
180
|
return;
|
|
134
181
|
}
|
|
182
|
+
|
|
183
|
+
if (state === "ended") {
|
|
184
|
+
this._leavingTone?.play();
|
|
185
|
+
}
|
|
186
|
+
|
|
135
187
|
this._state = state;
|
|
136
188
|
if (state === "connecting") {
|
|
137
189
|
this._connectingTone?.start();
|
|
190
|
+
if (this._connectionTimeoutMs !== undefined) {
|
|
191
|
+
this._connectionTimer = setTimeout(() => {
|
|
192
|
+
this._send({ type: "hangup" });
|
|
193
|
+
this._end(new CallError("CONNECTION_TIMEOUT", "Timed out connecting the call."));
|
|
194
|
+
}, this._connectionTimeoutMs);
|
|
195
|
+
}
|
|
138
196
|
} else {
|
|
139
197
|
this._connectingTone?.stop();
|
|
198
|
+
clearTimeout(this._connectionTimer);
|
|
199
|
+
this._connectionTimer = undefined;
|
|
140
200
|
}
|
|
141
201
|
for (const handler of this._stateHandlers) {
|
|
142
202
|
handler(state);
|
|
@@ -185,7 +245,13 @@ export class Call {
|
|
|
185
245
|
|
|
186
246
|
this._sock = new WebSocket(this._sockUrl);
|
|
187
247
|
this._sock.onopen = () => {
|
|
188
|
-
this._send({
|
|
248
|
+
this._send({
|
|
249
|
+
type: "auth",
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
webrtc_code: this._webrtcCode,
|
|
252
|
+
chat_session_id: this._chatSessionId,
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
189
255
|
};
|
|
190
256
|
this._sock.onmessage = (event) => {
|
|
191
257
|
this._onMessage(JSON.parse(event.data) as SignalingMessage);
|
|
@@ -197,6 +263,51 @@ export class Call {
|
|
|
197
263
|
await started;
|
|
198
264
|
}
|
|
199
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Sends a message to the agent, delivered to its `on_webrtc_message` handler.
|
|
268
|
+
* Messages sent before the message channel is ready are queued.
|
|
269
|
+
* @param payload - A JSON-serializable object.
|
|
270
|
+
*/
|
|
271
|
+
sendMessage(payload: Record<string, unknown>) {
|
|
272
|
+
if (this._state === "not-started" || this._state === "ended") {
|
|
273
|
+
throw new Error(`sendMessage() called while the call is ${this._state}.`);
|
|
274
|
+
}
|
|
275
|
+
const message = JSON.stringify({ type: "webrtc-message", payload });
|
|
276
|
+
if (this._chatReady && this._chatSock !== undefined) {
|
|
277
|
+
this._chatSock.send(message);
|
|
278
|
+
} else {
|
|
279
|
+
this._pendingChatMessages.push(message);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private _openChatSocket() {
|
|
284
|
+
this._chatSock = new WebSocket(this._chatSockUrl);
|
|
285
|
+
this._chatSock.onopen = () => {
|
|
286
|
+
this._chatSock?.send(
|
|
287
|
+
JSON.stringify({ webrtc_code: this._webrtcCode, chat_session_id: this._chatSessionId }),
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
this._chatSock.onmessage = (event) => {
|
|
291
|
+
const message = JSON.parse(event.data) as {
|
|
292
|
+
type?: string;
|
|
293
|
+
command_type?: string;
|
|
294
|
+
payload?: Record<string, unknown>;
|
|
295
|
+
};
|
|
296
|
+
// The server sends "ready" once the call session is found.
|
|
297
|
+
if (message.type === "ready" && !this._chatReady) {
|
|
298
|
+
this._chatReady = true;
|
|
299
|
+
for (const pending of this._pendingChatMessages) {
|
|
300
|
+
this._chatSock?.send(pending);
|
|
301
|
+
}
|
|
302
|
+
this._pendingChatMessages = [];
|
|
303
|
+
} else if (message.command_type === "send-webrtc-message") {
|
|
304
|
+
for (const handler of this._messageHandlers) {
|
|
305
|
+
handler(message.payload ?? {});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
200
311
|
async hangup() {
|
|
201
312
|
if (this._state === "not-started") {
|
|
202
313
|
throw new Error(`hangup() called before start().`);
|
|
@@ -224,7 +335,8 @@ export class Call {
|
|
|
224
335
|
return;
|
|
225
336
|
}
|
|
226
337
|
this._iceServers = message.ice_servers;
|
|
227
|
-
this.
|
|
338
|
+
this._openChatSocket();
|
|
339
|
+
this._send({ type: "connect", body: { custom_payload: this._customPayload } });
|
|
228
340
|
void this._connectPeer();
|
|
229
341
|
break;
|
|
230
342
|
case "desc":
|
|
@@ -351,12 +463,23 @@ export class Call {
|
|
|
351
463
|
}
|
|
352
464
|
|
|
353
465
|
private _teardown() {
|
|
466
|
+
// Close the signaling socket.
|
|
354
467
|
if (this._sock) {
|
|
355
468
|
// Don't fire close handlers for a socket we closed ourselves.
|
|
356
469
|
this._sock.onclose = null;
|
|
357
470
|
this._sock.close();
|
|
358
471
|
this._sock = undefined;
|
|
359
472
|
}
|
|
473
|
+
|
|
474
|
+
// Close the chat socket.
|
|
475
|
+
if (this._chatSock) {
|
|
476
|
+
this._chatSock.close();
|
|
477
|
+
this._chatSock = undefined;
|
|
478
|
+
}
|
|
479
|
+
this._chatReady = false;
|
|
480
|
+
this._pendingChatMessages = [];
|
|
481
|
+
|
|
482
|
+
// Close the peer connection.
|
|
360
483
|
if (this._peerConnection !== undefined) {
|
|
361
484
|
this._peerConnection.close();
|
|
362
485
|
this._peerConnection = undefined;
|
|
@@ -366,6 +489,8 @@ export class Call {
|
|
|
366
489
|
}
|
|
367
490
|
this._localStream = undefined;
|
|
368
491
|
this._remoteStream = undefined;
|
|
492
|
+
|
|
493
|
+
// Remove the audio element.
|
|
369
494
|
this._remoteAudio.srcObject = null;
|
|
370
495
|
this._remoteAudio.remove();
|
|
371
496
|
}
|
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
|
+
}
|