valenceai 0.5.1 → 1.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.
@@ -4,58 +4,88 @@ import FormData from 'form-data';
4
4
  import { config } from './config.js';
5
5
  import { getHeaders } from './client.js';
6
6
  import { log } from './utils/logger.js';
7
+ import { RateLimitAPI } from './rateLimit.js';
8
+ import { StreamingAPI } from './streaming.js';
7
9
 
8
10
  /**
9
11
  * Client for discrete (short) audio processing
10
12
  */
11
13
  class DiscreteClient {
12
- constructor() {}
14
+ constructor(clientConfig) {
15
+ this.config = clientConfig;
16
+ }
13
17
 
14
18
  /**
15
19
  * Get emotions for discrete (short) audio files
16
- * @param {string} filePath - Path to the audio file
20
+ * @param {string} filePath - Path to the audio file (mutually exclusive with audioArray)
21
+ * @param {Array} audioArray - Audio data as array (mutually exclusive with filePath)
17
22
  * @param {string} model - Model type ('4emotions' or '7emotions')
18
23
  * @returns {Promise<Object>} Emotion prediction results
19
- * @throws {Error} If file doesn't exist, API key missing, or request fails
24
+ * @throws {Error} If validation fails, API key missing, or request fails
20
25
  */
21
- async emotions(filePath, model = '4emotions') {
26
+ async emotions(filePath = null, audioArray = null, model = '4emotions') {
22
27
  // Validation
23
- if (!filePath || typeof filePath !== 'string') {
24
- throw new Error('filePath is required and must be a string');
28
+ if (filePath && audioArray) {
29
+ throw new Error('Provide either filePath or audioArray, not both');
30
+ }
31
+
32
+ if (!filePath && !audioArray) {
33
+ throw new Error('Must provide either filePath or audioArray');
25
34
  }
26
-
35
+
27
36
  if (!['4emotions', '7emotions'].includes(model)) {
28
37
  throw new Error('model must be either "4emotions" or "7emotions"');
29
38
  }
30
-
31
- if (!config.apiKey) {
39
+
40
+ if (!this.config.apiKey) {
32
41
  throw new Error('VALENCE_API_KEY is required');
33
42
  }
34
-
35
- if (!fs.existsSync(filePath)) {
36
- throw new Error(`File not found: ${filePath}`);
37
- }
38
-
39
- log(`Getting emotions for discrete audio: ${filePath} using ${model} model`, 'info');
40
-
43
+
44
+ const url = `${this.config.baseUrl}/v1/discrete/emotion?model=${model}`;
45
+
41
46
  try {
42
- const formData = new FormData();
43
- formData.append('file', fs.createReadStream(filePath));
44
- const url = `${config.discreteAudioUrl}?model=${model}`;
45
-
46
- const response = await axios.post(url, formData, {
47
- headers: {
48
- ...getHeaders(),
49
- ...formData.getHeaders()
50
- },
51
- timeout: 30000 // 30 second timeout
52
- });
53
-
54
- log('Discrete emotion prediction completed successfully', 'info');
55
- return response.data;
47
+ if (filePath) {
48
+ // File upload method
49
+ if (!fs.existsSync(filePath)) {
50
+ throw new Error(`File not found: ${filePath}`);
51
+ }
52
+
53
+ log(`Getting emotions for discrete audio: ${filePath} using ${model} model`, 'info');
54
+
55
+ const formData = new FormData();
56
+ formData.append('file', fs.createReadStream(filePath));
57
+
58
+ const response = await axios.post(url, formData, {
59
+ headers: {
60
+ 'x-api-key': this.config.apiKey,
61
+ ...formData.getHeaders()
62
+ },
63
+ timeout: 30000
64
+ });
65
+
66
+ log('Discrete emotion prediction completed successfully', 'info');
67
+ return response.data;
68
+
69
+ } else {
70
+ // JSON payload method
71
+ log(`Getting emotions for audio array using ${model} model`, 'info');
72
+
73
+ const payload = { payload: [audioArray] };
74
+
75
+ const response = await axios.post(url, payload, {
76
+ headers: {
77
+ 'x-api-key': this.config.apiKey,
78
+ 'Content-Type': 'application/json'
79
+ },
80
+ timeout: 30000
81
+ });
82
+
83
+ log('Discrete emotion prediction completed successfully', 'info');
84
+ return response.data;
85
+ }
56
86
  } catch (error) {
57
87
  log(`Error getting discrete emotions: ${error.message}`, 'error');
58
-
88
+
59
89
  if (error.response) {
60
90
  throw new Error(`API error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
61
91
  } else if (error.request) {
@@ -71,7 +101,8 @@ class DiscreteClient {
71
101
  * Client for async (long) audio processing
72
102
  */
73
103
  class AsyncClient {
74
- constructor(partSize = 5 * 1024 * 1024, maxRetries = 3) {
104
+ constructor(clientConfig, partSize = 5 * 1024 * 1024, maxRetries = 3) {
105
+ this.config = clientConfig;
75
106
  this.partSize = partSize;
76
107
  this.maxRetries = maxRetries;
77
108
  }
@@ -87,30 +118,30 @@ class AsyncClient {
87
118
  if (!filePath || typeof filePath !== 'string') {
88
119
  throw new Error('filePath is required and must be a string');
89
120
  }
90
-
91
- if (!config.apiKey) {
121
+
122
+ if (!this.config.apiKey) {
92
123
  throw new Error('VALENCE_API_KEY is required');
93
124
  }
94
-
125
+
95
126
  if (!fs.existsSync(filePath)) {
96
127
  throw new Error(`File not found: ${filePath}`);
97
128
  }
98
-
129
+
99
130
  if (this.partSize < 1024 * 1024 || this.partSize > 100 * 1024 * 1024) {
100
131
  throw new Error('partSize must be between 1MB and 100MB');
101
132
  }
102
-
133
+
103
134
  log(`Starting async audio upload for ${filePath}`, 'info');
104
-
135
+
105
136
  try {
106
137
  const fileSize = fs.statSync(filePath).size;
107
138
  const partCount = Math.ceil(fileSize / this.partSize);
108
- const initiateUrl = `${config.asyncAudioUrl}/upload/initiate`;
139
+ const initiateUrl = `${this.config.baseUrl}/v1/asynch/emotion/upload/initiate`;
109
140
 
110
141
  log(`File size: ${fileSize} bytes, parts: ${partCount}`, 'debug');
111
142
 
112
- const { data } = await axios.get(initiateUrl, {
113
- headers: getHeaders(),
143
+ const { data } = await axios.post(initiateUrl, null, {
144
+ headers: { 'x-api-key': this.config.apiKey },
114
145
  params: { file_name: filePath.split('/').pop(), part_count: partCount },
115
146
  timeout: 30000
116
147
  });
@@ -145,13 +176,13 @@ class AsyncClient {
145
176
 
146
177
  log('All parts uploaded, completing multipart upload', 'info');
147
178
 
148
- const completeUrl = `${config.asyncAudioUrl}/upload/complete`;
179
+ const completeUrl = `${this.config.baseUrl}/v1/asynch/emotion/upload/complete`;
149
180
  await axios.post(completeUrl, {
150
181
  request_id: data.request_id,
151
182
  upload_id: data.upload_id,
152
183
  parts: parts.sort((a, b) => a.PartNumber - b.PartNumber)
153
184
  }, {
154
- headers: getHeaders(),
185
+ headers: { 'x-api-key': this.config.apiKey },
155
186
  timeout: 30000
156
187
  });
157
188
 
@@ -175,7 +206,7 @@ class AsyncClient {
175
206
  * @param {string} requestId - Request ID from upload method
176
207
  * @param {number} maxAttempts - Maximum polling attempts (default: 20)
177
208
  * @param {number} intervalSeconds - Polling interval in seconds (default: 5)
178
- * @returns {Promise<Object>} Emotion prediction results
209
+ * @returns {Promise<Object>} Emotion prediction results with timeline data
179
210
  * @throws {Error} If requestId is invalid or prediction times out
180
211
  */
181
212
  async emotions(requestId, maxAttempts = 20, intervalSeconds = 5) {
@@ -183,53 +214,56 @@ class AsyncClient {
183
214
  if (!requestId || typeof requestId !== 'string') {
184
215
  throw new Error('requestId is required and must be a string');
185
216
  }
186
-
187
- if (!config.apiKey) {
217
+
218
+ if (!this.config.apiKey) {
188
219
  throw new Error('VALENCE_API_KEY is required');
189
220
  }
190
-
221
+
191
222
  if (maxAttempts < 1 || maxAttempts > 100) {
192
223
  throw new Error('maxAttempts must be between 1 and 100');
193
224
  }
194
-
225
+
195
226
  if (intervalSeconds < 1 || intervalSeconds > 60) {
196
227
  throw new Error('intervalSeconds must be between 1 and 60');
197
228
  }
198
-
229
+
199
230
  log(`Starting emotion prediction polling for request ${requestId}`, 'info');
200
-
201
- const url = `${config.asyncAudioUrl}/prediction`;
231
+
232
+ const url = `${this.config.baseUrl}/v1/asynch/emotion/status/${requestId}`;
202
233
  const intervalMs = intervalSeconds * 1000;
203
234
 
204
235
  for (let i = 0; i < maxAttempts; i++) {
205
236
  try {
206
237
  log(`Polling attempt ${i + 1}/${maxAttempts}`, 'debug');
207
-
238
+
208
239
  const res = await axios.get(url, {
209
- headers: getHeaders(),
210
- params: { request_id: requestId },
240
+ headers: { 'x-api-key': this.config.apiKey },
211
241
  timeout: 15000
212
242
  });
213
243
 
214
244
  if (res.status === 200 && res.data) {
215
- log(`Emotions retrieved for request ID: ${requestId}`, 'info');
216
- return res.data;
217
- }
218
-
219
- if (res.status === 202) {
220
- log('Prediction still processing, waiting...', 'debug');
245
+ const status = res.data.status;
246
+
247
+ if (status === 'completed') {
248
+ log(`Emotions retrieved for request ID: ${requestId}`, 'info');
249
+ return res.data;
250
+ } else if (['processing', 'upload_completed', 'initiated'].includes(status)) {
251
+ log(`Request ${requestId} status: ${status}, continuing to poll...`, 'debug');
252
+ } else if (status === 'failed') {
253
+ throw new Error(`Request ${requestId} failed during processing`);
254
+ }
221
255
  }
222
-
256
+
223
257
  } catch (error) {
224
258
  if (error.response?.status === 404) {
225
259
  throw new Error(`Request ID not found: ${requestId}`);
226
260
  } else if (error.response?.status >= 400 && error.response?.status < 500) {
227
261
  throw new Error(`Client error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
228
262
  }
229
-
263
+
230
264
  log(`Polling error (attempt ${i + 1}): ${error.message}`, 'warn');
231
265
  }
232
-
266
+
233
267
  if (i < maxAttempts - 1) {
234
268
  await new Promise(resolve => setTimeout(resolve, intervalMs));
235
269
  }
@@ -237,6 +271,54 @@ class AsyncClient {
237
271
 
238
272
  throw new Error(`Prediction not available after ${maxAttempts} attempts. The request may still be processing.`);
239
273
  }
274
+
275
+ /**
276
+ * Get timeline data from completed async emotion prediction
277
+ * @param {string} requestId - Request ID from upload method
278
+ * @returns {Promise<Array>} List of emotion predictions with timestamps
279
+ */
280
+ async getTimeline(requestId) {
281
+ const result = await this.emotions(requestId, 1);
282
+ return result.emotions || [];
283
+ }
284
+
285
+ /**
286
+ * Get emotion prediction at a specific time
287
+ * @param {string} requestId - Request ID from upload method
288
+ * @param {number} timeSeconds - Time in seconds to get emotion for
289
+ * @returns {Promise<Object|null>} Emotion prediction at specified time, or null if not found
290
+ */
291
+ async getEmotionAtTime(requestId, timeSeconds) {
292
+ const timeline = await this.getTimeline(requestId);
293
+ for (const emotionData of timeline) {
294
+ if (emotionData.start_time <= timeSeconds && timeSeconds < emotionData.end_time) {
295
+ return emotionData;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ /**
302
+ * Get the most frequently occurring emotion in the timeline
303
+ * @param {string} requestId - Request ID from upload method
304
+ * @returns {Promise<string|null>} The dominant emotion across the timeline
305
+ */
306
+ async getDominantEmotion(requestId) {
307
+ const timeline = await this.getTimeline(requestId);
308
+ if (!timeline || timeline.length === 0) {
309
+ return null;
310
+ }
311
+
312
+ const emotionCounts = {};
313
+ for (const emotionData of timeline) {
314
+ const emotion = emotionData.emotion;
315
+ emotionCounts[emotion] = (emotionCounts[emotion] || 0) + 1;
316
+ }
317
+
318
+ return Object.keys(emotionCounts).reduce((a, b) =>
319
+ emotionCounts[a] > emotionCounts[b] ? a : b
320
+ );
321
+ }
240
322
  }
241
323
 
242
324
  /**
@@ -245,12 +327,35 @@ class AsyncClient {
245
327
  export class ValenceClient {
246
328
  /**
247
329
  * Initialize the Valence client
248
- * @param {number} partSize - Size of each part in bytes for async uploads (default: 5MB)
249
- * @param {number} maxRetries - Maximum retry attempts for async uploads (default: 3)
330
+ * @param {Object} options - Configuration options
331
+ * @param {string} options.apiKey - API key for authentication (or set VALENCE_API_KEY env var)
332
+ * @param {string} options.baseUrl - Base URL for API endpoints (default: https://demo.getvalenceai.com)
333
+ * @param {string} options.websocketUrl - WebSocket URL for streaming (default: wss://demo.getvalenceai.com)
334
+ * @param {number} options.partSize - Size of parts for multipart upload (default: 5MB)
335
+ * @param {number} options.maxRetries - Max retry attempts for uploads (default: 3)
250
336
  */
251
- constructor(partSize = 5 * 1024 * 1024, maxRetries = 3) {
337
+ constructor(options = {}) {
338
+ // Build configuration with priority: parameter > env var > default
339
+ // Use hasOwnProperty to distinguish between undefined and explicitly set empty values
340
+ this.config = {
341
+ apiKey: options.hasOwnProperty('apiKey') ? options.apiKey : config.apiKey,
342
+ baseUrl: options.hasOwnProperty('baseUrl') ? options.baseUrl : config.baseUrl,
343
+ websocketUrl: options.hasOwnProperty('websocketUrl') ? options.websocketUrl : config.websocketUrl,
344
+ logLevel: options.hasOwnProperty('logLevel') ? options.logLevel : config.logLevel
345
+ };
346
+
347
+ // Validate API key
348
+ if (!this.config.apiKey) {
349
+ throw new Error('API key not provided and not set in environment (VALENCE_API_KEY)');
350
+ }
351
+
352
+ const partSize = options.partSize || 5 * 1024 * 1024;
353
+ const maxRetries = options.maxRetries || 3;
354
+
252
355
  // Initialize nested clients
253
- this.discrete = new DiscreteClient();
254
- this.asynch = new AsyncClient(partSize, maxRetries);
356
+ this.discrete = new DiscreteClient(this.config);
357
+ this.asynch = new AsyncClient(this.config, partSize, maxRetries);
358
+ this.rateLimit = new RateLimitAPI(this.config);
359
+ this.streaming = new StreamingAPI(this.config);
255
360
  }
256
361
  }