streamify-audio 2.2.1 → 2.2.5

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.
@@ -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,219 @@ 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;
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();
68
94
 
69
- let formatString;
70
- if (isLive) {
71
- formatString = 'bestaudio*/best';
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;
72
99
  } else {
73
- formatString = isYouTube ? '18/22/bestaudio[ext=webm]/bestaudio/best' : 'bestaudio/best';
74
- }
100
+ let url;
101
+ if (source === 'soundcloud') {
102
+ url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
103
+ } else if (['twitch', 'mixcloud', 'bandcamp', 'http'].includes(source)) {
104
+ url = this.track.uri || videoId;
105
+ } else {
106
+ url = `https://www.youtube.com/watch?v=${videoId}`;
107
+ }
75
108
 
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
- }
109
+ let formatString;
110
+ if (isLive) {
111
+ formatString = 'bestaudio*/best';
112
+ } else {
113
+ formatString = this.config.ytdlp.format;
114
+ }
93
115
 
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
- }
116
+ const ytdlpArgs = [
117
+ '-f', formatString,
118
+ '--no-playlist',
119
+ '--no-check-certificates',
120
+ '--no-warnings',
121
+ '--no-cache-dir',
122
+ '--no-mtime',
123
+ '--buffer-size', '16K',
124
+ '--quiet',
125
+ '--retries', '3',
126
+ '--fragment-retries', '3',
127
+ '-o', '-',
128
+ ...this.config.ytdlp.additionalArgs,
129
+ url
130
+ ];
131
+
132
+ if (isLive) {
133
+ ytdlpArgs.push('--no-live-from-start');
134
+ } else if (isYouTube) {
135
+ ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
136
+ }
99
137
 
100
- if (this.config.cookiesPath) {
101
- ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
102
- }
138
+ if (seekPosition > 0) {
139
+ const seekSeconds = Math.floor(seekPosition / 1000);
140
+ ytdlpArgs.push('--download-sections', `*${seekSeconds}-`);
141
+ log.info('STREAM', `Seeking to ${seekSeconds}s`);
142
+ }
103
143
 
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
- }
144
+ if (this.config.cookiesPath) {
145
+ ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
146
+ }
108
147
 
109
- this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, {
110
- env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
111
- });
148
+ if (this.config.sponsorblock?.enabled !== false && isYouTube) {
149
+ const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
150
+ ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
151
+ }
112
152
 
113
- this._spawnTime = Date.now() - this.startTime;
153
+ this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
154
+ ffmpegIn = 'pipe:0';
155
+ }
114
156
 
115
157
  const ffmpegFilters = { ...this.filters };
116
- if (seekPosition > 0) {
117
- ffmpegFilters.start = seekPosition / 1000;
158
+ const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
159
+
160
+ // If local, inject the input file before other args
161
+ if (isLocal) {
162
+ const filePath = this.track.absolutePath || videoId.replace('file://', '');
163
+ ffmpegArgs.unshift('-i', filePath);
164
+ if (seekPosition > 0) {
165
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
166
+ ffmpegArgs.unshift('-ss', seekSeconds);
167
+ }
118
168
  }
119
169
 
120
- const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
121
- this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs);
170
+ this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
171
+ this.metrics.spawn = Date.now() - spawnStart;
122
172
 
123
- this._firstDataTime = null;
124
- this.ytdlp.stdout.on('data', (chunk) => {
125
- if (!this._firstDataTime) {
126
- this._firstDataTime = Date.now() - this.startTime;
127
- }
128
- this.bytesReceived += chunk.length;
129
- });
173
+ if (this.ytdlp) {
174
+ this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
130
175
 
131
- this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
176
+ this.ytdlp.stderr.on('data', (data) => {
177
+ if (this.destroyed) return;
178
+ const msg = data.toString();
179
+ this.ytdlpError += msg;
180
+ if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
181
+ log.error('YTDLP', msg.trim());
182
+ } else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
183
+ log.debug('YTDLP', msg.trim());
184
+ }
185
+ });
132
186
 
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());
142
- }
143
- });
187
+ this.ytdlp.on('close', (code) => {
188
+ if (code !== 0 && code !== null && !this.destroyed) {
189
+ log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
190
+ }
191
+ if (this.ffmpeg && !this.ffmpeg.killed && this.ffmpeg.stdin) {
192
+ try { this.ffmpeg.stdin.end(); } catch(e) {}
193
+ }
194
+ });
195
+ }
144
196
 
