@qvac/decoder-audio 0.2.0 → 0.2.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/constants.d.ts +9 -0
- package/constants.js +31 -0
- package/lib/ffmpeg/channel-port-stream.js +98 -0
- package/lib/ffmpeg/constants.js +23 -0
- package/lib/ffmpeg/ffmpeg-decoder.js +314 -0
- package/lib/ffmpeg/ffmpeg-worker.js +293 -0
- package/lib/ffmpeg/index.d.ts +98 -0
- package/lib/ffmpeg/index.js +29 -0
- package/package.json +19 -12
- package/prebuilds/android-arm/qvac__decoder-audio.bare +0 -0
- package/prebuilds/android-arm64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/android-ia32/qvac__decoder-audio.bare +0 -0
- package/prebuilds/android-x64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__decoder-audio.bare.exports +1 -1
- package/prebuilds/darwin-x64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/darwin-x64/qvac__decoder-audio.bare.exports +1 -1
- package/prebuilds/ios-arm64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/ios-arm64/qvac__decoder-audio.bare.exports +1 -1
- package/prebuilds/ios-arm64-simulator/qvac__decoder-audio.bare +0 -0
- package/prebuilds/ios-arm64-simulator/qvac__decoder-audio.bare.exports +1 -1
- package/prebuilds/ios-x64-simulator/qvac__decoder-audio.bare +0 -0
- package/prebuilds/ios-x64-simulator/qvac__decoder-audio.bare.exports +1 -1
- package/prebuilds/linux-x64/qvac__decoder-audio.bare +0 -0
- package/prebuilds/win32-x64/qvac__decoder-audio.bare +0 -0
- package/test/mobile/test.cjs +103 -0
- package/test/mobile/testAssets/sample.mp3 +0 -0
- package/test/mobile/testAssets/sample.wav +0 -0
package/constants.d.ts
ADDED
package/constants.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audio formats that require decoding before processing
|
|
5
|
+
*/
|
|
6
|
+
const FORMATS_NEEDING_DECODE = [
|
|
7
|
+
'.mp3',
|
|
8
|
+
'.m4a',
|
|
9
|
+
'.ogg',
|
|
10
|
+
'.flac',
|
|
11
|
+
'.aac',
|
|
12
|
+
'.wav'
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* All supported audio formats (including raw)
|
|
17
|
+
*/
|
|
18
|
+
const SUPPORTED_AUDIO_FORMATS = [
|
|
19
|
+
'.mp3',
|
|
20
|
+
'.m4a',
|
|
21
|
+
'.ogg',
|
|
22
|
+
'.wav',
|
|
23
|
+
'.flac',
|
|
24
|
+
'.aac',
|
|
25
|
+
'.raw'
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
FORMATS_NEEDING_DECODE,
|
|
30
|
+
SUPPORTED_AUDIO_FORMATS
|
|
31
|
+
}
|
|
@@ -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.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Tether",
|
|
@@ -13,8 +13,8 @@
|
|
|
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
|
|
17
|
-
"lint:fix": "standard --fix
|
|
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
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"
|
|
@@ -23,19 +23,24 @@
|
|
|
23
23
|
"binding.js",
|
|
24
24
|
"index.js",
|
|
25
25
|
"gstreamer.js",
|
|
26
|
+
"constants.js",
|
|
27
|
+
"constants.d.ts",
|
|
26
28
|
"prebuilds",
|
|
27
29
|
"utils",
|
|
28
|
-
"
|
|
30
|
+
"lib",
|
|
31
|
+
"index.d.ts",
|
|
32
|
+
"test/mobile"
|
|
29
33
|
],
|
|
30
34
|
"exports": {
|
|
31
35
|
"./package": "./package.json",
|
|
32
|
-
".": "./index.js"
|
|
36
|
+
".": "./index.js",
|
|
37
|
+
"./constants": "./constants.js",
|
|
38
|
+
"./constants.js": "./constants.js"
|
|
33
39
|
},
|
|
34
40
|
"devDependencies": {
|
|
35
41
|
"@types/node": "^22.14.1",
|
|
36
|
-
"bare-process": "^4.2.1",
|
|
37
42
|
"brittle": "^3.13.1",
|
|
38
|
-
"cmake-bare": "^1.5
|
|
43
|
+
"cmake-bare": "^1.7.5",
|
|
39
44
|
"cmake-vcpkg": "^1.1.0",
|
|
40
45
|
"istanbul": "^0.4.5",
|
|
41
46
|
"standard": "^17.1.2",
|
|
@@ -46,12 +51,14 @@
|
|
|
46
51
|
"@qvac/error": "^0.1.0",
|
|
47
52
|
"@qvac/logging": "^0.1.0",
|
|
48
53
|
"@qvac/response": "^0.1.0",
|
|
49
|
-
"bare-assert": "^1.0
|
|
50
|
-
"bare-channel": "^5.2.
|
|
51
|
-
"bare-ffmpeg": "^1.0.0-
|
|
52
|
-
"bare-fs": "^4.
|
|
54
|
+
"bare-assert": "^1.1.0",
|
|
55
|
+
"bare-channel": "^5.2.2",
|
|
56
|
+
"bare-ffmpeg": "^1.0.0-32",
|
|
57
|
+
"bare-fs": "^4.5.1",
|
|
53
58
|
"bare-path": "^3.0.0",
|
|
54
|
-
"bare-
|
|
59
|
+
"bare-process": "^4.2.2",
|
|
60
|
+
"process": "npm:bare-process@^4.2.2",
|
|
61
|
+
"bare-worker": "^4.1.0"
|
|
55
62
|
},
|
|
56
63
|
"bugs": "https://github.com/tetherto/qvac-lib-decoder-audio/issues",
|
|
57
64
|
"types": "index.d.ts"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
|
|
Binary file
|
|
Binary file
|