ulai-js-sdk 0.1.0 → 0.1.2

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 ADDED
@@ -0,0 +1,557 @@
1
+ # Ulai Cloud JavaScript/TypeScript Client SDK (`ulai-js-sdk`)
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ulai-js-sdk.svg?color=blue)](https://www.npmjs.com/package/ulai-js-sdk)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6.svg)](https://www.typescriptlang.org/)
6
+
7
+ The official JavaScript and TypeScript client SDK for Ulai Cloud real-time WebRTC sessions. Enables web applications to establish ultra-low-latency, bidirectional audio streaming with AI voice agents, receive real-time streaming transcripts, and manage room lifecycle events.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Features](#features)
14
+ - [Installation](#installation)
15
+ - [Quick Start](#quick-start)
16
+ - [Pattern 1: Connect with Pre-provisioned Session Credentials (`Room`)](#pattern-1-connect-with-pre-provisioned-session-credentials-room)
17
+ - [Pattern 2: Connect using Ulai Cloud API Key (`UlaiClient`)](#pattern-2-connect-using-ulai-cloud-api-key-ulaiclient)
18
+ - [React / Next.js Example](#react--nextjs-example)
19
+ - [Vanilla JavaScript / HTML Example](#vanilla-javascript--html-example)
20
+ - [API Reference](#api-reference)
21
+ - [`Room`](#room)
22
+ - [Events](#events)
23
+ - [`UlaiClient`](#ulaiclient)
24
+ - [TypeScript Interfaces](#typescript-interfaces)
25
+ - [Handling Browser Audio & Autoplay Policies](#handling-browser-audio--autoplay-policies)
26
+ - [Network & ICE Configuration](#network--ice-configuration)
27
+ - [License](#license)
28
+
29
+ ---
30
+
31
+ ## Features
32
+
33
+ - **W3C Perfect Negotiation:** Native polite-peer pattern implementation eliminates SDP glare and race conditions during dynamic renegotiation.
34
+ - **Resilient Audio Pipelines:** Automatic track wrapping and fallback streams compatible with Pion SFU standalone and grouped media tracks.
35
+ - **Dynamic Candidate Buffering:** Queues incoming ICE candidates until remote session descriptions are active, avoiding `InvalidStateError`.
36
+ - **Automatic Reconnection:** Exponential backoff with jitter for WebRTC signaling and Control Plane event sockets.
37
+ - **Message Outbox:** Buffers signaling messages until WebSocket connections reach an open state.
38
+ - **Live Transcripts:** Real-time event streaming for interim and final speech recognition deltas (`transcriptDelta`).
39
+ - **Audio Management:** Direct microphone capture with hardware-level mute controls (`setMuted()`), query methods (`isMuted()`), and pre-configured noise suppression, echo cancellation, and AGC.
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ # npm
47
+ npm install ulai-js-sdk
48
+
49
+ # yarn
50
+ yarn add ulai-js-sdk
51
+
52
+ # pnpm
53
+ pnpm add ulai-js-sdk
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ ### Pattern 1: Connect with Pre-provisioned Session Credentials (`Room`)
61
+
62
+ This is the standard pattern for web applications. Your application server provisions a session and returns join credentials (`roomId`, `ticket`, `nodeAddress`, `eventsToken`), which the client SDK uses to join:
63
+
64
+ ```typescript
65
+ import { Room } from 'ulai-js-sdk';
66
+
67
+ // 1. Initialize Room with your Ulai Control Plane URL
68
+ const room = new Room('https://cp.ulai.co.in');
69
+
70
+ // 2. Set up event listeners
71
+ room.on('connected', () => {
72
+ console.log('Connected to Ulai WebRTC room!');
73
+ });
74
+
75
+ // Play remote incoming audio (e.g., from an AI agent or participant)
76
+ room.on('trackSubscribed', (track: MediaStreamTrack, stream: MediaStream) => {
77
+ if (track.kind === 'audio') {
78
+ const audio = document.createElement('audio');
79
+ audio.srcObject = stream;
80
+ audio.autoplay = true;
81
+ audio.style.display = 'none';
82
+ document.body.appendChild(audio);
83
+ }
84
+ });
85
+
86
+ // Receive live transcripts
87
+ room.on('transcriptDelta', (delta) => {
88
+ console.log(`[${delta.speaker}] ${delta.text} (final: ${delta.is_final})`);
89
+ });
90
+
91
+ room.on('sessionTerminated', () => {
92
+ console.log('Call ended by remote party');
93
+ });
94
+
95
+ // 3. Connect to WebRTC media and the Control Plane event feed
96
+ await room.connect({
97
+ roomId: 'session-xyz', // The session / room ID
98
+ ticket: 'ticket-token-abc', // SFU WebRTC signaling auth ticket
99
+ nodeAddress: 'sfu.ulai.co.in', // Address of the assigned SFU node
100
+ eventsToken: 'events-token-123' // Token for live events & transcripts
101
+ });
102
+
103
+ // 4. Request microphone access and stream audio to the room
104
+ await room.publishAudio();
105
+
106
+ // 5. Mute / Unmute controls
107
+ room.setMuted(true);
108
+ console.log('Is muted:', room.isMuted());
109
+
110
+ // 6. Graceful disconnect
111
+ room.disconnect();
112
+ ```
113
+
114
+ ---
115
+
116
+ ### Pattern 2: Connect using Ulai Cloud API Key (`UlaiClient`)
117
+
118
+ If you want the client to make direct join requests to the Ulai Cloud Control Plane:
119
+
120
+ ```typescript
121
+ import { UlaiClient } from 'ulai-js-sdk';
122
+
123
+ const client = new UlaiClient({
124
+ url: 'https://cp.ulai.co.in',
125
+ apiKey: 'YOUR_ULAI_API_KEY', // Optional Bearer token
126
+ });
127
+
128
+ // Requests join credentials from the Control Plane and connects WebRTC + Events
129
+ const room = await client.joinRoom({
130
+ roomId: 'session-xyz',
131
+ participantId: 'user-01',
132
+ });
133
+
134
+ // Hook into room events
135
+ room.on('trackSubscribed', (track, stream) => {
136
+ const audio = document.createElement('audio');
137
+ audio.srcObject = stream;
138
+ audio.autoplay = true;
139
+ document.body.appendChild(audio);
140
+ });
141
+
142
+ // Publish microphone
143
+ await room.publishAudio();
144
+ ```
145
+
146
+ ---
147
+
148
+ ## React / Next.js Example
149
+
150
+ ```tsx
151
+ 'use client';
152
+
153
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
154
+ import { Room, TranscriptDelta } from 'ulai-js-sdk';
155
+
156
+ interface TranscriptItem {
157
+ id: string;
158
+ speaker: string;
159
+ text: string;
160
+ isFinal: boolean;
161
+ }
162
+
163
+ export function VoiceRoom({
164
+ controlPlaneUrl = 'https://cp.ulai.co.in',
165
+ fetchSessionCredentials,
166
+ }: {
167
+ controlPlaneUrl?: string;
168
+ fetchSessionCredentials: () => Promise<{
169
+ roomId: string;
170
+ ticket: string;
171
+ nodeAddress: string;
172
+ eventsToken?: string;
173
+ }>;
174
+ }) {
175
+ const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'ended'>('idle');
176
+ const [isMuted, setIsMuted] = useState(false);
177
+ const [transcripts, setTranscripts] = useState<TranscriptItem[]>([]);
178
+
179
+ const roomRef = useRef<Room | null>(null);
180
+ const audioRef = useRef<HTMLAudioElement | null>(null);
181
+
182
+ const cleanup = useCallback(() => {
183
+ if (roomRef.current) {
184
+ roomRef.current.disconnect();
185
+ roomRef.current = null;
186
+ }
187
+ if (audioRef.current) {
188
+ audioRef.current.srcObject = null;
189
+ }
190
+ }, []);
191
+
192
+ useEffect(() => {
193
+ return () => cleanup();
194
+ }, [cleanup]);
195
+
196
+ const startSession = async () => {
197
+ try {
198
+ setStatus('connecting');
199
+ setTranscripts([]);
200
+
201
+ // 1. Fetch credentials from your backend
202
+ const creds = await fetchSessionCredentials();
203
+
204
+ // 2. Initialize Room
205
+ const room = new Room(controlPlaneUrl);
206
+ roomRef.current = room;
207
+
208
+ // 3. Register Event Handlers
209
+ room.on('connected', () => setStatus('connected'));
210
+
211
+ room.on('trackSubscribed', (track, stream) => {
212
+ if (track.kind === 'audio' && audioRef.current) {
213
+ audioRef.current.srcObject = stream;
214
+ audioRef.current.play().catch(console.error);
215
+ }
216
+ });
217
+
218
+ room.on('transcriptDelta', (delta: TranscriptDelta) => {
219
+ setTranscripts((prev) => {
220
+ const existingIndex = prev.findIndex((t) => t.id === delta.segment_id);
221
+ if (existingIndex !== -1) {
222
+ const updated = [...prev];
223
+ updated[existingIndex] = {
224
+ ...updated[existingIndex],
225
+ text: delta.text,
226
+ isFinal: delta.is_final,
227
+ };
228
+ return updated;
229
+ }
230
+ return [
231
+ ...prev,
232
+ { id: delta.segment_id, speaker: delta.speaker, text: delta.text, isFinal: delta.is_final },
233
+ ];
234
+ });
235
+ });
236
+
237
+ room.on('sessionTerminated', () => {
238
+ setStatus('ended');
239
+ cleanup();
240
+ });
241
+
242
+ room.on('disconnected', () => {
243
+ setStatus('idle');
244
+ cleanup();
245
+ });
246
+
247
+ // 4. Connect WebRTC & Events
248
+ await room.connect({
249
+ roomId: creds.roomId,
250
+ ticket: creds.ticket,
251
+ nodeAddress: creds.nodeAddress,
252
+ eventsToken: creds.eventsToken,
253
+ });
254
+
255
+ // 5. Publish microphone
256
+ await room.publishAudio();
257
+ } catch (err) {
258
+ console.error('Failed to start session:', err);
259
+ setStatus('idle');
260
+ cleanup();
261
+ }
262
+ };
263
+
264
+ const endSession = () => {
265
+ cleanup();
266
+ setStatus('idle');
267
+ };
268
+
269
+ const toggleMute = () => {
270
+ if (!roomRef.current) return;
271
+ const nextMuted = !isMuted;
272
+ roomRef.current.setMuted(nextMuted);
273
+ setIsMuted(nextMuted);
274
+ };
275
+
276
+ return (
277
+ <div style={{ maxWidth: 480, padding: 24, borderRadius: 12, border: '1px solid #e2e8f0' }}>
278
+ <audio ref={audioRef} autoPlay playsInline style={{ display: 'none' }} />
279
+
280
+ <h3>Live Voice Session</h3>
281
+ <p>Status: <strong>{status.toUpperCase()}</strong></p>
282
+
283
+ <div style={{ display: 'flex', gap: 12, margin: '16px 0' }}>
284
+ {status === 'idle' || status === 'ended' ? (
285
+ <button onClick={startSession} style={{ padding: '8px 16px', background: '#2563eb', color: '#fff', borderRadius: 8 }}>
286
+ Start Session
287
+ </button>
288
+ ) : (
289
+ <>
290
+ <button onClick={endSession} style={{ padding: '8px 16px', background: '#dc2626', color: '#fff', borderRadius: 8 }}>
291
+ End Session
292
+ </button>
293
+ <button onClick={toggleMute} style={{ padding: '8px 16px', borderRadius: 8 }}>
294
+ {isMuted ? 'Unmute Mic' : 'Mute Mic'}
295
+ </button>
296
+ </>
297
+ )}
298
+ </div>
299
+
300
+ <div style={{ height: 200, overflowY: 'auto', background: '#f8fafc', padding: 12, borderRadius: 8 }}>
301
+ {transcripts.map((t) => (
302
+ <div key={t.id} style={{ marginBottom: 6 }}>
303
+ <strong>{t.speaker}: </strong>
304
+ <span>{t.text}</span>
305
+ </div>
306
+ ))}
307
+ </div>
308
+ </div>
309
+ );
310
+ }
311
+ ```
312
+
313
+ ---
314
+
315
+ ## Vanilla JavaScript / HTML Example
316
+
317
+ ```html
318
+ <!DOCTYPE html>
319
+ <html lang="en">
320
+ <head>
321
+ <meta charset="UTF-8">
322
+ <title>Ulai Voice Session</title>
323
+ </head>
324
+ <body>
325
+ <h2>Ulai Voice Client</h2>
326
+ <button id="connectBtn">Connect</button>
327
+ <button id="muteBtn" disabled>Mute</button>
328
+ <div id="transcriptBox" style="margin-top: 16px; border: 1px solid #ddd; height: 220px; overflow-y: auto; padding: 10px;"></div>
329
+
330
+ <audio id="remoteAudio" autoplay playsinline></audio>
331
+
332
+ <script type="module">
333
+ import { Room } from 'ulai-js-sdk';
334
+
335
+ let room = null;
336
+ const connectBtn = document.getElementById('connectBtn');
337
+ const muteBtn = document.getElementById('muteBtn');
338
+ const transcriptBox = document.getElementById('transcriptBox');
339
+ const remoteAudio = document.getElementById('remoteAudio');
340
+
341
+ connectBtn.addEventListener('click', async () => {
342
+ if (room) {
343
+ room.disconnect();
344
+ room = null;
345
+ connectBtn.textContent = 'Connect';
346
+ muteBtn.disabled = true;
347
+ return;
348
+ }
349
+
350
+ connectBtn.textContent = 'Connecting...';
351
+
352
+ // Obtain credentials from your backend API
353
+ const res = await fetch('/api/get-room-credentials');
354
+ const creds = await res.json();
355
+
356
+ room = new Room(creds.controlPlaneUrl || 'https://cp.ulai.co.in');
357
+
358
+ room.on('connected', () => {
359
+ connectBtn.textContent = 'Disconnect';
360
+ muteBtn.disabled = false;
361
+ });
362
+
363
+ room.on('trackSubscribed', (track, stream) => {
364
+ if (track.kind === 'audio') {
365
+ remoteAudio.srcObject = stream;
366
+ }
367
+ });
368
+
369
+ room.on('transcriptDelta', (delta) => {
370
+ const item = document.createElement('div');
371
+ item.textContent = `[${delta.speaker}]: ${delta.text}`;
372
+ transcriptBox.appendChild(item);
373
+ transcriptBox.scrollTop = transcriptBox.scrollHeight;
374
+ });
375
+
376
+ room.on('sessionTerminated', () => {
377
+ room.disconnect();
378
+ room = null;
379
+ connectBtn.textContent = 'Connect';
380
+ muteBtn.disabled = true;
381
+ });
382
+
383
+ await room.connect({
384
+ roomId: creds.roomId,
385
+ ticket: creds.ticket,
386
+ nodeAddress: creds.nodeAddress,
387
+ eventsToken: creds.eventsToken,
388
+ });
389
+
390
+ await room.publishAudio();
391
+ });
392
+
393
+ muteBtn.addEventListener('click', () => {
394
+ if (!room) return;
395
+ const next = !room.isMuted();
396
+ room.setMuted(next);
397
+ muteBtn.textContent = next ? 'Unmute' : 'Mute';
398
+ });
399
+ </script>
400
+ </body>
401
+ </html>
402
+ ```
403
+
404
+ ---
405
+
406
+ ## API Reference
407
+
408
+ ### `Room`
409
+
410
+ Primary session manager. Extends `EventEmitter<RoomEvent>`.
411
+
412
+ #### Constructor
413
+ ```typescript
414
+ new Room(controlPlaneUrl: string)
415
+ ```
416
+ - `controlPlaneUrl`: Base URL of the Ulai Control Plane (e.g. `'https://cp.ulai.co.in'`).
417
+
418
+ #### Methods
419
+
420
+ - **`connect(options: JoinRoomOptions): Promise<void>`**
421
+ Connects to the SFU signaling WebSocket and the Control Plane event feed.
422
+ - **`publishAudio(): Promise<MediaStream>`**
423
+ Requests microphone access (`navigator.mediaDevices.getUserMedia`) with echo cancellation, noise suppression, and auto gain control, and attaches the audio track to the WebRTC connection.
424
+ - **`setMuted(muted: boolean): void`**
425
+ Mutes or unmutes the active microphone audio track. Emits `'muteChanged'`.
426
+ - **`isMuted(): boolean`**
427
+ Returns `true` if local audio is muted or inactive.
428
+ - **`getLocalStream(): MediaStream | null`**
429
+ Returns the active local `MediaStream`.
430
+ - **`getRemoteStreams(): MediaStream[]`**
431
+ Returns all active subscribed remote `MediaStream`s.
432
+ - **`disconnect(): void`**
433
+ Stops local microphone tracks, closes the WebRTC peer connection, disconnects signaling and event sockets, and cleans up resources.
434
+
435
+ #### Properties
436
+ - **`roomId: string | null`**: Current room/session ID.
437
+ - **`participantId: string | null`**: Current participant ID.
438
+
439
+ ---
440
+
441
+ ### Events
442
+
443
+ Register listeners using `room.on(eventName, handler)`.
444
+
445
+ | Event | Parameters | Description |
446
+ |---|---|---|
447
+ | `connected` | `()` | WebRTC connection successfully established. |
448
+ | `disconnected` | `()` | Connection closed or lost. |
449
+ | `reconnecting` | `()` | WebRTC connection interrupted; attempting reconnection. |
450
+ | `reconnected` | `()` | Reconnection established and media renegotiated. |
451
+ | `trackSubscribed` | `(track: MediaStreamTrack, stream: MediaStream)` | Remote audio track available. Attach `stream` to an `<audio>` element. |
452
+ | `trackUnsubscribed` | `(track: MediaStreamTrack, stream: MediaStream)` | Remote track ended or removed. |
453
+ | `transcriptDelta` | `(delta: TranscriptDelta)` | Streaming transcript update for user or remote speaker. |
454
+ | `sessionTerminated`| `(msg: any)` | Session ended by server/remote agent. SDK auto-disconnects. |
455
+ | `muteChanged` | `(muted: boolean)` | Local microphone mute status changed. |
456
+ | `participantJoined`| `(msg: any)` | A participant joined the session. |
457
+ | `participantLeft` | `(msg: any)` | A participant left the session. |
458
+ | `roster` | `(msg: RosterMessage)` | Current participant roster update. |
459
+ | `error` | `(error: any)` | Signaling, WebRTC, or event socket error. |
460
+
461
+ ---
462
+
463
+ ### `UlaiClient`
464
+
465
+ Utility class for directly requesting room join tickets and initializing a `Room`.
466
+
467
+ ```typescript
468
+ const client = new UlaiClient({
469
+ url: 'https://cp.ulai.co.in',
470
+ apiKey: 'optional_api_key'
471
+ });
472
+
473
+ const room: Room = await client.joinRoom({
474
+ roomId: 'session-id',
475
+ participantId: 'user-01'
476
+ });
477
+ ```
478
+
479
+ ---
480
+
481
+ ### TypeScript Interfaces
482
+
483
+ ```typescript
484
+ export interface JoinRoomOptions {
485
+ roomId: string;
486
+ participantId?: string;
487
+ eventsToken?: string;
488
+ ticket?: string;
489
+ nodeAddress?: string;
490
+ iceServers?: RTCIceServer[];
491
+ autoSubscribeAudio?: boolean;
492
+ }
493
+
494
+ export interface TranscriptDelta {
495
+ segment_id: string;
496
+ speaker: 'user' | 'agent' | string;
497
+ text: string;
498
+ is_final: boolean;
499
+ }
500
+
501
+ export interface ConnectOptions {
502
+ url: string;
503
+ apiKey?: string;
504
+ token?: string;
505
+ }
506
+
507
+ export interface RosterMessage {
508
+ participants: any[];
509
+ }
510
+ ```
511
+
512
+ ---
513
+
514
+ ## Handling Browser Audio & Autoplay Policies
515
+
516
+ Modern web browsers restrict media playback until a user interaction occurs. Follow these rules to ensure smooth audio delivery:
517
+
518
+ 1. **Invoke from a User Gesture:** Always call `room.connect()` and `room.publishAudio()` inside a direct user event handler (e.g., clicking a "Start Call" button).
519
+ 2. **Audio Tag Attributes:** When attaching remote streams, ensure `<audio autoplay playsinline>` attributes are present.
520
+ 3. **Catch Playback Errors:** Handle rejected audio play promises:
521
+ ```typescript
522
+ audioElement.play().catch((err) => {
523
+ console.warn('Audio playback deferred by browser autoplay policy:', err);
524
+ });
525
+ ```
526
+
527
+ ---
528
+
529
+ ## Network & ICE Configuration
530
+
531
+ Ulai Cloud WebRTC uses:
532
+ - **Signaling & Events:** Secure WebSockets (`wss://`) over port `443`.
533
+ - **Media (RTP/RTCP):** UDP media routing.
534
+
535
+ For environments requiring custom STUN/TURN traversal, provide custom `iceServers` in `JoinRoomOptions`:
536
+
537
+ ```typescript
538
+ await room.connect({
539
+ roomId: 'session-xyz',
540
+ ticket: 'ticket-token',
541
+ nodeAddress: 'sfu.ulai.co.in',
542
+ iceServers: [
543
+ { urls: 'stun:stun.l.google.com:19302' },
544
+ {
545
+ urls: 'turn:turn.yourcompany.com:3478',
546
+ username: 'turnuser',
547
+ credential: 'turnpassword',
548
+ },
549
+ ],
550
+ });
551
+ ```
552
+
553
+ ---
554
+
555
+ ## License
556
+
557
+ MIT © [Ulai Team](https://ulai.co.in)
package/dist/events.d.ts CHANGED
@@ -2,7 +2,20 @@ import EventEmitter from 'eventemitter3';
2
2
  export declare class EventsClient extends EventEmitter {
3
3
  private ws;
4
4
  private url;
5
+ private sessionId;
6
+ private eventsToken;
7
+ private lastEventId;
8
+ private isClosedManually;
9
+ private retryAttempts;
10
+ private readonly maxRetries;
11
+ private reconnectTimer;
12
+ private heartbeatTimer;
5
13
  constructor(cpBase: string);
14
+ private buildUrl;
6
15
  connect(sessionId: string, eventsToken: string): void;
16
+ private sendSubscribe;
17
+ private startHeartbeat;
18
+ private stopHeartbeat;
19
+ private cleanupSocket;
7
20
  close(): void;
8
21
  }