@qvac/decoder-audio 0.1.1 → 0.2.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.
Files changed (27) hide show
  1. package/index.js +6 -1
  2. package/lib/ffmpeg/channel-port-stream.js +98 -0
  3. package/lib/ffmpeg/constants.js +23 -0
  4. package/lib/ffmpeg/ffmpeg-decoder.js +314 -0
  5. package/lib/ffmpeg/ffmpeg-worker.js +293 -0
  6. package/lib/ffmpeg/index.d.ts +98 -0
  7. package/lib/ffmpeg/index.js +29 -0
  8. package/package.json +18 -12
  9. package/prebuilds/android-arm/qvac__decoder-audio.bare +0 -0
  10. package/prebuilds/android-arm64/qvac__decoder-audio.bare +0 -0
  11. package/prebuilds/android-ia32/qvac__decoder-audio.bare +0 -0
  12. package/prebuilds/android-x64/qvac__decoder-audio.bare +0 -0
  13. package/prebuilds/darwin-arm64/qvac__decoder-audio.bare +0 -0
  14. package/prebuilds/darwin-arm64/qvac__decoder-audio.bare.exports +1 -1
  15. package/prebuilds/darwin-x64/qvac__decoder-audio.bare +0 -0
  16. package/prebuilds/darwin-x64/qvac__decoder-audio.bare.exports +1 -1
  17. package/prebuilds/ios-arm64/qvac__decoder-audio.bare +0 -0
  18. package/prebuilds/ios-arm64/qvac__decoder-audio.bare.exports +1 -1
  19. package/prebuilds/ios-arm64-simulator/qvac__decoder-audio.bare +0 -0
  20. package/prebuilds/ios-arm64-simulator/qvac__decoder-audio.bare.exports +1 -1
  21. package/prebuilds/ios-x64-simulator/qvac__decoder-audio.bare +0 -0
  22. package/prebuilds/ios-x64-simulator/qvac__decoder-audio.bare.exports +1 -1
  23. package/prebuilds/linux-x64/qvac__decoder-audio.bare +0 -0
  24. package/prebuilds/win32-x64/qvac__decoder-audio.bare +0 -0
  25. package/test/mobile/test.cjs +103 -0
  26. package/test/mobile/testAssets/sample.mp3 +0 -0
  27. package/test/mobile/testAssets/sample.wav +0 -0
