ziplayer 0.3.7 → 0.3.9

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.
@@ -1,380 +1,439 @@
1
- import type { AudioFilter } from "../types";
2
- import { PREDEFINED_FILTERS } from "../types";
3
- import type { Player } from "./Player";
4
- import type { PlayerManager } from "./PlayerManager";
5
- import prism, { FFmpeg } from "prism-media";
6
- import type { Readable } from "stream";
7
- import { spawn, type ChildProcess } from "child_process";
8
- import ffmpegPath from "ffmpeg-static";
9
-
10
- type DebugFn = (message?: any, ...optionalParams: any[]) => void;
11
-
12
- export class FilterManager {
13
- private activeFilters: AudioFilter[] = [];
14
- private debug: DebugFn;
15
- private player: Player;
16
- private ffmpeg: FFmpeg | null = null;
17
- private currentInputStream: Readable | null = null;
18
- public StreamType: "webm/opus" | "ogg/opus" | "mp3" | "arbitrary" = "mp3";
19
- private ffmpegProcess: ChildProcess | null = null;
20
-
21
- constructor(player: Player, manager: PlayerManager) {
22
- this.player = player as Player;
23
-
24
- this.debug = (message?: any, ...optionalParams: any[]) => {
25
- if (manager.debugEnabled) {
26
- manager.emit("debug", `[FilterManager] ${message}`, ...optionalParams);
27
- }
28
- };
29
- }
30
-
31
- /**
32
- * Destroy the filter manager
33
- *
34
- * @returns {void}
35
- * @example
36
- * player.filter.destroy();
37
- */
38
- destroy(): void {
39
- this.activeFilters = [];
40
-
41
- if (this.ffmpeg) {
42
- try {
43
- this.ffmpeg.destroy();
44
- } catch {}
45
- this.ffmpeg = null;
46
- }
47
- if (this.ffmpegProcess) {
48
- try {
49
- this.ffmpegProcess.kill("SIGKILL");
50
- } catch {}
51
- this.ffmpegProcess = null;
52
- }
53
- if (this.currentInputStream && typeof (this.currentInputStream as any).destroy === "function") {
54
- try {
55
- (this.currentInputStream as any).destroy();
56
- } catch {}
57
- }
58
- this.currentInputStream = null;
59
- }
60
-
61
- /**
62
- * Get the combined FFmpeg filter string for all active filters
63
- *
64
- * @returns {string} Combined FFmpeg filter string
65
- * @example
66
- * const filterString = player.getFilterString();
67
- * console.log(`Filter string: ${filterString}`);
68
- */
69
- public getFilterString(): string {
70
- if (this.activeFilters.length === 0) return "";
71
- return this.activeFilters.map((f) => f.ffmpegFilter).join(",");
72
- }
73
-
74
- /**
75
- * Get all currently applied filters
76
- *
77
- * @returns {AudioFilter[]} Array of active filters
78
- * @example
79
- * const filters = player.getActiveFilters();
80
- * console.log(`Active filters: ${filters.map(f => f.name).join(', ')}`);
81
- */
82
- public getActiveFilters(): AudioFilter[] {
83
- return [...this.activeFilters];
84
- }
85
-
86
- /**
87
- * Check if a specific filter is currently applied
88
- *
89
- * @param {string} filterName - Name of the filter to check
90
- * @returns {boolean} True if filter is applied
91
- * @example
92
- * const hasBassBoost = player.hasFilter("bassboost");
93
- * console.log(`Has bass boost: ${hasBassBoost}`);
94
- */
95
- public hasFilter(filterName: string): boolean {
96
- return this.activeFilters.some((f) => f.name === filterName);
97
- }
98
-
99
- /**
100
- * Get available predefined filters
101
- *
102
- * @returns {AudioFilter[]} Array of all predefined filters
103
- * @example
104
- * const availableFilters = player.getAvailableFilters();
105
- * console.log(`Available filters: ${availableFilters.length}`);
106
- */
107
- public getAvailableFilters(): AudioFilter[] {
108
- return Object.values(PREDEFINED_FILTERS);
109
- }
110
-
111
- /**
112
- * Get filters by category
113
- *
114
- * @param {string} category - Category to filter by
115
- * @returns {AudioFilter[]} Array of filters in the category
116
- * @example
117
- * const eqFilters = player.getFiltersByCategory("eq");
118
- * console.log(`EQ filters: ${eqFilters.map(f => f.name).join(', ')}`);
119
- */
120
- public getFiltersByCategory(category: string): AudioFilter[] {
121
- return Object.values(PREDEFINED_FILTERS).filter((f) => f.category === category);
122
- }
123
-
124
- /**
125
- * Apply an audio filter to the player
126
- *
127
- * @param {string | AudioFilter} filter - Filter name or AudioFilter object
128
- * @returns {Promise<boolean>} True if filter was applied successfully
129
- * @example
130
- * // Apply predefined filter to current track
131
- * await player.applyFilter("bassboost");
132
- *
133
- * // Apply custom filter to current track
134
- * await player.applyFilter({
135
- * name: "custom",
136
- * ffmpegFilter: "volume=1.5,treble=g=5",
137
- * description: "Tăng âm lượng và âm cao"
138
- * });
139
- *
140
- * // Apply filter without affecting current track
141
- * await player.applyFilter("bassboost", false);
142
- */
143
- public async applyFilter(filter?: string | AudioFilter): Promise<boolean> {
144
- if (!filter) return false;
145
-
146
- let audioFilter: AudioFilter | undefined;
147
- if (typeof filter === "string") {
148
- const predefined = PREDEFINED_FILTERS[filter];
149
- if (!predefined) {
150
- this.debug(`[FilterManager] Predefined filter not found: ${filter}`);
151
- return false;
152
- }
153
- audioFilter = predefined;
154
- } else {
155
- audioFilter = filter;
156
- }
157
-
158
- if (this.activeFilters.some((f) => f.name === audioFilter.name)) {
159
- this.debug(`[FilterManager] Filter already applied: ${audioFilter.name}`);
160
- return false;
161
- }
162
-
163
- this.activeFilters.push(audioFilter);
164
- this.debug(`[FilterManager] Applied filter: ${audioFilter.name} - ${audioFilter.description}`);
165
- return await this.player.refreshPlayerResource();
166
- }
167
-
168
- /**
169
- * Apply multiple filters at once
170
- *
171
- * @param {(string | AudioFilter)[]} filters - Array of filter names or AudioFilter objects
172
- * @returns {Promise<boolean>} True if all filters were applied successfully
173
- * @example
174
- * // Apply multiple filters to current track
175
- * await player.applyFilters(["bassboost", "trebleboost"]);
176
- *
177
- * // Apply filters without affecting current track
178
- * await player.applyFilters(["bassboost", "trebleboost"], false);
179
- */
180
- public async applyFilters(filters: (string | AudioFilter)[]): Promise<boolean> {
181
- let allApplied = true;
182
- for (const f of filters) {
183
- const ok = await this.applyFilter(f);
184
- if (!ok) allApplied = false;
185
- }
186
- return allApplied;
187
- }
188
- /**
189
- * Remove an audio filter from the player
190
- *
191
- * @param {string} filterName - Name of the filter to remove
192
- * @returns {boolean} True if filter was removed successfully
193
- * @example
194
- * player.removeFilter("bassboost");
195
- */
196
- public async removeFilter(filterName: string): Promise<boolean> {
197
- const index = this.activeFilters.findIndex((f) => f.name === filterName);
198
- if (index === -1) {
199
- this.debug(`[FilterManager] Filter not found: ${filterName}`);
200
- return false;
201
- }
202
- const removed = this.activeFilters.splice(index, 1)[0];
203
- this.debug(`[FilterManager] Removed filter: ${removed.name}`);
204
- return await this.player.refreshPlayerResource();
205
- }
206
-
207
- /**
208
- * Clear all audio filters from the player
209
- *
210
- * @returns {boolean} True if filters were cleared successfully
211
- * @example
212
- * player.clearFilters();
213
- */
214
- public async clearAll(): Promise<boolean> {
215
- const count = this.activeFilters.length;
216
- this.activeFilters = [];
217
- this.debug(`[FilterManager] Cleared ${count} filters`);
218
- return await this.player.refreshPlayerResource();
219
- }
220
-
221
- /**
222
- * Apply filters and seek to a stream
223
- *
224
- * @param {Readable} stream - The stream to apply filters and seek to
225
- * @param {number} position - The position to seek to in milliseconds (default: 0)
226
- * @returns {Promise<Readable>} The stream with filters and seek applied
227
- */
228
- public async applyFiltersAndSeek(stream: Readable, position: number = -1): Promise<Readable> {
229
- const filterString = this.getFilterString();
230
- this.debug(`Applying filters and seek filters: ${filterString || "none"}, seek: ${position}ms`);
231
-
232
- // Tear down any previous FFmpeg instances.
233
- try {
234
- if (this.ffmpeg) {
235
- this.ffmpeg.destroy();
236
- this.ffmpeg = null;
237
- }
238
- if (this.ffmpegProcess) {
239
- this.ffmpegProcess.kill("SIGKILL");
240
- this.ffmpegProcess = null;
241
- }
242
- if (
243
- this.currentInputStream &&
244
- typeof (this.currentInputStream as any).destroy === "function" &&
245
- !(this.currentInputStream as any).destroyed
246
- ) {
247
- try {
248
- (this.currentInputStream as any).destroy();
249
- } catch {}
250
- }
251
- this.currentInputStream = null;
252
- } catch {}
253
-
254
- this.currentInputStream = stream;
255
-
256
- // ── INPUT-SIDE SEEKING ─────────────────────────────────────────────────────
257
- // When a seek position is requested, place -ss BEFORE -i so FFmpeg seeks
258
- // in the compressed domain (keyframe-level) rather than decoding every
259
- // frame up to the target. This is dramatically faster for large positions.
260
- //
261
- // Output-side (slow): ffmpeg -i pipe:0 -ss 109 ...
262
- // → reads and decodes all 109 s before outputting anything
263
- //
264
- // Input-side (fast): ffmpeg -ss 109 -i pipe:0 ...
265
- // → seeks to nearest keyframe < 109 s, outputs from there
266
- //
267
- // prism.FFmpeg always places user args after -i, so we spawn directly.
268
- if (position >= 0 && ffmpegPath) {
269
- return this.spawnFFmpegInputSeek(stream, position, filterString);
270
- }
271
-
272
- // ── FILTER-ONLY (no seek) — use prism.FFmpeg as before ────────────────────
273
- const args = ["-analyzeduration", "0", "-loglevel", "0"];
274
- if (filterString) {
275
- args.push("-af", filterString);
276
- }
277
- args.push(
278
- "-f",
279
- this.StreamType === "webm/opus" ? "webm/opus"
280
- : this.StreamType === "ogg/opus" ? "ogg/opus"
281
- : "mp3",
282
- );
283
- args.push("-ar", "48000", "-ac", "2");
284
-
285
- try {
286
- this.ffmpeg = stream.pipe(new prism.FFmpeg({ args }));
287
- } catch (spawnError) {
288
- this.debug(`FFmpeg spawn error:`, spawnError);
289
- this.currentInputStream = null;
290
- throw spawnError;
291
- }
292
-
293
- this.ffmpeg.on("close", () => {
294
- this.debug(`FFmpeg processing completed`);
295
- try {
296
- if (this.ffmpeg) {
297
- this.ffmpeg.destroy();
298
- this.ffmpeg = null;
299
- }
300
- } catch {}
301
- });
302
- this.ffmpeg.on("error", (err: Error) => {
303
- this.debug(`FFmpeg error:`, err);
304
- try {
305
- if (this.ffmpeg) {
306
- this.ffmpeg.destroy();
307
- this.ffmpeg = null;
308
- }
309
- if (this.currentInputStream && !(this.currentInputStream as any).destroyed) {
310
- try {
311
- (this.currentInputStream as any).destroy();
312
- } catch {}
313
- }
314
- } catch {}
315
- this.currentInputStream = null;
316
- });
317
-
318
- return this.ffmpeg;
319
- }
320
-
321
- private spawnFFmpegInputSeek(stream: Readable, position: number, filterString: string): Readable {
322
- const seekSeconds = (position / 1000).toFixed(3);
323
-
324
- const args: string[] = [
325
- // INPUT-SIDE SEEK: placed before -i
326
- "-ss",
327
- seekSeconds,
328
- "-i",
329
- "pipe:0",
330
- // Output options
331
- "-analyzeduration",
332
- "0",
333
- "-loglevel",
334
- "0",
335
- ];
336
-
337
- if (filterString) {
338
- args.push("-af", filterString);
339
- }
340
-
341
- const outFormat =
342
- this.StreamType === "webm/opus" ? "webm"
343
- : this.StreamType === "ogg/opus" ? "ogg"
344
- : "mp3";
345
-
346
- args.push("-f", outFormat, "-ar", "48000", "-ac", "2", "pipe:1");
347
-
348
- const proc = spawn(ffmpegPath!, args, {
349
- stdio: ["pipe", "pipe", "ignore"],
350
- });
351
- this.ffmpegProcess = proc;
352
-
353
- // Pipe source → ffmpeg stdin
354
- stream.pipe(proc.stdin!);
355
-
356
- // Suppress EPIPE on stdin when the process exits early
357
- proc.stdin!.on("error", (err: Error) => {
358
- if ((err as any).code !== "EPIPE") {
359
- this.debug(`FFmpeg stdin error: ${err.message}`);
360
- }
361
- });
362
-
363
- proc.stdout!.on("error", (err: Error) => {
364
- this.debug(`FFmpeg stdout error: ${err.message}`);
365
- });
366
-
367
- proc.on("close", (code) => {
368
- this.debug(`FFmpeg process exited (code: ${code})`);
369
- this.ffmpegProcess = null;
370
- });
371
-
372
- proc.on("error", (err: Error) => {
373
- this.debug(`FFmpeg process error: ${err.message}`);
374
- this.ffmpegProcess = null;
375
- throw err;
376
- });
377
-
378
- return proc.stdout as Readable;
379
- }
380
- }
1
+ import type { AudioFilter } from "../types";
2
+ import { PREDEFINED_FILTERS } from "../types";
3
+ import type { Player } from "./Player";
4
+ import type { PlayerManager } from "./PlayerManager";
5
+ import prism, { FFmpeg } from "prism-media";
6
+ import type { Readable } from "stream";
7
+ import { spawn, type ChildProcess } from "child_process";
8
+ import ffmpegPath from "ffmpeg-static";
9
+
10
+ type DebugFn = (message?: any, ...optionalParams: any[]) => void;
11
+
12
+ export type FilterManagerStreamType = "webm/opus" | "ogg/opus" | "arbitrary" | "mp3";
13
+
14
+ export class FilterManager {
15
+ private activeFilters: AudioFilter[] = [];
16
+ private debug: DebugFn;
17
+ private player: Player;
18
+ private ffmpeg: FFmpeg | null = null;
19
+ private currentInputStream: Readable | null = null;
20
+ public StreamType: FilterManagerStreamType = "arbitrary";
21
+ private ffmpegProcess: ChildProcess | null = null;
22
+ private ffmpegAbortController: AbortController | null = null;
23
+ private ffmpegGeneration = 0;
24
+ private pendingFFmpegProcess: ChildProcess | null = null;
25
+
26
+ constructor(player: Player, manager: PlayerManager) {
27
+ this.player = player as Player;
28
+ this.debug = (message?: any, ...optionalParams: any[]) => {
29
+ if (manager.debugEnabled) {
30
+ manager.emit("debug", `[FilterManager] ${message}`, ...optionalParams);
31
+ }
32
+ };
33
+ }
34
+
35
+ public setSourceStreamType(type: string): void {
36
+ if (type === "webm/opus" || type === "ogg/opus" || type === "mp3") {
37
+ this.StreamType = type as FilterManagerStreamType;
38
+ } else {
39
+ this.StreamType = "arbitrary";
40
+ }
41
+ this.debug(`[FilterManager] Source stream type set to: ${this.StreamType}`);
42
+ }
43
+
44
+ destroy(): void {
45
+ this.activeFilters = [];
46
+ this.teardownFFmpeg();
47
+ this.currentInputStream = null;
48
+ }
49
+
50
+ private teardownFFmpeg(): void {
51
+ // Abort any pending spawn first
52
+ if (this.ffmpegAbortController) {
53
+ this.ffmpegAbortController.abort();
54
+ this.ffmpegAbortController = null;
55
+ }
56
+
57
+ if (this.ffmpeg) {
58
+ try {
59
+ this.ffmpeg.destroy();
60
+ } catch {
61
+ /* ignore */
62
+ }
63
+ this.ffmpeg = null;
64
+ }
65
+
66
+ if (this.ffmpegProcess) {
67
+ try {
68
+ // Detach stdin so source stream doesn't get EPIPE when we kill
69
+ if (this.ffmpegProcess.stdin && !this.ffmpegProcess.stdin.destroyed) {
70
+ this.ffmpegProcess.stdin.destroy();
71
+ }
72
+ this.ffmpegProcess.kill("SIGKILL");
73
+ } catch {
74
+ /* ignore */
75
+ }
76
+ this.ffmpegProcess = null;
77
+ }
78
+ }
79
+
80
+ public getFilterString(): string {
81
+ if (this.activeFilters.length === 0) return "";
82
+ return this.activeFilters.map((f) => f.ffmpegFilter).join(",");
83
+ }
84
+
85
+ /**
86
+ * Get all currently applied filters
87
+ *
88
+ * @returns {AudioFilter[]} Array of active filters
89
+ * @example
90
+ * const filters = player.getActiveFilters();
91
+ * console.log(`Active filters: ${filters.map(f => f.name).join(', ')}`);
92
+ */
93
+ public getActiveFilters(): AudioFilter[] {
94
+ return [...this.activeFilters];
95
+ }
96
+
97
+ /**
98
+ * Check if a specific filter is currently applied
99
+ *
100
+ * @param {string} filterName - Name of the filter to check
101
+ * @returns {boolean} True if filter is applied
102
+ * @example
103
+ * const hasBassBoost = player.hasFilter("bassboost");
104
+ * console.log(`Has bass boost: ${hasBassBoost}`);
105
+ */
106
+ public hasFilter(filterName: string): boolean {
107
+ return this.activeFilters.some((f) => f.name === filterName);
108
+ }
109
+
110
+ /**
111
+ * Get available predefined filters
112
+ *
113
+ * @returns {AudioFilter[]} Array of all predefined filters
114
+ * @example
115
+ * const availableFilters = player.getAvailableFilters();
116
+ * console.log(`Available filters: ${availableFilters.length}`);
117
+ */
118
+ public getAvailableFilters(): AudioFilter[] {
119
+ return Object.values(PREDEFINED_FILTERS);
120
+ }
121
+
122
+ /**
123
+ * Get filters by category
124
+ *
125
+ * @param {string} category - Category to filter by
126
+ * @returns {AudioFilter[]} Array of filters in the category
127
+ * @example
128
+ * const eqFilters = player.getFiltersByCategory("eq");
129
+ * console.log(`EQ filters: ${eqFilters.map(f => f.name).join(', ')}`);
130
+ */
131
+ public getFiltersByCategory(category: string): AudioFilter[] {
132
+ return Object.values(PREDEFINED_FILTERS).filter((f) => f.category === category);
133
+ }
134
+
135
+ /**
136
+ * Apply an audio filter to the player
137
+ *
138
+ * @param {string | AudioFilter} filter - Filter name or AudioFilter object
139
+ * @returns {Promise<boolean>} True if filter was applied successfully
140
+ * @example
141
+ * // Apply predefined filter to current track
142
+ * await player.applyFilter("bassboost");
143
+ *
144
+ * // Apply custom filter to current track
145
+ * await player.applyFilter({
146
+ * name: "custom",
147
+ * ffmpegFilter: "volume=1.5,treble=g=5",
148
+ * description: "Tăng âm lượng và âm cao"
149
+ * });
150
+ *
151
+ * // Apply filter without affecting current track
152
+ * await player.applyFilter("bassboost", false);
153
+ */
154
+ public async applyFilter(filter?: string | AudioFilter): Promise<boolean> {
155
+ if (!filter) return false;
156
+
157
+ let audioFilter: AudioFilter | undefined;
158
+ if (typeof filter === "string") {
159
+ const predefined = PREDEFINED_FILTERS[filter];
160
+ if (!predefined) {
161
+ this.debug(`[FilterManager] Predefined filter not found: ${filter}`);
162
+ return false;
163
+ }
164
+ audioFilter = predefined;
165
+ } else {
166
+ audioFilter = filter;
167
+ }
168
+
169
+ if (this.activeFilters.some((f) => f.name === audioFilter!.name)) {
170
+ this.debug(`[FilterManager] Filter already applied: ${audioFilter.name}`);
171
+ return false;
172
+ }
173
+
174
+ this.activeFilters.push(audioFilter);
175
+ this.debug(`[FilterManager] Applied filter: ${audioFilter.name} - ${audioFilter.description}`);
176
+ return await this.player.refreshPlayerResource();
177
+ }
178
+
179
+ /**
180
+ * Apply multiple filters at once
181
+ *
182
+ * @param {(string | AudioFilter)[]} filters - Array of filter names or AudioFilter objects
183
+ * @returns {Promise<boolean>} True if all filters were applied successfully
184
+ * @example
185
+ * // Apply multiple filters to current track
186
+ * await player.applyFilters(["bassboost", "trebleboost"]);
187
+ *
188
+ * // Apply filters without affecting current track
189
+ * await player.applyFilters(["bassboost", "trebleboost"], false);
190
+ */
191
+ public async applyFilters(filters: (string | AudioFilter)[]): Promise<boolean> {
192
+ let allApplied = true;
193
+ for (const f of filters) {
194
+ const ok = await this.applyFilter(f);
195
+ if (!ok) allApplied = false;
196
+ }
197
+ return allApplied;
198
+ }
199
+
200
+ public async removeFilter(filterName: string): Promise<boolean> {
201
+ const index = this.activeFilters.findIndex((f) => f.name === filterName);
202
+ if (index === -1) {
203
+ this.debug(`[FilterManager] Filter not found: ${filterName}`);
204
+ return false;
205
+ }
206
+ const removed = this.activeFilters.splice(index, 1)[0];
207
+ this.debug(`[FilterManager] Removed filter: ${removed.name}`);
208
+ return await this.player.refreshPlayerResource();
209
+ }
210
+
211
+ /**
212
+ * Clear all audio filters from the player
213
+ *
214
+ * @returns {boolean} True if filters were cleared successfully
215
+ * @example
216
+ * player.clearFilters();
217
+ */
218
+ public async clearAll(): Promise<boolean> {
219
+ const count = this.activeFilters.length;
220
+ this.activeFilters = [];
221
+ this.debug(`[FilterManager] Cleared ${count} filters`);
222
+ return await this.player.refreshPlayerResource();
223
+ }
224
+
225
+ /**
226
+ * Apply filters and seek to a stream
227
+ *
228
+ * @param {Readable} stream - The stream to apply filters and seek to
229
+ * @param {number} position - The position to seek to in milliseconds (default: 0)
230
+ * @returns {Promise<Readable>} The stream with filters and seek applied
231
+ */
232
+ public async applyFiltersAndSeek(stream: Readable, position: number = -1): Promise<Readable> {
233
+ const generation = ++this.ffmpegGeneration;
234
+
235
+ const filterString = this.getFilterString();
236
+
237
+ this.debug(
238
+ `Applying filters and seek — filters: ${filterString || "none"}, seek: ${position}ms, srcType: ${this.StreamType}`,
239
+ );
240
+
241
+ // có request mới chen vào
242
+ if (generation !== this.ffmpegGeneration) {
243
+ throw new Error("FFmpeg generation outdated");
244
+ }
245
+
246
+ this.currentInputStream = stream;
247
+
248
+ const abortController = new AbortController();
249
+ this.ffmpegAbortController = abortController;
250
+
251
+ if (position >= 0 && ffmpegPath) {
252
+ return this.spawnFFmpegInputSeek(stream, position, filterString, abortController.signal, generation);
253
+ }
254
+
255
+ const args = ["-analyzeduration", "0", "-loglevel", "0"];
256
+
257
+ if (filterString) {
258
+ args.push("-af", filterString);
259
+ }
260
+
261
+ args.push(
262
+ "-f",
263
+ this.StreamType === "webm/opus" ? "webm/opus"
264
+ : this.StreamType === "ogg/opus" ? "ogg/opus"
265
+ : "mp3",
266
+ );
267
+
268
+ args.push("-ar", "48000", "-ac", "2");
269
+
270
+ try {
271
+ this.ffmpeg = stream.pipe(new prism.FFmpeg({ args }));
272
+ } catch (spawnError) {
273
+ this.debug(`FFmpeg spawn error:`, spawnError);
274
+ this.currentInputStream = null;
275
+ this.ffmpegAbortController = null;
276
+ throw spawnError;
277
+ }
278
+
279
+ // nếu bị supersede ngay sau khi spawn
280
+ if (generation !== this.ffmpegGeneration) {
281
+ try {
282
+ this.ffmpeg.destroy();
283
+ } catch {}
284
+
285
+ throw new Error("FFmpeg process superseded");
286
+ }
287
+
288
+ this.ffmpeg.on("close", () => {
289
+ this.debug(`FFmpeg processing completed`);
290
+
291
+ try {
292
+ this.ffmpeg?.destroy();
293
+ } catch {}
294
+
295
+ if (this.ffmpeg === this.ffmpeg) {
296
+ this.ffmpeg = null;
297
+ }
298
+
299
+ if (this.ffmpegAbortController === abortController) {
300
+ this.ffmpegAbortController = null;
301
+ }
302
+ });
303
+
304
+ this.ffmpeg.on("error", (err: Error) => {
305
+ this.debug(`FFmpeg error:`, err);
306
+
307
+ try {
308
+ this.ffmpeg?.destroy();
309
+ } catch {}
310
+
311
+ if (this.ffmpeg === this.ffmpeg) {
312
+ this.ffmpeg = null;
313
+ }
314
+
315
+ if (this.ffmpegAbortController === abortController) {
316
+ this.ffmpegAbortController = null;
317
+ }
318
+
319
+ this.currentInputStream = null;
320
+ });
321
+
322
+ return this.ffmpeg;
323
+ }
324
+
325
+ private spawnFFmpegInputSeek(
326
+ stream: Readable,
327
+ position: number,
328
+ filterString: string,
329
+ signal: AbortSignal,
330
+ generation: number,
331
+ ): Readable {
332
+ const seekSeconds = (position / 1000).toFixed(3);
333
+
334
+ const args: string[] = ["-i", "pipe:0", "-ss", seekSeconds, "-analyzeduration", "0", "-loglevel", "0"];
335
+
336
+ if (filterString) {
337
+ args.push("-af", filterString);
338
+ }
339
+
340
+ const outFormat =
341
+ this.StreamType === "webm/opus" ? "webm"
342
+ : this.StreamType === "ogg/opus" ? "ogg"
343
+ : "mp3";
344
+
345
+ args.push("-f", outFormat, "-ar", "48000", "-ac", "2", "pipe:1");
346
+
347
+ const proc = spawn(ffmpegPath!, args, {
348
+ stdio: ["pipe", "pipe", "ignore"],
349
+ });
350
+
351
+ const oldProcess = this.ffmpegProcess;
352
+
353
+ this.pendingFFmpegProcess = proc;
354
+
355
+ if (generation !== this.ffmpegGeneration) {
356
+ try {
357
+ proc.kill("SIGKILL");
358
+ } catch {}
359
+
360
+ throw new Error("FFmpeg process superseded");
361
+ }
362
+
363
+ const onAbort = () => {
364
+ signal.removeEventListener("abort", onAbort);
365
+
366
+ try {
367
+ stream.unpipe(proc.stdin!);
368
+ } catch {}
369
+
370
+ try {
371
+ if (proc.stdin && !proc.stdin.destroyed) {
372
+ proc.stdin.destroy();
373
+ }
374
+ } catch {}
375
+
376
+ try {
377
+ proc.kill("SIGKILL");
378
+ } catch {}
379
+
380
+ this.debug(`[FilterManager] FFmpeg process aborted (seek pos: ${position}ms)`);
381
+ };
382
+
383
+ if (signal.aborted) {
384
+ // Already aborted before we even spawned
385
+ onAbort();
386
+ } else {
387
+ signal.addEventListener("abort", onAbort);
388
+ }
389
+
390
+ // Pipe source → ffmpeg stdin
391
+ stream.pipe(proc.stdin!);
392
+
393
+ // Suppress EPIPE on stdin when the process exits early
394
+ proc.stdin!.on("error", (err: Error) => {
395
+ if ((err as any).code !== "EPIPE") {
396
+ this.debug(`FFmpeg stdin error: ${err.message}`);
397
+ }
398
+ // EPIPE is expected when proc is killed — silence it
399
+ });
400
+
401
+ proc.stdout!.on("error", (err: Error) => {
402
+ this.debug(`FFmpeg stdout error: ${err.message}`);
403
+ });
404
+
405
+ proc.on("close", (code) => {
406
+ signal.removeEventListener("abort", onAbort);
407
+ this.debug(`FFmpeg process exited (code: ${code})`);
408
+ if (this.ffmpegProcess === proc) {
409
+ this.ffmpegProcess = null;
410
+ }
411
+ });
412
+
413
+ proc.on("error", (err: Error) => {
414
+ signal.removeEventListener("abort", onAbort);
415
+ this.debug(`FFmpeg process error: ${err.message}`);
416
+ if (this.ffmpegProcess === proc) {
417
+ this.ffmpegProcess = null;
418
+ }
419
+ });
420
+
421
+ this.ffmpegProcess = proc;
422
+ this.pendingFFmpegProcess = null;
423
+
424
+ // kill old AFTER new ready
425
+ if (oldProcess && oldProcess !== proc) {
426
+ try {
427
+ if (oldProcess.stdin && !oldProcess.stdin.destroyed) {
428
+ oldProcess.stdin.destroy();
429
+ }
430
+ } catch {}
431
+
432
+ try {
433
+ oldProcess.kill("SIGKILL");
434
+ } catch {}
435
+ }
436
+
437
+ return proc.stdout as Readable;
438
+ }
439
+ }