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