@qvac/decoder-audio 0.3.0 → 0.3.1

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,4 +1,413 @@
1
1
  'use strict'
2
2
 
3
- const FFmpegDecoder = require('./lib/ffmpeg/ffmpeg-decoder')
4
- module.exports.FFmpegDecoder = FFmpegDecoder
3
+ const QvacResponse = require('@qvac/response')
4
+ const QvacLogger = require('@qvac/logging')
5
+ const ffmpeg = require('bare-ffmpeg')
6
+ const BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference')
7
+
8
+ /**
9
+ * FFmpeg-based audio decoder (single-threaded)
10
+ */
11
+ class FFmpegDecoder extends BaseInference {
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
20
+ }
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 args - Additional arguments passed to BaseInference
32
+ * @param {Object} [config.streamIndex] - Index of the stream to decode (default: 0)
33
+ * @param {number} [config.inputBitrate] - Input audio bitrate (default: 192000)
34
+ * @param {string} [config.audioFormat] - Output audio format (default: 'f32le')
35
+ * @param {number} [config.sampleRate] - Output sample rate (default: 16000)
36
+ * @param {Object} [config.logger] - Logger instance
37
+ */
38
+ constructor ({
39
+ config = {},
40
+ logger = null,
41
+ streamIndex = 0,
42
+ inputBitrate = 192000,
43
+ audioFormat = 's16le',
44
+ ...args
45
+ }) {
46
+ super({ ...args, logger })
47
+
48
+ this.config = {
49
+ streamIndex: config.streamIndex || streamIndex,
50
+ inputBitrate: config.inputBitrate || inputBitrate,
51
+ audioFormat: config.audioFormat || audioFormat,
52
+ sampleRate: config.sampleRate || 16000
53
+ }
54
+
55
+ this.logger = new QvacLogger(logger)
56
+ this.isLoaded = false
57
+ this.currentJob = null
58
+
59
+ // Encoder delay handling
60
+ this.samplesSkipped = 0
61
+ this.totalSkipSamples = 0
62
+ }
63
+
64
+ /**
65
+ * Load and initialize the decoder
66
+ */
67
+ async load () {
68
+ if (this.isLoaded) {
69
+ this.logger.info('FFmpegDecoder already loaded')
70
+ return
71
+ }
72
+
73
+ this.logger.info('Loading FFmpegDecoder with config:', this.config)
74
+
75
+ // Initialize format constants
76
+ this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16
77
+ this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT
78
+ this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
79
+
80
+ // Validate audio format
81
+ if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
82
+ throw new Error(`Unsupported audio format: ${this.config.audioFormat}`)
83
+ }
84
+
85
+ this.isLoaded = true
86
+ this.logger.info('FFmpegDecoder loaded successfully')
87
+ }
88
+
89
+ /**
90
+ * Unload the decoder and clean up resources
91
+ */
92
+ async unload () {
93
+ if (!this.isLoaded) {
94
+ return
95
+ }
96
+
97
+ this.logger.info('Unloading FFmpegDecoder')
98
+
99
+ this.isLoaded = false
100
+ this.currentJob = null
101
+ this.logger.info('FFmpegDecoder unloaded')
102
+ }
103
+
104
+ /**
105
+ * Run the decoder on an audio stream
106
+ * @param {Readable} audioStream - Input audio stream
107
+ * @returns {QvacResponse} Response with decoded audio
108
+ */
109
+ async run (audioStream) {
110
+ if (!this.isLoaded) {
111
+ throw new Error('Decoder not loaded. Call load() first.')
112
+ }
113
+
114
+ this.logger.info('Starting new audio stream processing')
115
+
116
+ const response = new QvacResponse({
117
+ cancelHandler: () => this.stop(),
118
+ pauseHandler: () => this.pause(),
119
+ continueHandler: () => this.unpause()
120
+ })
121
+
122
+ this.currentJob = {
123
+ response,
124
+ audioChunks: [],
125
+ isActive: true,
126
+ isPaused: false
127
+ }
128
+
129
+ // Process the audio stream
130
+ this._processStream(audioStream).catch(err => {
131
+ this.logger.error('Error processing audio stream:', err)
132
+ response.failed(err)
133
+ })
134
+
135
+ return response
136
+ }
137
+
138
+ _getBufferSize (inputBitrate) {
139
+ const maxBufferSize = 1024 * 1024 // 1MB max
140
+ return Math.min((inputBitrate / 8) * 4, maxBufferSize)
141
+ }
142
+
143
+ _processFrame (decoder, raw, resampler, job) {
144
+ const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
145
+ const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
146
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate
147
+
148
+ while (decoder.receiveFrame(raw)) {
149
+ const output = new ffmpeg.Frame()
150
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
151
+ output.format = OUTPUT_FORMAT
152
+ output.sampleRate = OUTPUT_SAMPLE_RATE
153
+ output.nbSamples = raw.nbSamples
154
+
155
+ const samples = new ffmpeg.Samples(
156
+ output.format,
157
+ output.channelLayout.nbChannels,
158
+ output.nbSamples
159
+ )
160
+ samples.fill(output)
161
+
162
+ const count = resampler.convert(raw, output)
163
+
164
+ // Handle encoder delay by skipping initial samples
165
+ if (this.samplesSkipped < this.totalSkipSamples) {
166
+ const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
167
+ this.samplesSkipped += samplesToSkip
168
+ if (samplesToSkip >= count) continue // Skip entire frame
169
+
170
+ // Skip partial frame
171
+ const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
172
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
173
+ const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
174
+ job.response.updateOutput({ outputArray: chunk })
175
+ } else {
176
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
177
+ const chunk = Buffer.from(samples.data.subarray(0, length))
178
+ job.response.updateOutput({ outputArray: chunk })
179
+ }
180
+ }
181
+ }
182
+
183
+ _processPacket (format, packet, raw, decoder, resampler, job) {
184
+ while (format.readFrame(packet)) {
185
+ decoder.sendPacket(packet)
186
+ this._processFrame(decoder, raw, resampler, job)
187
+ packet.unref()
188
+ }
189
+ }
190
+
191
+ _processFFmpegStream (format, stream, job) {
192
+ const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
193
+ const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
194
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate
195
+
196
+ this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
197
+
198
+ const packet = new ffmpeg.Packet()
199
+ const raw = new ffmpeg.Frame()
200
+
201
+ const resampler = new ffmpeg.Resampler(
202
+ stream.codecParameters.sampleRate,
203
+ stream.codecParameters.channelLayout,
204
+ stream.codecParameters.format,
205
+ OUTPUT_SAMPLE_RATE,
206
+ this.OUTPUT_CHANNEL_LAYOUT,
207
+ OUTPUT_FORMAT
208
+ )
209
+
210
+ const decoder = stream.decoder()
211
+ decoder.open()
212
+
213
+ // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
214
+ const codecName = stream.codec.name.toLowerCase()
215
+ const SKIP_MS = {
216
+ mp3: 400,
217
+ vorbis: 400,
218
+ opus: 150,
219
+ aac: 300
220
+ }
221
+
222
+ const skipMs = SKIP_MS[codecName] || 0
223
+ this.samplesSkipped = 0
224
+ this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000)
225
+
226
+ if (this.totalSkipSamples > 0) {
227
+ this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
228
+ }
229
+
230
+ this._processPacket(format, packet, raw, decoder, resampler, job)
231
+
232
+ // Flush resampler
233
+ const output = new ffmpeg.Frame()
234
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
235
+ output.format = OUTPUT_FORMAT
236
+ output.sampleRate = OUTPUT_SAMPLE_RATE
237
+ output.nbSamples = 1024
238
+
239
+ const samples = new ffmpeg.Samples(
240
+ output.format,
241
+ output.channelLayout.nbChannels,
242
+ output.nbSamples
243
+ )
244
+ samples.fill(output)
245
+
246
+ let flushCount
247
+ while ((flushCount = resampler.flush(output)) > 0) {
248
+ const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
249
+ const chunk = Buffer.from(samples.data.subarray(0, actualLength))
250
+ job.response.updateOutput({ outputArray: chunk })
251
+ }
252
+
253
+ decoder.destroy()
254
+ }
255
+
256
+ async _collectStreamData (audioStream, job) {
257
+ const chunks = []
258
+ let totalBytes = 0
259
+
260
+ for await (const chunk of audioStream) {
261
+ if (!job.isActive) {
262
+ this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection')
263
+ break
264
+ }
265
+
266
+ while (job.isPaused) {
267
+ this.logger.debug('[FFmpegDecoder] Job is paused, waiting to resume...')
268
+ await new Promise(resolve => setTimeout(resolve, 100))
269
+ }
270
+
271
+ chunks.push(chunk)
272
+ totalBytes += chunk.length
273
+ this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`)
274
+ }
275
+
276
+ return Buffer.concat(chunks)
277
+ }
278
+
279
+ async _processStream (audioStream) {
280
+ const job = this.currentJob
281
+ if (!job.isActive) {
282
+ return
283
+ }
284
+
285
+ try {
286
+ this.logger.info('[FFmpegDecoder] Starting stream processing')
287
+
288
+ // Collect all audio data from stream
289
+ const audioBuffer = await this._collectStreamData(audioStream, job)
290
+ this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
291
+
292
+ if (!job.isActive) {
293
+ this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
294
+ return
295
+ }
296
+
297
+ // Create FFmpeg IO context with the buffer
298
+ const bufferSize = this._getBufferSize(this.config.inputBitrate)
299
+ let bufferOffset = 0
300
+
301
+ const io = new ffmpeg.IOContext(bufferSize, {
302
+ onread: (buffer, requestedLen) => {
303
+ const remainingBytes = audioBuffer.length - bufferOffset
304
+ const bytesToRead = Math.min(requestedLen, remainingBytes)
305
+
306
+ if (bytesToRead <= 0) {
307
+ return 0 // EOF
308
+ }
309
+
310
+ audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
311
+ bufferOffset += bytesToRead
312
+
313
+ this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`)
314
+ return bytesToRead
315
+ },
316
+ onseek: (offset, whence) => {
317
+ const AVSEEK_SIZE = 0x10000
318
+
319
+ if (whence === AVSEEK_SIZE) {
320
+ return audioBuffer.length
321
+ }
322
+
323
+ let newOffset
324
+ if (whence === 0) {
325
+ newOffset = offset
326
+ } else if (whence === 1) {
327
+ newOffset = bufferOffset + offset
328
+ } else if (whence === 2) {
329
+ newOffset = audioBuffer.length + offset
330
+ } else {
331
+ return -1
332
+ }
333
+
334
+ if (newOffset < 0 || newOffset > audioBuffer.length) {
335
+ return -1
336
+ }
337
+
338
+ bufferOffset = newOffset
339
+ this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
340
+ return bufferOffset
341
+ }
342
+ })
343
+
344
+ this.logger.debug('[FFmpegDecoder] IOContext created')
345
+ const format = new ffmpeg.InputFormatContext(io)
346
+ this.logger.debug('[FFmpegDecoder] InputFormatContext created')
347
+
348
+ const streamIndex = this.config.streamIndex || 0
349
+ if (format.streams[streamIndex] === undefined) {
350
+ throw new Error('Stream index out of bounds')
351
+ }
352
+
353
+ // Process the stream and generate decoded output
354
+ this._processFFmpegStream(format, format.streams[streamIndex], job)
355
+
356
+ // Mark as complete
357
+ job.response.ended()
358
+ this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
359
+ } catch (err) {
360
+ this.logger.error('Error processing audio stream:', err)
361
+ job.response.failed(err)
362
+ }
363
+
364
+ this.logger.info('Audio _processStream completed')
365
+ }
366
+
367
+ /**
368
+ * Pause the current job
369
+ */
370
+ pause () {
371
+ if (this.currentJob) {
372
+ this.currentJob.isPaused = true
373
+ this.logger.debug('Decoder paused')
374
+ }
375
+ return Promise.resolve()
376
+ }
377
+
378
+ /**
379
+ * Unpause the current job
380
+ */
381
+ unpause () {
382
+ if (this.currentJob) {
383
+ this.currentJob.isPaused = false
384
+ this.logger.debug('Decoder unpaused')
385
+ }
386
+ return Promise.resolve()
387
+ }
388
+
389
+ /**
390
+ * Stop the current job
391
+ */
392
+ stop () {
393
+ if (this.currentJob) {
394
+ this.currentJob.isActive = false
395
+ this.currentJob.response.finish()
396
+ this.logger.debug('Decoder stopped')
397
+ }
398
+ return Promise.resolve()
399
+ }
400
+
401
+ /**
402
+ * Get the current status
403
+ */
404
+ status () {
405
+ return {
406
+ loaded: this.isLoaded,
407
+ active: this.currentJob?.isActive || false,
408
+ paused: this.currentJob?.isPaused || false
409
+ }
410
+ }
411
+ }
412
+
413
+ module.exports = { FFmpegDecoder }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/decoder-audio",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Tether",
@@ -21,7 +21,6 @@
21
21
  "constants.js",
