@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.
@@ -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,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
- // Played while the call is in the "connecting" state.
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({ type: "auth", body: this._webrtcCode });
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._send({ type: "connect", body: {} });
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
  }
@@ -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
+ }