streamify-audio 2.2.2 → 2.2.6

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 (41) hide show
  1. package/.github/workflows/npm-publish-github-packages.yml +36 -0
  2. package/README.md +81 -7
  3. package/docs/configuration.md +31 -2
  4. package/docs/discord/manager.md +14 -7
  5. package/docs/discord/player.md +84 -1
  6. package/docs/filters.md +39 -1
  7. package/docs/http/endpoints.md +25 -0
  8. package/docs/http/server.md +2 -0
  9. package/docs/sources.md +52 -4
  10. package/index.d.ts +31 -1
  11. package/index.js +67 -3
  12. package/package.json +8 -6
  13. package/src/cache/index.js +2 -1
  14. package/src/config.js +18 -1
  15. package/src/discord/Manager.js +101 -7
  16. package/src/discord/Player.js +281 -99
  17. package/src/discord/Stream.js +204 -137
  18. package/src/filters/ffmpeg.js +124 -15
  19. package/src/providers/bandcamp.js +49 -0
  20. package/src/providers/http.js +35 -0
  21. package/src/providers/local.js +36 -0
  22. package/src/providers/mixcloud.js +49 -0
  23. package/src/providers/soundcloud.js +5 -1
  24. package/src/providers/twitch.js +49 -0
  25. package/src/providers/youtube.js +58 -17
  26. package/src/server.js +60 -5
  27. package/src/utils/logger.js +34 -12
  28. package/tests/cache.test.js +234 -0
  29. package/tests/config.test.js +44 -0
  30. package/tests/error-handling.test.js +318 -0
  31. package/tests/ffmpeg.test.js +66 -0
  32. package/tests/filters-edge.test.js +333 -0
  33. package/tests/http.test.js +24 -0
  34. package/tests/integration.test.js +325 -0
  35. package/tests/local.test.js +37 -0
  36. package/tests/queue.test.js +94 -0
  37. package/tests/spotify.test.js +238 -0
  38. package/tests/stream.test.js +217 -0
  39. package/tests/twitch.test.js +42 -0
  40. package/tests/utils.test.js +60 -0
  41. package/tests/youtube.test.js +219 -0
@@ -21,7 +21,16 @@ class StreamController {
21
21
  this.resource = null;
22
22
  this.destroyed = false;
23
23
  this.startTime = null;
24
- this.bytesReceived = 0;
24
+ this.ytdlpError = '';
25
+ this.ffmpegError = '';
26
+
27
+ // Timing metrics
28
+ this.metrics = {
29
+ metadata: 0,
30
+ spawn: 0,
31
+ firstByte: 0,
32
+ total: 0
33
+ };
25
34
  }
26
35
 