145
197
  this.ffmpeg.stderr.on('data', (data) => {
146
198
  if (this.destroyed) return;
147
199
  const msg = data.toString();
200
+ this.ffmpegError += msg;
148
201
  if ((msg.includes('Error') || msg.includes('error')) && !msg.includes('Connection reset') && !msg.includes('Broken pipe')) {
149
202
  log.error('FFMPEG', msg.trim());
150
203
  }
151
204
  });
152
205
 
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
206
  await this._waitForData(isLive);
196
207
 
208
+ if (this.destroyed || !this.ffmpeg) {
209
+ throw new Error('Stream destroyed during initialization');
210
+ }
211
+
197
212
  this.resource = createAudioResource(this.ffmpeg.stdout, {
198
213
  inputType: StreamType.OggOpus,
199
214
  inlineVolume: false
200
215
  });
201
216
 
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}`);
217
+ const elapsed = Date.now() - startTimestamp;
218
+ this.metrics.total = elapsed;
219
+
220
+ log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms]`);
205
221
 
206
222
  return this.resource;
207
223
  }
208
224
 
209
225
  _waitForData(isLive = false) {
226
+ const ffmpeg = this.ffmpeg;
227
+ const ytdlp = this.ytdlp;
228
+
210
229
  return new Promise((resolve, reject) => {
230
+ if (!ffmpeg) return resolve();
231
+
211
232
  const timeoutMs = isLive ? 30000 : 15000;
212
233
  const timeout = setTimeout(() => {
213
- log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.bytesReceived}, isLive: ${isLive})`);
234
+ log.warn('STREAM', `Timeout waiting for data, proceeding anyway`);
214
235
  resolve();
215
236
  }, timeoutMs);
216
237
 
217
238
  let resolved = false;
218
239
 
219
- const checkInterval = setInterval(() => {
220
- if (resolved) {
221
- clearInterval(checkInterval);
222
- return;
223
- }
224
- if (this.bytesReceived > 0) {
240
+ // USE READABLE EVENT: Zero-consumption way to detect data
241
+ const onReadable = () => {
242
+ if (!resolved) {
225
243
  resolved = true;
226
244
  clearTimeout(timeout);
227
- clearInterval(checkInterval);
245
+ this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
246
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
228
247
  resolve();
229
248
  }
230
- }, 50);
249
+ };
231
250
 
232
- this.ffmpeg.on('close', () => {
251
+ if (ffmpeg.stdout) {
252
+ ffmpeg.stdout.on('readable', onReadable);
253
+ }
254
+
255
+ ffmpeg.on('close', () => {
233
256
  if (!resolved) {
234
257
  resolved = true;
235
258
  clearTimeout(timeout);
236
- clearInterval(checkInterval);
237
- reject(new Error('ffmpeg closed before producing data'));
259
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
260
+
261
+ if (this.destroyed) {
262
+ return reject(new Error('Stream destroyed during initialization'));
263
+ }
264
+
265
+ const sourceErr = ytdlp ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}` : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
266
+ reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
238
267
  }
239
268
  });
240
269
 
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}`));
247
- }
248
- });
270
+ if (ytdlp) {
271
+ ytdlp.on('close', (code) => {
272
+ if (!resolved && code !== 0 && code !== null) {
273
+ resolved = true;
274
+ clearTimeout(timeout);
275
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
276
+
277
+ if (this.destroyed) {
278
+ return reject(new Error('Stream destroyed during initialization'));
279
+ }
280
+
281
+ reject(new Error(`yt-dlp failed with code ${code}`));
282
+ }
283
+ });
284
+ }
249
285
  });
250
286
  }
251
287
 
@@ -254,7 +290,7 @@ class StreamController {
254
290
  this.destroyed = true;
255
291
 
256
292
  const elapsed = this.startTime ? Date.now() - this.startTime : 0;
257
- log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms | Received: ${this.bytesReceived}`);
293
+ log.info('STREAM', `Destroying stream | Duration: ${Math.floor(elapsed / 1000)}s`);
258
294
 
