@qvac/decoder-audio 0.5.0 → 0.6.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/index.js CHANGED
@@ -1,412 +1,345 @@
1
- 'use strict'
2
-
3
- const QvacLogger = require('@qvac/logging')
4
- const ffmpeg = require('bare-ffmpeg')
5
- const { createJobHandler } = require('@qvac/infer-base')
6
- const { QvacErrorDecoderAudio, ERR_CODES } = require('./utils/error')
7
-
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FFmpegDecoder = void 0;
4
+ /* eslint-disable @typescript-eslint/no-require-imports -- Bare modules and @qvac/logging expose CommonJS export shapes. */
5
+ const QvacLogger = require("@qvac/logging");
6
+ const ffmpeg = require("bare-ffmpeg");
7
+ /* eslint-enable @typescript-eslint/no-require-imports */
8
+ const infer_base_1 = require("@qvac/infer-base");
9
+ const error_1 = require("./utils/error");
8
10
  /**
9
11
  * FFmpeg-based audio decoder (single-threaded)
10
12
  */
11
13
  class FFmpegDecoder {
12
- SUPPORTED_AUDIO_FORMATS = {
13
- s16le: {
14
- format: null, // Will be set to ffmpeg.constants.sampleFormats.S16
15
- byteLength: 2
16
- },
17
- f32le: {
18
- format: null, // Will be set to ffmpeg.constants.sampleFormats.FLT
19
- byteLength: 4
14
+ SUPPORTED_AUDIO_FORMATS = {
15
+ s16le: {
16
+ format: null, // Will be set to ffmpeg.constants.sampleFormats.S16
17
+ byteLength: 2,
18
+ },
19
+ f32le: {
20
+ format: null, // Will be set to ffmpeg.constants.sampleFormats.FLT
21
+ byteLength: 4,
22
+ },
23
+ };
24
+ OUTPUT_CHANNEL_LAYOUT = null; // Will be set to ffmpeg.constants.channelLayouts.MONO
25
+ config;
26
+ logger;
27
+ isLoaded;
28
+ samplesSkipped;
29
+ totalSkipSamples;
30
+ _cancelled;
31
+ _job;
32
+ _runtimeStats;
33
+ /**
34
+ * Creates an instance of FFmpegDecoder.
35
+ * @param params - Configuration options. Top-level `streamIndex`, `inputBitrate`
36
+ * and `audioFormat` act as fallbacks for the matching `config` fields.
37
+ */
38
+ constructor({ config = {}, logger = null, streamIndex = 0, inputBitrate = 192000, audioFormat = "s16le", } = {}) {
39
+ this.config = {
40
+ streamIndex: config.streamIndex || streamIndex,
41
+ inputBitrate: config.inputBitrate || inputBitrate,
42
+ audioFormat: config.audioFormat || audioFormat,
43
+ sampleRate: config.sampleRate || 16000,
44
+ };
45
+ this.logger = new QvacLogger(logger ?? undefined);
46
+ this.isLoaded = false;
47
+ this._cancelled = false;
48
+ this._job = (0, infer_base_1.createJobHandler)({ cancel: () => this._cancelCurrent() });
49
+ // Encoder delay handling
50
+ this.samplesSkipped = 0;
51
+ this.totalSkipSamples = 0;
52
+ // Runtime stats
53
+ this._resetStats();
20
54
  }
21
- }
22
-
23
- OUTPUT_CHANNEL_LAYOUT = null // Will be set to ffmpeg.constants.channelLayouts.MONO
24
- /**
25
- * Creates an instance of FFmpegDecoder.
26
- * @param {Object} config - Configuration options
27
- * @param logger - Logger instance
28
- * @param streamIndex - Index of the stream to decode. Default: 0
29
- * @param inputBitrate - Input audio bitrate. Default: 192000
30
- * @param audioFormat - Output audio format. Default: 's16le'
31
- * @param {Object} [config.streamIndex] - Index of the stream to decode (default: 0)
32
- * @param {number} [config.inputBitrate] - Input audio bitrate (default: 192000)
33
- * @param {string} [config.audioFormat] - Output audio format (default: 'f32le')
34
- * @param {number} [config.sampleRate] - Output sample rate (default: 16000)
35
- * @param {Object} [config.logger] - Logger instance
36
- */
37
- constructor ({
38
- config = {},
39
- logger = null,
40
- streamIndex = 0,
41
- inputBitrate = 192000,
42
- audioFormat = 's16le'
43
- } = {}) {
44
- this.config = {
45
- streamIndex: config.streamIndex || streamIndex,
46
- inputBitrate: config.inputBitrate || inputBitrate,
47
- audioFormat: config.audioFormat || audioFormat,
48
- sampleRate: config.sampleRate || 16000
55
+ /**
56
+ * Resets the runtime stats
57
+ */
58
+ _resetStats() {
59
+ this._runtimeStats = {
60
+ decodeTimeMs: 0,
61
+ inputBytes: 0,
62
+ outputBytes: 0,
63
+ samplesDecoded: 0,
64
+ codecName: null,
65
+ inputSampleRate: 0,
66
+ outputSampleRate: this.config.sampleRate,
67
+ audioFormat: this.config.audioFormat,
68
+ };
49
69
  }
50
-
51
- this.logger = new QvacLogger(logger)
52
- this.isLoaded = false
53
- this._cancelled = false
54
- this._job = createJobHandler({ cancel: () => this._cancelCurrent() })
55
-
56
- // Encoder delay handling
57
- this.samplesSkipped = 0
58
- this.totalSkipSamples = 0
59
-
60
- // Runtime stats
61
- this._resetStats()
62
- }
63
-
64
- /**
65
- * Resets the runtime stats
66
- */
67
- _resetStats () {
68
- this._runtimeStats = {
69
- decodeTimeMs: 0,
70
- inputBytes: 0,
71
- outputBytes: 0,
72
- samplesDecoded: 0,
73
- codecName: null,
74
- inputSampleRate: 0,
75
- outputSampleRate: this.config.sampleRate,
76
- audioFormat: this.config.audioFormat
70
+ /**
71
+ * Get the current runtime stats
72
+ * @returns Current runtime stats
73
+ */
74
+ runtimeStats() {
75
+ return { ...this._runtimeStats };
77
76
  }
78
- }
79
-
80
- /**
81
- * Get the current runtime stats
82
- * @returns {Object} Current runtime stats
83
- */
84
- runtimeStats () {
85
- return { ...this._runtimeStats }
86
- }
87
-
88
- /**
89
- * Load and initialize the decoder
90
- */
91
- async load () {
92
- if (this.isLoaded) {
93
- this.logger.info('FFmpegDecoder already loaded')
94
- return
95
- }
96
-
97
- this.logger.info('Loading FFmpegDecoder with config:', this.config)
98
-
99
- // Initialize format constants
100
- this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16
101
- this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT
102
- this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
103
-
104
- // Validate audio format
105
- if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
106
- throw new QvacErrorDecoderAudio({
107
- code: ERR_CODES.UNSUPPORTED_AUDIO_FORMAT,
108
- adds: this.config.audioFormat
109
- })
110
- }
111
-
112
- this.isLoaded = true
113
- this.logger.info('FFmpegDecoder loaded successfully')
114
- }
115
-
116
- /**
117
- * Unload the decoder and clean up resources
118
- */
119
- async unload () {
120
- if (!this.isLoaded) {
121
- return
122
- }
123
-
124
- this.logger.info('Unloading FFmpegDecoder')
125
-
126
- this.isLoaded = false
127
- this._cancelCurrent()
128
- this._job.fail(new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED }))
129
- this.logger.info('FFmpegDecoder unloaded')
130
- }
131
-
132
- /**
133
- * Run the decoder on an audio stream
134
- * @param {Readable} audioStream - Input audio stream
135
- * @returns {QvacResponse} Response with decoded audio
136
- */
137
- run (audioStream) {
138
- if (!this.isLoaded) {
139
- throw new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED })
77
+ /**
78
+ * Load and initialize the decoder
79
+ */
80
+ // eslint-disable-next-line @typescript-eslint/require-await -- preserves the established promise-returning API, so failures surface as rejections rather than synchronous throws.
81
+ async load() {
82
+ if (this.isLoaded) {
83
+ this.logger.info("FFmpegDecoder already loaded");
84
+ return;
85
+ }
86
+ this.logger.info("Loading FFmpegDecoder with config:", this.config);
87
+ // Initialize format constants
88
+ this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16;
89
+ this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT;
90
+ this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO;
91
+ // Validate audio format
92
+ if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
93
+ throw new error_1.QvacErrorDecoderAudio({
94
+ code: error_1.ERR_CODES.UNSUPPORTED_AUDIO_FORMAT,
95
+ adds: this.config.audioFormat,
96
+ });
97
+ }
98
+ this.isLoaded = true;
99
+ this.logger.info("FFmpegDecoder loaded successfully");
140
100
  }
