valenceai 0.4.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/.env.example +0 -0
- package/CHANGELOG.md +154 -0
- package/README.dev.md +7 -0
- package/README.md +240 -0
- package/examples/uploadLong.js +10 -0
- package/examples/uploadShort.js +7 -0
- package/jest.config.js +27 -0
- package/package.json +30 -0
- package/src/asyncAudio.js +168 -0
- package/src/client.js +18 -0
- package/src/config.js +36 -0
- package/src/discreteAudio.js +61 -0
- package/src/index.js +2 -0
- package/src/utils/logger.js +16 -0
- package/src/utils/upload.js +0 -0
- package/src/valenceClient.js +256 -0
- package/tests/asyncAudio.test.js +331 -0
- package/tests/client.test.js +42 -0
- package/tests/config.test.js +90 -0
- package/tests/discreteAudio.test.js +168 -0
- package/tests/index.test.js +33 -0
- package/tests/logger.test.js +121 -0
- package/tests/setup.js +5 -0
- package/tests/valenceClient.test.js +21 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
dotenv.config();
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration object for the Valence SDK
|
|
6
|
+
* Loads settings from environment variables with sensible defaults
|
|
7
|
+
*/
|
|
8
|
+
export const config = {
|
|
9
|
+
apiKey: process.env.VALENCE_API_KEY,
|
|
10
|
+
discreteAudioUrl: process.env.VALENCE_DISCRETE_URL || 'https://xc8n2bo4f0.execute-api.us-west-2.amazonaws.com/emotionprediction',
|
|
11
|
+
asyncAudioUrl: process.env.VALENCE_ASYNC_URL || 'https://wsgol61783.execute-api.us-west-2.amazonaws.com/prod',
|
|
12
|
+
logLevel: process.env.VALENCE_LOG_LEVEL || 'info',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validates the current configuration
|
|
17
|
+
* @throws {Error} If required configuration is missing or invalid
|
|
18
|
+
*/
|
|
19
|
+
export function validateConfig() {
|
|
20
|
+
if (!config.apiKey) {
|
|
21
|
+
throw new Error('VALENCE_API_KEY environment variable is required');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!config.discreteAudioUrl || !config.discreteAudioUrl.startsWith('https://')) {
|
|
25
|
+
throw new Error('VALENCE_DISCRETE_URL must be a valid HTTPS URL');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!config.asyncAudioUrl || !config.asyncAudioUrl.startsWith('https://')) {
|
|
29
|
+
throw new Error('VALENCE_ASYNC_URL must be a valid HTTPS URL');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const validLogLevels = ['debug', 'info', 'warn', 'error'];
|
|
33
|
+
if (!validLogLevels.includes(config.logLevel)) {
|
|
34
|
+
throw new Error(`VALENCE_LOG_LEVEL must be one of: ${validLogLevels.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getHeaders } from './client.js';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
import { log } from './utils/logger.js';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import FormData from 'form-data';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Predicts emotions for discrete (short) audio files
|
|
10
|
+
* @param {string} filePath - Path to the audio file
|
|
11
|
+
* @param {string} model - Model type ('4emotions' or '7emotions')
|
|
12
|
+
* @returns {Promise<Object>} Emotion prediction results
|
|
13
|
+
* @throws {Error} If file doesn't exist, API key missing, or request fails
|
|
14
|
+
*/
|
|
15
|
+
export async function predictDiscreteAudioEmotion(filePath, model = '4emotions') {
|
|
16
|
+
// Validation
|
|
17
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
18
|
+
throw new Error('filePath is required and must be a string');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!['4emotions', '7emotions'].includes(model)) {
|
|
22
|
+
throw new Error('model must be either "4emotions" or "7emotions"');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!config.apiKey) {
|
|
26
|
+
throw new Error('VALENCE_API_KEY is required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
throw new Error(`File not found: ${filePath}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
log(`Predicting emotions for ${filePath} using ${model} model`, 'info');
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const formData = new FormData();
|
|
37
|
+
formData.append('file', fs.createReadStream(filePath));
|
|
38
|
+
const url = `${config.discreteAudioUrl}?model=${model}`;
|
|
39
|
+
|
|
40
|
+
const response = await axios.post(url, formData, {
|
|
41
|
+
headers: {
|
|
42
|
+
...getHeaders(),
|
|
43
|
+
...formData.getHeaders()
|
|
44
|
+
},
|
|
45
|
+
timeout: 30000 // 30 second timeout
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
log('Emotion prediction completed successfully', 'info');
|
|
49
|
+
return response.data;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
log(`Error predicting emotions: ${error.message}`, 'error');
|
|
52
|
+
|
|
53
|
+
if (error.response) {
|
|
54
|
+
throw new Error(`API error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
|
|
55
|
+
} else if (error.request) {
|
|
56
|
+
throw new Error('Network error: Unable to reach the API');
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error(`Request error: ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple logger function controlled by VALENCE_LOG_LEVEL environment variable
|
|
3
|
+
* @param {string} message - Message to log
|
|
4
|
+
* @param {string} level - Log level ('debug', 'info', 'warn', 'error')
|
|
5
|
+
*/
|
|
6
|
+
export function log(message, level = 'info') {
|
|
7
|
+
const logLevel = process.env.VALENCE_LOG_LEVEL || 'info';
|
|
8
|
+
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
9
|
+
const currentLevel = levels[logLevel] || 1;
|
|
10
|
+
const messageLevel = levels[level] || 1;
|
|
11
|
+
|
|
12
|
+
if (messageLevel >= currentLevel) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import FormData from 'form-data';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { getHeaders } from './client.js';
|
|
6
|
+
import { log } from './utils/logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client for discrete (short) audio processing
|
|
10
|
+
*/
|
|
11
|
+
class DiscreteClient {
|
|
12
|
+
constructor() {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get emotions for discrete (short) audio files
|
|
16
|
+
* @param {string} filePath - Path to the audio file
|
|
17
|
+
* @param {string} model - Model type ('4emotions' or '7emotions')
|
|
18
|
+
* @returns {Promise<Object>} Emotion prediction results
|
|
19
|
+
* @throws {Error} If file doesn't exist, API key missing, or request fails
|
|
20
|
+
*/
|
|
21
|
+
async emotions(filePath, model = '4emotions') {
|
|
22
|
+
// Validation
|
|
23
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
24
|
+
throw new Error('filePath is required and must be a string');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!['4emotions', '7emotions'].includes(model)) {
|
|
28
|
+
throw new Error('model must be either "4emotions" or "7emotions"');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!config.apiKey) {
|
|
32
|
+
throw new Error('VALENCE_API_KEY is required');
|
|
33
|
+
}
|
|
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
|
+
|
|
41
|
+
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;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
log(`Error getting discrete emotions: ${error.message}`, 'error');
|
|
58
|
+
|
|
59
|
+
if (error.response) {
|
|
60
|
+
throw new Error(`API error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
|
|
61
|
+
} else if (error.request) {
|
|
62
|
+
throw new Error('Network error: Unable to reach the API');
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error(`Request error: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Client for async (long) audio processing
|
|
72
|
+
*/
|
|
73
|
+
class AsyncClient {
|
|
74
|
+
constructor(partSize = 5 * 1024 * 1024, maxRetries = 3) {
|
|
75
|
+
this.partSize = partSize;
|
|
76
|
+
this.maxRetries = maxRetries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Upload async (long) audio files using multipart upload
|
|
81
|
+
* @param {string} filePath - Path to the audio file
|
|
82
|
+
* @returns {Promise<string>} Request ID for tracking the upload
|
|
83
|
+
* @throws {Error} If file doesn't exist, API key missing, or upload fails
|
|
84
|
+
*/
|
|
85
|
+
async upload(filePath) {
|
|
86
|
+
// Validation
|
|
87
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
88
|
+
throw new Error('filePath is required and must be a string');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!config.apiKey) {
|
|
92
|
+
throw new Error('VALENCE_API_KEY is required');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!fs.existsSync(filePath)) {
|
|
96
|
+
throw new Error(`File not found: ${filePath}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (this.partSize < 1024 * 1024 || this.partSize > 100 * 1024 * 1024) {
|
|
100
|
+
throw new Error('partSize must be between 1MB and 100MB');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log(`Starting async audio upload for ${filePath}`, 'info');
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const fileSize = fs.statSync(filePath).size;
|
|
107
|
+
const partCount = Math.ceil(fileSize / this.partSize);
|
|
108
|
+
const initiateUrl = `${config.asyncAudioUrl}/upload/initiate`;
|
|
109
|
+
|
|
110
|
+
log(`File size: ${fileSize} bytes, parts: ${partCount}`, 'debug');
|
|
111
|
+
|
|
112
|
+
const { data } = await axios.get(initiateUrl, {
|
|
113
|
+
headers: getHeaders(),
|
|
114
|
+
params: { file_name: filePath.split('/').pop(), part_count: partCount },
|
|
115
|
+
timeout: 30000
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const fileStream = fs.createReadStream(filePath, { highWaterMark: this.partSize });
|
|
119
|
+
const parts = [];
|
|
120
|
+
let index = 0;
|
|
121
|
+
|
|
122
|
+
for await (const chunk of fileStream) {
|
|
123
|
+
if (!data.presigned_urls || !data.presigned_urls[index]) {
|
|
124
|
+
throw new Error(`Missing presigned URL for part ${index + 1}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const part = data.presigned_urls[index];
|
|
128
|
+
const partNumber = part.part_number;
|
|
129
|
+
const url = part.url;
|
|
130
|
+
|
|
131
|
+
log(`Uploading part ${index + 1}/${partCount}`, 'debug');
|
|
132
|
+
|
|
133
|
+
const res = await axios.put(url, chunk, {
|
|
134
|
+
headers: { 'Content-Length': chunk.length },
|
|
135
|
+
timeout: 60000 // 1 minute timeout for each part
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!res.headers.etag) {
|
|
139
|
+
throw new Error(`Failed to get ETag for part ${partNumber}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
parts.push({ ETag: res.headers.etag, PartNumber: partNumber });
|
|
143
|
+
index++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
log('All parts uploaded, completing multipart upload', 'info');
|
|
147
|
+
|
|
148
|
+
const completeUrl = `${config.asyncAudioUrl}/upload/complete`;
|
|
149
|
+
await axios.post(completeUrl, {
|
|
150
|
+
request_id: data.request_id,
|
|
151
|
+
upload_id: data.upload_id,
|
|
152
|
+
parts: parts.sort((a, b) => a.PartNumber - b.PartNumber)
|
|
153
|
+
}, {
|
|
154
|
+
headers: getHeaders(),
|
|
155
|
+
timeout: 30000
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
log(`Async audio upload completed. Request ID: ${data.request_id}`, 'info');
|
|
159
|
+
return data.request_id;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
log(`Error uploading async audio: ${error.message}`, 'error');
|
|
162
|
+
|
|
163
|
+
if (error.response) {
|
|
164
|
+
throw new Error(`API error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
|
|
165
|
+
} else if (error.request) {
|
|
166
|
+
throw new Error('Network error: Unable to reach the API');
|
|
167
|
+
} else {
|
|
168
|
+
throw new Error(`Upload error: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get emotion prediction results for async audio processing
|
|
175
|
+
* @param {string} requestId - Request ID from upload method
|
|
176
|
+
* @param {number} maxAttempts - Maximum polling attempts (default: 20)
|
|
177
|
+
* @param {number} intervalSeconds - Polling interval in seconds (default: 5)
|
|
178
|
+
* @returns {Promise<Object>} Emotion prediction results
|
|
179
|
+
* @throws {Error} If requestId is invalid or prediction times out
|
|
180
|
+
*/
|
|
181
|
+
async emotions(requestId, maxAttempts = 20, intervalSeconds = 5) {
|
|
182
|
+
// Validation
|
|
183
|
+
if (!requestId || typeof requestId !== 'string') {
|
|
184
|
+
throw new Error('requestId is required and must be a string');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!config.apiKey) {
|
|
188
|
+
throw new Error('VALENCE_API_KEY is required');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (maxAttempts < 1 || maxAttempts > 100) {
|
|
192
|
+
throw new Error('maxAttempts must be between 1 and 100');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (intervalSeconds < 1 || intervalSeconds > 60) {
|
|
196
|
+
throw new Error('intervalSeconds must be between 1 and 60');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
log(`Starting emotion prediction polling for request ${requestId}`, 'info');
|
|
200
|
+
|
|
201
|
+
const url = `${config.asyncAudioUrl}/prediction`;
|
|
202
|
+
const intervalMs = intervalSeconds * 1000;
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
205
|
+
try {
|
|
206
|
+
log(`Polling attempt ${i + 1}/${maxAttempts}`, 'debug');
|
|
207
|
+
|
|
208
|
+
const res = await axios.get(url, {
|
|
209
|
+
headers: getHeaders(),
|
|
210
|
+
params: { request_id: requestId },
|
|
211
|
+
timeout: 15000
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
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');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.response?.status === 404) {
|
|
225
|
+
throw new Error(`Request ID not found: ${requestId}`);
|
|
226
|
+
} else if (error.response?.status >= 400 && error.response?.status < 500) {
|
|
227
|
+
throw new Error(`Client error (${error.response.status}): ${error.response.data?.message || error.response.statusText}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
log(`Polling error (attempt ${i + 1}): ${error.message}`, 'warn');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (i < maxAttempts - 1) {
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new Error(`Prediction not available after ${maxAttempts} attempts. The request may still be processing.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Main client for Valence API with nested discrete and async clients
|
|
244
|
+
*/
|
|
245
|
+
export class ValenceClient {
|
|
246
|
+
/**
|
|
247
|
+
* 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)
|
|
250
|
+
*/
|
|
251
|
+
constructor(partSize = 5 * 1024 * 1024, maxRetries = 3) {
|
|
252
|
+
// Initialize nested clients
|
|
253
|
+
this.discrete = new DiscreteClient();
|
|
254
|
+
this.asynch = new AsyncClient(partSize, maxRetries);
|
|
255
|
+
}
|
|
256
|
+
}
|