package/index.js CHANGED
@@ -15,7 +15,9 @@ class GSTDecoder extends BaseInference {
15
15
  /**
16
16
  * Creates an instance of GSTDecoder.
17
17
  * @constructor
18
- * @param {Object} config - environment-specific inference setup configuration
18
+ * @param {Object} options - Constructor options
19
+ * @param {Object} options.config - environment-specific inference setup configuration
20
+ * @param {*} [args] - Additional arguments passed to BaseInference
19
21
  */
20
22
 
21
23
  constructor ({ config, ...args }) {
@@ -101,6 +103,9 @@ class GSTDecoder extends BaseInference {
101
103
  }
102
104
  }
103
105
 
106
+ const FFmpegDecoder = require('./lib/ffmpeg/ffmpeg-decoder')
107
+
104
108
  module.exports = GSTDecoder
105
109
  module.exports.FORMATS_NEEDING_DECODE = FORMATS_NEEDING_DECODE
106
110
  module.exports.SUPPORTED_AUDIO_FORMATS = SUPPORTED_AUDIO_FORMATS
111
+ module.exports.FFmpegDecoder = FFmpegDecoder
@@ -0,0 +1,98 @@
1
+ 'use strict'
2
+
3
+ const QvacLogger = require('@qvac/logging')
4
+
5
+ class ChannelPortStream {
6
+ constructor (channelPort, logger) {
7
+ this.channelPort = channelPort
8
+ this.logger = new QvacLogger(logger)
9
+
10
+ this.channelEnd = false
11
+ this.channelClose = false
12
+
13
+ this.channelPort.on('end', () => {
14
+ this.logger.debug('ChannelPortStream Channel port ended')
15
+ this.channelEnd = true
16
+ })
17
+ this.channelPort.on('close', () => {
18
+ this.logger.debug('ChannelPortStream Channel port closed')
19
+ this.channelClose = true
20
+ })
21
+
22
+ this.bufferArray = []
23
+ this.bufferOneOffset = 0
24
+ }
25
+
26
+ portHasData () {
27
+ const hasData = (this.channelEnd || this.channelClose) && this.channelPort._queue.length
28
+ return hasData !== 0
29
+ }
30
+
31
+ _readFromChannelPort () {
32
+ let portHasDataCount = 0
33
+ while (this.portHasData()) {
34
+ portHasDataCount++
35
+ const chunk = this.channelPort.readSync()
36
+ if (!chunk || chunk.length === 0) {
37
+ this.logger.debug('Read chunk of length 0 from channelPort')
38
+ break
39
+ }
40
+ this.logger.debug(`Read chunk of length ${chunk.length} from channelPort`)
41
+ this.bufferArray.push(chunk)
42
+ }
43
+ this.logger.debug(`portHasData() loop ran ${portHasDataCount} times, bufferArray.length=${this.bufferArray.length}`)
44
+ }
45
+
46
+ _readFromBufferArray (buffer, len) {
47
+ let finalLen = 0
48
+ let bytesCopied = 0
49
+ if (this.bufferArray.length > 0) {
50
+ while (finalLen < len) {
51
+ // Get pending bytes in first buffer
52
+ const firstBufferPending = this.bufferArray[0].length - this.bufferOneOffset
53
+
54
+ // Get bytes to copy in case pending len is less than firstBufferPending
55
+ const toCopy = Math.min(len - finalLen, firstBufferPending)
56
+ this.logger.debug(`Copying ${toCopy} bytes from bufferArray[0] (pending: ${firstBufferPending}), bufferOneOffset=${this.bufferOneOffset}, finalLen=${finalLen}`)
57
+
58
+ // Copy bytes to buffer
59
+ bytesCopied += this.bufferArray[0].copy(buffer, finalLen, this.bufferOneOffset, this.bufferOneOffset + toCopy)
60
+ this.bufferOneOffset += toCopy
61
+
62
+ // Shift out buffer if it's empty
63
+ if (this.bufferOneOffset >= this.bufferArray[0].length) {
64
+ this.logger.debug('Finished bufferArray[0], shifting it out')
65
+ this.bufferArray.shift()
66
+ this.bufferOneOffset = 0
67
+ }
68
+
69
+ // Update finalLen
70
+ finalLen += toCopy
71
+
72
+ // Break inner loop if bufferArray has no more buffers
73
+ if (this.bufferArray.length === 0) {
74
+ this.logger.debug('bufferArray is empty, breaking inner loop')
75
+ break
76
+ }
77
+ }
78
+ } else {
79
+ this.logger.debug('bufferArray is empty, nothing to copy')
80
+ }
81
+
82
+ this.logger.debug(`_readFromBufferArray bytesCopied=${bytesCopied}`)
83
+ return finalLen
84
+ }
85
+
86
+ read (buffer, len) {
87
+ this.logger.debug(`Called with len=${len}`)
88
+
89
+ this._readFromChannelPort()
90
+
91
+ const finalLen = this._readFromBufferArray(buffer, len)
92
+
93
+ this.logger.debug(`Returning finalLen=${finalLen}`)
94
+ return finalLen
95
+ }
96
+ }
97
+
98
+ module.exports = ChannelPortStream
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ const FFmpegDecoderMessages = {
4
+ // Decoder messages
5
+ INIT: 'init', // Initialization message
6
+ STREAM_START: 'stream-start', // Start streaming message
7
+ CLEANUP: 'cleanup' // Cleanup message
8
+ }
9
+
10
+ const FFmpegWorkerMessages = {
11
+ // Worker messages
12
+ READY: 'ready', // Ready message
13
+ INIT_RESULT: 'init-result', // Initialization result message
14
+ RESULT_CHUNK: 'result-chunk', // Result chunk message
15
+ RESULT_END: 'result-end', // End streaming message
16
+ CLEANUP_DONE: 'cleanup-done', // Cleanup done message
17
+ ERROR: 'error' // Error message
18
+ }
19
+
20
+ module.exports = {
21
+ FFmpegDecoderMessages,
22
+ FFmpegWorkerMessages
23
+ }
@@ -0,0 +1,314 @@
1
+ 'use strict'
2
+
3
+ const { Worker } = require('bare-worker')
4
+ const QvacResponse = require('@qvac/response')
5
+ const QvacLogger = require('@qvac/logging')
6
+ const path = require('bare-path')
7
+ const Channel = require('bare-channel')
8
+ const { FFmpegDecoderMessages, FFmpegWorkerMessages } = require('./constants')
9
+ const BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference')
10
+
11
+ /**
12
+ * FFmpeg-based audio decoder that runs in a worker thread
13
+ */
14
+ class FFmpegDecoder extends BaseInference {
15
+ /**
16
+ * Creates an instance of FFmpegDecoder.
17
+ * @param {Object} config - Configuration options
18
+ * @param logger - Logger instance
19
+ * @param streamIndex - Index of the stream to decode. Default: 0
20
+ * @param inputBitrate - Input audio bitrate. Default: 192000
21
+ * @param audioFormat - Output audio format. Default: 's16le'
22
+ * @param args - Additional arguments passed to BaseInference
23
+ * @param {Object} [config.streamIndex] - Index of the stream to decode (default: 0)
24
+ * @param {number} [config.inputBitrate] - Input audio bitrate (default: 192000)
25
+ * @param {string} [config.audioFormat] - Output audio format (default: 'f32le')
26
+ * @param {number} [config.sampleRate] - Output sample rate (default: 16000)
27
+ * @param {Object} [config.logger] - Logger instance
28
+ */
29
+ constructor ({
30
+ config = {},
31
+ logger = null,
32
+ streamIndex = 0,
33
+ inputBitrate = 192000,
34
+ audioFormat = 's16le',
35
+ ...args
36
+ }) {
37
+ super({ ...args, logger })
38
+
39
+ this.config = {
40
+ streamIndex,
41
+ inputBitrate,
42
+ audioFormat
43
+ }
44
+
45
+ this.logger = new QvacLogger(logger)
46
+ this.worker = null
47
+ this.isLoaded = false
48
+ this.currentJob = null
49
+ }
50
+
51
+ /**
52
+ * Load and initialize the decoder
53
+ */
54
+ async load () {
55
+ if (this.isLoaded) {
56
+ this.logger.info('FFmpegDecoder already loaded')
57
+ return
58
+ }
59
+
60
+ this.logger.info('Loading FFmpegDecoder with config:', this.config)
61
+
62
+ // Create worker thread
63
+ const workerPath = path.join(__dirname, 'ffmpeg-worker.js')
64
+ this.worker = new Worker(workerPath)
65
+
66
+ // Wait for worker to be ready
67
+ await new Promise((resolve, reject) => {
68
+ const timeout = setTimeout(() => {
69
+ reject(new Error('Worker initialization timeout'))
70
+ }, 5000)
71
+
72
+ this.worker.once('message', (msg) => {
73
+ clearTimeout(timeout)
74
+ if (msg.type === FFmpegWorkerMessages.READY) {
75
+ resolve()
76
+ } else {
77
+ reject(new Error('Unexpected worker message during init'))
78
+ }
79
+ })
80
+ })
81
+
82
+ this.isLoaded = true
83
+ this.logger.info('FFmpegDecoder loaded successfully')
84
+ }
85
+
86
+ /**
87
+ * Unload the decoder and clean up resources
88
+ */
89
+ async unload () {
90
+ if (!this.isLoaded) {
91
+ return
92
+ }
93
+
94
+ this.logger.info('Unloading FFmpegDecoder')
95
+
96
+ if (this.worker) {
97
+ await new Promise((resolve) => {
98
+ this.worker.postMessage({ type: FFmpegDecoderMessages.CLEANUP })
99
+ this.worker.once('message', (msg) => {
100
+ if (msg.type === FFmpegWorkerMessages.CLEANUP_DONE) {
101
+ resolve()
102
+ }
103
+ })
104
+
105
+ // Fallback timeout
106
+ setTimeout(resolve, 1000)
107
+ })
108
+
109
+ await this.worker.terminate()
110
+ this.worker = null
111
+ }
112
+
113
+ this.isLoaded = false
114
+ this.currentJob = null
115
+ this.logger.info('FFmpegDecoder unloaded')
116
+ }
117
+
118
+ /**
119
+ * Run the decoder on an audio stream
120
+ * @param {Readable} audioStream - Input audio stream
121
+ * @returns {QvacResponse} Response with decoded audio
122
+ */
123
+ async run (audioStream) {
124
+ if (!this.isLoaded) {
125
+ throw new Error('Decoder not loaded. Call load() first.')
126
+ }
127
+
128
+ this.logger.info('Starting new audio stream processing')
129
+
130
+ const response = new QvacResponse({
131
+ cancelHandler: () => this.stop(),
132
+ pauseHandler: () => this.pause(),
133
+ continueHandler: () => this.unpause()
134
+ })
135
+
136
+ this.currentJob = {
137
+ response,
138
+ audioChunks: [],
139
+ isActive: true,
140
+ isPaused: false
141
+ }
142
+
143
+ // Process the audio stream
144
+ this._processStream(audioStream).catch(err => {
145
+ this.logger.error('Error processing audio stream:', err)
146
+ response.failed(err)
147
+ })
148
+
149
+ return response
150
+ }
151
+
152
+ _handleWorkerMessage (job) {
153
+ return new Promise((resolve, reject) => {
154
+ const messageHandler = async (msg) => {
155
+ this.logger.debug('[FFmpegDecoder] Received message from worker:', msg)
156
+ switch (msg.type) {
157
+ case FFmpegWorkerMessages.INIT_RESULT: {
158
+ if (msg.success) {
159
+ this.logger.info('[FFmpegDecoder] Decoder initialized successfully')
160
+ this.worker.postMessage({
161
+ type: FFmpegDecoderMessages.STREAM_START
162
+ })
163
+ } else {
164
+ this.worker.off('message', messageHandler)
165
+ this.logger.error('[FFmpegDecoder] Failed to initialize decoder:', msg.error)
166
+ }
167
+ break
168
+ }
169
+
170
+ case FFmpegWorkerMessages.RESULT_CHUNK: {
171
+ const decodedBuffer = Buffer.from(msg.data)
172
+ this.logger.debug('[FFmpegDecoder] Result chunk', decodedBuffer.length)
173
+ job.response.updateOutput({ outputArray: decodedBuffer })
174
+ break
175
+ }
176
+
177
+ case FFmpegWorkerMessages.RESULT_END: {
178
+ this.logger.debug('[FFmpegDecoder] Result end')
179
+ job.response.ended()
180
+ this.worker.off('message', messageHandler)
181
+ resolve()
182
+ break
183
+ }
184
+
185
+ case FFmpegWorkerMessages.ERROR: {
186
+ const errorMessage = msg.error || msg.data?.error || 'Unknown error'
187
+ this.logger.error('[FFmpegDecoder] Error from worker:', errorMessage)
188
+ job.response.failed(errorMessage)
189
+ this.worker.off('message', messageHandler)
190
+ reject(new Error(errorMessage))
191
+ break
192
+ }
193
+ }
194
+ }
195
+
196
+ this.worker.on('message', messageHandler)
197
+ this.worker.on('error', (err) => {
198
+ this.logger.error('[FFmpegDecoder] Error from worker:', err)
199
+ job.response.failed(err)
200
+ this.worker.off('message', messageHandler)
201
+ reject(err)
202
+ })
203
+ })
204
+ }
205
+
206
+ async _streamAudioToWorker (audioStream, port, job) {
207
+ let chunkCount = 0
208
+ for await (const chunk of audioStream) {
209
+ chunkCount++
210
+
211
+ this.logger.debug(`[FFmpegDecoder] Received audio chunk #${chunkCount}, size: ${chunk.length} bytes`)
212
+ if (!job.isActive) {
213
+ this.logger.info('[FFmpegDecoder] Job cancelled, stopping stream collection at chunk', chunkCount)
214
+ return
215
+ }
216
+
217
+ while (job.isPaused) {
218
+ this.logger.debug('[FFmpegDecoder] Job is paused, waiting to resume...')
219
+ await new Promise(resolve => setTimeout(resolve, 100))
220
+ }
221
+
222
+ port.writeSync(chunk)
223
+ }
224
+ return chunkCount
225
+ }
226
+
227
+ async _processStream (audioStream) {
228
+ const job = this.currentJob
229
+ if (!job.isActive) {
230
+ return
231
+ }
232
+
233
+ try {
234
+ const channel = new Channel()
235
+ const port = channel.connect()
236
+
237
+ this.logger.debug('[FFmpegDecoder] Processing audio stream with channel handle:', channel.handle)
238
+
239
+ // Worker promise to handle worker messages
240
+ const workerCompletionPromise = this._handleWorkerMessage(job)
241
+
242
+ // Initialize ffmpeg worker
243
+ this.worker.postMessage({
244
+ type: FFmpegDecoderMessages.INIT,
245
+ data: {
246
+ channelHandle: channel.handle,
247
+ config: this.config
248
+ }
249
+ })
250
+
251
+ // Stream all audio chunks to ffmpeg worker
252
+ const chunkCount = await this._streamAudioToWorker(audioStream, port, job)
253
+ this.logger.info(`[FFmpegDecoder] Finished streaming audio. Total chunks sent: ${chunkCount}`)
254
+
255
+ // Signal to worker that we're done sending audio
256
+ await port.close()
257
+
258
+ // Wait for worker to finish
259
+ await workerCompletionPromise
260
+ } catch (err) {
261
+ this.logger.error('Error processing audio stream:', err)
262
+ job.response.failed(err)
263
+ }
264
+
265
+ this.logger.info('Audio _processStream completed')
266
+ }
267
+
268
+ /**
269
+ * Pause the current job
270
+ */
271
+ pause () {
272
+ if (this.currentJob) {
273
+ this.currentJob.isPaused = true
274
+ this.logger.debug('Decoder paused')
275
+ }
276
+ return Promise.resolve()
277
+ }
278
+
279
+ /**
280
+ * Unpause the current job
281
+ */
282
+ unpause () {
283
+ if (this.currentJob) {
284
+ this.currentJob.isPaused = false
285
+ this.logger.debug('Decoder unpaused')
286
+ }
287
+ return Promise.resolve()
288
+ }
289
+
290
+ /**
291
+ * Stop the current job
292
+ */
293
+ stop () {
294
+ if (this.currentJob) {
295
+ this.currentJob.isActive = false
296
+ this.currentJob.response.finish()
297
+ this.logger.debug('Decoder stopped')
298
+ }
299
+ return Promise.resolve()
300
+ }
301
+
302
+ /**
303
+ * Get the current status
304
+ */
305
+ status () {
306
+ return {
307
+ loaded: this.isLoaded,
308
+ active: this.currentJob?.isActive || false,
309
+ paused: this.currentJob?.isPaused || false
310
+ }
311
+ }
312
+ }
313
+
314
+ module.exports = FFmpegDecoder
@@ -0,0 +1,293 @@
1
+ 'use strict'
2
+
3
+ const { parentPort } = require('bare-worker')
4
+ const ffmpeg = require('bare-ffmpeg')
5
+ const QvacLogger = require('@qvac/logging')
6
+ const Channel = require('bare-channel')
7
+ const ChannelPortStream = require('./channel-port-stream')
8
+ const { FFmpegDecoderMessages, FFmpegWorkerMessages } = require('./constants')
9
+
10
+ class FFmpegWorker {
11
+ SUPPORTED_AUDIO_FORMATS = {
12
+ s16le: {
13
+ format: ffmpeg.constants.sampleFormats.S16,
14
+ byteLength: 2
15
+ },
16
+ f32le: {
17
+ format: ffmpeg.constants.sampleFormats.FLT,
18
+ byteLength: 4
19
+ }
20
+ }
21
+
22
+ // Whisper addon expectations
23
+ OUTPUT_FORMAT_BYTE_LENGTH = 2 // S16LE uses 16 bits = 2 bytes
24
+ OUTPUT_FORMAT = ffmpeg.constants.sampleFormats.S16
25
+ OUTPUT_SAMPLE_RATE = 16000
26
+ OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
27
+
28
+ constructor (parentPort, logger = new QvacLogger(console)) {
29
+ this.parentPort = parentPort
30
+ this.logger = logger
31
+
32
+ this.channel = null
33
+ this.channelPort = null
34
+ this.channelPortStream = null
35
+
36
+ this.config = null
37
+ this.totalReadLen = 0
38
+
39
+ // Encoder delay handling
40
+ this.samplesSkipped = 0
41
+ this.totalSkipSamples = 0
42
+ }
43
+
44
+ sendMessage (type, data) {
45
+ this.parentPort.postMessage({ type, data })
46
+ }
47
+
48
+ sendResultChunk (data) {
49
+ this.sendMessage(FFmpegWorkerMessages.RESULT_CHUNK, data)
50
+ }
51
+
52
+ sendEndMessage () {
53
+ this.logger.debug('[ffmpeg-worker] Sending end message')
54
+ this.sendMessage(FFmpegWorkerMessages.RESULT_END)
55
+ }
56
+
57
+ initializeDecoder (data) {
58
+ const { channelHandle, config } = data
59
+ this.config = config
60
+
61
+ if (!this.SUPPORTED_AUDIO_FORMATS[config.audioFormat]) {
62
+ this.logger.error('[ffmpeg-worker] Unsupported audio format:', config.audioFormat)
63
+ return { success: false, error: 'Unsupported audio format' }
64
+ }
65
+
66
+ this.OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[config.audioFormat].format
67
+ this.OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[config.audioFormat].byteLength
68
+
69
+ this.logger.info('[ffmpeg-worker] Initializing decoder...')
70
+ try {
71
+ this.channel = Channel.from(channelHandle)
72
+ this.channelPort = this.channel.connect()
73
+ this.channelPortStream = new ChannelPortStream(this.channelPort, this.logger)
74
+ return { success: true }
75
+ } catch (err) {
76
+ this.logger.error('[ffmpeg-worker] Failed to initialize decoder:', err)
77
+ return { success: false, error: err.message }
78
+ }
79
+ }
80
+
81
+ startStream () {
82
+ this.decodeStream()
83
+ }
84
+
85
+ _processFrame (decoder, raw, resampler) {
86
+ while (decoder.receiveFrame(raw)) {
87
+ const output = new ffmpeg.Frame()
88
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
89
+ output.format = this.OUTPUT_FORMAT
90
+ output.sampleRate = this.OUTPUT_SAMPLE_RATE
91
+ output.nbSamples = raw.nbSamples
92
+
93
+ const samples = new ffmpeg.Samples(
94
+ output.format,
95
+ output.channelLayout.nbChannels,
96
+ output.nbSamples
97
+ )
98
+ samples.fill(output)
99
+
100
+ // samples.data.length is always fixed, like 8192 bytes. So we need to
101
+ // extract only the data we need through `subarray`. `resampler.convert`
102
+ // gives the number of samples converted per channel. So
103
+ // `bytesPerSample` * `samplesPerChannel` * `channelsPerSample` gives
104
+ // total amount of bytes.
105
+ const count = resampler.convert(raw, output)
106
+
107
+ // Handle encoder delay by skipping initial samples
108
+ if (this.samplesSkipped < this.totalSkipSamples) {
109
+ const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
110
+ this.samplesSkipped += samplesToSkip
111
+ if (samplesToSkip >= count) continue // Skip entire frame
112
+
113
+ // Skip partial frame
114
+ const skipBytes = this.OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
115
+ const length = this.OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
116
+ this.sendResultChunk(Buffer.from(samples.data.subarray(skipBytes, skipBytes + length)))
117
+ } else {
118
+ const length = this.OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
119
+ this.sendResultChunk(Buffer.from(samples.data.subarray(0, length)))
120
+ }
121
+ }
122
+ }
123
+
124
+ _processPacket (format, packet, raw, decoder, resampler) {
125
+ while (format.readFrame(packet)) {
126
+ decoder.sendPacket(packet)
127
+ this._processFrame(decoder, raw, resampler)
128
+ packet.unref()
129
+ }
130
+ }
131
+
132
+ _processStream (format, stream) {
133
+ console.log(stream.codec, stream.codecParameters)
134
+ const packet = new ffmpeg.Packet()
135
+ const raw = new ffmpeg.Frame()
136
+
137
+ const resampler = new ffmpeg.Resampler(
138
+ stream.codecParameters.sampleRate,
139
+ stream.codecParameters.channelLayout,
140
+ stream.codecParameters.format,
141
+ this.OUTPUT_SAMPLE_RATE,
142
+ this.OUTPUT_CHANNEL_LAYOUT,
143
+ this.OUTPUT_FORMAT
144
+ )
145
+
146
+ const decoder = stream.decoder()
147
+ decoder.open()
148
+
149
+ // Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
150
+ // Lossless codecs (WAV, FLAC) need no skipping
151
+ const codecName = stream.codec.name.toLowerCase()
152
+ const SKIP_MS = {
153
+ mp3: 400,
154
+ vorbis: 400,
155
+ opus: 150,
156
+ aac: 300
157
+ }
158
+
159
+ const skipMs = SKIP_MS[codecName] || 0
160
+ this.samplesSkipped = 0
161
+ this.totalSkipSamples = Math.floor((this.OUTPUT_SAMPLE_RATE * skipMs) / 1000)
162
+
163
+ if (this.totalSkipSamples > 0) {
164
+ console.log(`[ffmpeg-worker] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
165
+ this.logger.info(`[ffmpeg-worker] Skipping ${skipMs}ms for ${codecName} to remove encoder artifacts`)
166
+ }
167
+
168
+ this._processPacket(format, packet, raw, decoder, resampler)
169
+
170
+ const output = new ffmpeg.Frame()
171
+ output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
172
+ output.format = this.OUTPUT_FORMAT
173
+ output.sampleRate = this.OUTPUT_SAMPLE_RATE
174
+ output.nbSamples = 1024
175
+
176
+ const samples = new ffmpeg.Samples(
177
+ output.format,
178
+ output.channelLayout.nbChannels,
179
+ output.nbSamples
180
+ )
181
+ samples.fill(output)
182
+
183
+ while (resampler.flush(output) > 0) {
184
+ this.sendResultChunk(Buffer.from(samples.data))
185
+ }
186
+
187
+ decoder.destroy()
188
+ this.sendEndMessage()
189
+ }
190
+
191
+ _getBufferSize (inputBitrate) {
192
+ const maxBufferSize = 1024 * 1024 // 1MB max
193
+ return Math.min((inputBitrate / 8) * 4, maxBufferSize)
194
+ }
195
+
196
+ decodeStream () {
197
+ const {
198
+ streamIndex = 0,
199
+ inputBitrate
200
+ } = this.config
201
+
202
+ this.logger.debug('[ffmpeg-worker] Starting deocde with config', this.config)
203
+
204
+ if (!this.channelPortStream) {
205
+ this.logger.error('[ffmpeg-worker] Channel port stream not initialized')
206
+ return this.sendMessage(FFmpegWorkerMessages.ERROR, { error: 'Channel port stream not initialized' })
207
+ }
208
+
209
+ try {
210
+ const bufferSize = this._getBufferSize(inputBitrate)
211
+
212
+ const io = new ffmpeg.IOContext(bufferSize, {
213
+ onread: (buffer, requestedLen) => {
214
+ return this.channelPortStream.read(buffer, requestedLen)
215
+ }
216
+ })
217
+
218
+ this.logger.debug('[ffmpeg-worker] IOContext created')
219
+ const format = new ffmpeg.InputFormatContext(io)
220
+ this.logger.debug('[ffmpeg-worker] InputFormatContext created')
221
+
222
+ if (format.streams[streamIndex] === undefined) {
223
+ this.logger.error('[ffmpeg-worker] Stream index out of bounds')
224
+ return this.sendMessage(FFmpegWorkerMessages.ERROR, { error: 'Stream index out of bounds' })
225
+ }
226
+
227
+ this._processStream(format, format.streams[streamIndex])
228
+ } catch (err) {
229
+ this.logger.error('[ffmpeg-worker] Error during decodeChunk:', err)
230
+ return this.sendMessage(FFmpegWorkerMessages.ERROR, { error: err.message })
231
+ }
232
+ }
233
+
234
+ cleanup () {
235
+ this.logger.info('[ffmpeg-worker] Cleaning up decoder resources...')
236
+ this.logger.info('[ffmpeg-worker] Cleanup complete')
237
+ if (this.channelPort) {
238
+ this.channelPort.close()
239
+ this.logger.debug('[ffmpeg-worker] Channel port disposed')
240
+ }
241
+ if (this.channel) {
242
+ this.channel = null
243
+ this.logger.debug('[ffmpeg-worker] Channel disposed')
244
+ }
245
+ }
246
+ }
247
+
248
+ const logger = new QvacLogger()
249
+ const ffmpegWorkerInstance = new FFmpegWorker(parentPort, logger)
250
+
251
+ // Handle messages from main thread
252
+ parentPort.on('message', ({ type, data }) => {
253
+ logger.debug(`[ffmpeg-worker] Received message: ${type}`)
254
+ switch (type) {
255
+ case FFmpegDecoderMessages.INIT: {
256
+ logger.info('[ffmpeg-worker] Handling init message with channel handle:', data)
257
+ const initResult = ffmpegWorkerInstance.initializeDecoder(data)
258
+ parentPort.postMessage({ type: FFmpegWorkerMessages.INIT_RESULT, ...initResult })
259
+ break
260
+ }
261
+
262
+ case FFmpegDecoderMessages.STREAM_START: {
263
+ logger.info('[ffmpeg-worker] Handling decode message')
264
+ ffmpegWorkerInstance.startStream()
265
+ break
266
+ }
267
+
268
+ case FFmpegDecoderMessages.CLEANUP: {
269
+ logger.info('[ffmpeg-worker] Handling cleanup message')
270
+ ffmpegWorkerInstance.cleanup()
271
+ parentPort.postMessage({ type: FFmpegWorkerMessages.CLEANUP_DONE, success: true })
272
+ break
273
+ }
274
+
275
+ default: {
276
+ logger.warn(`[ffmpeg-worker] Unknown message type: ${type}`)
277
+ parentPort.postMessage({ type: FFmpegWorkerMessages.ERROR, error: 'Unknown message type' })
278
+ }
279
+ }
280
+ })
281
+
282
+ parentPort.on('close', () => {
283
+ logger.info('[ffmpeg-worker] Worker exiting')
284
+ })
285
+
286
+ parentPort.on('error', (err) => {
287
+ logger.error('[ffmpeg-worker] Worker error:', err)
288
+ parentPort.postMessage({ type: FFmpegWorkerMessages.ERROR, error: err })
289
+ })
290
+
291
+ // Notify main thread that worker is ready
292
+ logger.info('[ffmpeg-worker] Worker ready')
293
+ parentPort.postMessage({ type: 'ready' })
@@ -0,0 +1,98 @@
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;
@@ -0,0 +1,29 @@
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/decoder-audio",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Tether",
@@ -13,10 +13,10 @@
13
13
  "test": "npm run test:unit && npm run test:integration",
14
14
  "coverage:unit": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
15
15
  "coverage": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
16
- "lint": "standard --ignore 'addon/**'",
17
- "lint:fix": "standard --fix --ignore 'addon/**'",
16
+ "lint": "standard \"test/**/*.js\" \"*.js\"",
17
+ "lint:fix": "standard --fix \"test/**/*.js\" \"*.js\"",
18
18
  "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js",
19
- "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js",
19
+ "test:integration:generate": "brittle -r test/integration/all.js test/integration/ffmpeg-decoder.test.js",
20
20
  "test:integration": "npm run test:integration:generate && bare test/integration/all.js"
21
21
  },
22
22
  "files": [
@@ -27,19 +27,18 @@
27
27
  "constants.d.ts",
28
28
  "prebuilds",
29
29
  "utils",
30
- "index.d.ts"
30
+ "lib",
31
+ "index.d.ts",
32
+ "test/mobile"
31
33
  ],
32
34
  "exports": {
33
35
  "./package": "./package.json",
34
- ".": "./index.js",
35
- "./constants": "./constants.js",
36
- "./constants.js": "./constants.js"
36
+ ".": "./index.js"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22.14.1",
40
- "bare-process": "^4.2.1",
41
40
  "brittle": "^3.13.1",
42
- "cmake-bare": "^1.5.2",
41
+ "cmake-bare": "^1.7.5",
43
42
  "cmake-vcpkg": "^1.1.0",
44
43
  "istanbul": "^0.4.5",
45
44
  "standard": "^17.1.2",
@@ -48,9 +47,16 @@
48
47
  "dependencies": {
49
48
  "@qvac/infer-base": "^0.1.0",
50
49
  "@qvac/error": "^0.1.0",
50
+ "@qvac/logging": "^0.1.0",
51
51
  "@qvac/response": "^0.1.0",
52
- "bare-assert": "^1.0.2",
53
- "bare-fs": "^4.0.2"
52
+ "bare-assert": "^1.1.0",
53
+ "bare-channel": "^5.2.2",
54
+ "bare-ffmpeg": "^1.0.0-32",
55
+ "bare-fs": "^4.5.1",
56
+ "bare-path": "^3.0.0",
57
+ "bare-process": "^4.2.2",
58
+ "process": "npm:bare-process@^4.2.2",
59
+ "bare-worker": "^4.1.0"
54
60
  },
55
61
  "bugs": "https://github.com/tetherto/qvac-lib-decoder-audio/issues",
56
62
  "types": "index.d.ts"
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "current_versions": [
9
9
  {
10
- "version": "0.1.1"
10
+ "version": "0.2.1"
11
11
  }
12
12
  ],
13
13
  "exported_symbols": [
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "current_versions": [
9
9
  {
10
- "version": "0.1.1"
10
+ "version": "0.2.1"
11
11
  }
12
12
  ],
13
13
  "exported_symbols": [
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "current_versions": [
9
9
  {
10
- "version": "0.1.1"
10
+ "version": "0.2.1"
11
11
  }
12
12
  ],
13
13
  "exported_symbols": [
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "current_versions": [
9
9
  {
10
- "version": "0.1.1"
10
+ "version": "0.2.1"
11
11
  }
12
12
  ],
13
13
  "exported_symbols": [
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "current_versions": [
9
9
  {
10
- "version": "0.1.1"
10
+ "version": "0.2.1"
11
11
  }
12
12
  ],
13
13
  "exported_symbols": [
@@ -0,0 +1,103 @@
1
+ const GSTDecoder = require('@qvac/decoder-audio')
2
+ const fs = require('bare-fs')
3
+
4
+ async function testDecodeMp3() {
5
+ const decoder = new GSTDecoder({
6
+ config: {
7
+ audioFormat: 'f32le',
8
+ sampleRate: 16000
9
+ }
10
+ })
11
+
12
+ try {
13
+ await decoder.load()
14
+
15
+ const audioPath = getAssetPath('sample.mp3')
16
+ const audioStream = fs.createReadStream(audioPath)
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
+ return {
43
+ success: true,
44
+ totalBytes,
45
+ updateCount
46
+ }
47
+ } catch (error) {
48
+ console.error('Error during testDecodeMp3:', error)
49
+ throw error
50
+ }
51
+ }
52
+
53
+ async function testDecodeWav() {
54
+ const decoder = new GSTDecoder({
55
+ config: {
56
+ audioFormat: 'f32le',
57
+ sampleRate: 16000
58
+ }
59
+ })
60
+
61
+ try {
62
+ await decoder.load()
63
+
64
+ const audioPath = getAssetPath('sample.wav')
65
+ const audioStream = fs.createReadStream(audioPath)
66
+ const response = await decoder.run(audioStream)
67
+
68
+ let totalBytes = 0
69
+ let updateCount = 0
70
+
71
+ await response
72
+ .onUpdate(output => {
73
+ if (output && output.outputArray) {
74
+ const bytes = new Uint8Array(output.outputArray)
75
+ totalBytes += bytes.length
76
+ updateCount++
77
+ }
78
+ })
79
+ .await()
80
+
81
+ if (totalBytes === 0) {
82
+ throw new Error('No audio data decoded')
83
+ }
84
+
85
+ if (updateCount === 0) {
86
+ throw new Error('No decoder updates received')
87
+ }
88
+
89
+ console.log(`WAV decode complete: ${totalBytes} bytes, ${updateCount} updates`)
90
+
91
+ return {
92
+ success: true,
93
+ totalBytes,
94
+ updateCount
95
+ }
96
+ } catch (error) {
97
+ console.error('Error during testDecodeWav:', error)
98
+ throw error
99
+ }
100
+ }
101
+
102
+ module.exports = { testDecodeMp3, testDecodeWav }
103
+