141
-
142
- this.logger.info('Starting new audio stream processing')
143
-
144
- this._cancelled = false
145
- const response = this._job.start()
146
-
147
- this._processStream(audioStream)
148
- .then(() => {
149
- this._job.end(this.runtimeStats())
150
- })
151
- .catch(err => {
152
- this.logger.error('Error processing audio stream:', err)
153
- this._job.active?.updateStats(this.runtimeStats())
154
- this._job.fail(err)
155
- })
156
-
157
- return response
158
- }
159
-
160
- _cancelCurrent () {
161
- this._cancelled = true
162
- this.logger.debug('Decoder cancel requested')
163
- return Promise.resolve()
164
- }
165
-
166
- _getBufferSize (inputBitrate) {
167
- const maxBufferSize = 1024 * 1024 // 1MB max
168
- return Math.min((inputBitrate / 8) * 4, maxBufferSize)
169
- }
170
-
171
- _processFrame (decoder, raw, resampler) {
172
- const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
173
- const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
174
- const OUTPUT_SAMPLE_RATE = this.config.sampleRate
175
-
176
- while (decoder.receiveFrame(raw)) {
177
- const output = new ffmpeg.Frame()
178
- output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
179
- output.format = OUTPUT_FORMAT
180
- output.sampleRate = OUTPUT_SAMPLE_RATE
181
- output.nbSamples = raw.nbSamples
182
-
183
- const samples = new ffmpeg.Samples(
184
- output.format,
185
- output.channelLayout.nbChannels,
186
- output.nbSamples
187
- )
188
- samples.fill(output)
189
-
190
- const count = resampler.convert(raw, output)
191
-
192
- // Handle encoder delay by skipping initial samples
193
- if (this.samplesSkipped < this.totalSkipSamples) {
194
- const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
195
- this.samplesSkipped += samplesToSkip
196
- if (samplesToSkip >= count) continue // Skip entire frame
197
-
198
- // Skip partial frame
199
- const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
200
- const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
201
- const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
202
- this._job.output({ outputArray: chunk })
203
-
204
- // Track stats for partial frame
205
- this._runtimeStats.samplesDecoded += (count - samplesToSkip)
206
- this._runtimeStats.outputBytes += length
207
- } else {
208
- const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
209
- const chunk = Buffer.from(samples.data.subarray(0, length))
210
- this._job.output({ outputArray: chunk })
211
-
212
- // Track stats
213
- this._runtimeStats.samplesDecoded += count
214
- this._runtimeStats.outputBytes += length
215
- }
101
+ /**
102
+ * Unload the decoder and clean up resources
103
+ */
104
+ // eslint-disable-next-line @typescript-eslint/require-await -- preserves the established promise-returning API, so failures surface as rejections rather than synchronous throws.
105
+ async unload() {
106
+ if (!this.isLoaded) {
107
+ return;
108
+ }
109
+ this.logger.info("Unloading FFmpegDecoder");
110
+ this.isLoaded = false;
111
+ void this._cancelCurrent();
112
+ this._job.fail(new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED }));
113
+ this.logger.info("FFmpegDecoder unloaded");
216
114
  }
