sfxmix 1.4.6 → 1.5.2
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/demo/demo_9.js +40 -0
- package/index.js +185 -87
- package/package.json +1 -1
package/demo/demo_9.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import SfxMix from '../index.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const sfx = new SfxMix();
|
|
10
|
+
|
|
11
|
+
const evalDir = path.join(__dirname, 'eval');
|
|
12
|
+
const files = [
|
|
13
|
+
'everyo_collap_en_us_secyut_1y7pqko.mp3',
|
|
14
|
+
'theyre_callin_it_a_sudden_208g2a.mp3',
|
|
15
|
+
'were_on_cnn_too_en_us_secyut_ve1xs.mp3'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
console.log('=== Truncation Detection Test ===\n');
|
|
19
|
+
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const filePath = path.join(evalDir, file);
|
|
22
|
+
try {
|
|
23
|
+
const result = await sfx.add(filePath).isTruncated();
|
|
24
|
+
console.log(`File: ${file}`);
|
|
25
|
+
console.log(` Truncated: ${result.truncated}`);
|
|
26
|
+
console.log(` Tail RMS: ${result.tailRmsDb} dB`);
|
|
27
|
+
console.log(` Tail Peak: ${result.tailPeakDb} dB`);
|
|
28
|
+
console.log(` Duration: ${result.duration}s`);
|
|
29
|
+
console.log(` Threshold: ${result.threshold} dB`);
|
|
30
|
+
console.log(` Tail analyzed: ${result.tailDuration} ms`);
|
|
31
|
+
console.log('');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`Error analyzing ${file}:`, err.message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
sfx.cleanup();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
main();
|
package/index.js
CHANGED
|
@@ -131,77 +131,79 @@ class SfxMix {
|
|
|
131
131
|
return this.convertAudio(inputFile, outputFile, options);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
async _processActions() {
|
|
135
|
+
for (let action of this.actions) {
|
|
136
|
+
if (action.type === 'add') {
|
|
137
|
+
if (this.currentFile == null) {
|
|
138
|
+
this.currentFile = path.isAbsolute(action.input) ? action.input : path.resolve(process.cwd(), action.input);
|
|
139
|
+
} else {
|
|
140
|
+
const tempFile = this.getTempFile('concat');
|
|
141
|
+
await this.concatenateAudioFiles([this.currentFile, action.input], tempFile);
|
|
142
|
+
if (this.isTempFile(this.currentFile)) {
|
|
143
|
+
this.safeDeleteFile(this.currentFile);
|
|
144
|
+
}
|
|
145
|
+
this.currentFile = tempFile;
|
|
146
|
+
}
|
|
147
|
+
} else if (action.type === 'mix') {
|
|
148
|
+
if (this.currentFile == null) {
|
|
149
|
+
throw new Error('No audio to mix with. Add or concatenate audio before mixing.');
|
|
150
|
+
}
|
|
151
|
+
const tempFile = this.getTempFile('mix');
|
|
152
|
+
await this.mixAudioFiles(this.currentFile, action.input, tempFile, action.options);
|
|
153
|
+
if (this.isTempFile(this.currentFile)) {
|
|
154
|
+
this.safeDeleteFile(this.currentFile);
|
|
155
|
+
}
|
|
156
|
+
this.currentFile = tempFile;
|
|
157
|
+
} else if (action.type === 'silence') {
|
|
158
|
+
const tempSilenceFile = this.getTempFile('silence');
|
|
159
|
+
let audioInfo = null;
|
|
160
|
+
if (this.currentFile != null) {
|
|
161
|
+
try {
|
|
162
|
+
audioInfo = await this.getAudioInfo(this.currentFile);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn('Could not get audio info, using defaults:', err.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await this.generateSilence(action.duration, tempSilenceFile, audioInfo);
|
|
168
|
+
if (this.currentFile == null) {
|
|
169
|
+
this.currentFile = tempSilenceFile;
|
|
170
|
+
} else {
|
|
171
|
+
const tempFile = this.getTempFile('concat');
|
|
172
|
+
await this.concatenateAudioFiles([this.currentFile, tempSilenceFile], tempFile);
|
|
173
|
+
if (this.isTempFile(this.currentFile)) {
|
|
174
|
+
this.safeDeleteFile(this.currentFile);
|
|
175
|
+
}
|
|
176
|
+
this.safeDeleteFile(tempSilenceFile);
|
|
177
|
+
this.currentFile = tempFile;
|
|
178
|
+
}
|
|
179
|
+
} else if (action.type === 'filter') {
|
|
180
|
+
if (this.currentFile == null) {
|
|
181
|
+
throw new Error('No audio to apply filter to. Add audio before applying filters.');
|
|
182
|
+
}
|
|
183
|
+
const tempFile = this.getTempFile('filter');
|
|
184
|
+
await this.applyFilter(this.currentFile, action.filterName, action.options, tempFile);
|
|
185
|
+
if (this.isTempFile(this.currentFile)) {
|
|
186
|
+
this.safeDeleteFile(this.currentFile);
|
|
187
|
+
}
|
|
188
|
+
this.currentFile = tempFile;
|
|
189
|
+
} else if (action.type === 'trim') {
|
|
190
|
+
if (this.currentFile == null) {
|
|
191
|
+
throw new Error('No audio to trim. Add audio before trimming.');
|
|
192
|
+
}
|
|
193
|
+
const tempFile = this.getTempFile('trim');
|
|
194
|
+
await this.applyTrim(this.currentFile, action.options, tempFile);
|
|
195
|
+
if (this.isTempFile(this.currentFile)) {
|
|
196
|
+
this.safeDeleteFile(this.currentFile);
|
|
197
|
+
}
|
|
198
|
+
this.currentFile = tempFile;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
134
203
|
save(output, outputOptions = {}) {
|
|
135
204
|
return new Promise(async (resolve, reject) => {
|
|
136
205
|
try {
|
|
137
|
-
|
|
138
|
-
for (let action of this.actions) {
|
|
139
|
-
if (action.type === 'add') {
|
|
140
|
-
if (this.currentFile == null) {
|
|
141
|
-
this.currentFile = path.isAbsolute(action.input) ? action.input : path.resolve(process.cwd(), action.input);
|
|
142
|
-
} else {
|
|
143
|
-
const tempFile = this.getTempFile('concat');
|
|
144
|
-
await this.concatenateAudioFiles([this.currentFile, action.input], tempFile);
|
|
145
|
-
if (this.isTempFile(this.currentFile)) {
|
|
146
|
-
this.safeDeleteFile(this.currentFile);
|
|
147
|
-
}
|
|
148
|
-
this.currentFile = tempFile;
|
|
149
|
-
}
|
|
150
|
-
} else if (action.type === 'mix') {
|
|
151
|
-
if (this.currentFile == null) {
|
|
152
|
-
throw new Error('No audio to mix with. Add or concatenate audio before mixing.');
|
|
153
|
-
}
|
|
154
|
-
const tempFile = this.getTempFile('mix');
|
|
155
|
-
await this.mixAudioFiles(this.currentFile, action.input, tempFile, action.options);
|
|
156
|
-
if (this.isTempFile(this.currentFile)) {
|
|
157
|
-
this.safeDeleteFile(this.currentFile);
|
|
158
|
-
}
|
|
159
|
-
this.currentFile = tempFile;
|
|
160
|
-
} else if (action.type === 'silence') {
|
|
161
|
-
const tempSilenceFile = this.getTempFile('silence');
|
|
162
|
-
// Get audio info from current file to match channels and sample rate
|
|
163
|
-
let audioInfo = null;
|
|
164
|
-
if (this.currentFile != null) {
|
|
165
|
-
try {
|
|
166
|
-
audioInfo = await this.getAudioInfo(this.currentFile);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
console.warn('Could not get audio info, using defaults:', err.message);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
await this.generateSilence(action.duration, tempSilenceFile, audioInfo);
|
|
172
|
-
if (this.currentFile == null) {
|
|
173
|
-
this.currentFile = tempSilenceFile;
|
|
174
|
-
} else {
|
|
175
|
-
const tempFile = this.getTempFile('concat');
|
|
176
|
-
await this.concatenateAudioFiles([this.currentFile, tempSilenceFile], tempFile);
|
|
177
|
-
if (this.isTempFile(this.currentFile)) {
|
|
178
|
-
this.safeDeleteFile(this.currentFile);
|
|
179
|
-
}
|
|
180
|
-
this.safeDeleteFile(tempSilenceFile);
|
|
181
|
-
this.currentFile = tempFile;
|
|
182
|
-
}
|
|
183
|
-
} else if (action.type === 'filter') {
|
|
184
|
-
if (this.currentFile == null) {
|
|
185
|
-
throw new Error('No audio to apply filter to. Add audio before applying filters.');
|
|
186
|
-
}
|
|
187
|
-
const tempFile = this.getTempFile('filter');
|
|
188
|
-
await this.applyFilter(this.currentFile, action.filterName, action.options, tempFile);
|
|
189
|
-
if (this.isTempFile(this.currentFile)) {
|
|
190
|
-
this.safeDeleteFile(this.currentFile);
|
|
191
|
-
}
|
|
192
|
-
this.currentFile = tempFile;
|
|
193
|
-
} else if (action.type === 'trim') {
|
|
194
|
-
if (this.currentFile == null) {
|
|
195
|
-
throw new Error('No audio to trim. Add audio before trimming.');
|
|
196
|
-
}
|
|
197
|
-
const tempFile = this.getTempFile('trim');
|
|
198
|
-
await this.applyTrim(this.currentFile, action.options, tempFile);
|
|
199
|
-
if (this.isTempFile(this.currentFile)) {
|
|
200
|
-
this.safeDeleteFile(this.currentFile);
|
|
201
|
-
}
|
|
202
|
-
this.currentFile = tempFile;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
206
|
+
await this._processActions();
|
|
205
207
|
|
|
206
208
|
// Prepare output
|
|
207
209
|
const absoluteOutput = path.resolve(process.cwd(), output);
|
|
@@ -368,6 +370,111 @@ class SfxMix {
|
|
|
368
370
|
});
|
|
369
371
|
}
|
|
370
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Check if the current audio is truncated (doesn't end in silence).
|
|
375
|
+
* Terminal operation: processes the action chain, analyzes the tail of the
|
|
376
|
+
* resulting audio, then resets. Use after .add() like any other terminal op.
|
|
377
|
+
*
|
|
378
|
+
* @param {Object} options - Analysis options
|
|
379
|
+
* @param {number} options.tailDuration - Milliseconds of audio tail to analyze (default: 50)
|
|
380
|
+
* @param {number} options.threshold - dB threshold; above this = truncated (default: -30)
|
|
381
|
+
* @returns {Promise<Object>} Result with truncated flag and audio metrics
|
|
382
|
+
*/
|
|
383
|
+
isTruncated(options = {}) {
|
|
384
|
+
return new Promise(async (resolve, reject) => {
|
|
385
|
+
try {
|
|
386
|
+
await this._processActions();
|
|
387
|
+
|
|
388
|
+
if (!this.currentFile) {
|
|
389
|
+
throw new Error('No audio to analyze. Add audio before checking truncation.');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const result = await this._analyzeTail(this.currentFile, options);
|
|
393
|
+
|
|
394
|
+
// Clean up temp files
|
|
395
|
+
if (this.isTempFile(this.currentFile)) {
|
|
396
|
+
this.safeDeleteFile(this.currentFile);
|
|
397
|
+
}
|
|
398
|
+
this.reset();
|
|
399
|
+
resolve(result);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (this.currentFile && this.isTempFile(this.currentFile)) {
|
|
402
|
+
this.safeDeleteFile(this.currentFile);
|
|
403
|
+
}
|
|
404
|
+
this.reset();
|
|
405
|
+
reject(err);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_analyzeTail(filePath, options = {}) {
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
const tailDuration = options.tailDuration || 50;
|
|
413
|
+
const threshold = options.threshold !== undefined ? options.threshold : -30;
|
|
414
|
+
const sampleRate = 44100;
|
|
415
|
+
const tailSamples = Math.floor((tailDuration / 1000) * sampleRate);
|
|
416
|
+
|
|
417
|
+
const chunks = [];
|
|
418
|
+
|
|
419
|
+
const stream = ffmpeg(filePath)
|
|
420
|
+
.audioChannels(1)
|
|
421
|
+
.audioFrequency(sampleRate)
|
|
422
|
+
.format('s16le')
|
|
423
|
+
.on('error', err => reject(err))
|
|
424
|
+
.pipe();
|
|
425
|
+
|
|
426
|
+
stream.on('data', chunk => chunks.push(chunk));
|
|
427
|
+
|
|
428
|
+
stream.on('end', () => {
|
|
429
|
+
const buffer = Buffer.concat(chunks);
|
|
430
|
+
const allSamples = new Int16Array(
|
|
431
|
+
buffer.buffer,
|
|
432
|
+
buffer.byteOffset,
|
|
433
|
+
Math.floor(buffer.length / 2)
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const duration = allSamples.length / sampleRate;
|
|
437
|
+
|
|
438
|
+
if (allSamples.length === 0) {
|
|
439
|
+
return resolve({
|
|
440
|
+
truncated: false,
|
|
441
|
+
tailRmsDb: -Infinity,
|
|
442
|
+
tailPeakDb: -Infinity,
|
|
443
|
+
duration: 0,
|
|
444
|
+
threshold,
|
|
445
|
+
tailDuration
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const startIdx = Math.max(0, allSamples.length - tailSamples);
|
|
450
|
+
const samples = allSamples.slice(startIdx);
|
|
451
|
+
|
|
452
|
+
let sumSquares = 0;
|
|
453
|
+
let peak = 0;
|
|
454
|
+
for (let i = 0; i < samples.length; i++) {
|
|
455
|
+
const normalized = samples[i] / 32768;
|
|
456
|
+
sumSquares += normalized * normalized;
|
|
457
|
+
peak = Math.max(peak, Math.abs(normalized));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const rms = Math.sqrt(sumSquares / samples.length);
|
|
461
|
+
const rmsDb = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
|
|
462
|
+
const peakDb = peak > 0 ? 20 * Math.log10(peak) : -Infinity;
|
|
463
|
+
|
|
464
|
+
resolve({
|
|
465
|
+
truncated: rmsDb > threshold,
|
|
466
|
+
tailRmsDb: Math.round(rmsDb * 100) / 100,
|
|
467
|
+
tailPeakDb: Math.round(peakDb * 100) / 100,
|
|
468
|
+
duration: Math.round(duration * 1000) / 1000,
|
|
469
|
+
threshold,
|
|
470
|
+
tailDuration
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
stream.on('error', err => reject(err));
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
371
478
|
getAudioInfo(inputFile) {
|
|
372
479
|
return new Promise((resolve, reject) => {
|
|
373
480
|
ffmpeg.ffprobe(inputFile, (err, metadata) => {
|
|
@@ -490,13 +597,13 @@ class SfxMix {
|
|
|
490
597
|
// stopDuration: min duration of non-silence to stop trimming at end (in seconds).
|
|
491
598
|
// Default 0.05s (50ms) to skip short noise artifacts/blips at the end that would prevent proper trimming.
|
|
492
599
|
// stopThreshold: noise tolerance for end (in dB, e.g., -50dB). Higher values (e.g. -20) are more aggressive.
|
|
493
|
-
// paddingStart: milliseconds of
|
|
494
|
-
// paddingEnd: milliseconds of
|
|
600
|
+
// paddingStart: milliseconds of original audio to preserve at the start (after the silence boundary)
|
|
601
|
+
// paddingEnd: milliseconds of original audio to preserve at the end (after the silence boundary)
|
|
495
602
|
|
|
496
603
|
const startDuration = options.startDuration !== undefined ? options.startDuration : 0;
|
|
497
|
-
const startThreshold = options.startThreshold !== undefined ? options.startThreshold : -
|
|
604
|
+
const startThreshold = options.startThreshold !== undefined ? options.startThreshold : -30;
|
|
498
605
|
const stopDuration = options.stopDuration !== undefined ? options.stopDuration : 0.05;
|
|
499
|
-
const stopThreshold = options.stopThreshold !== undefined ? options.stopThreshold : -
|
|
606
|
+
const stopThreshold = options.stopThreshold !== undefined ? options.stopThreshold : -30;
|
|
500
607
|
const paddingStart = options.paddingStart || 0; // in milliseconds
|
|
501
608
|
const paddingEnd = options.paddingEnd || 0; // in milliseconds
|
|
502
609
|
|
|
@@ -521,28 +628,19 @@ class SfxMix {
|
|
|
521
628
|
// This ensures we ONLY remove silence from the beginning and end, preserving intermediate silences
|
|
522
629
|
const filters = [];
|
|
523
630
|
|
|
524
|
-
// Remove silence from the start
|
|
525
|
-
|
|
631
|
+
// Remove silence from the start, preserving paddingStart ms of original audio if specified
|
|
632
|
+
const startSilenceParam = paddingStart > 0 ? `:start_silence=${paddingStart / 1000}` : '';
|
|
633
|
+
filters.push(`silenceremove=start_periods=1:start_duration=${startDuration}:start_threshold=${startThreshold}dB${startSilenceParam}:detection=peak`);
|
|
526
634
|
|
|
527
635
|
// Reverse the audio
|
|
528
636
|
filters.push('areverse');
|
|
529
637
|
|
|
530
|
-
// Remove silence from what is now the start (but was the end)
|
|
531
|
-
|
|
638
|
+
// Remove silence from what is now the start (but was the end), preserving paddingEnd ms of original audio if specified
|
|
639
|
+
const endSilenceParam = paddingEnd > 0 ? `:start_silence=${paddingEnd / 1000}` : '';
|
|
640
|
+
filters.push(`silenceremove=start_periods=1:start_duration=${stopDuration}:start_threshold=${stopThreshold}dB${endSilenceParam}:detection=peak`);
|
|
532
641
|
|
|
533
642
|
// Reverse back to original direction
|
|
534
643
|
filters.push('areverse');
|
|
535
|
-
|
|
536
|
-
// Add padding at start if specified
|
|
537
|
-
if (paddingStart > 0) {
|
|
538
|
-
filters.push(`adelay=${paddingStart}|${paddingStart}`);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Add padding at end if specified
|
|
542
|
-
if (paddingEnd > 0) {
|
|
543
|
-
const paddingSec = paddingEnd / 1000;
|
|
544
|
-
filters.push(`apad=pad_dur=${paddingSec}`);
|
|
545
|
-
}
|
|
546
644
|
|
|
547
645
|
// CRITICAL: Resample at the end to fix "inadequate AVFrame plane padding" error
|
|
548
646
|
// This regenerates the audio frames with proper padding for the encoder
|