@qvac/decoder-audio 0.3.8 → 0.4.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # qvac-lib-decoder-audio
1
+ # decoder-audio
2
2
 
3
3
  This decoder library leverages FFmpeg for efficient audio decoding. It simplifies processing of input audio, particularly as a preprocessing step for other addons.
4
4
 
@@ -240,7 +240,7 @@ Coverage reports are generated in the 'coverage/unit/' directory. Open the corre
240
240
 
241
241
  ## Resources
242
242
 
243
- * GitHub Repo: [tetherto/qvac](https://github.com/tetherto/qvac/tree/main/packages/qvac-lib-decoder-audio)
243
+ * GitHub Repo: [tetherto/qvac](https://github.com/tetherto/qvac/tree/main/packages/decoder-audio)
244
244
 
245
245
  ## License
246
246
 
package/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference')
2
- import QvacResponse from '@qvac/response'
1
+ import { QvacResponse } from '@qvac/infer-base'
3
2
 
4
3
  interface AudioFormatConfig {
5
4
  format: number | null
@@ -24,13 +23,6 @@ interface FFmpegDecoderConstructorParams {
24
23
  streamIndex?: number
25
24
  inputBitrate?: number
26
25
  audioFormat?: 's16le' | 'f32le'
27
- [key: string]: any
28
- }
29
-
30
- interface DecoderStatus {
31
- loaded: boolean
32
- active: boolean
33
- paused: boolean
34
26
  }
35
27
 
36
28
  export interface DecoderOutput {
@@ -48,20 +40,16 @@ interface RuntimeStats {
48
40
  audioFormat: 's16le' | 'f32le'
49
41
  }
50
42
 
51
- declare class FFmpegDecoder extends BaseInference {
43
+ declare class FFmpegDecoder {
52
44
  SUPPORTED_AUDIO_FORMATS: SupportedAudioFormats
53
45
  OUTPUT_CHANNEL_LAYOUT: number | null
54
46
 
55
- constructor(params: FFmpegDecoderConstructorParams)
47
+ constructor(params?: FFmpegDecoderConstructorParams)
56
48
 
57
49
  load(): Promise<void>
58
50
  unload(): Promise<void>
59
- run(audioStream: AsyncIterable<Buffer>): Promise<QvacResponse<DecoderOutput>>
60
- pause(): Promise<void>
61
- unpause(): Promise<void>
62
- stop(): Promise<void>
63
- status(): DecoderStatus
64
-
51
+ run(audioStream: AsyncIterable<Buffer>): QvacResponse<DecoderOutput>
52
+
65
53
  runtimeStats(): RuntimeStats
66
54
  }
67
55
 
package/index.js CHANGED
@@ -1,15 +1,14 @@
1
1
  'use strict'
2
2
 
3
- const QvacResponse = require('@qvac/response')
4
3
  const QvacLogger = require('@qvac/logging')
5
4
  const ffmpeg = require('bare-ffmpeg')
6
- const BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference')
5
+ const { createJobHandler } = require('@qvac/infer-base')
7
6
  const { QvacErrorDecoderAudio, ERR_CODES } = require('./utils/error')
8
7
 
9
8
  /**
10
9
  * FFmpeg-based audio decoder (single-threaded)
11
10
  */
12
- class FFmpegDecoder extends BaseInference {
11
+ class FFmpegDecoder {
13
12
  SUPPORTED_AUDIO_FORMATS = {
14
13
  s16le: {
15
14
  format: null, // Will be set to ffmpeg.constants.sampleFormats.S16
@@ -29,7 +28,6 @@ class FFmpegDecoder extends BaseInference {
29
28
  * @param streamIndex - Index of the stream to decode. Default: 0
30
29
  * @param inputBitrate - Input audio bitrate. Default: 192000
31
30
  * @param audioFormat - Output audio format. Default: 's16le'
32
- * @param args - Additional arguments passed to BaseInference
33
31
  * @param {Object} [config.streamIndex] - Index of the stream to decode (default: 0)
34
32
  * @param {number} [config.inputBitrate] - Input audio bitrate (default: 192000)
35
33
  * @param {string} [config.audioFormat] - Output audio format (default: 'f32le')
@@ -41,11 +39,8 @@ class FFmpegDecoder extends BaseInference {
41
39
  logger = null,
42
40
  streamIndex = 0,
43
41
  inputBitrate = 192000,
44
- audioFormat = 's16le',
45
- ...args
46
- }) {
47
- super({ ...args, logger })
48
-
42
+ audioFormat = 's16le'
43
+ } = {}) {
49
44
  this.config = {
50
45
  streamIndex: config.streamIndex || streamIndex,
51
46
  inputBitrate: config.inputBitrate || inputBitrate,
@@ -55,7 +50,8 @@ class FFmpegDecoder extends BaseInference {
55
50
 
56
51
  this.logger = new QvacLogger(logger)
57
52
  this.isLoaded = false
58
- this.currentJob = null
53
+ this._cancelled = false
54
+ this._job = createJobHandler({ cancel: () => this._cancelCurrent() })
59
55
 
60
56
  // Encoder delay handling
61
57
  this.samplesSkipped = 0
@@ -128,7 +124,8 @@ class FFmpegDecoder extends BaseInference {
128
124
  this.logger.info('Unloading FFmpegDecoder')
129
125
 
130
126
  this.isLoaded = false
131
- this.currentJob = null
127
+ this._cancelCurrent()
128
+ this._job.fail(new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED }))
132
129
  this.logger.info('FFmpegDecoder unloaded')
133
130
  }
134
131
 
@@ -137,41 +134,41 @@ class FFmpegDecoder extends BaseInference {
137
134
  * @param {Readable} audioStream - Input audio stream
138
135
  * @returns {QvacResponse} Response with decoded audio
139
136
  */
140
- async run (audioStream) {
137
+ run (audioStream) {
141
138
  if (!this.isLoaded) {
142
139
  throw new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED })
143
140
  }
144
141
 
145
142
  this.logger.info('Starting new audio stream processing')
146
143
 
147
- const response = new QvacResponse({
148
- cancelHandler: () => this.stop(),
149
- pauseHandler: () => this.pause(),
150
- continueHandler: () => this.unpause()
151
- })
152
-
153
- this.currentJob = {
154
- response,
155
- audioChunks: [],
156
- isActive: true,
157
- isPaused: false
158
- }
144
+ this._cancelled = false
145
+ const response = this._job.start()
159
146
 
160
- // Process the audio stream
161
- this._processStream(audioStream).catch(err => {
162
- this.logger.error('Error processing audio stream:', err)
163
- response.failed(err)
164
- })
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
+ })
165
156
 
166
157
  return response
167
158
  }
168
159
 
160
+ _cancelCurrent () {
161
+ this._cancelled = true
162
+ this.logger.debug('Decoder cancel requested')
163
+ return Promise.resolve()
164
+ }
165
+
169
166
  _getBufferSize (inputBitrate) {
170
167
  const maxBufferSize = 1024 * 1024 // 1MB max
171
168
  return Math.min((inputBitrate / 8) * 4, maxBufferSize)
172
169
  }
173
170
 
174
- _processFrame (decoder, raw, resampler, job) {
171
+ _processFrame (decoder, raw, resampler) {
175
172
  const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
176
173
  const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
177
174
  const OUTPUT_SAMPLE_RATE = this.config.sampleRate
@@ -202,7 +199,7 @@ class FFmpegDecoder extends BaseInference {
202
199
  const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
203
200
  const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
204
201
  const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
205
- job.response.updateOutput({ outputArray: chunk })
202
+ this._job.output({ outputArray: chunk })
206
203
 
207
204
  // Track stats for partial frame
208
205
  this._runtimeStats.samplesDecoded += (count - samplesToSkip)
@@ -210,7 +207,7 @@ class FFmpegDecoder extends BaseInference {
210
207
  } else {
211
208
  const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
212
209
  const chunk = Buffer.from(samples.data.subarray(0, length))
213
- job.response.updateOutput({ outputArray: chunk })
210
+ this._job.output({ outputArray: chunk })
214
211
 
215
212
  // Track stats
216
213
  this._runtimeStats.samplesDecoded += count
@@ -219,15 +216,19 @@ class FFmpegDecoder extends BaseInference {
219
216
  }
220
217
  }
221
218
 
222
- _processPacket (format, packet, raw, decoder, resampler, job) {
219
+ _processPacket (format, packet, raw, decoder, resampler) {
223
220
  while (format.readFrame(packet)) {
221
+ if (this._cancelled) {
222
+ packet.unref()
223
+ throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
224
+ }
224
225
  decoder.sendPacket(packet)
225
- this._processFrame(decoder, raw, resampler, job)
226
+ this._processFrame(decoder, raw, resampler)
226
227
  packet.unref()
227
228
  }
228
229
  }
229
230
 
230
- _processFFmpegStream (format, stream, job) {
231
+ _processFFmpegStream (format, stream) {
231
232
  const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
232
233
  const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
233
234
  const OUTPUT_SAMPLE_RATE = this.config.sampleRate
@@ -270,7 +271,7 @@ class FFmpegDecoder extends BaseInference {
270
271
  this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
271
272
  }
272
273
 
273
- this._processPacket(format, packet, raw, decoder, resampler, job)
274
+ this._processPacket(format, packet, raw, decoder, resampler)
274
275
 
275
276
  // Flush resampler
276
277
  const output = new ffmpeg.Frame()
@@ -290,7 +291,7 @@ class FFmpegDecoder extends BaseInference {
290
291
  while ((flushCount = resampler.flush(output)) > 0) {
291
292
  const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
292
293
  const chunk = Buffer.from(samples.data.subarray(0, actualLength))
293
- job.response.updateOutput({ outputArray: chunk })
294
+ this._job.output({ outputArray: chunk })
294
295
 
295
296
  // Track stats for flushed samples
296
297
  this._runtimeStats.samplesDecoded += flushCount
@@ -300,19 +301,14 @@ class FFmpegDecoder extends BaseInference {
300
301
  decoder.destroy()
301
302
  }
302
303
 
303
- async _collectStreamData (audioStream, job) {
304
+ async _collectStreamData (audioStream) {
304
305
  const chunks = []
305
306
  let totalBytes = 0
306
307
 
307
308
  for await (const chunk of audioStream) {
308
- if (!job.isActive) {
309
+ if (this._cancelled) {
309
310
  this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection')
310
- break
311
- }
312
-
313
- while (job.isPaused) {
314
- this.logger.debug('[FFmpegDecoder] Job is paused, waiting to resume...')
315
- await new Promise(resolve => setTimeout(resolve, 100))
311
+ throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
316
312
  }
317
313
 
318
314
  chunks.push(chunk)
@@ -324,157 +320,92 @@ class FFmpegDecoder extends BaseInference {
324
320
  }
325
321
 
326
322
  async _processStream (audioStream) {
327
- const job = this.currentJob
328
- if (!job.isActive) {
329
- return
330
- }
331
-
332
323
  // Reset and start tracking stats
333
324
  this._resetStats()
334
325
  const startTime = Date.now()
335
326
 
336
- try {
337
- this.logger.info('[FFmpegDecoder] Starting stream processing')
327
+ this.logger.info('[FFmpegDecoder] Starting stream processing')
338
328
 
339
- // Collect all audio data from stream
340
- const audioBuffer = await this._collectStreamData(audioStream, job)
341
- this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
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`)
342
332
 
343
- // Track input bytes
344
- this._runtimeStats.inputBytes = audioBuffer.length
333
+ // Track input bytes
334
+ this._runtimeStats.inputBytes = audioBuffer.length
345
335
 
346
- if (!job.isActive) {
347
- this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
348
- return
349
- }
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 })
340
+ }
350
341
 
351
- // Create FFmpeg IO context with the buffer
352
- const bufferSize = this._getBufferSize(this.config.inputBitrate)
353
- let bufferOffset = 0
354
-
355
- const io = new ffmpeg.IOContext(bufferSize, {
356
- onread: (buffer, requestedLen) => {
357
- const remainingBytes = audioBuffer.length - bufferOffset
358
- const bytesToRead = Math.min(requestedLen, remainingBytes)
359
-
360
- if (bytesToRead <= 0) {
361
- return 0 // EOF
362
- }
363
-
364
- audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
365
- bufferOffset += bytesToRead
366
-
367
- this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`)
368
- return bytesToRead
369
- },
370
- onseek: (offset, whence) => {
371
- const AVSEEK_SIZE = 0x10000
372
-
373
- if (whence === AVSEEK_SIZE) {
374
- return audioBuffer.length
375
- }
376
-
377
- let newOffset
378
- if (whence === 0) {
379
- newOffset = offset
380
- } else if (whence === 1) {
381
- newOffset = bufferOffset + offset
382
- } else if (whence === 2) {
383
- newOffset = audioBuffer.length + offset
384
- } else {
385
- return -1
386
- }
387
-
388
- if (newOffset < 0 || newOffset > audioBuffer.length) {
389
- return -1
390
- }
391
-
392
- bufferOffset = newOffset
393
- this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
394
- return bufferOffset
395
- }
396
- })
342
+ // Create FFmpeg IO context with the buffer
343
+ const bufferSize = this._getBufferSize(this.config.inputBitrate)
344
+ let bufferOffset = 0
397
345
 
398
- this.logger.debug('[FFmpegDecoder] IOContext created')
399
- const format = new ffmpeg.InputFormatContext(io)
400
- this.logger.debug('[FFmpegDecoder] InputFormatContext created')
346
+ const io = new ffmpeg.IOContext(bufferSize, {
347
+ onread: (buffer, requestedLen) => {
348
+ const remainingBytes = audioBuffer.length - bufferOffset
349
+ const bytesToRead = Math.min(requestedLen, remainingBytes)
401
350
 
402
- const streamIndex = this.config.streamIndex || 0
403
- if (format.streams[streamIndex] === undefined) {
404
- throw new QvacErrorDecoderAudio({
405
- code: ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS,
406
- adds: streamIndex
407
- })
408
- }
351
+ if (bytesToRead <= 0) {
352
+ return 0 // EOF
353
+ }
409
354
 
410
- // Process the stream and generate decoded output
411
- this._processFFmpegStream(format, format.streams[streamIndex], job)
355
+ audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead)
356
+ bufferOffset += bytesToRead
412
357
 
413
- // Calculate final decode time
414
- this._runtimeStats.decodeTimeMs = Date.now() - startTime
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
415
363
 
416
- // Update stats on response before ending
417
- job.response.updateStats(this.runtimeStats())
364
+ if (whence === AVSEEK_SIZE) {
365
+ return audioBuffer.length
366
+ }
418
367
 
419
- // Mark as complete
420
- job.response.ended()
421
- this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
422
- this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`)
423
- } catch (err) {
424
- // Still capture stats even on error
425
- this._runtimeStats.decodeTimeMs = Date.now() - startTime
426
- job.response.updateStats(this.runtimeStats())
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
377
+ }
427
378
 
428
- this.logger.error('Error processing audio stream:', err)
429
- job.response.failed(err)
430
- }
379
+ if (newOffset < 0 || newOffset > audioBuffer.length) {
380
+ return -1
381
+ }
431
382
 
432
- this.logger.info('Audio _processStream completed')
433
- }
383
+ bufferOffset = newOffset
384
+ this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`)
385
+ return bufferOffset
386
+ }
387
+ })
434
388
 
435
- /**
436
- * Pause the current job
437
- */
438
- pause () {
439
- if (this.currentJob) {
440
- this.currentJob.isPaused = true
441
- this.logger.debug('Decoder paused')
442
- }
443
- return Promise.resolve()
444
- }
389
+ this.logger.debug('[FFmpegDecoder] IOContext created')
390
+ const format = new ffmpeg.InputFormatContext(io)
391
+ this.logger.debug('[FFmpegDecoder] InputFormatContext created')
445
392
 
446
- /**
447
- * Unpause the current job
448
- */
449
- unpause () {
450
- if (this.currentJob) {
451
- this.currentJob.isPaused = false
452
- this.logger.debug('Decoder unpaused')
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
+ })
453
399
  }
454
- return Promise.resolve()
455
- }
456
400
 
457
- /**
458
- * Stop the current job
459
- */
460
- stop () {
461
- if (this.currentJob) {
462
- this.currentJob.isActive = false
463
- this.currentJob.response.finish()
464
- this.logger.debug('Decoder stopped')
465
- }
466
- return Promise.resolve()
467
- }
401
+ // Process the stream and generate decoded output
402
+ this._processFFmpegStream(format, format.streams[streamIndex])
468
403
 
469
- /**
470
- * Get the current status
471
- */
472
- status () {
473
- return {
474
- loaded: this.isLoaded,
475
- active: this.currentJob?.isActive || false,
476
- paused: this.currentJob?.isPaused || false
477
- }
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)}`)
478
409
  }
479
410
  }
480
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/decoder-audio",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Tether",
@@ -48,21 +48,19 @@
48
48
  "@qvac/error": "^0.1.0",
49
49
  "@qvac/infer-base": "^0.4.0",
50
50
  "@qvac/logging": "^0.1.0",
51
- "@qvac/response": "^0.1.0",
52
51
  "bare-assert": "^1.1.0",
53
52
  "bare-channel": "^5.2.2",
54
53
  "bare-ffmpeg": "^1.0.0-32",
55
54
  "bare-fs": "^4.5.1",
56
55
  "bare-path": "^3.0.0",
57
- "bare-process": "^4.2.2",
58
- "process": "npm:bare-process@^4.2.2"
56
+ "bare-process": "^4.2.2"
59
57
  },
60
58
  "repository": {
61
59
  "type": "git",
62
60
  "url": "git+https://github.com/tetherto/qvac.git",
63
- "directory": "packages/qvac-lib-decoder-audio"
61
+ "directory": "packages/decoder-audio"
64
62
  },
65
63
  "bugs": "https://github.com/tetherto/qvac/issues",
66
- "homepage": "https://github.com/tetherto/qvac/tree/main/packages/qvac-lib-decoder-audio#readme",
64
+ "homepage": "https://github.com/tetherto/qvac/tree/main/packages/decoder-audio#readme",
67
65
  "types": "index.d.ts"
68
66
  }
package/utils/error.js CHANGED
@@ -17,7 +17,8 @@ const ERR_CODES = Object.freeze({
17
17
  BUFFER_SIZE_TOO_SMALL: 11008,
18
18
  UNSUPPORTED_AUDIO_FORMAT: 11009,
19
19
  DECODER_NOT_LOADED: 11010,
20
- STREAM_INDEX_OUT_OF_BOUNDS: 11011
20
+ STREAM_INDEX_OUT_OF_BOUNDS: 11011,
21
+ JOB_CANCELLED: 11012
21
22
  })
22
23
 
23
24
  addCodes({
@@ -64,6 +65,10 @@ addCodes({
64
65
  [ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS]: {
65
66
  name: 'STREAM_INDEX_OUT_OF_BOUNDS',
66
67
  message: (index) => `Stream index out of bounds: ${index}`
68
+ },
69
+ [ERR_CODES.JOB_CANCELLED]: {
70
+ name: 'JOB_CANCELLED',
71
+ message: 'Decoder job cancelled'
67
72
  }
68
73
  }, {
69
74
  name,