217
- }
218
-
219
- _processPacket (format, packet, raw, decoder, resampler) {
220
- while (format.readFrame(packet)) {
221
- if (this._cancelled) {
222
- packet.unref()
223
- throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
224
- }
225
- decoder.sendPacket(packet)
226
- this._processFrame(decoder, raw, resampler)
227
- packet.unref()
115
+ /**
116
+ * Run the decoder on an audio stream
117
+ * @param audioStream - Input audio stream
118
+ * @returns Response with decoded audio
119
+ */
120
+ run(audioStream) {
121
+ if (!this.isLoaded) {
122
+ throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED });
123
+ }
124
+ this.logger.info("Starting new audio stream processing");
125
+ this._cancelled = false;
126
+ const response = this._job.start();
127
+ void this._processStream(audioStream)
128
+ .then(() => {
129
+ this._job.end(this.runtimeStats());
130
+ })
131
+ .catch((err) => {
132
+ this.logger.error("Error processing audio stream:", err);
133
+ this._job.active?.updateStats(this.runtimeStats());
134
+ this._job.fail(err);
135
+ });
136
+ return response;
228
137
  }
229
- }
230
-
231
- _processFFmpegStream (format, stream) {
232
- const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
233
- const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
234
- const OUTPUT_SAMPLE_RATE = this.config.sampleRate
235
-
236
- this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
237
-
238
- // Track codec info in stats
239
- this._runtimeStats.codecName = stream.codec.name
240
- this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate
241
-
242
- const packet = new ffmpeg.Packet()
243
- const raw = new ffmpeg.Frame()
244
-
245
- const resampler = new ffmpeg.Resampler(
246
- stream.codecParameters.sampleRate,
247
- stream.codecParameters.channelLayout,
248
- stream.codecParameters.format,
249
- OUTPUT_SAMPLE_RATE,
250
- this.OUTPUT_CHANNEL_LAYOUT,
251
- OUTPUT_FORMAT
252
- )
253
-
254
- const decoder = stream.decoder()
255
- decoder.open()
256
-
257
- // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
258
- const codecName = stream.codec.name.toLowerCase()
259
- const SKIP_MS = {
260
- mp3: 400,
261
- vorbis: 400,
262
- opus: 150,
263
- aac: 300
138
+ _cancelCurrent() {
139
+ this._cancelled = true;
140
+ this.logger.debug("Decoder cancel requested");
141
+ return Promise.resolve();
264
142
  }
265
-
266
- const skipMs = SKIP_MS[codecName] || 0
267
- this.samplesSkipped = 0
268
- this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000)
269
-
270
- if (this.totalSkipSamples > 0) {
271
- this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
143
+ _getBufferSize(inputBitrate) {
144
+ const maxBufferSize = 1024 * 1024; // 1MB max
145
+ return Math.min((inputBitrate / 8) * 4, maxBufferSize);
272
146
  }
273
-
274
- this._processPacket(format, packet, raw, decoder, resampler)
275
-
276
- // Flush resampler
277
- const output = new ffmpeg.Frame()
278
- output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
279
- output.format = OUTPUT_FORMAT
280
- output.sampleRate = OUTPUT_SAMPLE_RATE
281
- output.nbSamples = 1024
282
-
283
- const samples = new ffmpeg.Samples(
284
- output.format,
285
- output.channelLayout.nbChannels,
286
- output.nbSamples
287
- )
288
- samples.fill(output)
289
-
290
- let flushCount
291
- while ((flushCount = resampler.flush(output)) > 0) {
292
- const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
293
- const chunk = Buffer.from(samples.data.subarray(0, actualLength))
294
- this._job.output({ outputArray: chunk })
295
-
296
- // Track stats for flushed samples
297
- this._runtimeStats.samplesDecoded += flushCount
298
- this._runtimeStats.outputBytes += actualLength
147
+ /**
148
+ * Resolves the output constants populated by `load()`. Unreachable before
149
+ * `load()` succeeds, since every caller sits behind the `isLoaded` guard.
150
+ */
151
+ _resolveOutputFormat() {
152
+ const audioFormat = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat];
153
+ if (audioFormat.format === null || this.OUTPUT_CHANNEL_LAYOUT === null) {
154
+ throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED });
155
+ }
156
+ return {
157
+ format: audioFormat.format,
158
+ byteLength: audioFormat.byteLength,
159
+ channelLayout: this.OUTPUT_CHANNEL_LAYOUT,
160
+ };
299
161
  }