22
22
  "constants.d.ts",
23
23
  "utils",
24
- "lib",
25
24
  "index.d.ts",
26
25
  "test/mobile"
27
26
  ],
package/utils/error.js CHANGED
@@ -40,7 +40,7 @@ addCodes({
40
40
  },
41
41
  [ERR_CODES.FAILED_TO_GET_STATUS]: {
42
42
  name: 'FAILED_TO_GET_STATUS',
43
- message: (message) => `Failed to get addon status, error: ${message}`
43
+ message: (message) => `Failed to get decoder status, error: ${message}`
44
44
  },
45
45
  [ERR_CODES.FAILED_TO_DESTROY]: {
46
46
  name: 'FAILED_TO_DESTROY',
@@ -1,413 +0,0 @@
1
- 'use strict'
2
-
3
- const QvacResponse = require('@qvac/response')
4
- const QvacLogger = require('@qvac/logging')
5
- const ffmpeg = require('bare-ffmpeg')
6
- const BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference')
7
-
8
- /**
9
- * FFmpeg-based audio decoder (single-threaded)
10
- */
11
- class FFmpegDecoder extends BaseInference {
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
20
- }
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 args - Additional arguments passed to BaseInference
32
- * @param {Object} [config.streamIndex] - Index of the stream to decode (default: 0)
33
- * @param {number} [config.inputBitrate] - Input audio bitrate (default: 192000)
34
- * @param {string} [config.audioFormat] - Output audio format (default: 'f32le')
35
- * @param {number} [config.sampleRate] - Output sample rate (default: 16000)
36
- * @param {Object} [config.logger] - Logger instance
37
- */
38
- constructor ({
39
- config = {},
40
- logger = null,
41
- streamIndex = 0,
42
- inputBitrate = 192000,
43
- audioFormat = 's16le',
44
- ...args
45
- }) {
46
- super({ ...args, logger })
47
-
48
- this.config = {
49
- streamIndex: config.streamIndex || streamIndex,
50
- inputBitrate: config.inputBitrate || inputBitrate,
51
- audioFormat: config.audioFormat || audioFormat,
52
- sampleRate: config.sampleRate || 16000
53
- }
54
-
55
- this.logger = new QvacLogger(logger)
56
- this.isLoaded = false
57
- this.currentJob = null
58
-
59
- // Encoder delay handling
60
- this.samplesSkipped = 0
61
- this.totalSkipSamples = 0
62
- }
63
-
64
- /**
65
- * Load and initialize the decoder
66
- */
67
- async load () {
68
- if (this.isLoaded) {
69
- this.logger.info('FFmpegDecoder already loaded')
70
- return
71
- }
72
-
73
- this.logger.info('Loading FFmpegDecoder with config:', this.config)
74
-
75
- // Initialize format constants
76
- this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16
77
- this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT
78
- this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
79
-
80
- // Validate audio format
81
- if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
82
- throw new Error(`Unsupported audio format: ${this.config.audioFormat}`)
83
- }
84
-
85
- this.isLoaded = true
86
- this.logger.info('FFmpegDecoder loaded successfully')
87
- }
88
-
89
- /**
90
- * Unload the decoder and clean up resources
91
- */
92
- async unload () {
93
- if (!this.isLoaded) {
94
- return
95
- }
96
-
97
- this.logger.info('Unloading FFmpegDecoder')
98
-
99
- this.isLoaded = false
100
- this.currentJob = null
101
- this.logger.info('FFmpegDecoder unloaded')
102
- }
103
-
104
- /**
105
- * Run the decoder on an audio stream
106
- * @param {Readable} audioStream - Input audio stream
107
- * @returns {QvacResponse} Response with decoded audio
108
- */
109
- async run (audioStream) {
110
- if (!this.isLoaded) {
111
- throw new Error('Decoder not loaded. Call load() first.')
112
- }
113
-
114
- this.logger.info('Starting new audio stream processing')
115
-
116
- const response = new QvacResponse({
117
- cancelHandler: () => this.stop(),
118
- pauseHandler: () => this.pause(),
119
- continueHandler: () => this.unpause()
120
- })
121
-
122
- this.currentJob = {
123
- response,
124
- audioChunks: [],
125
- isActive: true,
126
- isPaused: false
127
- }
128
-
129
- // Process the audio stream
130
- this._processStream(audioStream).catch(err => {
131
- this.logger.error('Error processing audio stream:', err)
132
- response.failed(err)
133
- })
134
-
135
- return response
136
- }
137
-
138
- _getBufferSize (inputBitrate) {
139
- const maxBufferSize = 1024 * 1024 // 1MB max
140
- return Math.min((inputBitrate / 8) * 4, maxBufferSize)
141
- }
142
-
143
- _processFrame (decoder, raw, resampler, job) {
144
- const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
145
- const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
146
- const OUTPUT_SAMPLE_RATE = this.config.sampleRate
147
-
148
- while (decoder.receiveFrame(raw)) {
149
- const output = new ffmpeg.Frame()
150
- output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
151
- output.format = OUTPUT_FORMAT
152
- output.sampleRate = OUTPUT_SAMPLE_RATE
153
- output.nbSamples = raw.nbSamples
154
-
155
- const samples = new ffmpeg.Samples(
156
- output.format,
157
- output.channelLayout.nbChannels,
158
- output.nbSamples
159
- )
160
- samples.fill(output)
161
-
162
- const count = resampler.convert(raw, output)
163
-
164
- // Handle encoder delay by skipping initial samples
165
- if (this.samplesSkipped < this.totalSkipSamples) {
166
- const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
167
- this.samplesSkipped += samplesToSkip
168
- if (samplesToSkip >= count) continue // Skip entire frame
169
-
170
- // Skip partial frame
171
- const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
172
- const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
173
- const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
174
- job.response.updateOutput({ outputArray: chunk })
175
- } else {
176
- const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
177
- const chunk = Buffer.from(samples.data.subarray(0, length))
178
- job.response.updateOutput({ outputArray: chunk })
179
- }
180
- }
181
- }
182
-
183
- _processPacket (format, packet, raw, decoder, resampler, job) {
184
- while (format.readFrame(packet)) {
185
- decoder.sendPacket(packet)
186
- this._processFrame(decoder, raw, resampler, job)
187
- packet.unref()
188
- }
189
- }
190
-
191
- _processFFmpegStream (format, stream, job) {
192
- const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
193
- const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
194
- const OUTPUT_SAMPLE_RATE = this.config.sampleRate
195
-
196
- this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
197
-
198
- const packet = new ffmpeg.Packet()
199
- const raw = new ffmpeg.Frame()
200
-
201
- const resampler = new ffmpeg.Resampler(
202
- stream.codecParameters.sampleRate,
203
- stream.codecParameters.channelLayout,
204
- stream.codecParameters.format,
205
- OUTPUT_SAMPLE_RATE,
206
- this.OUTPUT_CHANNEL_LAYOUT,
207
- OUTPUT_FORMAT
208
- )
209
-
210
- const decoder = stream.decoder()
211
- decoder.open()
212
-
213
- // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
214
- const codecName = stream.codec.name.toLowerCase()
215
- const SKIP_MS = {
216
- mp3: 400,
217
- vorbis: 400,
218
- opus: 150,
219
- aac: 300
220
- }
221
-
222
- const skipMs = SKIP_MS[codecName] || 0
223
- this.samplesSkipped = 0
224
- this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000)
225
-
226
- if (this.totalSkipSamples > 0) {
227
- this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
228
- }
229
-
230
- this._processPacket(format, packet, raw, decoder, resampler, job)
231
-
232
- // Flush resampler
233
- const output = new ffmpeg.Frame()
234
- output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
235
- output.format = OUTPUT_FORMAT
236
- output.sampleRate = OUTPUT_SAMPLE_RATE
237
- output.nbSamples = 1024
238
-
239
- const samples = new ffmpeg.Samples(
240
- output.format,
241
- output.channelLayout.nbChannels,
242
- output.nbSamples
243
- )
244
- samples.fill(output)
245
-
246
- let flushCount
247
- while ((flushCount = resampler.flush(output)) > 0) {
248
- const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
249
- const chunk = Buffer.from(samples.data.subarray(0, actualLength))
250
- job.response.updateOutput({ outputArray: chunk })
251
- }
252
-
253
- decoder.destroy()
254
- }
255
-
256
- async _collectStreamData (audioStream, job) {
257
- const chunks = []
258
- let totalBytes = 0
259
-
260
- for await (const chunk of audioStream) {
261
- if (!job.isActive) {
262
- this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection')
263
- break
264
- }
265
-
266
- while (job.isPaused) {
267
- this.logger.debug('[FFmpegDecoder] Job is paused, waiting to resume...')
268
- await new Promise(resolve => setTimeout(resolve, 100))
269
- }
270
-
271
- chunks.push(chunk)
272
- totalBytes += chunk.length
273
- this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`)
274
- }
275
-
276
- return Buffer.concat(chunks)
277
- }
278
-
279
- async _processStream (audioStream) {
280
- const job = this.currentJob
281
- if (!job.isActive) {
282
- return
283
- }
284
-
285
- try {
286
- this.logger.info('[FFmpegDecoder] Starting stream processing')
287
-
288
- // Collect all audio data from stream
289
- const audioBuffer = await this._collectStreamData(audioStream, job)
290
- this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
291
-
292
- if (!job.isActive) {
293
- this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
294
- return
295
- }
296
-
297
- // Create FFmpeg IO context with the buffer
298
- const bufferSize = this._getBufferSize(this.config.inputBitrate)
299
- let bufferOffset = 0
300
-
301
- const io = new ffmpeg.IOContext(bufferSize, {
302
- onread: (buffer, requestedLen) => {
303
- const remainingBytes = audioBuffer.length - bufferOffset
304
- const bytesToRead = Math.min(requestedLen, remainingBytes)
305
-
306
- if (bytesToRead <= 0) {
307
- return 0 // EOF
308
- }
309
-
310
- audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
311
- bufferOffset += bytesToRead
312
-
313
- this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`)
314
- return bytesToRead
315
- },
316
- onseek: (offset, whence) => {
317
- const AVSEEK_SIZE = 0x10000
318
-
319
- if (whence === AVSEEK_SIZE) {
320
- return audioBuffer.length
321
- }
322
-
323
- let newOffset
324
- if (whence === 0) {
325
- newOffset = offset
326
- } else if (whence === 1) {
327
- newOffset = bufferOffset + offset
328
- } else if (whence === 2) {
329
- newOffset = audioBuffer.length + offset
330
- } else {
331
- return -1
332
- }
333
-
334
- if (newOffset < 0 || newOffset > audioBuffer.length) {
335
- return -1
336
- }
337
-
338
- bufferOffset = newOffset
339
- this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
340
- return bufferOffset
341
- }
342
- })
343
-
344
- this.logger.debug('[FFmpegDecoder] IOContext created')
345
- const format = new ffmpeg.InputFormatContext(io)
346
- this.logger.debug('[FFmpegDecoder] InputFormatContext created')
347
-
348
- const streamIndex = this.config.streamIndex || 0
349
- if (format.streams[streamIndex] === undefined) {
350
- throw new Error('Stream index out of bounds')
351
- }
352
-
353
- // Process the stream and generate decoded output
354
- this._processFFmpegStream(format, format.streams[streamIndex], job)
355
-
356
- // Mark as complete
357
- job.response.ended()
358
- this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
359
- } catch (err) {
360
- this.logger.error('Error processing audio stream:', err)
361
- job.response.failed(err)
362
- }
363
-
364
- this.logger.info('Audio _processStream completed')
365
- }
366
-
367
- /**
368
- * Pause the current job
369
- */
370
- pause () {
371
- if (this.currentJob) {
372
- this.currentJob.isPaused = true
373
- this.logger.debug('Decoder paused')
374
- }
375
- return Promise.resolve()
376
- }
377
-
378
- /**
379
- * Unpause the current job
380
- */
381
- unpause () {
382
- if (this.currentJob) {
383
- this.currentJob.isPaused = false
384
- this.logger.debug('Decoder unpaused')
385
- }
386
- return Promise.resolve()
387
- }
388
-
389
- /**
390
- * Stop the current job
391
- */
392
- stop () {
393
- if (this.currentJob) {
394
- this.currentJob.isActive = false
395
- this.currentJob.response.finish()
396
- this.logger.debug('Decoder stopped')
397
- }
398
- return Promise.resolve()
399
- }
400
-
401
- /**
402
- * Get the current status
403
- */
404
- status () {
405
- return {
406
- loaded: this.isLoaded,
407
- active: this.currentJob?.isActive || false,
408
- paused: this.currentJob?.isPaused || false
409
- }
410
- }
411
- }
412
-
413
- module.exports = FFmpegDecoder
@@ -1,98 +0,0 @@
1
- import { Readable } from "stream";
2
- import { QvacResponse } from "@qvac/infer-base";
3
- import type { Loader, WhisperConfig } from "..";
4
-
5
- declare interface FFmpegDecoderConfig {
6
- streamIndex?: number;
7
- inputBitrate?: number;
8
- }
9
-
10
- declare interface TranscriptionAddonArgs {
11
- loader: Loader;
12
- params?: {
13
- decoder?: FFmpegDecoderConfig;
14
- [key: string]: unknown;
15
- };
16
- logger?: any;
17
- modelName?: string;
18
- vadModelName?: string;
19
- diskPath?: string;
20
- [key: string]: unknown;
21
- }
22
-
23
- declare interface TranscriptionAddonConfig {
24
- path?: string;
25
- opts?: {
26
- stats?: boolean;
27
- [key: string]: unknown;
28
- };
29
- whisperConfig?: WhisperConfig;
30
- [key: string]: unknown;
31
- }
32
-
33
- /**
34
- * TranscriptionAddon with FFmpeg decoder support
35
- *
36
- * Provides a complete transcription pipeline that:
37
- * - Decodes various audio formats (WAV, MP3, M4A, OGG, Opus, FLAC, etc.)
38
- * - Resamples to 16kHz mono
39
- * - Transcribes using Whisper model
40
- */
41
- declare class TranscriptionAddon {
42
- /**
43
- * Creates an instance of TranscriptionAddon
44
- * @param args - Configuration arguments including loader, model paths, and decoder config
45
- * @param config - Whisper configuration including VAD settings
46
- */
47
- constructor(args: TranscriptionAddonArgs, config?: TranscriptionAddonConfig);
48
-
49
- /**
50
- * Load model, decoder, and activate addon
51
- * @param closeLoader - Whether to close the loader after loading
52
- * @param reportProgress - Callback function to report loading progress
53
- */
54
- load(
55
- closeLoader?: boolean,
56
- reportProgress?: (data: any) => void
57
- ): Promise<void>;
58
-
59
- /**
60
- * Unload and clean up resources
61
- */
62
- unload(): Promise<void>;
63
-
64
- /**
65
- * Run transcription on an audio stream
66
- * @param audioStream - Audio stream in any format supported by FFmpeg
67
- * @returns Promise that resolves to a QvacResponse with transcription results
68
- */
69
- run(audioStream: Readable): Promise<QvacResponse>;
70
-
71
- /**
72
- * Download model files
73
- * @param progressReport - Optional progress report instance
74
- */
75
- download(progressReport?: any): Promise<any>;
76
-
77
- /**
78
- * Delete local model files
79
- */
80
- delete(): Promise<any>;
81
-
82
- /**
83
- * Pause inference
84
- */
85
- pause(): void;
86
-
87
- /**
88
- * Resume inference
89
- */
90
- resume(): void;
91
-
92
- /**
93
- * Destroy and clean up all resources
94
- */
95
- destroy(): Promise<void>;
96
- }
97
-
98
- export = TranscriptionAddon;
@@ -1,29 +0,0 @@
1
- 'use strict'
2
-
3
- const TranscriptionPipeline = require('@qvac/util-transcription')
4
- const TranscriptionWhispercpp = require('../index')
5
- const FFmpegDecoder = require('./ffmpeg-decoder')
6
-
7
- class TranscriptionAddon extends TranscriptionPipeline {
8
- constructor ({ loader, params, logger, ...args }, config = {}) {
9
- const whisperAddon = new TranscriptionWhispercpp(
10
- {
11
- loader,
12
- params,
13
- logger,
14
- ...args
15
- },
16
- { ...config }
17
- )
18
-
19
- // Create FFmpeg decoder instance
20
- const decoder = new FFmpegDecoder({
21
- config: { ...params.decoder, audio_format: config?.audio_format },
22
- logger
23
- })
24
-
25
- super({ whisperAddon, decoder }, params.decoder)
26
- }
27
- }
28
-
29
- module.exports = TranscriptionAddon