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.
Files changed (3) hide show
  1. package/demo/demo_9.js +40 -0
  2. package/index.js +185 -87
  3. 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
- // Process all actions
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 silence to add at the start (after removing silence)
494
- // paddingEnd: milliseconds of silence to add at the end (after removing silence)
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 : -50;
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 : -50;
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
- filters.push(`silenceremove=start_periods=1:start_duration=${startDuration}:start_threshold=${startThreshold}dB:detection=peak`);
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
- filters.push(`silenceremove=start_periods=1:start_duration=${stopDuration}:start_threshold=${stopThreshold}dB:detection=peak`);
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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sfxmix",
3
3
  "description": "🎧 SfxMix - Powerful and easy-to-use module for processing audio.",
4
- "version": "1.4.6",
4
+ "version": "1.5.2",
5
5
  "main": "index.js",
6
6
  "dependencies": {
7
7
  "fluent-ffmpeg": "^2.1.3"