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.
@@ -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 };