259
295
  try {
260
296
  if (this.ytdlp && this.ffmpeg) {
@@ -287,4 +323,4 @@ function createStream(track, filters, config) {
287
323
  return new StreamController(track, filters, config);
288
324
  }
289
325
 
290
- module.exports = { createStream, StreamController };
326
+ 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,7 +67,13 @@ function buildEqualizer(bands) {
36
67
  }
37
68
 
38
69
  function buildFfmpegArgs(filters = {}, config = {}) {
39
- const args = ['-i', 'pipe:0', '-vn'];
70
+ const args = [
71
+ '-thread_queue_size', '4096',
72
+ '-copyts',
73
+ '-start_at_zero',
74
+ '-i', 'pipe:0',
75
+ '-vn'
76
+ ];
40
77
  const audioFilters = [];
41
78
 
42
79
  if (filters.equalizer && Array.isArray(filters.equalizer)) {
@@ -64,11 +101,7 @@ function buildFfmpegArgs(filters = {}, config = {}) {
64
101
  const speed = parseFloat(filters.speed);
65
102
  if (!isNaN(speed) && speed !== 1) {
66
103
  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
- }
104
+ audioFilters.push(`atempo=${clampedSpeed}`);
72
105
  }
73
106
 
74
107
  const pitch = parseFloat(filters.pitch);
@@ -185,18 +218,24 @@ function buildFfmpegArgs(filters = {}, config = {}) {
185
218
  audioFilters.push('loudnorm');
186
219
  }
187
220
 
221
+ if (filters.echo === 'true' || filters.echo === true) {
222
+ audioFilters.push('aecho=0.8:0.88:60:0.4');
223
+ }
224
+
225
+ if (filters.reverb === 'true' || filters.reverb === true) {
226
+ audioFilters.push('aecho=0.8:0.88:60:0.4,aecho=0.8:0.88:30:0.2');
227
+ }
228
+
188
229
  if (filters.nightcore === 'true' || filters.nightcore === true) {
189
- audioFilters.push('atempo=1.25');
190
- audioFilters.push('asetrate=48000*1.25,aresample=48000');
230
+ audioFilters.push('atempo=1.25,asetrate=48000*1.25,aresample=48000');
191
231
  }
192
232
 
193
233
  if (filters.vaporwave === 'true' || filters.vaporwave === true) {
194
- audioFilters.push('atempo=0.8');
195
- audioFilters.push('asetrate=48000*0.8,aresample=48000');
234
+ audioFilters.push('atempo=0.8,asetrate=48000*0.8,aresample=48000');
196
235
  }
197
236
 
198
237
  if (filters.bassboost === 'true' || filters.bassboost === true) {
199
- audioFilters.push('bass=g=10');
238
+ audioFilters.push('bass=g=10,equalizer=f=40:width_type=h:width=20:g=10');
200
239
  }
201
240
 
202
241
  if (filters['8d'] === 'true' || filters['8d'] === true) {
@@ -211,7 +250,14 @@ function buildFfmpegArgs(filters = {}, config = {}) {
211
250
  const format = config.audio?.format || 'opus';
212
251
 
213
252
  if (format === 'opus') {
214
- args.push('-acodec', 'libopus', '-b:a', bitrate, '-f', 'ogg');
253
+ args.push(
254
+ '-acodec', 'libopus',
255
+ '-b:a', bitrate,
256
+ '-vbr', config.audio?.vbr !== false ? 'on' : 'off',
257
+ '-compression_level', (config.audio?.compressionLevel ?? 10).toString(),
258
+ '-application', config.audio?.application || 'audio',
259
+ '-f', 'ogg'
260
+ );
215
261
  } else if (format === 'mp3') {
216
262
  args.push('-acodec', 'libmp3lame', '-b:a', bitrate, '-f', 'mp3');
217
263
  } else if (format === 'aac') {
@@ -255,6 +301,8 @@ function getAvailableFilters() {
255
301
  nightcore: { type: 'boolean', description: 'Nightcore preset' },
256
302
  vaporwave: { type: 'boolean', description: 'Vaporwave preset' },
257
303
  bassboost: { type: 'boolean', description: 'Bass boost preset' },
304
+ echo: { type: 'boolean', description: 'Echo effect' },
305
+ reverb: { type: 'boolean', description: 'Reverb effect' },
258
306
  '8d': { type: 'boolean', description: '8D audio effect' }
259
307
  };
260
308
  }
@@ -267,4 +315,61 @@ function getEQBands() {
267
315
  return EQ_BANDS;
268
316
  }
269
317
 
270
- module.exports = { buildFfmpegArgs, getAvailableFilters, getPresets, getEQBands, PRESETS, EQ_BANDS };
318
+ function getEffectPresets() {
319
+ return EFFECT_PRESETS;
320
+ }
321
+
322
+ function getEffectPresetsInfo() {
323
+ return Object.entries(EFFECT_PRESETS).map(([name, data]) => ({
324
+ name,
325
+ description: data.description,
326
+ filters: Object.keys(data.filters)
327
+ }));
328
+ }
329
+
330
+ function applyEffectPreset(name, intensity = 1.0) {
331
+ const preset = EFFECT_PRESETS[name];
332
+ if (!preset) return null;
333
+
334
+ const filters = {};
335
+ for (const [key, value] of Object.entries(preset.filters)) {
336
+ if (typeof value === 'number') {
337
+ if (key === 'speed' || key === 'pitch') {
338
+ // Scale relative to 1.0
339
+ filters[key] = 1.0 + (value - 1.0) * intensity;
340
+ } else {
341
+ filters[key] = value * intensity;
342
+ }
343
+ } else if (typeof value === 'boolean') {
344
+ filters[key] = value;
345
+ } else if (Array.isArray(value)) {
346
+ filters[key] = value.map(v => typeof v === 'number' ? v * intensity : v);
347
+ } else if (typeof value === 'object') {
348
+ filters[key] = { ...value };
349
+ for (const [k, v] of Object.entries(filters[key])) {
350
+ if (typeof v === 'number') {
351
+ if (k === 'speed' || k === 'pitch' || k === 'frequency') {
352
+ // Some frequency based ones might also need base values but speed/pitch are most critical
353
+ filters[key][k] = 1.0 + (v - 1.0) * intensity;
354
+ } else {
355
+ filters[key][k] = v * intensity;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ return filters;
362
+ }
363
+
364
+ module.exports = {
365
+ buildFfmpegArgs,
366
+ getAvailableFilters,
367
+ getPresets,
368
+ getEQBands,
369
+ getEffectPresets,
370
+ getEffectPresetsInfo,
371
+ applyEffectPreset,
372
+ PRESETS,
373
+ EQ_BANDS,
374
+ EFFECT_PRESETS
375
+ };
@@ -0,0 +1,49 @@
1
+ const log = require('../utils/logger');
2
+ const { spawn } = require('child_process');
3
+
4
+ async function getInfo(url, config) {
5
+ log.info('BANDCAMP', `Getting info: ${url}`);
6
+
7
+ return new Promise((resolve, reject) => {
8
+ const args = [
9
+ '-q', '--no-warnings',
10
+ '--skip-download',
11
+ '-J',
12
+ url
13
+ ];
14
+
15
+ const proc = spawn(config.ytdlpPath, args, {
16
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
17
+ });
18
+
19
+ let stdout = '';
20
+ let stderr = '';
21
+
22
+ proc.stdout.on('data', (data) => { stdout += data; });
23
+ proc.stderr.on('data', (data) => { stderr += data; });
24
+
25
+ proc.on('close', (code) => {
26
+ if (code !== 0) {
27
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
28
+ }
29
+ try {
30
+ const data = JSON.parse(stdout);
31
+ resolve({
32
+ id: data.id,
33
+ title: data.title,
34
+ duration: data.duration || 0,
35
+ author: data.uploader || data.artist,
36
+ thumbnail: data.thumbnail,
37
+ uri: data.webpage_url,
38
+ streamUrl: `/bandcamp/stream/${Buffer.from(url).toString('base64')}`,
39
+ source: 'bandcamp',
40
+ isLive: false
41
+ });
42
+ } catch (e) {
43
+ reject(new Error('Failed to parse Bandcamp data'));
44
+ }
45
+ });
46
+ });
47
+ }
48
+
49
+ module.exports = { getInfo };
@@ -0,0 +1,25 @@
1
+ const log = require('../utils/logger');
2
+
3
+ async function getInfo(url, config) {
4
+ log.info('HTTP', `Getting info for direct URL: ${url}`);
5
+
6
+ // For direct URLs, we can't easily get metadata without downloading or using ffprobe
7
+ // For now, we'll return a basic object.
8
+ // In a full implementation, we might use ffprobe here.
9
+
10
+ const filename = url.split('/').pop().split('?')[0] || 'Direct Audio';
11
+
12
+ return {
13
+ id: Buffer.from(url).toString('base64'),
14
+ title: filename,
15
+ duration: 0,
16
+ author: 'Direct Link',
17
+ thumbnail: null,
18
+ uri: url,
19
+ streamUrl: `/http/stream/${Buffer.from(url).toString('base64')}`,
20
+ source: 'http',
21
+ isLive: true // Treat as live/unknown duration
22
+ };
23
+ }
24
+
25
+ module.exports = { getInfo };