@qvac/decoder-audio 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +14 -1
- package/index.js +61 -0
- package/package.json +9 -4
- package/test/mobile/integration-runtime.cjs +21 -0
- package/test/mobile/integration.auto.cjs +11 -0
- package/test/mobile/testAssets/sample_m4a.m4a +0 -0
- package/test/mobile/ffmpeg-test.cjs +0 -105
- /package/test/mobile/testAssets/{sample.mp3 → sample_mp3.mp3} +0 -0
- /package/test/mobile/testAssets/{sample.wav → sample_wav.wav} +0 -0
package/index.d.ts
CHANGED
|
@@ -33,6 +33,17 @@ interface DecoderStatus {
|
|
|
33
33
|
paused: boolean
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
interface RuntimeStats {
|
|
37
|
+
decodeTimeMs: number
|
|
38
|
+
inputBytes: number
|
|
39
|
+
outputBytes: number
|
|
40
|
+
samplesDecoded: number
|
|
41
|
+
codecName: string | null
|
|
42
|
+
inputSampleRate: number
|
|
43
|
+
outputSampleRate: number
|
|
44
|
+
audioFormat: 's16le' | 'f32le'
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
declare class FFmpegDecoder extends BaseInference {
|
|
37
48
|
SUPPORTED_AUDIO_FORMATS: SupportedAudioFormats
|
|
38
49
|
OUTPUT_CHANNEL_LAYOUT: number | null
|
|
@@ -46,6 +57,8 @@ declare class FFmpegDecoder extends BaseInference {
|
|
|
46
57
|
unpause(): Promise<void>
|
|
47
58
|
stop(): Promise<void>
|
|
48
59
|
status(): DecoderStatus
|
|
60
|
+
|
|
61
|
+
runtimeStats(): RuntimeStats
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
export { FFmpegDecoder }
|
|
64
|
+
export { FFmpegDecoder, RuntimeStats }
|
package/index.js
CHANGED
|
@@ -59,6 +59,33 @@ class FFmpegDecoder extends BaseInference {
|
|
|
59
59
|
// Encoder delay handling
|
|
60
60
|
this.samplesSkipped = 0
|
|
61
61
|
this.totalSkipSamples = 0
|
|
62
|
+
|
|
63
|
+
// Runtime stats
|
|
64
|
+
this._resetStats()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resets the runtime stats
|
|
69
|
+
*/
|
|
70
|
+
_resetStats () {
|
|
71
|
+
this._runtimeStats = {
|
|
72
|
+
decodeTimeMs: 0,
|
|
73
|
+
inputBytes: 0,
|
|
74
|
+
outputBytes: 0,
|
|
75
|
+
samplesDecoded: 0,
|
|
76
|
+
codecName: null,
|
|
77
|
+
inputSampleRate: 0,
|
|
78
|
+
outputSampleRate: this.config.sampleRate,
|
|
79
|
+
audioFormat: this.config.audioFormat
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the current runtime stats
|
|
85
|
+
* @returns {Object} Current runtime stats
|
|
86
|
+
*/
|
|
87
|
+
runtimeStats () {
|
|
88
|
+
return { ...this._runtimeStats }
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
/**
|
|
@@ -172,10 +199,18 @@ class FFmpegDecoder extends BaseInference {
|
|
|
172
199
|
const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
|
|
173
200
|
const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
|
|
174
201
|
job.response.updateOutput({ outputArray: chunk })
|
|
202
|
+
|
|
203
|
+
// Track stats for partial frame
|
|
204
|
+
this._runtimeStats.samplesDecoded += (count - samplesToSkip)
|
|
205
|
+
this._runtimeStats.outputBytes += length
|
|
175
206
|
} else {
|
|
176
207
|
const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
|
|
177
208
|
const chunk = Buffer.from(samples.data.subarray(0, length))
|
|
178
209
|
job.response.updateOutput({ outputArray: chunk })
|
|
210
|
+
|
|
211
|
+
// Track stats
|
|
212
|
+
this._runtimeStats.samplesDecoded += count
|
|
213
|
+
this._runtimeStats.outputBytes += length
|
|
179
214
|
}
|
|
180
215
|
}
|
|
181
216
|
}
|
|
@@ -195,6 +230,10 @@ class FFmpegDecoder extends BaseInference {
|
|
|
195
230
|
|
|
196
231
|
this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
|
|
197
232
|
|
|
233
|
+
// Track codec info in stats
|
|
234
|
+
this._runtimeStats.codecName = stream.codec.name
|
|
235
|
+
this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate
|
|
236
|
+
|
|
198
237
|
const packet = new ffmpeg.Packet()
|
|
199
238
|
const raw = new ffmpeg.Frame()
|
|
200
239
|
|
|
@@ -248,6 +287,10 @@ class FFmpegDecoder extends BaseInference {
|
|
|
248
287
|
const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
|
|
249
288
|
const chunk = Buffer.from(samples.data.subarray(0, actualLength))
|
|
250
289
|
job.response.updateOutput({ outputArray: chunk })
|
|
290
|
+
|
|
291
|
+
// Track stats for flushed samples
|
|
292
|
+
this._runtimeStats.samplesDecoded += flushCount
|
|
293
|
+
this._runtimeStats.outputBytes += actualLength
|
|
251
294
|
}
|
|
252
295
|
|
|
253
296
|
decoder.destroy()
|
|
@@ -282,6 +325,10 @@ class FFmpegDecoder extends BaseInference {
|
|
|
282
325
|
return
|
|
283
326
|
}
|
|
284
327
|
|
|
328
|
+
// Reset and start tracking stats
|
|
329
|
+
this._resetStats()
|
|
330
|
+
const startTime = Date.now()
|
|
331
|
+
|
|
285
332
|
try {
|
|
286
333
|
this.logger.info('[FFmpegDecoder] Starting stream processing')
|
|
287
334
|
|
|
@@ -289,6 +336,9 @@ class FFmpegDecoder extends BaseInference {
|
|
|
289
336
|
const audioBuffer = await this._collectStreamData(audioStream, job)
|
|
290
337
|
this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
|
|
291
338
|
|
|
339
|
+
// Track input bytes
|
|
340
|
+
this._runtimeStats.inputBytes = audioBuffer.length
|
|
341
|
+
|
|
292
342
|
if (!job.isActive) {
|
|
293
343
|
this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
|
|
294
344
|
return
|
|
@@ -353,10 +403,21 @@ class FFmpegDecoder extends BaseInference {
|
|
|
353
403
|
// Process the stream and generate decoded output
|
|
354
404
|
this._processFFmpegStream(format, format.streams[streamIndex], job)
|
|
355
405
|
|
|
406
|
+
// Calculate final decode time
|
|
407
|
+
this._runtimeStats.decodeTimeMs = Date.now() - startTime
|
|
408
|
+
|
|
409
|
+
// Update stats on response before ending
|
|
410
|
+
job.response.updateStats(this.runtimeStats())
|
|
411
|
+
|
|
356
412
|
// Mark as complete
|
|
357
413
|
job.response.ended()
|
|
358
414
|
this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
|
|
415
|
+
this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`)
|
|
359
416
|
} catch (err) {
|
|
417
|
+
// Still capture stats even on error
|
|
418
|
+
this._runtimeStats.decodeTimeMs = Date.now() - startTime
|
|
419
|
+
job.response.updateStats(this.runtimeStats())
|
|
420
|
+
|
|
360
421
|
this.logger.error('Error processing audio stream:', err)
|
|
361
422
|
job.response.failed(err)
|
|
362
423
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/decoder-audio",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Tether",
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
"test": "npm run test:unit && npm run test:integration",
|
|
13
13
|
"coverage:unit": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
|
|
14
14
|
"coverage": "brittle-bare --coverage --cov-dir=coverage/unit test/unit/*.test.js && npx istanbul report html --include=coverage/unit/coverage-final.json",
|
|
15
|
-
"lint": "standard \"test/**/*.js\" \"*.js\"",
|
|
16
|
-
"lint:fix": "standard --fix \"test/**/*.js\" \"*.js\"",
|
|
17
|
-
"test:integration": "brittle-bare test/integration/*.test.js"
|
|
15
|
+
"lint": "standard \"test/**/*.js\" \"*.js\" \"scripts/**/*.js\"",
|
|
16
|
+
"lint:fix": "standard --fix \"test/**/*.js\" \"*.js\" \"scripts/**/*.js\"",
|
|
17
|
+
"test:integration": "npm run test:integration:generate && brittle-bare test/integration/*.test.js",
|
|
18
|
+
"test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate",
|
|
19
|
+
"test:mobile:generate": "bare ./scripts/generate-mobile-integration-tests.js",
|
|
20
|
+
"test:mobile:validate": "node scripts/validate-mobile-tests.js"
|
|
18
21
|
},
|
|
19
22
|
"files": [
|
|
20
23
|
"index.js",
|
|
@@ -32,6 +35,8 @@
|
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/node": "^22.14.1",
|
|
38
|
+
"bare-os": "^3.6.2",
|
|
39
|
+
"bare-url": "^2.1.6",
|
|
35
40
|
"brittle": "^3.13.1",
|
|
36
41
|
"istanbul": "^0.4.5",
|
|
37
42
|
"standard": "^17.1.2",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('bare-path')
|
|
4
|
+
const fs = require('bare-fs')
|
|
5
|
+
const { pathToFileURL } = require('bare-url')
|
|
6
|
+
|
|
7
|
+
async function runIntegrationModule (relativeModulePath, options = {}) {
|
|
8
|
+
const modulePath = path.join(__dirname, relativeModulePath)
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(modulePath)) {
|
|
11
|
+
console.warn(`[integration-runner] Missing module: ${relativeModulePath}`)
|
|
12
|
+
return 'missing'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const moduleUrl = pathToFileURL(modulePath).href
|
|
16
|
+
await import(moduleUrl)
|
|
17
|
+
return modulePath
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
global.runIntegrationModule = runIntegrationModule
|
|
21
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
require('./integration-runtime.cjs')
|
|
3
|
+
|
|
4
|
+
// AUTO-GENERATED FILE. Run `npm run test:mobile:generate` to update.
|
|
5
|
+
// Each function mirrors a single file under test/integration/.
|
|
6
|
+
|
|
7
|
+
/* global runIntegrationModule */
|
|
8
|
+
|
|
9
|
+
async function runFfmpegDecoderTest (options = {}) { // eslint-disable-line no-unused-vars
|
|
10
|
+
return runIntegrationModule('../integration/ffmpeg-decoder.test.js', options)
|
|
11
|
+
}
|
|
Binary file
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
const { FFmpegDecoder } = require('@qvac/decoder-audio')
|
|
2
|
-
|
|
3
|
-
async function testFFmpegDecodeMp3() {
|
|
4
|
-
try {
|
|
5
|
-
const decoder = new FFmpegDecoder({
|
|
6
|
-
config: {
|
|
7
|
-
audioFormat: 's16le',
|
|
8
|
-
sampleRate: 16000
|
|
9
|
-
}
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
await decoder.load()
|
|
13
|
-
|
|
14
|
-
const audioPath = getAssetPath('sample.mp3')
|
|
15
|
-
const audioStream = fs.createReadStream(audioPath)
|
|
16
|
-
|
|
17
|
-
const response = await decoder.run(audioStream)
|
|
18
|
-
|
|
19
|
-
let totalBytes = 0
|
|
20
|
-
let updateCount = 0
|
|
21
|
-
|
|
22
|
-
await response
|
|
23
|
-
.onUpdate(output => {
|
|
24
|
-
if (output && output.outputArray) {
|
|
25
|
-
const bytes = new Uint8Array(output.outputArray)
|
|
26
|
-
totalBytes += bytes.length
|
|
27
|
-
updateCount++
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
.await()
|
|
31
|
-
|
|
32
|
-
if (totalBytes === 0) {
|
|
33
|
-
throw new Error('No audio data decoded')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (updateCount === 0) {
|
|
37
|
-
throw new Error('No decoder updates received')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
console.log(`MP3 decode complete: ${totalBytes} bytes, ${updateCount} updates`)
|
|
41
|
-
|
|
42
|
-
await decoder.unload()
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
success: true,
|
|
46
|
-
totalBytes,
|
|
47
|
-
updateCount
|
|
48
|
-
}
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error('Error during testFFmpegDecodeMp3:', error)
|
|
51
|
-
throw error
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function testFFmpegDecodeWav() {
|
|
56
|
-
try {
|
|
57
|
-
const decoder = new FFmpegDecoder({
|
|
58
|
-
config: {
|
|
59
|
-
audioFormat: 's16le',
|
|
60
|
-
sampleRate: 16000
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
await decoder.load()
|
|
65
|
-
|
|
66
|
-
const audioPath = getAssetPath('sample.wav')
|
|
67
|
-
const audioStream = fs.createReadStream(audioPath)
|
|
68
|
-
|
|
69
|
-
const response = await decoder.run(audioStream)
|
|
70
|
-
|
|
71
|
-
let totalBytes = 0
|
|
72
|
-
let updateCount = 0
|
|
73
|
-
|
|
74
|
-
await response
|
|
75
|
-
.onUpdate(output => {
|
|
76
|
-
if (output && output.outputArray) {
|
|
77
|
-
const bytes = new Uint8Array(output.outputArray)
|
|
78
|
-
totalBytes += bytes.length
|
|
79
|
-
updateCount++
|
|
80
|
-
}
|
|
81
|
-
})
|
|
82
|
-
.await()
|
|
83
|
-
|
|
84
|
-
if (totalBytes === 0) {
|
|
85
|
-
throw new Error('No audio data decoded')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (updateCount === 0) {
|
|
89
|
-
throw new Error('No decoder updates received')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
console.log(`MP3 decode complete: ${totalBytes} bytes, ${updateCount} updates`)
|
|
93
|
-
|
|
94
|
-
await decoder.unload()
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
success: true,
|
|
98
|
-
totalBytes,
|
|
99
|
-
updateCount
|
|
100
|
-
}
|
|
101
|
-
} catch (error) {
|
|
102
|
-
console.error('Error during testFFmpegDecodeMp3:', error)
|
|
103
|
-
throw error
|
|
104
|
-
}
|
|
105
|
-
}
|
|
File without changes
|
|
File without changes
|