27
36
  async create(seekPosition = 0) {
@@ -34,20 +43,25 @@ class StreamController {
34
43
  }
35
44
 
36
45
  this.startTime = Date.now();
46
+ const startTimestamp = this.startTime;
37
47
  const source = this.track.source || 'youtube';
38
48
 
39
49
  let videoId = this.track._resolvedId || this.track.id;
40
50
 
51
+ // Skip metadata resolution if we already have it
41
52
  if (source === 'spotify' && !this.track._resolvedId) {
42
53
  log.info('STREAM', `Resolving Spotify track to YouTube: ${this.track.title}`);
43
54
  try {
44
55
  const spotify = require('../providers/spotify');
45
56
  videoId = await spotify.resolveToYouTube(this.track.id, this.config);
46
57
  this.track._resolvedId = videoId;
58
+ this.metrics.metadata = Date.now() - startTimestamp;
47
59
  } catch (error) {
48
60
  log.error('STREAM', `Spotify resolution failed: ${error.message}`);
49
61
  throw new Error(`Failed to resolve Spotify track: ${error.message}`);
50
62
  }
63
+ } else {
64
+ this.metrics.metadata = 0; // Already resolved
51
65
  }
52
66
 
53
67
  if (!videoId || videoId === 'undefined') {
@@ -55,197 +69,250 @@ class StreamController {
55
69
  }
56
70
 
57
71
  log.info('STREAM', `Creating stream for ${videoId} (${source})`);
58
-
59
- let url;
60
- if (source === 'soundcloud') {
61
- url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
62
- } else {
63
- url = `https://www.youtube.com/watch?v=${videoId}`;
72
+
73
+ // Log Filter Chain Trace (No data impact)
74
+ const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger' && k !== 'volume');
75
+ if (filterNames.length > 0) {
76
+ const chain = filterNames.map(name => {
77
+ const val = this.filters[name];
78
+ let displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
79
+ if (displayVal === true || displayVal === 'true') displayVal = 'ON';
80
+ return `[${name.toUpperCase()} (${displayVal})]`;
81
+ }).join(' ➔ ');
82
+ log.info('STREAM', `Filter Chain: ${chain}`);
64
83
  }
65
84
 
66
85
  const isYouTube = source === 'youtube' || source === 'spotify';
67
86
  const isLive = this.track.isLive === true || this.track.duration === 0;
68
-
69
- let formatString;
70
- if (isLive) {
71
- formatString = 'bestaudio*/best';
87
+ const isLocal = source === 'local';
88
+
89
+ let ytdlp;
90
+ let ffmpegIn;
91
+
92
+ const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
93
+ const spawnStart = Date.now();
94
+
95
+ if (isLocal) {
96
+ // Skip yt-dlp for local files
97
+ ffmpegIn = 'pipe:0'; // We'll just pass the file path to ffmpeg -i
98
+ this.metrics.spawn = Date.now() - spawnStart;
99
+ } else if (this.track._directUrl && !isLive && seekPosition === 0) {
100
+ // OPTIMIZATION: Bypass yt-dlp if we already have a direct stream URL
101
+ ffmpegIn = this.track._directUrl;
102
+ log.info('STREAM', `Bypassing yt-dlp, using direct URL for ${videoId}`);
103
+ this.metrics.spawn = 0;
72
104
  } else {
73
- formatString = isYouTube ? '18/22/bestaudio[ext=webm]/bestaudio/best' : 'bestaudio/best';
74
- }
75
-
76
- const ytdlpArgs = [
77
- '-f', formatString,
78
- '--no-playlist',
79
- '--no-check-certificates',
80
- '--no-warnings',
81
- '--retries', '3',
82
- '--fragment-retries', '3',
83
- '-o', '-',
84
- url
85
- ];
86
-
87
- if (isLive) {
88
- ytdlpArgs.push('--no-live-from-start');
89
- log.info('STREAM', `Live stream detected, using live-compatible format`);
90
- } else if (isYouTube) {
91
- ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
92
- }
105
+ let url;
106
+ if (source === 'soundcloud') {
107
+ url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
108
+ } else if (['twitch', 'mixcloud', 'bandcamp', 'http'].includes(source)) {
109
+ url = this.track.uri || videoId;
110
+ } else {
111
+ url = `https://www.youtube.com/watch?v=${videoId}`;
112
+ }
93
113
 
94
- if (seekPosition > 0) {
95
- const seekSeconds = Math.floor(seekPosition / 1000);
96
- ytdlpArgs.push('--download-sections', `*${seekSeconds}-`);
97
- log.info('STREAM', `Seeking to ${seekSeconds}s`);
98
- }
114
+ let formatString;
115
+ if (isLive) {
116
+ formatString = 'bestaudio*/best';
117
+ } else {
118
+ formatString = this.config.ytdlp.format;
119
+ }
99
120
 
100
- if (this.config.cookiesPath) {
101
- ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
102
- }
121
+ const ytdlpArgs = [
122
+ '-f', formatString,
123
+ '--no-playlist',
124
+ '--no-check-certificates',
125
+ '--no-warnings',
126
+ '--no-cache-dir',
127
+ '--no-mtime',
128
+ '--buffer-size', '16K',
129
+ '--quiet',
130
+ '--retries', '3',
131
+ '--fragment-retries', '3',
132
+ '-o', '-',
133
+ ...this.config.ytdlp.additionalArgs,
134
+ url
135
+ ];
136
+
137
+ if (isLive) {
138
+ ytdlpArgs.push('--no-live-from-start');
139
+ } else if (isYouTube) {
140
+ ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
141
+ }
103
142
 
104
- if (this.config.sponsorblock?.enabled !== false && isYouTube) {
105
- const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
106
- ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
107
- }
143
+ if (seekPosition > 0) {
144
+ const seekSeconds = Math.floor(seekPosition / 1000);
145
+ ytdlpArgs.push('--download-sections', `*${seekSeconds}-`);
146
+ log.info('STREAM', `Seeking to ${seekSeconds}s`);
147
+ }
108
148
 
109
- this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, {
110
- env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
111
- });
149
+ if (this.config.cookiesPath) {
150
+ ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
151
+ }
112
152
 
113
- this._spawnTime = Date.now() - this.startTime;
153
+ if (this.config.sponsorblock?.enabled !== false && isYouTube) {
154
+ const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
155
+ ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
156
+ }
114
157
 
115
- const ffmpegFilters = { ...this.filters };
116
- if (seekPosition > 0) {
117
- ffmpegFilters.start = seekPosition / 1000;
158
+ this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
159
+ ffmpegIn = 'pipe:0';
118
160
  }
119
161
 
162
+ const ffmpegFilters = { ...this.filters };
120
163
  const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
121
- this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs);
122
-
123
- this._firstDataTime = null;
124
- this.ytdlp.stdout.on('data', (chunk) => {
125
- if (!this._firstDataTime) {
126
- this._firstDataTime = Date.now() - this.startTime;
164
+
165
+ // Input injection - only override if NOT using pipe:0 (buildFfmpegArgs already includes -i pipe:0)
166
+ if (isLocal) {
167
+ const filePath = this.track.absolutePath || videoId.replace('file://', '');
168
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
169
+ if (pipeIndex > 0) {
170
+ ffmpegArgs[pipeIndex] = filePath;
171
+ if (seekPosition > 0) {
172
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
173
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
174
+ }
127
175
  }
128
- this.bytesReceived += chunk.length;
129
- });
130
-
131
- this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
132
-
133
- let ytdlpError = '';
134
- this.ytdlp.stderr.on('data', (data) => {
135
- if (this.destroyed) return;
136
- const msg = data.toString();
137
- ytdlpError += msg;
138
- if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
139
- log.error('YTDLP', msg.trim());
140
- } else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
141
- log.debug('YTDLP', msg.trim());
176
+ } else if (ffmpegIn !== 'pipe:0') {
177
+ // Direct URL - replace the pipe:0 input with the direct URL
178
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
179
+ if (pipeIndex > 0) {
180
+ ffmpegArgs[pipeIndex] = ffmpegIn;
181
+ if (this.track._headers) {
182
+ const headers = Object.entries(this.track._headers)
183
+ .map(([k, v]) => `${k}: ${v}`)
184
+ .join('\r\n');
185
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-headers', headers);
186
+ }
142
187
  }
143
- });
188
+ }
189
+ // else: pipe:0 - buildFfmpegArgs already has -i pipe:0, nothing to change
190
+
191
+ this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
192
+ this.metrics.spawn = Date.now() - spawnStart;
193
+
194
+ if (this.ytdlp) {
195
+ this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
196
+
197
+ this.ytdlp.stderr.on('data', (data) => {
198
+ if (this.destroyed) return;
199
+ const msg = data.toString();
200
+ this.ytdlpError += msg;
201
+ if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
202
+ log.error('YTDLP', msg.trim());
203
+ } else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
204
+ log.debug('YTDLP', msg.trim());
205
+ }
206
+ });
207
+
208
+ this.ytdlp.on('close', (code) => {
209
+ if (code !== 0 && code !== null && !this.destroyed) {
210
+ log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
211
+ }
212
+ if (this.ffmpeg && !this.ffmpeg.killed && this.ffmpeg.stdin) {
213
+ try { this.ffmpeg.stdin.end(); } catch(e) {}
214
+ }
215
+ });
216
+ }
144
217
 
