@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.
@@ -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"; // The remote hung up before the call connected.
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
- // Played while the call is in the "connecting" state.
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
- // 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.
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
- const permissionTimer = setTimeout(() => {
159
- this._setState("waiting-for-permission");
160
- }, 500);
233
+ const permissionTimer = setTimeout(() => {
234
+ this._setState("waiting-for-permission");
235
+ }, 500);
161
236
 
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
- }
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
- // hangup() was called during the permission prompt.
175
- if (this.state === "ended") {
176
- this._teardown();
177
- return;
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({ type: "auth", body: this._webrtcCode });
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._send({ type: "connect", body: {} });
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
  }
@@ -68,7 +68,7 @@ export class JingleTone implements ConnectingTone {
68
68
  private nextBarStart = 0;
69
69
 
70
70
  // Configuration
71
- private readonly volume = 0.25;
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
+ }