@qvac/decoder-audio 0.3.0 → 0.3.2

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.d.ts CHANGED
@@ -33,6 +33,17 @@ interface DecoderStatus {
33
33
  paused: boolean
34
34
  }
35
35
 
36
+ interface RuntimeStats {
37
+ decodeTimeMs: number
38
+ inputBytes: number
39
+ outputBytes: number
40
+ samplesDecoded: number
41
+ codecName: string | null
42
+ inputSampleRate: number
43
+ outputSampleRate: number
44
+ audioFormat: 's16le' | 'f32le'
45
+ }
46
+
36
47
  declare class FFmpegDecoder extends BaseInference {
37
48
  SUPPORTED_AUDIO_FORMATS: SupportedAudioFormats
38
49
  OUTPUT_CHANNEL_LAYOUT: number | null
@@ -46,6 +57,8 @@ declare class FFmpegDecoder extends BaseInference {
46
57
  unpause(): Promise<void>
47
58
  stop(): Promise<void>
48
59
  status(): DecoderStatus
60
+
61
+ runtimeStats(): RuntimeStats
49
62
  }
50
63
 
51
- export { FFmpegDecoder }
64
+ export { FFmpegDecoder, RuntimeStats }
package/index.js CHANGED
@@ -1,4 +1,474 @@
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
+ // Runtime stats
64
+ this._resetStats()
65
+ }
66
+
67
+ /**
68
+ * Resets the runtime stats
69
+ */
70
+ _resetStats () {
71
+ this._runtimeStats = {
72
+ decodeTimeMs: 0,
73
+ inputBytes: 0,
74
+ outputBytes: 0,
75
+ samplesDecoded: 0,
76
+ codecName: null,
77
+ inputSampleRate: 0,
78
+ outputSampleRate: this.config.sampleRate,
79
+ audioFormat: this.config.audioFormat
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the current runtime stats
85
+ * @returns {Object} Current runtime stats
86
+ */
87
+ runtimeStats () {
88
+ return { ...this._runtimeStats }
89
+ }
90
+
91
+ /**
92
+ * Load and initialize the decoder
93
+ */
94
+ async load () {
95
+ if (this.isLoaded) {
96
+ this.logger.info('FFmpegDecoder already loaded')
97
+ return
98
+ }
99
+
100
+ this.logger.info('Loading FFmpegDecoder with config:', this.config)
101
+
102
+ // Initialize format constants
103
+ this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16
104
+ this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT
105
+ this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
106
+
107
+ // Validate audio format
108
+ if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
109
+ throw new Error(`Unsupported audio format: ${this.config.audioFormat}`)
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.currentJob = null
128
+ this.logger.info('FFmpegDecoder unloaded')
129
+ }
130
+
131
+ /**
132
+ * Run the decoder on an audio stream
133
+ * @param {Readable} audioStream - Input audio stream
134
+ * @returns {QvacResponse} Response with decoded audio
135
+ */
136
+ async run (audioStream) {
137
+ if (!this.isLoaded) {
138
+ throw new Error('Decoder not loaded. Call load() first.')
139
+ }
140
+
141
+ this.logger.info('Starting new audio stream processing')
142
+
143
+ const response = new QvacResponse({
144
+ cancelHandler: () => this.stop(),
145
+ pauseHandler: () => this.pause(),
146
+ continueHandler: () => this.unpause()
147
+ })
148
+
149
+ this.currentJob = {
150
+ response,
151
+ audioChunks: [],
152
+ isActive: true,
153
+ isPaused: false
154
+ }
155
+
156
+ // Process the audio stream
157
+ this._processStream(audioStream).catch(err => {
158
+ this.logger.error('Error processing audio stream:', err)
159
+ response.failed(err)
160
+ })
161
+
162
+ return response
163
+ }
164
+
165
+ _getBufferSize (inputBitrate) {
166
+ const maxBufferSize = 1024 * 1024 // 1MB max
167
+ return Math.min((inputBitrate / 8) * 4, maxBufferSize)
168
+ }
169
+
170
+ _processFrame (decoder, raw, resampler, job) {
171
+ const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
172
+ const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
173
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate
174
+
175
+ while (decoder.receiveFrame(raw)) {
176
+ const output = new ffmpeg.Frame()
177
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
178
+ output.format = OUTPUT_FORMAT
179
+ output.sampleRate = OUTPUT_SAMPLE_RATE
180
+ output.nbSamples = raw.nbSamples
181
+
182
+ const samples = new ffmpeg.Samples(
183
+ output.format,
184
+ output.channelLayout.nbChannels,
185
+ output.nbSamples
186
+ )
187
+ samples.fill(output)
188
+
189
+ const count = resampler.convert(raw, output)
190
+
191
+ // Handle encoder delay by skipping initial samples
192
+ if (this.samplesSkipped < this.totalSkipSamples) {
193
+ const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
194
+ this.samplesSkipped += samplesToSkip
195
+ if (samplesToSkip >= count) continue // Skip entire frame
196
+
197
+ // Skip partial frame
198
+ const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
199
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
200
+ const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
201
+ job.response.updateOutput({ outputArray: chunk })
202
+
203
+ // Track stats for partial frame
204
+ this._runtimeStats.samplesDecoded += (count - samplesToSkip)
205
+ this._runtimeStats.outputBytes += length
206
+ } else {
207
+ const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
208
+ const chunk = Buffer.from(samples.data.subarray(0, length))
209
+ job.response.updateOutput({ outputArray: chunk })
210
+
211
+ // Track stats
212
+ this._runtimeStats.samplesDecoded += count
213
+ this._runtimeStats.outputBytes += length
214
+ }
215
+ }
216
+ }
217
+
218
+ _processPacket (format, packet, raw, decoder, resampler, job) {
219
+ while (format.readFrame(packet)) {
220
+ decoder.sendPacket(packet)
221
+ this._processFrame(decoder, raw, resampler, job)
222
+ packet.unref()
223
+ }
224
+ }
225
+
226
+ _processFFmpegStream (format, stream, job) {
227
+ const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
228
+ const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
229
+ const OUTPUT_SAMPLE_RATE = this.config.sampleRate
230
+
231
+ this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
232
+
233
+ // Track codec info in stats
234
+ this._runtimeStats.codecName = stream.codec.name
235
+ this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate
236
+
237
+ const packet = new ffmpeg.Packet()
238
+ const raw = new ffmpeg.Frame()
239
+
240
+ const resampler = new ffmpeg.Resampler(
241
+ stream.codecParameters.sampleRate,
242
+ stream.codecParameters.channelLayout,
243
+ stream.codecParameters.format,
244
+ OUTPUT_SAMPLE_RATE,
245
+ this.OUTPUT_CHANNEL_LAYOUT,
246
+ OUTPUT_FORMAT
247
+ )
248
+
249
+ const decoder = stream.decoder()
250
+ decoder.open()
251
+
252
+ // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
253
+ const codecName = stream.codec.name.toLowerCase()
254
+ const SKIP_MS = {
255
+ mp3: 400,
256
+ vorbis: 400,
257
+ opus: 150,
258
+ aac: 300
259
+ }
260
+
261
+ const skipMs = SKIP_MS[codecName] || 0
262
+ this.samplesSkipped = 0
263
+ this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000)
264
+
265
+ if (this.totalSkipSamples > 0) {
266
+ this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
267
+ }
268
+
269
+ this._processPacket(format, packet, raw, decoder, resampler, job)
270
+
271
+ // Flush resampler
272
+ const output = new ffmpeg.Frame()
273
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
274
+ output.format = OUTPUT_FORMAT
275
+ output.sampleRate = OUTPUT_SAMPLE_RATE
276
+ output.nbSamples = 1024
277
+
278
+ const samples = new ffmpeg.Samples(
279
+ output.format,
280
+ output.channelLayout.nbChannels,
281
+ output.nbSamples
282
+ )
283
+ samples.fill(output)
284
+
285
+ let flushCount
286
+ while ((flushCount = resampler.flush(output)) > 0) {
287
+ const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
288
+ const chunk = Buffer.from(samples.data.subarray(0, actualLength))
289
+ job.response.updateOutput({ outputArray: chunk })
290
+
291
+ // Track stats for flushed samples
292
+ this._runtimeStats.samplesDecoded += flushCount
293
+ this._runtimeStats.outputBytes += actualLength
294
+ }
295
+
296
+ decoder.destroy()
297
+ }
298
+
299
+ async _collectStreamData (audioStream, job) {
300
+ const chunks = []
301
+ let totalBytes = 0
302
+
303
+ for await (const chunk of audioStream) {
304
+ if (!job.isActive) {
305
+ this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection')
306
+ break
307
+ }
308
+
309
+ while (job.isPaused) {
310
+ this.logger.debug('[FFmpegDecoder] Job is paused, waiting to resume...')
311
+ await new Promise(resolve => setTimeout(resolve, 100))
312
+ }
313
+
314
+ chunks.push(chunk)
315
+ totalBytes += chunk.length
316
+ this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`)
317
+ }
318
+
319
+ return Buffer.concat(chunks)
320
+ }
321
+
322
+ async _processStream (audioStream) {
323
+ const job = this.currentJob
324
+ if (!job.isActive) {
325
+ return
326
+ }
327
+
328
+ // Reset and start tracking stats
329
+ this._resetStats()
330
+ const startTime = Date.now()
331
+
332
+ try {
333
+ this.logger.info('[FFmpegDecoder] Starting stream processing')
334
+
335
+ // Collect all audio data from stream
336
+ const audioBuffer = await this._collectStreamData(audioStream, job)
337
+ this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
338
+
339
+ // Track input bytes
340
+ this._runtimeStats.inputBytes = audioBuffer.length
341
+
342
+ if (!job.isActive) {
343
+ this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
344
+ return
345
+ }
346
+
347
+ // Create FFmpeg IO context with the buffer
348
+ const bufferSize = this._getBufferSize(this.config.inputBitrate)
349
+ let bufferOffset = 0
350
+
351
+ const io = new ffmpeg.IOContext(bufferSize, {
352
+ onread: (buffer, requestedLen) => {
353
+ const remainingBytes = audioBuffer.length - bufferOffset
354
+ const bytesToRead = Math.min(requestedLen, remainingBytes)
355
+
356
+ if (bytesToRead <= 0) {
357
+ return 0 // EOF
358
+ }
359
+
360
+ audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
361
+ bufferOffset += bytesToRead
362
+
363
+ this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`)
364
+ return bytesToRead
365
+ },
366
+ onseek: (offset, whence) => {
367
+ const AVSEEK_SIZE = 0x10000
368
+
369
+ if (whence === AVSEEK_SIZE) {
370
+ return audioBuffer.length
371
+ }
372
+
373
+ let newOffset
374
+ if (whence === 0) {
375
+ newOffset = offset
376
+ } else if (whence === 1) {
377
+ newOffset = bufferOffset + offset
378
+ } else if (whence === 2) {
379
+ newOffset = audioBuffer.length + offset
380
+ } else {
381
+ return -1
382
+ }
383
+
384
+ if (newOffset < 0 || newOffset > audioBuffer.length) {
385
+ return -1
386
+ }
387
+
388
+ bufferOffset = newOffset
389
+ this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
390
+ return bufferOffset
391
+ }
392
+ })
393
+
394
+ this.logger.debug('[FFmpegDecoder] IOContext created')
395
+ const format = new ffmpeg.InputFormatContext(io)
396
+ this.logger.debug('[FFmpegDecoder] InputFormatContext created')
397
+
398
+ const streamIndex = this.config.streamIndex || 0
399
+ if (format.streams[streamIndex] === undefined) {
400
+ throw new Error('Stream index out of bounds')
401
+ }
402
+
403
+ // Process the stream and generate decoded output
404
+ this._processFFmpegStream(format, format.streams[streamIndex], job)
405
+
406
+ // Calculate final decode time
407
+ this._runtimeStats.decodeTimeMs = Date.now() - startTime
408
+
409
+ // Update stats on response before ending
410
+ job.response.updateStats(this.runtimeStats())
411
+
412
+ // Mark as complete
413
+ job.response.ended()
414
+ this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
415
+ this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`)
416
+ } catch (err) {
417
+ // Still capture stats even on error
418
+ this._runtimeStats.decodeTimeMs = Date.now() - startTime
419
+ job.response.updateStats(this.runtimeStats())
420
+
421
+ this.logger.error('Error processing audio stream:', err)
422
+ job.response.failed(err)
423
+ }
424
+
425
+ this.logger.info('Audio _processStream completed')
426
+ }
427
+
428
+ /**
429
+ * Pause the current job
430
+ */
431
+ pause () {
432
+ if (this.currentJob) {
433
+ this.currentJob.isPaused = true
434
+ this.logger.debug('Decoder paused')
435
+ }
436
+ return Promise.resolve()
437
+ }
438
+
439
+ /**
440
+ * Unpause the current job
441
+ */
442
+ unpause () {
443
+ if (this.currentJob) {
444
+ this.currentJob.isPaused = false
445
+ this.logger.debug('Decoder unpaused')
446
+ }
447
+ return Promise.resolve()
448
+ }
449
+
450
+ /**
451
+ * Stop the current job
452
+ */
453
+ stop () {
454
+ if (this.currentJob) {
455
+ this.currentJob.isActive = false
456
+ this.currentJob.response.finish()
457
+ this.logger.debug('Decoder stopped')
458
+ }
459
+ return Promise.resolve()
460
+ }
461
+
462
+ /**
463
+ * Get the current status
464
+ */
465
+ status () {
466
+ return {
467
+ loaded: this.isLoaded,
468
+ active: this.currentJob?.isActive || false,
469
+ paused: this.currentJob?.isPaused || false
470
+ }
471
+ }
472
+ }
473
+
474
+ 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.2",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Tether",
@@ -12,16 +12,18 @@
12
12
  "test": "npm run test:unit && npm run test:integration",
13
13
  "coverage:unit": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
14
14
  "coverage": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
15
- "lint": "standard \"test/**/*.js\" \"*.js\"",
16
- "lint:fix": "standard --fix \"test/**/*.js\" \"*.js\"",
17
- "test:integration": "brittle-bare test/integration/*.test.js"
15
+ "lint": "standard \"test/**/*.js\" \"*.js\" \"scripts/**/*.js\"",
16
+ "lint:fix": "standard --fix \"test/**/*.js\" \"*.js\" \"scripts/**/*.js\"",
17
+ "test:integration": "npm run test:integration:generate && brittle-bare test/integration/*.test.js",
18
+ "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate",
19
+ "test:mobile:generate": "bare ./scripts/generate-mobile-integration-tests.js",
20
+ "test:mobile:validate": "node scripts/validate-mobile-tests.js"
18
21
  },
19
22
  "files": [
20
23
  "index.js",
21
24
  "constants.js",
22
25
  "constants.d.ts",
23
26
  "utils",
24
- "lib",
25
27
  "index.d.ts",
26
28
  "test/mobile"
27
29
  ],
@@ -33,6 +35,8 @@
33
35
  },
34
36
  "devDependencies": {
35
37
  "@types/node": "^22.14.1",
38
+ "bare-os": "^3.6.2",
39
+ "bare-url": "^2.1.6",
36
40
  "brittle": "^3.13.1",
37
41
  "istanbul": "^0.4.5",
38
42
  "standard": "^17.1.2",
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const path = require('bare-path')
4
+ const fs = require('bare-fs')
5
+ const { pathToFileURL } = require('bare-url')
6
+
7
+ async function runIntegrationModule (relativeModulePath, options = {}) {
8
+ const modulePath = path.join(__dirname, relativeModulePath)
9
+
10
+ if (!fs.existsSync(modulePath)) {
11
+ console.warn(`[integration-runner] Missing module: ${relativeModulePath}`)
12
+ return 'missing'
13
+ }
14
+
15
+ const moduleUrl = pathToFileURL(modulePath).href
16
+ await import(moduleUrl)
17
+ return modulePath
18
+ }
19
+
20
+ global.runIntegrationModule = runIntegrationModule
21
+
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+ require('./integration-runtime.cjs')
3
+
4
+ // AUTO-GENERATED FILE. Run `npm run test:mobile:generate` to update.
5
+ // Each function mirrors a single file under test/integration/.
6
+
7
+ /* global runIntegrationModule */
8
+
9
+ async function runFfmpegDecoderTest (options = {}) { // eslint-disable-line no-unused-vars
10
+ return runIntegrationModule('../integration/ffmpeg-decoder.test.js', options)
11
+ }
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
@@ -1,105 +0,0 @@
1
- const { FFmpegDecoder } = require('@qvac/decoder-audio')
2
-
3
- async function testFFmpegDecodeMp3() {
4
- try {
5
- const decoder = new FFmpegDecoder({
6
- config: {
7
- audioFormat: 's16le',
8
- sampleRate: 16000
9
- }
10
- })
11
-
12
- await decoder.load()
13
-
14
- const audioPath = getAssetPath('sample.mp3')
15
- const audioStream = fs.createReadStream(audioPath)
16
-
17
- const response = await decoder.run(audioStream)
18
-
19
- let totalBytes = 0
20
- let updateCount = 0
21
-
22
- await response
23
- .onUpdate(output => {
24
- if (output && output.outputArray) {
25
- const bytes = new Uint8Array(output.outputArray)
26
- totalBytes += bytes.length
27
- updateCount++
28
- }
29
- })
30
- .await()
31
-
32
- if (totalBytes === 0) {
33
- throw new Error('No audio data decoded')
34
- }
35
-
36
- if (updateCount === 0) {
37
- throw new Error('No decoder updates received')
38
- }
39
-
40
- console.log(`MP3 decode complete: ${totalBytes} bytes, ${updateCount} updates`)
41
-
42
- await decoder.unload()
43
-
44
- return {
45
- success: true,
46
- totalBytes,
47
- updateCount
48
- }
49
- } catch (error) {
50
- console.error('Error during testFFmpegDecodeMp3:', error)
51
- throw error
52
- }
53
- }
54
-
55
- async function testFFmpegDecodeWav() {
56
- try {
57
- const decoder = new FFmpegDecoder({
58
- config: {
59
- audioFormat: 's16le',
60
- sampleRate: 16000
61
- }
62
- })
63
-
64
- await decoder.load()
65
-
66
- const audioPath = getAssetPath('sample.wav')
67
- const audioStream = fs.createReadStream(audioPath)
68
-
69
- const response = await decoder.run(audioStream)
70
-
71
- let totalBytes = 0
72
- let updateCount = 0
73
-
74
- await response
75
- .onUpdate(output => {
76
- if (output && output.outputArray) {
77
- const bytes = new Uint8Array(output.outputArray)
78
- totalBytes += bytes.length
79
- updateCount++
80
- }
81
- })
82
- .await()
83
-
84
- if (totalBytes === 0) {
85
- throw new Error('No audio data decoded')
86
- }
87
-
88
- if (updateCount === 0) {
89
- throw new Error('No decoder updates received')
90
- }
91
-
92
- console.log(`MP3 decode complete: ${totalBytes} bytes, ${updateCount} updates`)
93
-
94
- await decoder.unload()
95
-
96
- return {
97
- success: true,
98
- totalBytes,
99
- updateCount
100
- }
101
- } catch (error) {
102
- console.error('Error during testFFmpegDecodeMp3:', error)
103
- throw error
104
- }
105
- }