145
218
  this.ffmpeg.stderr.on('data', (data) => {
146
219
  if (this.destroyed) return;
147
220
  const msg = data.toString();
221
+ this.ffmpegError += msg;
148
222
  if ((msg.includes('Error') || msg.includes('error')) && !msg.includes('Connection reset') && !msg.includes('Broken pipe')) {
149
223
  log.error('FFMPEG', msg.trim());
150
224
  }
151
225
  });
152
226
 
153
- this.ytdlp.on('close', (code) => {
154
- if (code !== 0 && code !== null && !this.destroyed) {
155
- log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
156
- if (ytdlpError) {
157
- log.error('STREAM', `yt-dlp stderr: ${ytdlpError.slice(-500)}`);
158
- }
159
- }
160
- if (this.ffmpeg && !this.ffmpeg.killed) {
161
- this.ffmpeg.stdin.end();
162
- }
163
- });
164
-
165
- this.ytdlp.on('error', (error) => {
166
- if (error.code !== 'EPIPE' && !this.destroyed) {
167
- log.error('STREAM', `yt-dlp spawn error: ${error.message}`);
168
- }
169
- });
170
-
171
- this.ffmpeg.on('error', (error) => {
172
- if (error.code !== 'EPIPE' && !this.destroyed) {
173
- log.error('STREAM', `ffmpeg spawn error: ${error.message}`);
174
- }
175
- });
176
-
177
- this.ytdlp.stdout.on('error', (error) => {
178
- if (error.code !== 'EPIPE' && !this.destroyed) {
179
- log.debug('STREAM', `yt-dlp stdout error: ${error.message}`);
180
- }
181
- });
182
-
183
- this.ffmpeg.stdin.on('error', (error) => {
184
- if (error.code !== 'EPIPE' && !this.destroyed) {
185
- log.debug('STREAM', `ffmpeg stdin error: ${error.message}`);
186
- }
187
- });
188
-
189
- this.ffmpeg.stdout.on('error', (error) => {
190
- if (error.code !== 'EPIPE' && !this.destroyed) {
191
- log.debug('STREAM', `ffmpeg stdout error: ${error.message}`);
192
- }
193
- });
194
-
195
227
  await this._waitForData(isLive);
