ttp-agent-sdk 2.0.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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * AudioRecorder - Handles audio recording using AudioWorklet
3
+ */
4
+ import EventEmitter from './EventEmitter.js';
5
+
6
+ export default class AudioRecorder extends EventEmitter {
7
+ constructor(config) {
8
+ super();
9
+ this.config = config;
10
+ this.audioContext = null;
11
+ this.audioWorkletNode = null;
12
+ this.mediaStream = null;
13
+ this.isRecording = false;
14
+ }
15
+
16
+ /**
17
+ * Start audio recording
18
+ */
19
+ async start() {
20
+ try {
21
+ // Get user media
22
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
23
+ audio: {
24
+ sampleRate: this.config.sampleRate,
25
+ channelCount: 1,
26
+ echoCancellation: true,
27
+ noiseSuppression: true,
28
+ autoGainControl: true
29
+ }
30
+ });
31
+
32
+ // Create AudioContext
33
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
34
+ sampleRate: this.config.sampleRate
35
+ });
36
+
37
+ // Resume AudioContext if suspended
38
+ if (this.audioContext.state === 'suspended') {
39
+ await this.audioContext.resume();
40
+ }
41
+
42
+ // Load AudioWorklet module
43
+ await this.audioContext.audioWorklet.addModule('/audio-processor.js');
44
+
45
+ // Create AudioWorklet node
46
+ this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
47
+
48
+ // Create media stream source
49
+ const source = this.audioContext.createMediaStreamSource(this.mediaStream);
50
+ source.connect(this.audioWorkletNode);
51
+
52
+ // Handle messages from AudioWorklet
53
+ this.audioWorkletNode.port.onmessage = (event) => {
54
+ const { type, data } = event.data;
55
+
56
+ if (type === 'pcm_audio_data') {
57
+ this.emit('audioData', data);
58
+ }
59
+ };
60
+
61
+ // Enable continuous mode
62
+ this.audioWorkletNode.port.postMessage({
63
+ type: 'setForceContinuous',
64
+ data: { enabled: true }
65
+ });
66
+
67
+ this.isRecording = true;
68
+ this.emit('recordingStarted');
69
+
70
+ } catch (error) {
71
+ this.emit('error', error);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Stop audio recording
78
+ */
79
+ async stop() {
80
+ if (!this.isRecording) {
81
+ return;
82
+ }
83
+
84
+ try {
85
+ // Flush any remaining audio data
86
+ if (this.audioWorkletNode) {
87
+ this.audioWorkletNode.port.postMessage({ type: 'flush' });
88
+ await new Promise(resolve => setTimeout(resolve, 100));
89
+ }
90
+
91
+ // Disconnect and cleanup
92
+ if (this.mediaStream) {
93
+ this.mediaStream.getTracks().forEach(track => track.stop());
94
+ this.mediaStream = null;
95
+ }
96
+
97
+ if (this.audioContext && this.audioContext.state !== 'closed') {
98
+ await this.audioContext.close();
99
+ this.audioContext = null;
100
+ }
101
+
102
+ this.audioWorkletNode = null;
103
+ this.isRecording = false;
104
+ this.emit('recordingStopped');
105
+
106
+ } catch (error) {
107
+ this.emit('error', error);
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get recording status
114
+ */
115
+ getStatus() {
116
+ return {
117
+ isRecording: this.isRecording,
118
+ audioContextState: this.audioContext ? this.audioContext.state : 'closed'
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Cleanup resources
124
+ */
125
+ destroy() {
126
+ this.stop();
127
+ }
128
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ConnectionManager - Global connection manager to prevent multiple connections to the same URL
3
+ */
4
+ class ConnectionManager {
5
+ constructor() {
6
+ this.connections = new Map(); // Map of URL -> connection info
7
+ }
8
+
9
+ /**
10
+ * Register a connection attempt
11
+ */
12
+ registerConnection(url, connectionId) {
13
+ if (!this.connections.has(url)) {
14
+ this.connections.set(url, {
15
+ connectionId,
16
+ timestamp: Date.now(),
17
+ count: 1
18
+ });
19
+ console.log(`🔌 ConnectionManager: Registered connection ${connectionId} for ${url}`);
20
+ return true;
21
+ }
22
+
23
+ const existing = this.connections.get(url);
24
+ const timeSinceLastConnection = Date.now() - existing.timestamp;
25
+
26
+ // If it's been more than 30 seconds since the last connection, allow it
27
+ if (timeSinceLastConnection > 30000) {
28
+ this.connections.set(url, {
29
+ connectionId,
30
+ timestamp: Date.now(),
31
+ count: 1
32
+ });
33
+ console.log(`🔌 ConnectionManager: Allowed new connection ${connectionId} for ${url} (old connection was ${timeSinceLastConnection}ms ago)`);
34
+ return true;
35
+ }
36
+
37
+ // Otherwise, prevent the connection
38
+ existing.count++;
39
+ console.log(`🔌 ConnectionManager: Blocked connection ${connectionId} for ${url} (${existing.count} attempts in ${timeSinceLastConnection}ms)`);
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * Unregister a connection
45
+ */
46
+ unregisterConnection(url, connectionId) {
47
+ const existing = this.connections.get(url);
48
+ if (existing && existing.connectionId === connectionId) {
49
+ this.connections.delete(url);
50
+ console.log(`🔌 ConnectionManager: Unregistered connection ${connectionId} for ${url}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if a connection is allowed
56
+ */
57
+ isConnectionAllowed(url) {
58
+ const existing = this.connections.get(url);
59
+ if (!existing) {
60
+ return true;
61
+ }
62
+
63
+ const timeSinceLastConnection = Date.now() - existing.timestamp;
64
+ return timeSinceLastConnection > 30000; // Allow if more than 30 seconds ago
65
+ }
66
+
67
+ /**
68
+ * Get connection info
69
+ */
70
+ getConnectionInfo(url) {
71
+ return this.connections.get(url);
72
+ }
73
+
74
+ /**
75
+ * Clear all connections (useful for testing)
76
+ */
77
+ clearAll() {
78
+ this.connections.clear();
79
+ console.log('🔌 ConnectionManager: Cleared all connections');
80
+ }
81
+ }
82
+
83
+ // Global instance
84
+ const connectionManager = new ConnectionManager();
85
+
86
+ export default connectionManager;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * EventEmitter - Simple event system for the VoiceSDK
3
+ */
4
+ export default class EventEmitter {
5
+ constructor() {
6
+ this.events = {};
7
+ }
8
+
9
+ /**
10
+ * Add event listener
11
+ */
12
+ on(event, callback) {
13
+ if (!this.events[event]) {
14
+ this.events[event] = [];
15
+ }
16
+ this.events[event].push(callback);
17
+ }
18
+
19
+ /**
20
+ * Remove event listener
21
+ */
22
+ off(event, callback) {
23
+ if (!this.events[event]) return;
24
+
25
+ this.events[event] = this.events[event].filter(cb => cb !== callback);
26
+ }
27
+
28
+ /**
29
+ * Emit event
30
+ */
31
+ emit(event, ...args) {
32
+ if (!this.events[event]) return;
33
+
34
+ this.events[event].forEach(callback => {
35
+ try {
36
+ callback(...args);
37
+ } catch (error) {
38
+ console.error(`Error in event listener for ${event}:`, error);
39
+ }
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Remove all listeners for an event
45
+ */
46
+ removeAllListeners(event) {
47
+ if (event) {
48
+ delete this.events[event];
49
+ } else {
50
+ this.events = {};
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * VoiceSDK - Core voice interaction SDK
3
+ * Handles WebSocket connection, audio recording, and audio playback
4
+ */
5
+ import EventEmitter from './EventEmitter.js';
6
+ import WebSocketManagerV2 from './WebSocketManagerV2.js';
7
+ import AudioRecorder from './AudioRecorder.js';
8
+ import AudioPlayer from './AudioPlayer.js';
9
+
10
+ export default class VoiceSDK extends EventEmitter {
11
+ constructor(config = {}) {
12
+ super();
13
+
14
+ // Configuration
15
+ this.config = {
16
+ websocketUrl: config.websocketUrl || 'wss://speech.bidme.co.il/ws/conv',
17
+ agentId: config.agentId, // Optional - for direct agent access (unsecured method)
18
+ appId: config.appId, // User's app ID for authentication
19
+ ttpId: config.ttpId, // Optional - custom TTP ID (fallback if appId not provided)
20
+ voice: config.voice || 'default',
21
+ language: config.language || 'en',
22
+ sampleRate: config.sampleRate || 16000,
23
+ ...config
24
+ };
25
+
26
+ // State
27
+ this.isConnected = false;
28
+ this.isRecording = false;
29
+ this.isPlaying = false;
30
+ this.isDestroyed = false;
31
+
32
+ // Components
33
+ this.webSocketManager = new WebSocketManagerV2({
34
+ ...this.config,
35
+ autoReconnect: this.config.autoReconnect !== false // Default to true unless explicitly disabled
36
+ });
37
+ this.audioRecorder = new AudioRecorder(this.config);
38
+ this.audioPlayer = new AudioPlayer(this.config);
39
+
40
+ // Bind event handlers
41
+ this.setupEventHandlers();
42
+ }
43
+
44
+ /**
45
+ * Setup event handlers for all components
46
+ */
47
+ setupEventHandlers() {
48
+ // WebSocket events
49
+ this.webSocketManager.on('connected', () => {
50
+ this.isConnected = true;
51
+ this.sendHelloMessage();
52
+ this.emit('connected');
53
+ });
54
+
55
+ this.webSocketManager.on('disconnected', () => {
56
+ this.isConnected = false;
57
+ this.emit('disconnected');
58
+ });
59
+
60
+ this.webSocketManager.on('error', (error) => {
61
+ this.emit('error', error);
62
+ });
63
+
64
+ this.webSocketManager.on('message', (message) => {
65
+ this.emit('message', message);
66
+ });
67
+
68
+ this.webSocketManager.on('binaryAudio', (audioData) => {
69
+ this.audioPlayer.playAudio(audioData);
70
+ });
71
+
72
+ this.webSocketManager.on('bargeIn', (message) => {
73
+ this.emit('bargeIn', message);
74
+ });
75
+
76
+ this.webSocketManager.on('stopPlaying', (message) => {
77
+ this.emit('stopPlaying', message);
78
+ // Immediately stop all audio playback
79
+ this.audioPlayer.stopImmediate();
80
+ });
81
+
82
+ // Audio recorder events
83
+ this.audioRecorder.on('recordingStarted', () => {
84
+ this.isRecording = true;
85
+ this.emit('recordingStarted');
86
+ });
87
+
88
+ this.audioRecorder.on('recordingStopped', () => {
89
+ this.isRecording = false;
90
+ this.emit('recordingStopped');
91
+ });
92
+
93
+ this.audioRecorder.on('audioData', (audioData) => {
94
+ if (this.isConnected) {
95
+ this.webSocketManager.sendBinary(audioData);
96
+ }
97
+ });
98
+
99
+ // Audio player events
100
+ this.audioPlayer.on('playbackStarted', () => {
101
+ this.isPlaying = true;
102
+ this.emit('playbackStarted');
103
+ });
104
+
105
+ this.audioPlayer.on('playbackStopped', () => {
106
+ this.isPlaying = false;
107
+ this.emit('playbackStopped');
108
+ });
109
+
110
+ this.audioPlayer.on('playbackError', (error) => {
111
+ this.emit('playbackError', error);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Connect to the voice server
117
+ */
118
+ async connect() {
119
+ if (this.isDestroyed) {
120
+ return false; // Prevent connect after destroy
121
+ }
122
+
123
+ try {
124
+ // Build WebSocket URL with query parameters if needed
125
+ const wsUrl = this.buildWebSocketUrl();
126
+ console.log('VoiceSDK: Using WebSocket URL:', wsUrl);
127
+
128
+ // Update the WebSocket manager with the URL that includes query parameters
129
+ this.webSocketManager.config.websocketUrl = wsUrl;
130
+
131
+ await this.webSocketManager.connect();
132
+ return true;
133
+ } catch (error) {
134
+ this.emit('error', error);
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Build WebSocket URL with query parameters for authentication
141
+ */
142
+ buildWebSocketUrl() {
143
+ let url = this.config.websocketUrl;
144
+ const params = new URLSearchParams();
145
+
146
+ // Add agentId as query parameter if provided
147
+ if (this.config.agentId) {
148
+ params.append('agentId', this.config.agentId);
149
+ console.log('VoiceSDK: Adding agentId to URL:', this.config.agentId);
150
+ }
151
+
152
+ // Add appId as query parameter if provided
153
+ if (this.config.appId) {
154
+ params.append('appId', this.config.appId);
155
+ console.log('VoiceSDK: Adding appId to URL:', this.config.appId);
156
+ }
157
+
158
+ // Add other parameters if needed
159
+ if (this.config.voice && this.config.voice !== 'default') {
160
+ params.append('voice', this.config.voice);
161
+ }
162
+
163
+ if (this.config.language && this.config.language !== 'en') {
164
+ params.append('language', this.config.language);
165
+ }
166
+
167
+ // Append query parameters to URL if any exist
168
+ if (params.toString()) {
169
+ const separator = url.includes('?') ? '&' : '?';
170
+ url += separator + params.toString();
171
+ }
172
+
173
+ return url;
174
+ }
175
+
176
+ /**
177
+ * Disconnect from the voice server
178
+ */
179
+ disconnect() {
180
+ if (this.isDestroyed) {
181
+ console.log(`🎙️ VoiceSDK: Disconnect called but already destroyed`);
182
+ return; // Prevent disconnect after destroy
183
+ }
184
+ console.log(`🎙️ VoiceSDK: Disconnecting from voice server`);
185
+ this.stopRecording();
186
+ this.webSocketManager.disconnect();
187
+ }
188
+
189
+ /**
190
+ * Reset reconnection attempts (useful for manual reconnection)
191
+ */
192
+ resetReconnectionAttempts() {
193
+ if (this.isDestroyed) {
194
+ return;
195
+ }
196
+ this.webSocketManager.resetReconnectionAttempts();
197
+ }
198
+
199
+ /**
200
+ * Manually reconnect to the voice server
201
+ */
202
+ async reconnect() {
203
+ if (this.isDestroyed) {
204
+ return false;
205
+ }
206
+
207
+ this.disconnect();
208
+ this.resetReconnectionAttempts();
209
+ return await this.connect();
210
+ }
211
+
212
+ /**
213
+ * Start voice recording and streaming
214
+ */
215
+ async startRecording() {
216
+ if (!this.isConnected) {
217
+ throw new Error('Not connected to voice server');
218
+ }
219
+
220
+ try {
221
+ // Send start continuous mode message
222
+ this.webSocketManager.sendMessage({
223
+ t: 'start_continuous_mode',
224
+ ttpId: this.generateTtpId(),
225
+ voice: this.config.voice,
226
+ language: this.config.language
227
+ });
228
+
229
+ // Start audio recording
230
+ await this.audioRecorder.start();
231
+ return true;
232
+ } catch (error) {
233
+ this.emit('error', error);
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Stop voice recording and streaming
240
+ */
241
+ async stopRecording() {
242
+ if (!this.isRecording) {
243
+ return;
244
+ }
245
+
246
+ try {
247
+ // Send stop continuous mode message
248
+ this.webSocketManager.sendMessage({
249
+ t: 'stop_continuous_mode',
250
+ ttpId: this.generateTtpId()
251
+ });
252
+
253
+ // Stop audio recording
254
+ await this.audioRecorder.stop();
255
+
256
+ // Stop audio playback immediately when stopping recording
257
+ this.audioPlayer.stopImmediate();
258
+
259
+ return true;
260
+ } catch (error) {
261
+ this.emit('error', error);
262
+ return false;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Toggle recording state
268
+ */
269
+ async toggleRecording() {
270
+ if (this.isRecording) {
271
+ return await this.stopRecording();
272
+ } else {
273
+ return await this.startRecording();
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Stop audio playback immediately (for barge-in scenarios)
279
+ */
280
+ stopAudioPlayback() {
281
+ this.audioPlayer.stopImmediate();
282
+ }
283
+
284
+ /**
285
+ * Handle barge-in (user starts speaking while audio is playing)
286
+ */
287
+ async handleBargeIn() {
288
+ // Stop current audio playback immediately
289
+ this.stopAudioPlayback();
290
+
291
+ // If not already recording, start recording
292
+ if (!this.isRecording) {
293
+ await this.startRecording();
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Get current connection status
299
+ */
300
+ getStatus() {
301
+ return {
302
+ isConnected: this.isConnected,
303
+ isRecording: this.isRecording,
304
+ isPlaying: this.isPlaying
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Update configuration
310
+ */
311
+ updateConfig(newConfig) {
312
+ this.config = { ...this.config, ...newConfig };
313
+ }
314
+
315
+ /**
316
+ * Generate unique TTP ID
317
+ */
318
+ generateTtpId() {
319
+ return 'sdk_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
320
+ }
321
+
322
+ /**
323
+ * Send hello message with appropriate authentication
324
+ */
325
+ sendHelloMessage() {
326
+ if (!this.isConnected) {
327
+ console.warn('VoiceSDK: Cannot send hello message - not connected');
328
+ return;
329
+ }
330
+
331
+ const helloMessage = {
332
+ t: "hello"
333
+ };
334
+
335
+ // Use app ID for authentication (preferred method)
336
+ if (this.config.appId) {
337
+ helloMessage.appId = this.config.appId;
338
+ console.log('VoiceSDK: Sending hello message with appId (app-based authentication)');
339
+ } else if (this.config.ttpId) {
340
+ // Fallback to custom TTP ID if app ID not provided
341
+ helloMessage.ttpId = this.config.ttpId;
342
+ console.log('VoiceSDK: Sending hello message with custom TTP ID (fallback method)');
343
+ } else {
344
+ // Generate TTP ID as last resort
345
+ helloMessage.ttpId = this.generateTtpId();
346
+ console.log('VoiceSDK: Sending hello message with generated TTP ID (last resort)');
347
+ }
348
+
349
+ // Note: agentId is now sent as query parameter in WebSocket URL, not in hello message
350
+
351
+ // Log authentication method for debugging
352
+ if (this.config.appId) {
353
+ console.log('VoiceSDK: Using app ID for authentication:', this.config.appId);
354
+ } else if (this.config.ttpId) {
355
+ console.log('VoiceSDK: Using custom TTP ID:', this.config.ttpId);
356
+ } else {
357
+ console.log('VoiceSDK: Using generated TTP ID:', helloMessage.ttpId);
358
+ }
359
+
360
+ try {
361
+ this.webSocketManager.sendMessage(helloMessage);
362
+ console.log('VoiceSDK: Hello message sent:', helloMessage);
363
+ } catch (error) {
364
+ console.error('VoiceSDK: Failed to send hello message:', error);
365
+ this.emit('error', error);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Cleanup resources
371
+ */
372
+ destroy() {
373
+ if (this.isDestroyed) {
374
+ console.log(`🎙️ VoiceSDK: Destroy called but already destroyed`);
375
+ return; // Prevent multiple destroy calls
376
+ }
377
+
378
+ console.log(`🎙️ VoiceSDK: Destroying VoiceSDK instance`);
379
+
380
+ // Disconnect first, before setting isDestroyed
381
+ this.disconnect();
382
+
383
+ this.isDestroyed = true;
384
+ this.audioRecorder.destroy();
385
+ this.audioPlayer.destroy();
386
+ this.removeAllListeners();
387
+
388
+ console.log(`🎙️ VoiceSDK: VoiceSDK instance destroyed`);
389
+ }
390
+ }