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.
- 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/cache/index.js +2 -1
- package/src/config.js +18 -1
- package/src/discord/Manager.js +101 -7
- package/src/discord/Player.js +281 -99
- package/src/discord/Stream.js +204 -137
- package/src/filters/ffmpeg.js +124 -15
- package/src/providers/bandcamp.js +49 -0
- package/src/providers/http.js +35 -0
- package/src/providers/local.js +36 -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 +58 -17
- package/src/server.js +60 -5
- package/src/utils/logger.js +34 -12
- package/tests/cache.test.js +234 -0
- package/tests/config.test.js +44 -0
- package/tests/error-handling.test.js +318 -0
- package/tests/ffmpeg.test.js +66 -0
- package/tests/filters-edge.test.js +333 -0
- package/tests/http.test.js +24 -0
- package/tests/integration.test.js +325 -0
- package/tests/local.test.js +37 -0
- package/tests/queue.test.js +94 -0
- package/tests/spotify.test.js +238 -0
- package/tests/stream.test.js +217 -0
- package/tests/twitch.test.js +42 -0
- package/tests/utils.test.js +60 -0
- package/tests/youtube.test.js +219 -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,250 @@ 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;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
let formatString;
|
|
115
|
+
if (isLive) {
|
|
116
|
+
formatString = 'bestaudio*/best';
|
|
117
|
+
} else {
|
|
118
|
+
formatString = this.config.ytdlp.format;
|
|
119
|
+
}
|
|
99
120
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
149
|
+
if (this.config.cookiesPath) {
|
|
150
|
+
ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
|
|
151
|
+
}
|
|
112
152
|
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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() -
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
+
if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
|
|
228
278
|
resolve();
|
|
229
279
|
}
|
|
230
|
-
}
|
|
280
|
+
};
|
|
231
281
|
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
reject(new Error('ffmpeg closed before producing data'));
|
|
238
|
-
}
|
|
239
|
-
});
|
|
290
|
+
if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
|
|
240
291
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
reject(new Error(`
|
|
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
|
|
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 };
|
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,8 +67,18 @@ function buildEqualizer(bands) {
|
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
function buildFfmpegArgs(filters = {}, config = {}) {
|
|
39
|
-
|
|
40
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
};
|