196
228
 
229
+ if (this.destroyed || !this.ffmpeg) {
230
+ throw new Error('Stream destroyed during initialization');
231
+ }
232
+
197
233
  this.resource = createAudioResource(this.ffmpeg.stdout, {
198
234
  inputType: StreamType.OggOpus,
199
235
  inlineVolume: false
200
236
  });
201
237
 
202
- const elapsed = Date.now() - this.startTime;
203
- const firstData = this._firstDataTime || elapsed;
204
- log.info('STREAM', `Ready ${elapsed}ms | First byte: ${firstData}ms | Buffered: ${this.bytesReceived}`);
238
+ const elapsed = Date.now() - startTimestamp;
239
+ this.metrics.total = elapsed;
240
+
241
+ log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms]`);
205
242
 
206
243
  return this.resource;
207
244
  }
208
245
 
209
246
  _waitForData(isLive = false) {
247
+ const ffmpeg = this.ffmpeg;
248
+ const ytdlp = this.ytdlp;
249
+ const MIN_BUFFER_SIZE = 32 * 1024;
250
+
210
251
  return new Promise((resolve, reject) => {
252
+ if (!ffmpeg) return resolve();
253
+
211
254
  const timeoutMs = isLive ? 30000 : 15000;
212
255
  const timeout = setTimeout(() => {
213
- log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.bytesReceived}, isLive: ${isLive})`);
256
+ log.warn('STREAM', `Timeout waiting for data, proceeding anyway`);
214
257
  resolve();
215
258
  }, timeoutMs);
216
259
 
217
260
  let resolved = false;