300
-
301
- decoder.destroy()
302
- }
303
-
304
- async _collectStreamData (audioStream) {
305
- const chunks = []
306
- let totalBytes = 0
307
-
308
- for await (const chunk of audioStream) {
309
- if (this._cancelled) {
310
- this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection')
311
- throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
312
- }
313
-
314
- chunks.push(chunk)
315
- totalBytes += chunk.length
316
- this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`)
162
+ _processFrame(decoder, raw, resampler) {
163
+ const { format: OUTPUT_FORMAT, byteLength: OUTPUT_FORMAT_BYTE_LENGTH, channelLayout: OUTPUT_CHANNEL_LAYOUT, } = this._resolveOutputFormat();
164
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate;
165
+ while (decoder.receiveFrame(raw)) {
166
+ const output = new ffmpeg.Frame();
167
+ output.channelLayout = OUTPUT_CHANNEL_LAYOUT;
168
+ output.format = OUTPUT_FORMAT;
169
+ output.sampleRate = OUTPUT_SAMPLE_RATE;
170
+ output.nbSamples = raw.nbSamples;
171
+ const samples = new ffmpeg.Samples();
172
+ samples.fill(output);
173
+ const count = resampler.convert(raw, output);
174
+ // Handle encoder delay by skipping initial samples
175
+ if (this.samplesSkipped < this.totalSkipSamples) {
176
+ const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped);
177
+ this.samplesSkipped += samplesToSkip;
178
+ if (samplesToSkip >= count)
179
+ continue; // Skip entire frame
180
+ // Skip partial frame
181
+ const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels;
182
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels;
183
+ const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length));
184
+ this._job.output({ outputArray: chunk });
185
+ // Track stats for partial frame
186
+ this._runtimeStats.samplesDecoded += count - samplesToSkip;
187
+ this._runtimeStats.outputBytes += length;
188
+ }
189
+ else {
190
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels;
191
+ const chunk = Buffer.from(samples.data.subarray(0, length));
192
+ this._job.output({ outputArray: chunk });
193
+ // Track stats
194
+ this._runtimeStats.samplesDecoded += count;
195
+ this._runtimeStats.outputBytes += length;
196
+ }
197
+ }
317
198
  }
318
-
319
- return Buffer.concat(chunks)
320
- }
321
-
322
- async _processStream (audioStream) {
323
- // Reset and start tracking stats
324
- this._resetStats()
325
- const startTime = Date.now()
326
-
327
- this.logger.info('[FFmpegDecoder] Starting stream processing')
328
-
329
- // Collect all audio data from stream
330
- const audioBuffer = await this._collectStreamData(audioStream)
331
- this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
332
-
333
- // Track input bytes
334
- this._runtimeStats.inputBytes = audioBuffer.length
335
-
336
- if (this._cancelled) {
337
- this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
338
- this._runtimeStats.decodeTimeMs = Date.now() - startTime
339
- throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
199
+ _processPacket(format, packet, raw, decoder, resampler) {
200
+ while (format.readFrame(packet)) {
201
+ if (this._cancelled) {
202
+ packet.unref();
203
+ throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
204
+ }
205
+ decoder.sendPacket(packet);
206
+ this._processFrame(decoder, raw, resampler);
207
+ packet.unref();
208
+ }
340
209
  }
341
-
342
- // Create FFmpeg IO context with the buffer
343
- const bufferSize = this._getBufferSize(this.config.inputBitrate)
344
- let bufferOffset = 0
345
-
346
- const io = new ffmpeg.IOContext(bufferSize, {
347
- onread: (buffer, requestedLen) => {
348
- const remainingBytes = audioBuffer.length - bufferOffset
349
- const bytesToRead = Math.min(requestedLen, remainingBytes)
350
-
351
- if (bytesToRead <= 0) {
352
- return 0 // EOF
210
+ _processFFmpegStream(format, stream) {
211
+ const { format: OUTPUT_FORMAT, byteLength: OUTPUT_FORMAT_BYTE_LENGTH, channelLayout: OUTPUT_CHANNEL_LAYOUT, } = this._resolveOutputFormat();
212
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate;
213
+ this.logger.debug("[FFmpegDecoder] Stream codec:", stream.codec, stream.codecParameters);
214
+ // Track codec info in stats
215
+ this._runtimeStats.codecName = stream.codec.name;
216
+ this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate;
217
+ const packet = new ffmpeg.Packet();
218
+ const raw = new ffmpeg.Frame();
219
+ const resampler = new ffmpeg.Resampler(stream.codecParameters.sampleRate, stream.codecParameters.channelLayout, stream.codecParameters.format, OUTPUT_SAMPLE_RATE, OUTPUT_CHANNEL_LAYOUT, OUTPUT_FORMAT);
220
+ const decoder = stream.decoder();
221
+ decoder.open();
222
+ // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
223
+ const codecName = stream.codec.name.toLowerCase();
224
+ const SKIP_MS = {
225
+ mp3: 400,
226
+ vorbis: 400,
227
+ opus: 150,
228
+ aac: 300,
229
+ };
230
+ const skipMs = SKIP_MS[codecName] || 0;
231
+ this.samplesSkipped = 0;
232
+ this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000);
233
+ if (this.totalSkipSamples > 0) {
234
+ this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`);
353
235
  }
