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.
- package/README.md +357 -177
- package/package.json +15 -6
- package/src/config.js +25 -14
- package/src/rateLimit.js +77 -0
- package/src/streaming.js +193 -0
- package/src/utils/logger.js +3 -3
- package/src/valenceClient.js +173 -68
- package/tests/asyncAudio.test.js +128 -71
- package/tests/client.test.js +10 -25
- package/tests/config.test.js +21 -21
- package/tests/e2e.asyncWorkflow.test.js +343 -0
- package/tests/e2e.streaming.test.js +420 -0
- package/tests/logger.test.js +3 -0
- package/tests/rateLimit.test.js +137 -0
- package/tests/setup.js +5 -4
- package/tests/streaming.test.js +187 -0
- package/tests/valenceClient.test.js +50 -5
package/src/valenceClient.js
CHANGED
|
@@ -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
|
|
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 (
|
|
24
|
-
throw new Error('
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
113
|
-
headers:
|
|
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.
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 {
|
|
249
|
-
* @param {
|
|
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(
|
|
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
|
}
|