streamify-audio 2.0.0
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/LICENSE +21 -0
- package/README.md +430 -0
- package/bin/streamify.js +84 -0
- package/docs/README.md +31 -0
- package/docs/automation.md +186 -0
- package/docs/configuration.md +169 -0
- package/docs/discord/events.md +206 -0
- package/docs/discord/manager.md +180 -0
- package/docs/discord/player.md +180 -0
- package/docs/discord/queue.md +197 -0
- package/docs/examples/advanced-bot.md +391 -0
- package/docs/examples/basic-bot.md +182 -0
- package/docs/examples/lavalink.md +156 -0
- package/docs/filters.md +309 -0
- package/docs/http/endpoints.md +199 -0
- package/docs/http/server.md +172 -0
- package/docs/quick-start.md +92 -0
- package/docs/sources.md +141 -0
- package/docs/sponsorblock.md +95 -0
- package/index.d.ts +350 -0
- package/index.js +252 -0
- package/package.json +57 -0
- package/silence.ogg +0 -0
- package/src/cache/index.js +61 -0
- package/src/config.js +133 -0
- package/src/discord/Manager.js +453 -0
- package/src/discord/Player.js +658 -0
- package/src/discord/Queue.js +129 -0
- package/src/discord/Stream.js +252 -0
- package/src/filters/ffmpeg.js +270 -0
- package/src/providers/soundcloud.js +166 -0
- package/src/providers/spotify.js +216 -0
- package/src/providers/youtube.js +320 -0
- package/src/server.js +318 -0
- package/src/utils/logger.js +139 -0
- package/src/utils/stream.js +108 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
class Queue {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.tracks = [];
|
|
4
|
+
this.previous = [];
|
|
5
|
+
this.current = null;
|
|
6
|
+
this.maxPreviousTracks = options.maxPreviousTracks || 25;
|
|
7
|
+
this.repeatMode = 'off';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
add(track, position) {
|
|
11
|
+
if (position !== undefined && position >= 0 && position < this.tracks.length) {
|
|
12
|
+
this.tracks.splice(position, 0, track);
|
|
13
|
+
} else {
|
|
14
|
+
this.tracks.push(track);
|
|
15
|
+
}
|
|
16
|
+
return this.tracks.length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
addMany(tracks, position) {
|
|
20
|
+
if (position !== undefined && position >= 0 && position < this.tracks.length) {
|
|
21
|
+
this.tracks.splice(position, 0, ...tracks);
|
|
22
|
+
} else {
|
|
23
|
+
this.tracks.push(...tracks);
|
|
24
|
+
}
|
|
25
|
+
return this.tracks.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
remove(index) {
|
|
29
|
+
if (index < 0 || index >= this.tracks.length) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return this.tracks.splice(index, 1)[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clear() {
|
|
36
|
+
const cleared = this.tracks.length;
|
|
37
|
+
this.tracks = [];
|
|
38
|
+
return cleared;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
shuffle() {
|
|
42
|
+
for (let i = this.tracks.length - 1; i > 0; i--) {
|
|
43
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
44
|
+
[this.tracks[i], this.tracks[j]] = [this.tracks[j], this.tracks[i]];
|
|
45
|
+
}
|
|
46
|
+
return this.tracks.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
move(from, to) {
|
|
50
|
+
if (from < 0 || from >= this.tracks.length) return false;
|
|
51
|
+
if (to < 0 || to >= this.tracks.length) return false;
|
|
52
|
+
|
|
53
|
+
const track = this.tracks.splice(from, 1)[0];
|
|
54
|
+
this.tracks.splice(to, 0, track);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
shift() {
|
|
59
|
+
if (this.current) {
|
|
60
|
+
this.previous.unshift(this.current);
|
|
61
|
+
if (this.previous.length > this.maxPreviousTracks) {
|
|
62
|
+
this.previous.pop();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this.repeatMode === 'track' && this.current) {
|
|
67
|
+
return this.current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.repeatMode === 'queue' && this.tracks.length === 0 && this.previous.length > 0) {
|
|
71
|
+
this.tracks = [...this.previous].reverse();
|
|
72
|
+
this.previous = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.current = this.tracks.shift() || null;
|
|
76
|
+
return this.current;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
unshift() {
|
|
80
|
+
if (this.previous.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.current) {
|
|
85
|
+
this.tracks.unshift(this.current);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.current = this.previous.shift();
|
|
89
|
+
return this.current;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setCurrent(track) {
|
|
93
|
+
this.current = track;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setRepeatMode(mode) {
|
|
97
|
+
if (['off', 'track', 'queue'].includes(mode)) {
|
|
98
|
+
this.repeatMode = mode;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get size() {
|
|
105
|
+
return this.tracks.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get isEmpty() {
|
|
109
|
+
return this.tracks.length === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get totalDuration() {
|
|
113
|
+
let duration = this.current?.duration || 0;
|
|
114
|
+
duration += this.tracks.reduce((sum, track) => sum + (track.duration || 0), 0);
|
|
115
|
+
return duration;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
toJSON() {
|
|
119
|
+
return {
|
|
120
|
+
current: this.current,
|
|
121
|
+
tracks: this.tracks,
|
|
122
|
+
previous: this.previous,
|
|
123
|
+
repeatMode: this.repeatMode,
|
|
124
|
+
size: this.size
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = Queue;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { createAudioResource, StreamType } = require('@discordjs/voice');
|
|
3
|
+
const { buildFfmpegArgs } = require('../filters/ffmpeg');
|
|
4
|
+
const log = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
class StreamController {
|
|
7
|
+
constructor(track, filters, config) {
|
|
8
|
+
this.track = track;
|
|
9
|
+
this.filters = filters || {};
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.ytdlp = null;
|
|
12
|
+
this.ffmpeg = null;
|
|
13
|
+
this.resource = null;
|
|
14
|
+
this.destroyed = false;
|
|
15
|
+
this.startTime = null;
|
|
16
|
+
this.bytesReceived = 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async create(seekPosition = 0) {
|
|
20
|
+
if (this.destroyed) {
|
|
21
|
+
throw new Error('Stream already destroyed');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (this.resource) {
|
|
25
|
+
return this.resource;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.startTime = Date.now();
|
|
29
|
+
const videoId = this.track._resolvedId || this.track.id;
|
|
30
|
+
const source = this.track.source || 'youtube';
|
|
31
|
+
|
|
32
|
+
log.info('STREAM', `Creating stream for ${videoId} (${source})`);
|
|
33
|
+
|
|
34
|
+
let url;
|
|
35
|
+
if (source === 'soundcloud') {
|
|
36
|
+
url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
|
|
37
|
+
} else {
|
|
38
|
+
url = `https://www.youtube.com/watch?v=${videoId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isYouTube = source === 'youtube' || source === 'spotify';
|
|
42
|
+
const ytdlpArgs = [
|
|
43
|
+
'-f', isYouTube ? '18/22/bestaudio[ext=webm]/bestaudio/best' : 'bestaudio/best',
|
|
44
|
+
'--no-playlist',
|
|
45
|
+
'--no-check-certificates',
|
|
46
|
+
'--no-warnings',
|
|
47
|
+
'--retries', '3',
|
|
48
|
+
'--fragment-retries', '3',
|
|
49
|
+
'-o', '-',
|
|
50
|
+
url
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
if (isYouTube) {
|
|
54
|
+
ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (seekPosition > 0) {
|
|
58
|
+
const seekSeconds = Math.floor(seekPosition / 1000);
|
|
59
|
+
ytdlpArgs.push('--download-sections', `*${seekSeconds}-`);
|
|
60
|
+
log.info('STREAM', `Seeking to ${seekSeconds}s`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.config.cookiesPath) {
|
|
64
|
+
ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.config.sponsorblock?.enabled !== false && isYouTube) {
|
|
68
|
+
const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
|
|
69
|
+
ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, {
|
|
73
|
+
env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this._spawnTime = Date.now() - this.startTime;
|
|
77
|
+
|
|
78
|
+
const ffmpegFilters = { ...this.filters };
|
|
79
|
+
if (seekPosition > 0) {
|
|
80
|
+
ffmpegFilters.start = seekPosition / 1000;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
|
|
84
|
+
this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs);
|
|
85
|
+
|
|
86
|
+
this._firstDataTime = null;
|
|
87
|
+
this.ytdlp.stdout.on('data', (chunk) => {
|
|
88
|
+
if (!this._firstDataTime) {
|
|
89
|
+
this._firstDataTime = Date.now() - this.startTime;
|
|
90
|
+
}
|
|
91
|
+
this.bytesReceived += chunk.length;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
|
|
95
|
+
|
|
96
|
+
let ytdlpError = '';
|
|
97
|
+
this.ytdlp.stderr.on('data', (data) => {
|
|
98
|
+
if (this.destroyed) return;
|
|
99
|
+
const msg = data.toString();
|
|
100
|
+
ytdlpError += msg;
|
|
101
|
+
if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
|
|
102
|
+
log.error('YTDLP', msg.trim());
|
|
103
|
+
} else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
|
|
104
|
+
log.debug('YTDLP', msg.trim());
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.ffmpeg.stderr.on('data', (data) => {
|
|
109
|
+
if (this.destroyed) return;
|
|
110
|
+
const msg = data.toString();
|
|
111
|
+
if ((msg.includes('Error') || msg.includes('error')) && !msg.includes('Connection reset') && !msg.includes('Broken pipe')) {
|
|
112
|
+
log.error('FFMPEG', msg.trim());
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.ytdlp.on('close', (code) => {
|
|
117
|
+
if (code !== 0 && code !== null && !this.destroyed) {
|
|
118
|
+
log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
|
|
119
|
+
if (ytdlpError) {
|
|
120
|
+
log.error('STREAM', `yt-dlp stderr: ${ytdlpError.slice(-500)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (this.ffmpeg && !this.ffmpeg.killed) {
|
|
124
|
+
this.ffmpeg.stdin.end();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
this.ytdlp.on('error', (error) => {
|
|
129
|
+
if (error.code !== 'EPIPE' && !this.destroyed) {
|
|
130
|
+
log.error('STREAM', `yt-dlp spawn error: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.ffmpeg.on('error', (error) => {
|
|
135
|
+
if (error.code !== 'EPIPE' && !this.destroyed) {
|
|
136
|
+
log.error('STREAM', `ffmpeg spawn error: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this.ytdlp.stdout.on('error', (error) => {
|
|
141
|
+
if (error.code !== 'EPIPE' && !this.destroyed) {
|
|
142
|
+
log.debug('STREAM', `yt-dlp stdout error: ${error.message}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this.ffmpeg.stdin.on('error', (error) => {
|
|
147
|
+
if (error.code !== 'EPIPE' && !this.destroyed) {
|
|
148
|
+
log.debug('STREAM', `ffmpeg stdin error: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.ffmpeg.stdout.on('error', (error) => {
|
|
153
|
+
if (error.code !== 'EPIPE' && !this.destroyed) {
|
|
154
|
+
log.debug('STREAM', `ffmpeg stdout error: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await this._waitForData();
|
|
159
|
+
|
|
160
|
+
this.resource = createAudioResource(this.ffmpeg.stdout, {
|
|
161
|
+
inputType: StreamType.OggOpus,
|
|
162
|
+
inlineVolume: false
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const elapsed = Date.now() - this.startTime;
|
|
166
|
+
const firstData = this._firstDataTime || elapsed;
|
|
167
|
+
log.info('STREAM', `Ready ${elapsed}ms | First byte: ${firstData}ms | Buffered: ${this.bytesReceived}`);
|
|
168
|
+
|
|
169
|
+
return this.resource;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_waitForData() {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const timeout = setTimeout(() => {
|
|
175
|
+
log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.bytesReceived})`);
|
|
176
|
+
resolve();
|
|
177
|
+
}, 15000);
|
|
178
|
+
|
|
179
|
+
let resolved = false;
|
|
180
|
+
|
|
181
|
+
const checkInterval = setInterval(() => {
|
|
182
|
+
if (resolved) {
|
|
183
|
+
clearInterval(checkInterval);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (this.bytesReceived > 0) {
|
|
187
|
+
resolved = true;
|
|
188
|
+
clearTimeout(timeout);
|
|
189
|
+
clearInterval(checkInterval);
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
}, 50);
|
|
193
|
+
|
|
194
|
+
this.ffmpeg.on('close', () => {
|
|
195
|
+
if (!resolved) {
|
|
196
|
+
resolved = true;
|
|
197
|
+
clearTimeout(timeout);
|
|
198
|
+
clearInterval(checkInterval);
|
|
199
|
+
reject(new Error('ffmpeg closed before producing data'));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.ytdlp.on('close', (code) => {
|
|
204
|
+
if (!resolved && code !== 0) {
|
|
205
|
+
resolved = true;
|
|
206
|
+
clearTimeout(timeout);
|
|
207
|
+
clearInterval(checkInterval);
|
|
208
|
+
reject(new Error(`yt-dlp failed with code ${code}`));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
destroy() {
|
|
215
|
+
if (this.destroyed) return;
|
|
216
|
+
this.destroyed = true;
|
|
217
|
+
|
|
218
|
+
const elapsed = this.startTime ? Date.now() - this.startTime : 0;
|
|
219
|
+
log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms | Received: ${this.bytesReceived}`);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (this.ytdlp && this.ffmpeg) {
|
|
223
|
+
this.ytdlp.stdout.unpipe(this.ffmpeg.stdin);
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {}
|
|
226
|
+
|
|
227
|
+
if (this.ytdlp && !this.ytdlp.killed) {
|
|
228
|
+
try {
|
|
229
|
+
this.ytdlp.stdout.destroy();
|
|
230
|
+
this.ytdlp.kill('SIGKILL');
|
|
231
|
+
} catch (e) {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (this.ffmpeg && !this.ffmpeg.killed) {
|
|
235
|
+
try {
|
|
236
|
+
this.ffmpeg.stdin.destroy();
|
|
237
|
+
this.ffmpeg.stdout.destroy();
|
|
238
|
+
this.ffmpeg.kill('SIGKILL');
|
|
239
|
+
} catch (e) {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.ytdlp = null;
|
|
243
|
+
this.ffmpeg = null;
|
|
244
|
+
this.resource = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createStream(track, filters, config) {
|
|
249
|
+
return new StreamController(track, filters, config);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { createStream, StreamController };
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const EQ_BANDS = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000];
|
|
2
|
+
|
|
3
|
+
const PRESETS = {
|
|
4
|
+
flat: Array(15).fill(0),
|
|
5
|
+
rock: [0.3, 0.25, 0.2, 0.1, -0.05, -0.1, 0.1, 0.25, 0.35, 0.4, 0.4, 0.35, 0.3, 0.25, 0.2],
|
|
6
|
+
pop: [0.2, 0.35, 0.4, 0.35, 0.2, 0, -0.1, -0.1, 0, 0.15, 0.2, 0.25, 0.3, 0.35, 0.35],
|
|
7
|
+
jazz: [0.2, 0.15, 0.1, 0, -0.1, -0.1, 0, 0.1, 0.2, 0.25, 0.25, 0.2, 0.15, 0.1, 0.1],
|
|
8
|
+
classical: [0.3, 0.25, 0.2, 0.15, 0.1, 0, -0.1, -0.1, 0, 0.1, 0.2, 0.25, 0.3, 0.35, 0.35],
|
|
9
|
+
electronic: [0.4, 0.35, 0.25, 0, -0.1, -0.15, 0, 0.1, 0.2, 0.3, 0.35, 0.4, 0.35, 0.3, 0.25],
|
|
10
|
+
hiphop: [0.4, 0.35, 0.3, 0.2, 0.1, 0, -0.1, -0.1, 0, 0.15, 0.2, 0.15, 0.1, 0.1, 0.15],
|
|
11
|
+
acoustic: [0.3, 0.25, 0.2, 0.15, 0.1, 0.1, 0.15, 0.2, 0.2, 0.15, 0.1, 0.1, 0.15, 0.2, 0.25],
|
|
12
|
+
rnb: [0.35, 0.4, 0.35, 0.2, 0.05, -0.05, 0, 0.1, 0.15, 0.15, 0.1, 0.05, 0.1, 0.15, 0.2],
|
|
13
|
+
latin: [0.25, 0.2, 0.1, 0, 0, 0, 0, 0.1, 0.2, 0.3, 0.35, 0.35, 0.3, 0.25, 0.2],
|
|
14
|
+
loudness: [0.4, 0.35, 0.25, 0.1, 0, -0.1, -0.1, 0, 0.1, 0.2, 0.3, 0.35, 0.4, 0.45, 0.45],
|
|
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
|
+
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
|
+
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]
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function buildEqualizer(bands) {
|
|
22
|
+
if (!bands || !Array.isArray(bands)) return null;
|
|
23
|
+
|
|
24
|
+
const eqParts = [];
|
|
25
|
+
for (let i = 0; i < Math.min(bands.length, 15); i++) {
|
|
26
|
+
const gain = bands[i];
|
|
27
|
+
if (typeof gain === 'number' && gain !== 0) {
|
|
28
|
+
const clampedGain = Math.max(-0.25, Math.min(1.0, gain));
|
|
29
|
+
const freq = EQ_BANDS[i];
|
|
30
|
+
const width = freq < 1000 ? freq * 0.5 : freq * 0.3;
|
|
31
|
+
eqParts.push(`equalizer=f=${freq}:width_type=h:width=${width}:g=${clampedGain * 12}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return eqParts.length > 0 ? eqParts.join(',') : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildFfmpegArgs(filters = {}, config = {}) {
|
|
39
|
+
const args = ['-i', 'pipe:0', '-vn'];
|
|
40
|
+
const audioFilters = [];
|
|
41
|
+
|
|
42
|
+
if (filters.equalizer && Array.isArray(filters.equalizer)) {
|
|
43
|
+
const eq = buildEqualizer(filters.equalizer);
|
|
44
|
+
if (eq) audioFilters.push(eq);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (filters.preset && PRESETS[filters.preset]) {
|
|
48
|
+
const eq = buildEqualizer(PRESETS[filters.preset]);
|
|
49
|
+
if (eq) audioFilters.push(eq);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const bass = parseFloat(filters.bass);
|
|
53
|
+
if (!isNaN(bass) && bass !== 0) {
|
|
54
|
+
const clampedBass = Math.max(-20, Math.min(20, bass));
|
|
55
|
+
audioFilters.push(`bass=g=${clampedBass}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const treble = parseFloat(filters.treble);
|
|
59
|
+
if (!isNaN(treble) && treble !== 0) {
|
|
60
|
+
const clampedTreble = Math.max(-20, Math.min(20, treble));
|
|
61
|
+
audioFilters.push(`treble=g=${clampedTreble}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const speed = parseFloat(filters.speed);
|
|
65
|
+
if (!isNaN(speed) && speed !== 1) {
|
|
66
|
+
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
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pitch = parseFloat(filters.pitch);
|
|
75
|
+
if (!isNaN(pitch) && pitch !== 1) {
|
|
76
|
+
const clampedPitch = Math.max(0.5, Math.min(2.0, pitch));
|
|
77
|
+
audioFilters.push(`asetrate=48000*${clampedPitch},aresample=48000`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const volume = parseFloat(filters.volume);
|
|
81
|
+
if (!isNaN(volume) && volume !== 100) {
|
|
82
|
+
const clampedVolume = Math.max(0, Math.min(200, volume));
|
|
83
|
+
audioFilters.push(`volume=${clampedVolume / 100}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (filters.tremolo) {
|
|
87
|
+
const freq = parseFloat(filters.tremolo.frequency) || 4;
|
|
88
|
+
const depth = parseFloat(filters.tremolo.depth) || 0.5;
|
|
89
|
+
const clampedFreq = Math.max(0.1, Math.min(20, freq));
|
|
90
|
+
const clampedDepth = Math.max(0, Math.min(1, depth));
|
|
91
|
+
audioFilters.push(`tremolo=f=${clampedFreq}:d=${clampedDepth}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (filters.vibrato) {
|
|
95
|
+
const freq = parseFloat(filters.vibrato.frequency) || 4;
|
|
96
|
+
const depth = parseFloat(filters.vibrato.depth) || 0.5;
|
|
97
|
+
const clampedFreq = Math.max(0.1, Math.min(14, freq));
|
|
98
|
+
const clampedDepth = Math.max(0, Math.min(1, depth));
|
|
99
|
+
audioFilters.push(`vibrato=f=${clampedFreq}:d=${clampedDepth}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (filters.rotation) {
|
|
103
|
+
const rotSpeed = parseFloat(filters.rotation.speed) || 0.125;
|
|
104
|
+
const clampedSpeed = Math.max(0.01, Math.min(5, rotSpeed));
|
|
105
|
+
audioFilters.push(`apulsator=mode=sine:hz=${clampedSpeed}:width=1`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (filters.lowpass) {
|
|
109
|
+
const freq = parseFloat(filters.lowpass);
|
|
110
|
+
if (!isNaN(freq)) {
|
|
111
|
+
const clampedFreq = Math.max(100, Math.min(20000, freq));
|
|
112
|
+
audioFilters.push(`lowpass=f=${clampedFreq}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (filters.highpass) {
|
|
117
|
+
const freq = parseFloat(filters.highpass);
|
|
118
|
+
if (!isNaN(freq)) {
|
|
119
|
+
const clampedFreq = Math.max(20, Math.min(10000, freq));
|
|
120
|
+
audioFilters.push(`highpass=f=${clampedFreq}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (filters.bandpass) {
|
|
125
|
+
const freq = parseFloat(filters.bandpass.frequency) || 1000;
|
|
126
|
+
const width = parseFloat(filters.bandpass.width) || 200;
|
|
127
|
+
audioFilters.push(`bandpass=f=${freq}:width_type=h:width=${width}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (filters.bandreject || filters.notch) {
|
|
131
|
+
const opt = filters.bandreject || filters.notch;
|
|
132
|
+
const freq = parseFloat(opt.frequency) || 1000;
|
|
133
|
+
const width = parseFloat(opt.width) || 200;
|
|
134
|
+
audioFilters.push(`bandreject=f=${freq}:width_type=h:width=${width}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (filters.lowshelf) {
|
|
138
|
+
const freq = parseFloat(filters.lowshelf.frequency) || 200;
|
|
139
|
+
const gain = parseFloat(filters.lowshelf.gain) || 0;
|
|
140
|
+
audioFilters.push(`lowshelf=f=${freq}:g=${gain}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (filters.highshelf) {
|
|
144
|
+
const freq = parseFloat(filters.highshelf.frequency) || 3000;
|
|
145
|
+
const gain = parseFloat(filters.highshelf.gain) || 0;
|
|
146
|
+
audioFilters.push(`highshelf=f=${freq}:g=${gain}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (filters.peaking) {
|
|
150
|
+
const freq = parseFloat(filters.peaking.frequency) || 1000;
|
|
151
|
+
const gain = parseFloat(filters.peaking.gain) || 0;
|
|
152
|
+
const q = parseFloat(filters.peaking.q) || 1;
|
|
153
|
+
audioFilters.push(`equalizer=f=${freq}:width_type=q:width=${q}:g=${gain}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (filters.karaoke === 'true' || filters.karaoke === true) {
|
|
157
|
+
audioFilters.push('pan=stereo|c0=c0-c1|c1=c1-c0');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (filters.mono === 'true' || filters.mono === true) {
|
|
161
|
+
audioFilters.push('pan=mono|c0=0.5*c0+0.5*c1');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (filters.surround === 'true' || filters.surround === true) {
|
|
165
|
+
audioFilters.push('surround');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (filters.flanger === 'true' || filters.flanger === true) {
|
|
169
|
+
audioFilters.push('flanger');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (filters.phaser === 'true' || filters.phaser === true) {
|
|
173
|
+
audioFilters.push('aphaser');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (filters.chorus === 'true' || filters.chorus === true) {
|
|
177
|
+
audioFilters.push('chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (filters.compressor === 'true' || filters.compressor === true) {
|
|
181
|
+
audioFilters.push('acompressor=threshold=-20dB:ratio=4:attack=5:release=50');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (filters.normalizer === 'true' || filters.normalizer === true) {
|
|
185
|
+
audioFilters.push('loudnorm');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (filters.nightcore === 'true' || filters.nightcore === true) {
|
|
189
|
+
audioFilters.push('atempo=1.25');
|
|
190
|
+
audioFilters.push('asetrate=48000*1.25,aresample=48000');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (filters.vaporwave === 'true' || filters.vaporwave === true) {
|
|
194
|
+
audioFilters.push('atempo=0.8');
|
|
195
|
+
audioFilters.push('asetrate=48000*0.8,aresample=48000');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (filters.bassboost === 'true' || filters.bassboost === true) {
|
|
199
|
+
audioFilters.push('bass=g=10');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (filters['8d'] === 'true' || filters['8d'] === true) {
|
|
203
|
+
audioFilters.push('apulsator=mode=sine:hz=0.125');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (audioFilters.length > 0) {
|
|
207
|
+
args.push('-af', audioFilters.join(','));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const bitrate = config.audio?.bitrate || '128k';
|
|
211
|
+
const format = config.audio?.format || 'opus';
|
|
212
|
+
|
|
213
|
+
if (format === 'opus') {
|
|
214
|
+
args.push('-acodec', 'libopus', '-b:a', bitrate, '-f', 'ogg');
|
|
215
|
+
} else if (format === 'mp3') {
|
|
216
|
+
args.push('-acodec', 'libmp3lame', '-b:a', bitrate, '-f', 'mp3');
|
|
217
|
+
} else if (format === 'aac') {
|
|
218
|
+
args.push('-acodec', 'aac', '-b:a', bitrate, '-f', 'adts');
|
|
219
|
+
} else {
|
|
220
|
+
args.push('-acodec', 'libopus', '-b:a', bitrate, '-f', 'ogg');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
args.push('-');
|
|
224
|
+
|
|
225
|
+
return args;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getAvailableFilters() {
|
|
229
|
+
return {
|
|
230
|
+
bass: { type: 'number', min: -20, max: 20, description: 'Bass boost/cut in dB' },
|
|
231
|
+
treble: { type: 'number', min: -20, max: 20, description: 'Treble boost/cut in dB' },
|
|
232
|
+
speed: { type: 'number', min: 0.5, max: 2.0, description: 'Playback speed multiplier' },
|
|
233
|
+
pitch: { type: 'number', min: 0.5, max: 2.0, description: 'Pitch shift multiplier' },
|
|
234
|
+
volume: { type: 'number', min: 0, max: 200, description: 'Volume percentage' },
|
|
235
|
+
equalizer: { type: 'array', length: 15, itemType: 'number', min: -0.25, max: 1.0, description: '15-band equalizer (bands 0-14)' },
|
|
236
|
+
preset: { type: 'string', values: Object.keys(PRESETS), description: 'EQ preset name' },
|
|
237
|
+
tremolo: { type: 'object', properties: { frequency: { min: 0.1, max: 20 }, depth: { min: 0, max: 1 } }, description: 'Tremolo effect' },
|
|
238
|
+
vibrato: { type: 'object', properties: { frequency: { min: 0.1, max: 14 }, depth: { min: 0, max: 1 } }, description: 'Vibrato effect' },
|
|
239
|
+
rotation: { type: 'object', properties: { speed: { min: 0.01, max: 5 } }, description: 'Audio rotation (8D)' },
|
|
240
|
+
lowpass: { type: 'number', min: 100, max: 20000, description: 'Low-pass filter (Hz)' },
|
|
241
|
+
highpass: { type: 'number', min: 20, max: 10000, description: 'High-pass filter (Hz)' },
|
|
242
|
+
bandpass: { type: 'object', properties: { frequency: {}, width: {} }, description: 'Band-pass filter' },
|
|
243
|
+
bandreject: { type: 'object', properties: { frequency: {}, width: {} }, description: 'Band-reject/notch filter' },
|
|
244
|
+
lowshelf: { type: 'object', properties: { frequency: {}, gain: {} }, description: 'Low-shelf filter' },
|
|
245
|
+
highshelf: { type: 'object', properties: { frequency: {}, gain: {} }, description: 'High-shelf filter' },
|
|
246
|
+
peaking: { type: 'object', properties: { frequency: {}, gain: {}, q: {} }, description: 'Peaking EQ filter' },
|
|
247
|
+
karaoke: { type: 'boolean', description: 'Reduce vocals' },
|
|
248
|
+
mono: { type: 'boolean', description: 'Convert to mono' },
|
|
249
|
+
surround: { type: 'boolean', description: 'Surround sound effect' },
|
|
250
|
+
flanger: { type: 'boolean', description: 'Flanger effect' },
|
|
251
|
+
phaser: { type: 'boolean', description: 'Phaser effect' },
|
|
252
|
+
chorus: { type: 'boolean', description: 'Chorus effect' },
|
|
253
|
+
compressor: { type: 'boolean', description: 'Dynamic range compression' },
|
|
254
|
+
normalizer: { type: 'boolean', description: 'Loudness normalization' },
|
|
255
|
+
nightcore: { type: 'boolean', description: 'Nightcore preset' },
|
|
256
|
+
vaporwave: { type: 'boolean', description: 'Vaporwave preset' },
|
|
257
|
+
bassboost: { type: 'boolean', description: 'Bass boost preset' },
|
|
258
|
+
'8d': { type: 'boolean', description: '8D audio effect' }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getPresets() {
|
|
263
|
+
return PRESETS;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getEQBands() {
|
|
267
|
+
return EQ_BANDS;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { buildFfmpegArgs, getAvailableFilters, getPresets, getEQBands, PRESETS, EQ_BANDS };
|