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.
- package/.github/workflows/npm-publish-github-packages.yml +36 -0
- package/README.md +81 -7
- package/docs/configuration.md +31 -2
- package/docs/discord/manager.md +14 -7
- package/docs/discord/player.md +84 -1
- package/docs/filters.md +39 -1
- package/docs/http/endpoints.md +25 -0
- package/docs/http/server.md +2 -0
- package/docs/sources.md +52 -4
- package/index.d.ts +31 -1
- package/index.js +67 -3
- package/package.json +8 -6
- package/src/config.js +17 -1
- package/src/discord/Manager.js +101 -7
- package/src/discord/Player.js +274 -86
- package/src/discord/Stream.js +171 -135
- package/src/filters/ffmpeg.js +119 -14
- package/src/providers/bandcamp.js +49 -0
- package/src/providers/http.js +25 -0
- package/src/providers/local.js +31 -0
- package/src/providers/mixcloud.js +49 -0
- package/src/providers/soundcloud.js +5 -1
- package/src/providers/twitch.js +49 -0
- package/src/providers/youtube.js +26 -5
- package/src/server.js +60 -5
- package/src/utils/logger.js +34 -12
- package/tests/config.test.js +44 -0
- package/tests/ffmpeg.test.js +66 -0
- package/tests/http.test.js +24 -0
- package/tests/local.test.js +37 -0
- package/tests/queue.test.js +94 -0
- package/tests/twitch.test.js +42 -0
- package/tests/utils.test.js +60 -0
package/src/discord/Stream.js
CHANGED
|
@@ -21,7 +21,16 @@ class StreamController {
|
|
|
21
21
|
this.resource = null;
|
|
22
22
|
this.destroyed = false;
|
|
23
23
|
this.startTime = null;
|
|
24
|
-
this.
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
144
|
+
if (this.config.cookiesPath) {
|
|
145
|
+
ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
|
|
146
|
+
}
|
|
108
147
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
153
|
+
this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
|
|
154
|
+
ffmpegIn = 'pipe:0';
|
|
155
|
+
}
|
|
114
156
|
|
|
115
157
|
const ffmpegFilters = { ...this.filters };
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
this.
|
|
170
|
+
this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
|
|
171
|
+
this.metrics.spawn = Date.now() - spawnStart;
|
|
122
172
|
|
|
123
|
-
this.
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
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() -
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
249
|
+
};
|
|
231
250
|
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
resolved
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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 };
|
package/src/filters/ffmpeg.js
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 };
|