261
+ let bufferedSize = 0;
262
+ let firstByteRecorded = false;
263
+
264
+ const checkBuffer = () => {
265
+ if (resolved) return;
218
266
 
219
- const checkInterval = setInterval(() => {
220
- if (resolved) {
221
- clearInterval(checkInterval);
222
- return;
267
+ if (!firstByteRecorded && ffmpeg.stdout && ffmpeg.stdout.readableLength > 0) {
268
+ firstByteRecorded = true;
269
+ this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
223
270
  }
224
- if (this.bytesReceived > 0) {
271
+
272
+ bufferedSize = ffmpeg.stdout ? ffmpeg.stdout.readableLength : 0;
273
+
274
+ if (bufferedSize >= MIN_BUFFER_SIZE) {
225
275
  resolved = true;
226
276
  clearTimeout(timeout);
227
- clearInterval(checkInterval);
277
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
228
278
  resolve();
229
279
  }
230
- }, 50);
280
+ };
231
281
 
232
- this.ffmpeg.on('close', () => {
282
+ if (ffmpeg.stdout) {
283
+ ffmpeg.stdout.on('readable', checkBuffer);
284
+ }
285
+
286
+ ffmpeg.on('close', () => {
233
287
  if (!resolved) {
234
288
  resolved = true;
235
289
  clearTimeout(timeout);
236
- clearInterval(checkInterval);
237
- reject(new Error('ffmpeg closed before producing data'));
238
- }
239
- });
290
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
240
291
 
241
- this.ytdlp.on('close', (code) => {
242
- if (!resolved && code !== 0) {
243
- resolved = true;
244
- clearTimeout(timeout);
245
- clearInterval(checkInterval);
246
- reject(new Error(`yt-dlp failed with code ${code}`));
292
+ if (this.destroyed) {
293
+ return reject(new Error('Stream destroyed during initialization'));
294
+ }
295
+
296
+ const sourceErr = ytdlp ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}` : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
297
+ reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
247
298
  }
248
299
  });
300
+
301
+ if (ytdlp) {
302
+ ytdlp.on('close', (code) => {
303
+ if (!resolved && code !== 0 && code !== null) {
304
+ resolved = true;
305
+ clearTimeout(timeout);
306
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
307
+
308
+ if (this.destroyed) {
309
+ return reject(new Error('Stream destroyed during initialization'));
310
+ }
311
+
312
+ reject(new Error(`yt-dlp failed with code ${code}`));
313
+ }
314
+ });
315
+ }
249
316
  });
250
317
  }
251
318
 
@@ -254,7 +321,7 @@ class StreamController {
254
321
  this.destroyed = true;
255
322
 
256
323
  const elapsed = this.startTime ? Date.now() - this.startTime : 0;
257
- log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms | Received: ${this.bytesReceived}`);
324
+ log.info('STREAM', `Destroying stream | Duration: ${Math.floor(elapsed / 1000)}s`);
258
325
 
259
326
  try {
260
327
  if (this.ytdlp && this.ffmpeg) {
@@ -287,4 +354,4 @@ function createStream(track, filters, config) {
287
354
  return new StreamController(track, filters, config);
288
355
  }
289
356
 
290
- module.exports = { createStream, StreamController };
357
+ module.exports = { createStream, StreamController };
@@ -15,7 +15,38 @@ const PRESETS = {
15
15
  piano: [0.2, 0.15, 0.1, 0.05, 0, 0.05, 0.1, 0.15, 0.2, 0.2, 0.15, 0.1, 0.1, 0.15, 0.2],
16
16
  vocal: [-0.2, -0.15, -0.1, 0, 0.2, 0.35, 0.4, 0.4, 0.35, 0.2, 0, -0.1, -0.15, -0.15, -0.1],
17
17
  bass_heavy: [0.5, 0.45, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
18
- treble_heavy: [0, 0, 0, 0, 0, 0, 0, 0, 0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.5]
18
+ treble_heavy: [0, 0, 0, 0, 0, 0, 0, 0, 0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.5],
19
+ extra_bass: [0.6, 0.55, 0.5, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0],
20
+ crystal_clear: [-0.1, -0.1, -0.05, 0, 0.1, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.5, 0.5, 0.5]
21
+ };
22
+
23
+ const EFFECT_PRESETS = {
24
+ bassboost: { filters: { bass: 12, equalizer: [0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, description: 'Heavy bass boost' },
25
+ nightcore: { filters: { speed: 1.25, pitch: 1.25 }, description: 'Speed up with higher pitch' },
26
+ vaporwave: { filters: { speed: 0.8, pitch: 0.8 }, description: 'Slow down with lower pitch' },
27
+ '8d': { filters: { rotation: { speed: 0.15 } }, description: '8D rotating audio effect' },
28
+ karaoke: { filters: { karaoke: true }, description: 'Reduce vocals' },
29
+ trebleboost: { filters: { treble: 10 }, description: 'Boost treble frequencies' },
30
+ deep: { filters: { bass: 15, pitch: 0.85, equalizer: [0.5, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, description: 'Deep bass with lower pitch' },
31
+ lofi: { filters: { lowpass: 2500, bass: 8, treble: -5 }, description: 'Lo-fi aesthetic' },
32
+ radio: { filters: { highpass: 400, lowpass: 4500, compressor: true }, description: 'Radio/telephone effect' },
33
+ telephone: { filters: { highpass: 600, lowpass: 3000, compressor: true }, description: 'Old telephone effect' },
34
+ soft: { filters: { bass: -3, treble: -5, volume: 80, compressor: true }, description: 'Softer, compressed sound' },
35
+ loud: { filters: { bass: 3, treble: 2, volume: 120, compressor: true, normalizer: true }, description: 'Louder, punchier sound' },
36
+ chipmunk: { filters: { pitch: 1.6 }, description: 'High-pitched chipmunk voice' },
37
+ darth: { filters: { pitch: 0.65 }, description: 'Deep Darth Vader voice' },
38
+ echo: { filters: { echo: true }, description: 'Echo/reverb effect' },
39
+ vibrato: { filters: { vibrato: { frequency: 6, depth: 0.6 } }, description: 'Vibrato effect' },
40
+ tremolo: { filters: { tremolo: { frequency: 5, depth: 0.5 } }, description: 'Tremolo effect' },
41
+ reverb: { filters: { reverb: true }, description: 'Classic reverb effect' },
42
+ surround: { filters: { surround: true }, description: 'Surround sound effect' },
43
+ boost: { filters: { volume: 150, treble: 5, bass: 5, compressor: true }, description: 'Boost volume and clarity' },
44
+ subboost: { filters: { bass: 15, lowpass: 100 }, description: 'Extreme sub-woofer boost' },
45
+ pop: { filters: { preset: 'pop' }, description: 'Pop EQ preset' },
46
+ rock: { filters: { preset: 'rock' }, description: 'Rock EQ preset' },
47
+ electronic: { filters: { preset: 'electronic' }, description: 'Electronic EQ preset' },
48
+ jazz: { filters: { preset: 'jazz' }, description: 'Jazz EQ preset' },
49
+ classical: { filters: { preset: 'classical' }, description: 'Classical EQ preset' }
19
50
  };
20
51
 
21
52
  function buildEqualizer(bands) {
@@ -36,8 +67,18 @@ function buildEqualizer(bands) {
36
67
  }
37
68
 
38
69
  function buildFfmpegArgs(filters = {}, config = {}) {
39
- const args = ['-i', 'pipe:0', '-vn'];
40
- const audioFilters = [];
70
+ filters = filters || {};
71
+ const args = [
72
+ '-thread_queue_size', '4096',
73
+ '-probesize', '128K',
74
+ '-analyzeduration', '0',
75
+ '-fflags', '+discardcorrupt',
76
+ '-copyts',
77
+ '-start_at_zero',
78
+ '-i', 'pipe:0',
79
+ '-vn'
80
+ ];
81
+ const audioFilters = ['aresample=async=1:first_pts=0'];
41
82
 
42
83
  if (filters.equalizer && Array.isArray(filters.equalizer)) {
43
84
  const eq = buildEqualizer(filters.equalizer);
@@ -64,11 +105,7 @@ function buildFfmpegArgs(filters = {}, config = {}) {
64
105
  const speed = parseFloat(filters.speed);
65
106
  if (!isNaN(speed) && speed !== 1) {
66
107
  const clampedSpeed = Math.max(0.5, Math.min(2.0, speed));
67
- if (clampedSpeed < 1) {
68
- audioFilters.push(`atempo=${clampedSpeed}`);
69
- } else if (clampedSpeed <= 2) {
70
- audioFilters.push(`atempo=${clampedSpeed}`);
71
- }
108
+ audioFilters.push(`atempo=${clampedSpeed}`);
72
109
  }
73
110
 
74
111
  const pitch = parseFloat(filters.pitch);
@@ -185,18 +222,24 @@ function buildFfmpegArgs(filters = {}, config = {}) {
185
222
  audioFilters.push('loudnorm');
186
223
  }
187
224
 
225
+ if (filters.echo === 'true' || filters.echo === true) {
226
+ audioFilters.push('aecho=0.8:0.88:60:0.4');
227
+ }
228
+
229
+ if (filters.reverb === 'true' || filters.reverb === true) {
230
+ audioFilters.push('aecho=0.8:0.88:60:0.4,aecho=0.8:0.88:30:0.2');
231
+ }
232
+
188
233
  if (filters.nightcore === 'true' || filters.nightcore === true) {
189
- audioFilters.push('atempo=1.25');
190
- audioFilters.push('asetrate=48000*1.25,aresample=48000');
234
+ audioFilters.push('atempo=1.25,asetrate=48000*1.25,aresample=48000');
191
235
  }
192
236
 
193
237
  if (filters.vaporwave === 'true' || filters.vaporwave === true) {
194
- audioFilters.push('atempo=0.8');
195
- audioFilters.push('asetrate=48000*0.8,aresample=48000');
238
+ audioFilters.push('atempo=0.8,asetrate=48000*0.8,aresample=48000');
196
239
  }
197
240
 
198
241
  if (filters.bassboost === 'true' || filters.bassboost === true) {
199
- audioFilters.push('bass=g=10');
242
+ audioFilters.push('bass=g=10,equalizer=f=40:width_type=h:width=20:g=10');
200
243
  }
201
244
 
202
245
  if (filters['8d'] === 'true' || filters['8d'] === true) {
@@ -211,7 +254,14 @@ function buildFfmpegArgs(filters = {}, config = {}) {
211
254
  const format = config.audio?.format || 'opus';
212
255
 
213
256
  if (format === 'opus') {
214
- args.push('-acodec', 'libopus', '-b:a', bitrate, '-f', 'ogg');
257
+ args.push(
258
+ '-acodec', 'libopus',
259
+ '-b:a', bitrate,
260
+ '-vbr', config.audio?.vbr !== false ? 'on' : 'off',
261
+ '-compression_level', (config.audio?.compressionLevel ?? 10).toString(),
262
+ '-application', config.audio?.application || 'audio',
263
+ '-f', 'ogg'
264
+ );
215
265
  } else if (format === 'mp3') {
216
266
  args.push('-acodec', 'libmp3lame', '-b:a', bitrate, '-f', 'mp3');
217
267
  } else if (format === 'aac') {
@@ -255,6 +305,8 @@ function getAvailableFilters() {
255
305
  nightcore: { type: 'boolean', description: 'Nightcore preset' },
256
306
  vaporwave: { type: 'boolean', description: 'Vaporwave preset' },
257
307
  bassboost: { type: 'boolean', description: 'Bass boost preset' },
308
+ echo: { type: 'boolean', description: 'Echo effect' },
309
+ reverb: { type: 'boolean', description: 'Reverb effect' },
258
310
  '8d': { type: 'boolean', description: '8D audio effect' }
259
311
  };
260
312
  }
@@ -267,4 +319,61 @@ function getEQBands() {
267
319
  return EQ_BANDS;
268
320
  }
269
321
 
270
- module.exports = { buildFfmpegArgs, getAvailableFilters, getPresets, getEQBands, PRESETS, EQ_BANDS };
322
+ function getEffectPresets() {
323
+ return EFFECT_PRESETS;
324
+ }
325
+
326
+ function getEffectPresetsInfo() {
327
+ return Object.entries(EFFECT_PRESETS).map(([name, data]) => ({
328
+ name,
329
+ description: data.description,
330
+ filters: Object.keys(data.filters)
331
+ }));
332
+ }
333
+
334
+ function applyEffectPreset(name, intensity = 1.0) {
335
+ const preset = EFFECT_PRESETS[name];
336
+ if (!preset) return null;
337
+
338
+ const filters = {};
339
+ for (const [key, value] of Object.entries(preset.filters)) {
340
+ if (typeof value === 'number') {
341
+ if (key === 'speed' || key === 'pitch') {
342
+ // Scale relative to 1.0
343
+ filters[key] = 1.0 + (value - 1.0) * intensity;
344
+ } else {
345
+ filters[key] = value * intensity;
346
+ }
347
+ } else if (typeof value === 'boolean') {
348
+ filters[key] = value;
349
+ } else if (Array.isArray(value)) {
350
+ filters[key] = value.map(v => typeof v === 'number' ? v * intensity : v);
351
+ } else if (typeof value === 'object') {
352
+ filters[key] = { ...value };
353
+ for (const [k, v] of Object.entries(filters[key])) {
354
+ if (typeof v === 'number') {
355
+ if (k === 'speed' || k === 'pitch' || k === 'frequency') {
356
+ // Some frequency based ones might also need base values but speed/pitch are most critical
357
+ filters[key][k] = 1.0 + (v - 1.0) * intensity;
358
+ } else {
359
+ filters[key][k] = v * intensity;
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ return filters;
366
+ }
367
+
368
+ module.exports = {
369
+ buildFfmpegArgs,
370
+ getAvailableFilters,
371
+ getPresets,
372
+ getEQBands,
373
+ getEffectPresets,
374
+ getEffectPresetsInfo,
375
+ applyEffectPreset,
376
+ PRESETS,
377
+ EQ_BANDS,
378
+ EFFECT_PRESETS
379
+ };