354
-
355
- audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
356
- bufferOffset += bytesToRead
357
-
358
- this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`)
359
- return bytesToRead
360
- },
361
- onseek: (offset, whence) => {
362
- const AVSEEK_SIZE = 0x10000
363
-
364
- if (whence === AVSEEK_SIZE) {
365
- return audioBuffer.length
236
+ this._processPacket(format, packet, raw, decoder, resampler);
237
+ // Flush resampler
238
+ const output = new ffmpeg.Frame();
239
+ output.channelLayout = OUTPUT_CHANNEL_LAYOUT;
240
+ output.format = OUTPUT_FORMAT;
241
+ output.sampleRate = OUTPUT_SAMPLE_RATE;
242
+ output.nbSamples = 1024;
243
+ const samples = new ffmpeg.Samples();
244
+ samples.fill(output);
245
+ let flushCount;
246
+ while ((flushCount = resampler.flush(output)) > 0) {
247
+ const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels;
248
+ const chunk = Buffer.from(samples.data.subarray(0, actualLength));
249
+ this._job.output({ outputArray: chunk });
250
+ // Track stats for flushed samples
251
+ this._runtimeStats.samplesDecoded += flushCount;
252
+ this._runtimeStats.outputBytes += actualLength;
366
253
  }
367
-
368
- let newOffset
369
- if (whence === 0) {
370
- newOffset = offset
371
- } else if (whence === 1) {
372
- newOffset = bufferOffset + offset
373
- } else if (whence === 2) {
374
- newOffset = audioBuffer.length + offset
375
- } else {
376
- return -1
254
+ decoder.destroy();
255
+ }
256
+ async _collectStreamData(audioStream) {
257
+ const chunks = [];
258
+ let totalBytes = 0;
259
+ for await (const chunk of audioStream) {
260
+ if (this._cancelled) {
261
+ this.logger.info("[FFmpegDecoder] Job cancelled, stopping stream collection");
262
+ throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
263
+ }
264
+ chunks.push(chunk);
265
+ totalBytes += chunk.length;
266
+ this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`);
267
+ }
268
+ return Buffer.concat(chunks);
269
+ }
270
+ async _processStream(audioStream) {
271
+ // Reset and start tracking stats
272
+ this._resetStats();
273
+ const startTime = Date.now();
274
+ this.logger.info("[FFmpegDecoder] Starting stream processing");
275
+ // Collect all audio data from stream
276
+ const audioBuffer = await this._collectStreamData(audioStream);
277
+ this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`);
278
+ // Track input bytes
279
+ this._runtimeStats.inputBytes = audioBuffer.length;
280
+ if (this._cancelled) {
281
+ this.logger.info("[FFmpegDecoder] Job cancelled after data collection");
282
+ this._runtimeStats.decodeTimeMs = Date.now() - startTime;
283
+ throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
377
284
  }
378
-
379
- if (newOffset < 0 || newOffset > audioBuffer.length) {
380
- return -1
285
+ // Create FFmpeg IO context with the buffer
286
+ const bufferSize = this._getBufferSize(this.config.inputBitrate);
287
+ let bufferOffset = 0;
288
+ const io = new ffmpeg.IOContext(bufferSize, {
289
+ onread: (buffer, requestedLen) => {
290
+ const remainingBytes = audioBuffer.length - bufferOffset;
291
+ const bytesToRead = Math.min(requestedLen, remainingBytes);
292
+ if (bytesToRead <= 0) {
293
+ return 0; // EOF
294
+ }
295
+ audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead);
296
+ bufferOffset += bytesToRead;
297
+ this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`);
298
+ return bytesToRead;
299
+ },
300
+ onseek: (offset, whence) => {
301
+ const AVSEEK_SIZE = 0x10000;
302
+ if (whence === AVSEEK_SIZE) {
303
+ return audioBuffer.length;
304
+ }
305
+ let newOffset;
306
+ if (whence === 0) {
307
+ newOffset = offset;
308
+ }
309
+ else if (whence === 1) {
310
+ newOffset = bufferOffset + offset;
311
+ }
312
+ else if (whence === 2) {
313
+ newOffset = audioBuffer.length + offset;
314
+ }
315
+ else {
316
+ return -1;
317
+ }
318
+ if (newOffset < 0 || newOffset > audioBuffer.length) {
319
+ return -1;
320
+ }
321
+ bufferOffset = newOffset;
322
+ this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`);
323
+ return bufferOffset;
324
+ },
325
+ });
326
+ this.logger.debug("[FFmpegDecoder] IOContext created");
327
+ const format = new ffmpeg.InputFormatContext(io);
328
+ this.logger.debug("[FFmpegDecoder] InputFormatContext created");
329
+ const streamIndex = this.config.streamIndex || 0;
330
+ const stream = format.streams[streamIndex];
331
+ if (stream === undefined) {
332
+ throw new error_1.QvacErrorDecoderAudio({
333
+ code: error_1.ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS,
334
+ adds: [streamIndex],
335
+ });
381
336
  }
382
-
383
- bufferOffset = newOffset
384
- this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
385
- return bufferOffset
386
- }
387
- })
388
-
389
- this.logger.debug('[FFmpegDecoder] IOContext created')
390
- const format = new ffmpeg.InputFormatContext(io)
391
- this.logger.debug('[FFmpegDecoder] InputFormatContext created')
392
-
393
- const streamIndex = this.config.streamIndex || 0
394
- if (format.streams[streamIndex] === undefined) {
395
- throw new QvacErrorDecoderAudio({
396
- code: ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS,
397
- adds: streamIndex
398
- })
337
+ // Process the stream and generate decoded output
338
+ this._processFFmpegStream(format, stream);
339
+ // Calculate final decode time
340
+ this._runtimeStats.decodeTimeMs = Date.now() - startTime;
341
+ this.logger.info("[FFmpegDecoder] Stream processing completed successfully");
342
+ this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`);
399
343
  }
400
-
401
- // Process the stream and generate decoded output
402
- this._processFFmpegStream(format, format.streams[streamIndex])
403
-
404
- // Calculate final decode time
405
- this._runtimeStats.decodeTimeMs = Date.now() - startTime
406
-
407
- this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
408
- this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`)
409
- }
410
344
  }
411
-
412
- module.exports = { FFmpegDecoder }
345
+ exports.FFmpegDecoder = FFmpegDecoder;