sfxmix 1.2.8 → 1.3.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 +1 -1
- package/index.js +90 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ sfx.save('output.mp3');
|
|
|
240
240
|
Normalizes audio loudness to a specified target using the EBU R128 standard.
|
|
241
241
|
|
|
242
242
|
- **Options:**
|
|
243
|
-
- `tp` (number): Maximum true peak level in dBTP (default: `-
|
|
243
|
+
- `tp` (number): Maximum true peak level in dBTP (default: `-1.5`).
|
|
244
244
|
- `i` (number): Target integrated loudness in LUFS (default: `-20`).
|
|
245
245
|
- `lra` (number): Loudness range in LU (default: `11`).
|
|
246
246
|
|
package/index.js
CHANGED
|
@@ -9,6 +9,9 @@ class SfxMix {
|
|
|
9
9
|
this.actions = [];
|
|
10
10
|
this.currentFile = null;
|
|
11
11
|
this.TMP_DIR = path.resolve(config.tmpDir || path.join(__dirname, 'tmp'));
|
|
12
|
+
|
|
13
|
+
// Default bitrate: null = auto-detect, or specify a value (e.g., 64000, 128000, 192000)
|
|
14
|
+
this.bitrate = config.bitrate !== undefined ? config.bitrate : 64000;
|
|
12
15
|
|
|
13
16
|
// Ensure the temporary directory exists and is writable
|
|
14
17
|
try {
|
|
@@ -127,7 +130,16 @@ class SfxMix {
|
|
|
127
130
|
this.currentFile = tempFile;
|
|
128
131
|
} else if (action.type === 'silence') {
|
|
129
132
|
const tempSilenceFile = path.join(this.TMP_DIR, `temp_silence_${uuidv4()}.mp3`);
|
|
130
|
-
|
|
133
|
+
// Get audio info from current file to match channels and sample rate
|
|
134
|
+
let audioInfo = null;
|
|
135
|
+
if (this.currentFile != null) {
|
|
136
|
+
try {
|
|
137
|
+
audioInfo = await this.getAudioInfo(this.currentFile);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn('Could not get audio info, using defaults:', err.message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await this.generateSilence(action.duration, tempSilenceFile, audioInfo);
|
|
131
143
|
if (this.currentFile == null) {
|
|
132
144
|
this.currentFile = tempSilenceFile;
|
|
133
145
|
} else {
|
|
@@ -274,11 +286,38 @@ class SfxMix {
|
|
|
274
286
|
});
|
|
275
287
|
}
|
|
276
288
|
|
|
277
|
-
|
|
289
|
+
getAudioInfo(inputFile) {
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
ffmpeg.ffprobe(inputFile, (err, metadata) => {
|
|
292
|
+
if (err) {
|
|
293
|
+
reject(err);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
|
|
298
|
+
if (!audioStream) {
|
|
299
|
+
reject(new Error('No audio stream found'));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
resolve({
|
|
304
|
+
channels: audioStream.channels || 2,
|
|
305
|
+
sampleRate: audioStream.sample_rate || 44100,
|
|
306
|
+
bitrate: metadata.format.bit_rate || 128000
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
generateSilence(durationMs, outputFile, audioInfo = null) {
|
|
278
313
|
return new Promise((resolve, reject) => {
|
|
279
314
|
const durationSec = durationMs / 1000;
|
|
280
|
-
const sampleRate = 44100;
|
|
281
|
-
const numChannels = 2;
|
|
315
|
+
const sampleRate = audioInfo?.sampleRate || 44100;
|
|
316
|
+
const numChannels = audioInfo?.channels || 2;
|
|
317
|
+
|
|
318
|
+
// Use audioInfo bitrate if available, otherwise use bitrate or 128000 as fallback
|
|
319
|
+
const bitrate = audioInfo?.bitrate || this.bitrate || 128000;
|
|
320
|
+
|
|
282
321
|
const bytesPerSample = 2; // 16-bit audio
|
|
283
322
|
const bytesPerSecond = sampleRate * numChannels * bytesPerSample;
|
|
284
323
|
let totalBytes = Math.floor(durationSec * bytesPerSecond);
|
|
@@ -295,12 +334,15 @@ class SfxMix {
|
|
|
295
334
|
}
|
|
296
335
|
});
|
|
297
336
|
|
|
337
|
+
const bitrateKbps = Math.floor(bitrate / 1000) + 'k';
|
|
338
|
+
|
|
298
339
|
ffmpeg()
|
|
299
340
|
.input(silenceStream)
|
|
300
341
|
.inputFormat('s16le')
|
|
301
342
|
.audioChannels(numChannels)
|
|
302
343
|
.audioFrequency(sampleRate)
|
|
303
344
|
.audioCodec('libmp3lame')
|
|
345
|
+
.audioBitrate(bitrateKbps)
|
|
304
346
|
.format('mp3')
|
|
305
347
|
.output(outputFile)
|
|
306
348
|
.on('end', () => {
|
|
@@ -316,32 +358,57 @@ class SfxMix {
|
|
|
316
358
|
}
|
|
317
359
|
|
|
318
360
|
applyFilter(inputFile, filterName, options, outputFile) {
|
|
319
|
-
return new Promise((resolve, reject) => {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
361
|
+
return new Promise(async (resolve, reject) => {
|
|
362
|
+
try {
|
|
363
|
+
const filterChain = this.getFilterChain(filterName, options);
|
|
364
|
+
if (!filterChain) {
|
|
365
|
+
return reject(new Error(`Unknown filter: ${filterName}`));
|
|
366
|
+
}
|
|
324
367
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
368
|
+
let bitrateKbps;
|
|
369
|
+
|
|
370
|
+
// If bitrate is null, auto-detect from input file
|
|
371
|
+
if (this.bitrate === null) {
|
|
372
|
+
let audioInfo = null;
|
|
373
|
+
try {
|
|
374
|
+
audioInfo = await this.getAudioInfo(inputFile);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.warn('Could not get audio info for filter, using 128k default:', err.message);
|
|
377
|
+
}
|
|
378
|
+
bitrateKbps = audioInfo ? Math.floor(audioInfo.bitrate / 1000) + 'k' : '128k';
|
|
379
|
+
} else {
|
|
380
|
+
// Use configured default bitrate
|
|
381
|
+
bitrateKbps = Math.floor(this.bitrate / 1000) + 'k';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const command = ffmpeg()
|
|
385
|
+
.input(inputFile)
|
|
386
|
+
.audioFilters(filterChain)
|
|
387
|
+
.audioCodec('libmp3lame')
|
|
388
|
+
.audioBitrate(bitrateKbps)
|
|
389
|
+
.format('mp3')
|
|
390
|
+
.output(outputFile);
|
|
391
|
+
|
|
392
|
+
command
|
|
393
|
+
.on('end', () => resolve())
|
|
394
|
+
.on('error', (err) => reject(err))
|
|
395
|
+
.run();
|
|
396
|
+
} catch (err) {
|
|
397
|
+
reject(err);
|
|
398
|
+
}
|
|
334
399
|
});
|
|
335
400
|
}
|
|
336
401
|
|
|
337
402
|
getFilterChain(filterName, options) {
|
|
338
403
|
switch (filterName) {
|
|
339
404
|
case 'normalize':
|
|
340
|
-
// Normalize audio using
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
405
|
+
// Normalize audio using dynaudnorm filter which is more robust
|
|
406
|
+
// tp parameter is converted to target peak (p parameter, range 0-1)
|
|
407
|
+
// Default tp of -1.5 dB corresponds to approximately 0.95 peak
|
|
408
|
+
const tp = options.tp || -1.5;
|
|
409
|
+
const targetPeak = Math.pow(10, tp / 20); // Convert dB to linear scale
|
|
410
|
+
const p = Math.min(0.99, Math.max(0.5, targetPeak));
|
|
411
|
+
return `dynaudnorm=p=${p.toFixed(2)}:m=100:s=10`;
|
|
345
412
|
|
|
346
413
|
case 'telephone':
|
|
347
414
|
// Telephone effect with parameters
|