@qvac/decoder-audio 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/constants.d.ts +2 -3
- package/constants.js +20 -25
- package/index.d.ts +106 -54
- package/index.js +324 -391
- package/package.json +29 -15
- package/test/mobile/integration-runtime.cjs +24 -0
- package/utils/createStreamAccumulator.d.ts +24 -0
- package/utils/createStreamAccumulator.js +44 -57
- package/utils/error.d.ts +19 -0
- package/utils/error.js +75 -79
package/index.js
CHANGED
|
@@ -1,412 +1,345 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FFmpegDecoder = void 0;
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-require-imports -- Bare modules and @qvac/logging expose CommonJS export shapes. */
|
|
5
|
+
const QvacLogger = require("@qvac/logging");
|
|
6
|
+
const ffmpeg = require("bare-ffmpeg");
|
|
7
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
8
|
+
const infer_base_1 = require("@qvac/infer-base");
|
|
9
|
+
const error_1 = require("./utils/error");
|
|
8
10
|
/**
|
|
9
11
|
* FFmpeg-based audio decoder (single-threaded)
|
|
10
12
|
*/
|
|
11
13
|
class FFmpegDecoder {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
SUPPORTED_AUDIO_FORMATS = {
|
|
15
|
+
s16le: {
|
|
16
|
+
format: null, // Will be set to ffmpeg.constants.sampleFormats.S16
|
|
17
|
+
byteLength: 2,
|
|
18
|
+
},
|
|
19
|
+
f32le: {
|
|
20
|
+
format: null, // Will be set to ffmpeg.constants.sampleFormats.FLT
|
|
21
|
+
byteLength: 4,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
OUTPUT_CHANNEL_LAYOUT = null; // Will be set to ffmpeg.constants.channelLayouts.MONO
|
|
25
|
+
config;
|
|
26
|
+
logger;
|
|
27
|
+
isLoaded;
|
|
28
|
+
samplesSkipped;
|
|
29
|
+
totalSkipSamples;
|
|
30
|
+
_cancelled;
|
|
31
|
+
_job;
|
|
32
|
+
_runtimeStats;
|
|
33
|
+
/**
|
|
34
|
+
* Creates an instance of FFmpegDecoder.
|
|
35
|
+
* @param params - Configuration options. Top-level `streamIndex`, `inputBitrate`
|
|
36
|
+
* and `audioFormat` act as fallbacks for the matching `config` fields.
|
|
37
|
+
*/
|
|
38
|
+
constructor({ config = {}, logger = null, streamIndex = 0, inputBitrate = 192000, audioFormat = "s16le", } = {}) {
|
|
39
|
+
this.config = {
|
|
40
|
+
streamIndex: config.streamIndex || streamIndex,
|
|
41
|
+
inputBitrate: config.inputBitrate || inputBitrate,
|
|
42
|
+
audioFormat: config.audioFormat || audioFormat,
|
|
43
|
+
sampleRate: config.sampleRate || 16000,
|
|
44
|
+
};
|
|
45
|
+
this.logger = new QvacLogger(logger ?? undefined);
|
|
46
|
+
this.isLoaded = false;
|
|
47
|
+
this._cancelled = false;
|
|
48
|
+
this._job = (0, infer_base_1.createJobHandler)({ cancel: () => this._cancelCurrent() });
|
|
49
|
+
// Encoder delay handling
|
|
50
|
+
this.samplesSkipped = 0;
|
|
51
|
+
this.totalSkipSamples = 0;
|
|
52
|
+
// Runtime stats
|
|
53
|
+
this._resetStats();
|
|
20
54
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* @param {Object} [config.logger] - Logger instance
|
|
36
|
-
*/
|
|
37
|
-
constructor ({
|
|
38
|
-
config = {},
|
|
39
|
-
logger = null,
|
|
40
|
-
streamIndex = 0,
|
|
41
|
-
inputBitrate = 192000,
|
|
42
|
-
audioFormat = 's16le'
|
|
43
|
-
} = {}) {
|
|
44
|
-
this.config = {
|
|
45
|
-
streamIndex: config.streamIndex || streamIndex,
|
|
46
|
-
inputBitrate: config.inputBitrate || inputBitrate,
|
|
47
|
-
audioFormat: config.audioFormat || audioFormat,
|
|
48
|
-
sampleRate: config.sampleRate || 16000
|
|
55
|
+
/**
|
|
56
|
+
* Resets the runtime stats
|
|
57
|
+
*/
|
|
58
|
+
_resetStats() {
|
|
59
|
+
this._runtimeStats = {
|
|
60
|
+
decodeTimeMs: 0,
|
|
61
|
+
inputBytes: 0,
|
|
62
|
+
outputBytes: 0,
|
|
63
|
+
samplesDecoded: 0,
|
|
64
|
+
codecName: null,
|
|
65
|
+
inputSampleRate: 0,
|
|
66
|
+
outputSampleRate: this.config.sampleRate,
|
|
67
|
+
audioFormat: this.config.audioFormat,
|
|
68
|
+
};
|
|
49
69
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Encoder delay handling
|
|
57
|
-
this.samplesSkipped = 0
|
|
58
|
-
this.totalSkipSamples = 0
|
|
59
|
-
|
|
60
|
-
// Runtime stats
|
|
61
|
-
this._resetStats()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resets the runtime stats
|
|
66
|
-
*/
|
|
67
|
-
_resetStats () {
|
|
68
|
-
this._runtimeStats = {
|
|
69
|
-
decodeTimeMs: 0,
|
|
70
|
-
inputBytes: 0,
|
|
71
|
-
outputBytes: 0,
|
|
72
|
-
samplesDecoded: 0,
|
|
73
|
-
codecName: null,
|
|
74
|
-
inputSampleRate: 0,
|
|
75
|
-
outputSampleRate: this.config.sampleRate,
|
|
76
|
-
audioFormat: this.config.audioFormat
|
|
70
|
+
/**
|
|
71
|
+
* Get the current runtime stats
|
|
72
|
+
* @returns Current runtime stats
|
|
73
|
+
*/
|
|
74
|
+
runtimeStats() {
|
|
75
|
+
return { ...this._runtimeStats };
|
|
77
76
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT
|
|
102
|
-
this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO
|
|
103
|
-
|
|
104
|
-
// Validate audio format
|
|
105
|
-
if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
|
|
106
|
-
throw new QvacErrorDecoderAudio({
|
|
107
|
-
code: ERR_CODES.UNSUPPORTED_AUDIO_FORMAT,
|
|
108
|
-
adds: this.config.audioFormat
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
this.isLoaded = true
|
|
113
|
-
this.logger.info('FFmpegDecoder loaded successfully')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Unload the decoder and clean up resources
|
|
118
|
-
*/
|
|
119
|
-
async unload () {
|
|
120
|
-
if (!this.isLoaded) {
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
this.logger.info('Unloading FFmpegDecoder')
|
|
125
|
-
|
|
126
|
-
this.isLoaded = false
|
|
127
|
-
this._cancelCurrent()
|
|
128
|
-
this._job.fail(new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED }))
|
|
129
|
-
this.logger.info('FFmpegDecoder unloaded')
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Run the decoder on an audio stream
|
|
134
|
-
* @param {Readable} audioStream - Input audio stream
|
|
135
|
-
* @returns {QvacResponse} Response with decoded audio
|
|
136
|
-
*/
|
|
137
|
-
run (audioStream) {
|
|
138
|
-
if (!this.isLoaded) {
|
|
139
|
-
throw new QvacErrorDecoderAudio({ code: ERR_CODES.DECODER_NOT_LOADED })
|
|
77
|
+
/**
|
|
78
|
+
* Load and initialize the decoder
|
|
79
|
+
*/
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- preserves the established promise-returning API, so failures surface as rejections rather than synchronous throws.
|
|
81
|
+
async load() {
|
|
82
|
+
if (this.isLoaded) {
|
|
83
|
+
this.logger.info("FFmpegDecoder already loaded");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.logger.info("Loading FFmpegDecoder with config:", this.config);
|
|
87
|
+
// Initialize format constants
|
|
88
|
+
this.SUPPORTED_AUDIO_FORMATS.s16le.format = ffmpeg.constants.sampleFormats.S16;
|
|
89
|
+
this.SUPPORTED_AUDIO_FORMATS.f32le.format = ffmpeg.constants.sampleFormats.FLT;
|
|
90
|
+
this.OUTPUT_CHANNEL_LAYOUT = ffmpeg.constants.channelLayouts.MONO;
|
|
91
|
+
// Validate audio format
|
|
92
|
+
if (!this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat]) {
|
|
93
|
+
throw new error_1.QvacErrorDecoderAudio({
|
|
94
|
+
code: error_1.ERR_CODES.UNSUPPORTED_AUDIO_FORMAT,
|
|
95
|
+
adds: this.config.audioFormat,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
this.isLoaded = true;
|
|
99
|
+
this.logger.info("FFmpegDecoder loaded successfully");
|
|
140
100
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.
|
|
153
|
-
this.
|
|
154
|
-
this._job.fail(err)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
return response
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
_cancelCurrent () {
|
|
161
|
-
this._cancelled = true
|
|
162
|
-
this.logger.debug('Decoder cancel requested')
|
|
163
|
-
return Promise.resolve()
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
_getBufferSize (inputBitrate) {
|
|
167
|
-
const maxBufferSize = 1024 * 1024 // 1MB max
|
|
168
|
-
return Math.min((inputBitrate / 8) * 4, maxBufferSize)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
_processFrame (decoder, raw, resampler) {
|
|
172
|
-
const OUTPUT_FORMAT = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].format
|
|
173
|
-
const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
|
|
174
|
-
const OUTPUT_SAMPLE_RATE = this.config.sampleRate
|
|
175
|
-
|
|
176
|
-
while (decoder.receiveFrame(raw)) {
|
|
177
|
-
const output = new ffmpeg.Frame()
|
|
178
|
-
output.channelLayout = this.OUTPUT_CHANNEL_LAYOUT
|
|
179
|
-
output.format = OUTPUT_FORMAT
|
|
180
|
-
output.sampleRate = OUTPUT_SAMPLE_RATE
|
|
181
|
-
output.nbSamples = raw.nbSamples
|
|
182
|
-
|
|
183
|
-
const samples = new ffmpeg.Samples(
|
|
184
|
-
output.format,
|
|
185
|
-
output.channelLayout.nbChannels,
|
|
186
|
-
output.nbSamples
|
|
187
|
-
)
|
|
188
|
-
samples.fill(output)
|
|
189
|
-
|
|
190
|
-
const count = resampler.convert(raw, output)
|
|
191
|
-
|
|
192
|
-
// Handle encoder delay by skipping initial samples
|
|
193
|
-
if (this.samplesSkipped < this.totalSkipSamples) {
|
|
194
|
-
const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped)
|
|
195
|
-
this.samplesSkipped += samplesToSkip
|
|
196
|
-
if (samplesToSkip >= count) continue // Skip entire frame
|
|
197
|
-
|
|
198
|
-
// Skip partial frame
|
|
199
|
-
const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels
|
|
200
|
-
const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels
|
|
201
|
-
const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length))
|
|
202
|
-
this._job.output({ outputArray: chunk })
|
|
203
|
-
|
|
204
|
-
// Track stats for partial frame
|
|
205
|
-
this._runtimeStats.samplesDecoded += (count - samplesToSkip)
|
|
206
|
-
this._runtimeStats.outputBytes += length
|
|
207
|
-
} else {
|
|
208
|
-
const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels
|
|
209
|
-
const chunk = Buffer.from(samples.data.subarray(0, length))
|
|
210
|
-
this._job.output({ outputArray: chunk })
|
|
211
|
-
|
|
212
|
-
// Track stats
|
|
213
|
-
this._runtimeStats.samplesDecoded += count
|
|
214
|
-
this._runtimeStats.outputBytes += length
|
|
215
|
-
}
|
|
101
|
+
/**
|
|
102
|
+
* Unload the decoder and clean up resources
|
|
103
|
+
*/
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- preserves the established promise-returning API, so failures surface as rejections rather than synchronous throws.
|
|
105
|
+
async unload() {
|
|
106
|
+
if (!this.isLoaded) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.logger.info("Unloading FFmpegDecoder");
|
|
110
|
+
this.isLoaded = false;
|
|
111
|
+
void this._cancelCurrent();
|
|
112
|
+
this._job.fail(new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED }));
|
|
113
|
+
this.logger.info("FFmpegDecoder unloaded");
|
|
216
114
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Run the decoder on an audio stream
|
|
117
|
+
* @param audioStream - Input audio stream
|
|
118
|
+
* @returns Response with decoded audio
|
|
119
|
+
*/
|
|
120
|
+
run(audioStream) {
|
|
121
|
+
if (!this.isLoaded) {
|
|
122
|
+
throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED });
|
|
123
|
+
}
|
|
124
|
+
this.logger.info("Starting new audio stream processing");
|
|
125
|
+
this._cancelled = false;
|
|
126
|
+
const response = this._job.start();
|
|
127
|
+
void this._processStream(audioStream)
|
|
128
|
+
.then(() => {
|
|
129
|
+
this._job.end(this.runtimeStats());
|
|
130
|
+
})
|
|
131
|
+
.catch((err) => {
|
|
132
|
+
this.logger.error("Error processing audio stream:", err);
|
|
133
|
+
this._job.active?.updateStats(this.runtimeStats());
|
|
134
|
+
this._job.fail(err);
|
|
135
|
+
});
|
|
136
|
+
return response;
|
|
228
137
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const OUTPUT_FORMAT_BYTE_LENGTH = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat].byteLength
|
|
234
|
-
const OUTPUT_SAMPLE_RATE = this.config.sampleRate
|
|
235
|
-
|
|
236
|
-
this.logger.debug('[FFmpegDecoder] Stream codec:', stream.codec, stream.codecParameters)
|
|
237
|
-
|
|
238
|
-
// Track codec info in stats
|
|
239
|
-
this._runtimeStats.codecName = stream.codec.name
|
|
240
|
-
this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate
|
|
241
|
-
|
|
242
|
-
const packet = new ffmpeg.Packet()
|
|
243
|
-
const raw = new ffmpeg.Frame()
|
|
244
|
-
|
|
245
|
-
const resampler = new ffmpeg.Resampler(
|
|
246
|
-
stream.codecParameters.sampleRate,
|
|
247
|
-
stream.codecParameters.channelLayout,
|
|
248
|
-
stream.codecParameters.format,
|
|
249
|
-
OUTPUT_SAMPLE_RATE,
|
|
250
|
-
this.OUTPUT_CHANNEL_LAYOUT,
|
|
251
|
-
OUTPUT_FORMAT
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
const decoder = stream.decoder()
|
|
255
|
-
decoder.open()
|
|
256
|
-
|
|
257
|
-
// Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
|
|
258
|
-
const codecName = stream.codec.name.toLowerCase()
|
|
259
|
-
const SKIP_MS = {
|
|
260
|
-
mp3: 400,
|
|
261
|
-
vorbis: 400,
|
|
262
|
-
opus: 150,
|
|
263
|
-
aac: 300
|
|
138
|
+
_cancelCurrent() {
|
|
139
|
+
this._cancelled = true;
|
|
140
|
+
this.logger.debug("Decoder cancel requested");
|
|
141
|
+
return Promise.resolve();
|
|
264
142
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000)
|
|
269
|
-
|
|
270
|
-
if (this.totalSkipSamples > 0) {
|
|
271
|
-
this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`)
|
|
143
|
+
_getBufferSize(inputBitrate) {
|
|
144
|
+
const maxBufferSize = 1024 * 1024; // 1MB max
|
|
145
|
+
return Math.min((inputBitrate / 8) * 4, maxBufferSize);
|
|
272
146
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
288
|
-
samples.fill(output)
|
|
289
|
-
|
|
290
|
-
let flushCount
|
|
291
|
-
while ((flushCount = resampler.flush(output)) > 0) {
|
|
292
|
-
const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels
|
|
293
|
-
const chunk = Buffer.from(samples.data.subarray(0, actualLength))
|
|
294
|
-
this._job.output({ outputArray: chunk })
|
|
295
|
-
|
|
296
|
-
// Track stats for flushed samples
|
|
297
|
-
this._runtimeStats.samplesDecoded += flushCount
|
|
298
|
-
this._runtimeStats.outputBytes += actualLength
|
|
147
|
+
/**
|
|
148
|
+
* Resolves the output constants populated by `load()`. Unreachable before
|
|
149
|
+
* `load()` succeeds, since every caller sits behind the `isLoaded` guard.
|
|
150
|
+
*/
|
|
151
|
+
_resolveOutputFormat() {
|
|
152
|
+
const audioFormat = this.SUPPORTED_AUDIO_FORMATS[this.config.audioFormat];
|
|
153
|
+
if (audioFormat.format === null || this.OUTPUT_CHANNEL_LAYOUT === null) {
|
|
154
|
+
throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.DECODER_NOT_LOADED });
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
format: audioFormat.format,
|
|
158
|
+
byteLength: audioFormat.byteLength,
|
|
159
|
+
channelLayout: this.OUTPUT_CHANNEL_LAYOUT,
|
|
160
|
+
};
|
|
299
161
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
162
|
+
_processFrame(decoder, raw, resampler) {
|
|
163
|
+
const { format: OUTPUT_FORMAT, byteLength: OUTPUT_FORMAT_BYTE_LENGTH, channelLayout: OUTPUT_CHANNEL_LAYOUT, } = this._resolveOutputFormat();
|
|
164
|
+
const OUTPUT_SAMPLE_RATE = this.config.sampleRate;
|
|
165
|
+
while (decoder.receiveFrame(raw)) {
|
|
166
|
+
const output = new ffmpeg.Frame();
|
|
167
|
+
output.channelLayout = OUTPUT_CHANNEL_LAYOUT;
|
|
168
|
+
output.format = OUTPUT_FORMAT;
|
|
169
|
+
output.sampleRate = OUTPUT_SAMPLE_RATE;
|
|
170
|
+
output.nbSamples = raw.nbSamples;
|
|
171
|
+
const samples = new ffmpeg.Samples();
|
|
172
|
+
samples.fill(output);
|
|
173
|
+
const count = resampler.convert(raw, output);
|
|
174
|
+
// Handle encoder delay by skipping initial samples
|
|
175
|
+
if (this.samplesSkipped < this.totalSkipSamples) {
|
|
176
|
+
const samplesToSkip = Math.min(count, this.totalSkipSamples - this.samplesSkipped);
|
|
177
|
+
this.samplesSkipped += samplesToSkip;
|
|
178
|
+
if (samplesToSkip >= count)
|
|
179
|
+
continue; // Skip entire frame
|
|
180
|
+
// Skip partial frame
|
|
181
|
+
const skipBytes = OUTPUT_FORMAT_BYTE_LENGTH * samplesToSkip * output.channelLayout.nbChannels;
|
|
182
|
+
const length = OUTPUT_FORMAT_BYTE_LENGTH * (count - samplesToSkip) * output.channelLayout.nbChannels;
|
|
183
|
+
const chunk = Buffer.from(samples.data.subarray(skipBytes, skipBytes + length));
|
|
184
|
+
this._job.output({ outputArray: chunk });
|
|
185
|
+
// Track stats for partial frame
|
|
186
|
+
this._runtimeStats.samplesDecoded += count - samplesToSkip;
|
|
187
|
+
this._runtimeStats.outputBytes += length;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const length = OUTPUT_FORMAT_BYTE_LENGTH * count * output.channelLayout.nbChannels;
|
|
191
|
+
const chunk = Buffer.from(samples.data.subarray(0, length));
|
|
192
|
+
this._job.output({ outputArray: chunk });
|
|
193
|
+
// Track stats
|
|
194
|
+
this._runtimeStats.samplesDecoded += count;
|
|
195
|
+
this._runtimeStats.outputBytes += length;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
317
198
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// Collect all audio data from stream
|
|
330
|
-
const audioBuffer = await this._collectStreamData(audioStream)
|
|
331
|
-
this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`)
|
|
332
|
-
|
|
333
|
-
// Track input bytes
|
|
334
|
-
this._runtimeStats.inputBytes = audioBuffer.length
|
|
335
|
-
|
|
336
|
-
if (this._cancelled) {
|
|
337
|
-
this.logger.info('[FFmpegDecoder] Job cancelled after data collection')
|
|
338
|
-
this._runtimeStats.decodeTimeMs = Date.now() - startTime
|
|
339
|
-
throw new QvacErrorDecoderAudio({ code: ERR_CODES.JOB_CANCELLED })
|
|
199
|
+
_processPacket(format, packet, raw, decoder, resampler) {
|
|
200
|
+
while (format.readFrame(packet)) {
|
|
201
|
+
if (this._cancelled) {
|
|
202
|
+
packet.unref();
|
|
203
|
+
throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
|
|
204
|
+
}
|
|
205
|
+
decoder.sendPacket(packet);
|
|
206
|
+
this._processFrame(decoder, raw, resampler);
|
|
207
|
+
packet.unref();
|
|
208
|
+
}
|
|
340
209
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
210
|
+
_processFFmpegStream(format, stream) {
|
|
211
|
+
const { format: OUTPUT_FORMAT, byteLength: OUTPUT_FORMAT_BYTE_LENGTH, channelLayout: OUTPUT_CHANNEL_LAYOUT, } = this._resolveOutputFormat();
|
|
212
|
+
const OUTPUT_SAMPLE_RATE = this.config.sampleRate;
|
|
213
|
+
this.logger.debug("[FFmpegDecoder] Stream codec:", stream.codec, stream.codecParameters);
|
|
214
|
+
// Track codec info in stats
|
|
215
|
+
this._runtimeStats.codecName = stream.codec.name;
|
|
216
|
+
this._runtimeStats.inputSampleRate = stream.codecParameters.sampleRate;
|
|
217
|
+
const packet = new ffmpeg.Packet();
|
|
218
|
+
const raw = new ffmpeg.Frame();
|
|
219
|
+
const resampler = new ffmpeg.Resampler(stream.codecParameters.sampleRate, stream.codecParameters.channelLayout, stream.codecParameters.format, OUTPUT_SAMPLE_RATE, OUTPUT_CHANNEL_LAYOUT, OUTPUT_FORMAT);
|
|
220
|
+
const decoder = stream.decoder();
|
|
221
|
+
decoder.open();
|
|
222
|
+
// Auto-detect encoder delay: lossy codecs need ~400ms skipped to remove artifacts
|
|
223
|
+
const codecName = stream.codec.name.toLowerCase();
|
|
224
|
+
const SKIP_MS = {
|
|
225
|
+
mp3: 400,
|
|
226
|
+
vorbis: 400,
|
|
227
|
+
opus: 150,
|
|
228
|
+
aac: 300,
|
|
229
|
+
};
|
|
230
|
+
const skipMs = SKIP_MS[codecName] || 0;
|
|
231
|
+
this.samplesSkipped = 0;
|
|
232
|
+
this.totalSkipSamples = Math.floor((OUTPUT_SAMPLE_RATE * skipMs) / 1000);
|
|
233
|
+
if (this.totalSkipSamples > 0) {
|
|
234
|
+
this.logger.info(`[FFmpegDecoder] Skipping ${skipMs}ms (${this.totalSkipSamples} samples) for ${codecName} to remove encoder artifacts`);
|
|
353
235
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
236
|
+
this._processPacket(format, packet, raw, decoder, resampler);
|
|
237
|
+
// Flush resampler
|
|
238
|
+
const output = new ffmpeg.Frame();
|
|
239
|
+
output.channelLayout = OUTPUT_CHANNEL_LAYOUT;
|
|
240
|
+
output.format = OUTPUT_FORMAT;
|
|
241
|
+
output.sampleRate = OUTPUT_SAMPLE_RATE;
|
|
242
|
+
output.nbSamples = 1024;
|
|
243
|
+
const samples = new ffmpeg.Samples();
|
|
244
|
+
samples.fill(output);
|
|
245
|
+
let flushCount;
|
|
246
|
+
while ((flushCount = resampler.flush(output)) > 0) {
|
|
247
|
+
const actualLength = OUTPUT_FORMAT_BYTE_LENGTH * flushCount * output.channelLayout.nbChannels;
|
|
248
|
+
const chunk = Buffer.from(samples.data.subarray(0, actualLength));
|
|
249
|
+
this._job.output({ outputArray: chunk });
|
|
250
|
+
// Track stats for flushed samples
|
|
251
|
+
this._runtimeStats.samplesDecoded += flushCount;
|
|
252
|
+
this._runtimeStats.outputBytes += actualLength;
|
|
366
253
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
254
|
+
decoder.destroy();
|
|
255
|
+
}
|
|
256
|
+
async _collectStreamData(audioStream) {
|
|
257
|
+
const chunks = [];
|
|
258
|
+
let totalBytes = 0;
|
|
259
|
+
for await (const chunk of audioStream) {
|
|
260
|
+
if (this._cancelled) {
|
|
261
|
+
this.logger.info("[FFmpegDecoder] Job cancelled, stopping stream collection");
|
|
262
|
+
throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
|
|
263
|
+
}
|
|
264
|
+
chunks.push(chunk);
|
|
265
|
+
totalBytes += chunk.length;
|
|
266
|
+
this.logger.debug(`[FFmpegDecoder] Collected chunk, total bytes: ${totalBytes}`);
|
|
267
|
+
}
|
|
268
|
+
return Buffer.concat(chunks);
|
|
269
|
+
}
|
|
270
|
+
async _processStream(audioStream) {
|
|
271
|
+
// Reset and start tracking stats
|
|
272
|
+
this._resetStats();
|
|
273
|
+
const startTime = Date.now();
|
|
274
|
+
this.logger.info("[FFmpegDecoder] Starting stream processing");
|
|
275
|
+
// Collect all audio data from stream
|
|
276
|
+
const audioBuffer = await this._collectStreamData(audioStream);
|
|
277
|
+
this.logger.info(`[FFmpegDecoder] Collected ${audioBuffer.length} bytes of audio data`);
|
|
278
|
+
// Track input bytes
|
|
279
|
+
this._runtimeStats.inputBytes = audioBuffer.length;
|
|
280
|
+
if (this._cancelled) {
|
|
281
|
+
this.logger.info("[FFmpegDecoder] Job cancelled after data collection");
|
|
282
|
+
this._runtimeStats.decodeTimeMs = Date.now() - startTime;
|
|
283
|
+
throw new error_1.QvacErrorDecoderAudio({ code: error_1.ERR_CODES.JOB_CANCELLED });
|
|
377
284
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
285
|
+
// Create FFmpeg IO context with the buffer
|
|
286
|
+
const bufferSize = this._getBufferSize(this.config.inputBitrate);
|
|
287
|
+
let bufferOffset = 0;
|
|
288
|
+
const io = new ffmpeg.IOContext(bufferSize, {
|
|
289
|
+
onread: (buffer, requestedLen) => {
|
|
290
|
+
const remainingBytes = audioBuffer.length - bufferOffset;
|
|
291
|
+
const bytesToRead = Math.min(requestedLen, remainingBytes);
|
|
292
|
+
if (bytesToRead <= 0) {
|
|
293
|
+
return 0; // EOF
|
|
294
|
+
}
|
|
295
|
+
audioBuffer.copy(buffer, 0, bufferOffset, bufferOffset + bytesToRead);
|
|
296
|
+
bufferOffset += bytesToRead;
|
|
297
|
+
this.logger.debug(`[FFmpegDecoder] Read ${bytesToRead} bytes from buffer, offset now: ${bufferOffset}`);
|
|
298
|
+
return bytesToRead;
|
|
299
|
+
},
|
|
300
|
+
onseek: (offset, whence) => {
|
|
301
|
+
const AVSEEK_SIZE = 0x10000;
|
|
302
|
+
if (whence === AVSEEK_SIZE) {
|
|
303
|
+
return audioBuffer.length;
|
|
304
|
+
}
|
|
305
|
+
let newOffset;
|
|
306
|
+
if (whence === 0) {
|
|
307
|
+
newOffset = offset;
|
|
308
|
+
}
|
|
309
|
+
else if (whence === 1) {
|
|
310
|
+
newOffset = bufferOffset + offset;
|
|
311
|
+
}
|
|
312
|
+
else if (whence === 2) {
|
|
313
|
+
newOffset = audioBuffer.length + offset;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
return -1;
|
|
317
|
+
}
|
|
318
|
+
if (newOffset < 0 || newOffset > audioBuffer.length) {
|
|
319
|
+
return -1;
|
|
320
|
+
}
|
|
321
|
+
bufferOffset = newOffset;
|
|
322
|
+
this.logger.debug(`[FFmpegDecoder] Seek to offset: ${bufferOffset}`);
|
|
323
|
+
return bufferOffset;
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
this.logger.debug("[FFmpegDecoder] IOContext created");
|
|
327
|
+
const format = new ffmpeg.InputFormatContext(io);
|
|
328
|
+
this.logger.debug("[FFmpegDecoder] InputFormatContext created");
|
|
329
|
+
const streamIndex = this.config.streamIndex || 0;
|
|
330
|
+
const stream = format.streams[streamIndex];
|
|
331
|
+
if (stream === undefined) {
|
|
332
|
+
throw new error_1.QvacErrorDecoderAudio({
|
|
333
|
+
code: error_1.ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS,
|
|
334
|
+
adds: [streamIndex],
|
|
335
|
+
});
|
|
381
336
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
this.logger.debug('[FFmpegDecoder] IOContext created')
|
|
390
|
-
const format = new ffmpeg.InputFormatContext(io)
|
|
391
|
-
this.logger.debug('[FFmpegDecoder] InputFormatContext created')
|
|
392
|
-
|
|
393
|
-
const streamIndex = this.config.streamIndex || 0
|
|
394
|
-
if (format.streams[streamIndex] === undefined) {
|
|
395
|
-
throw new QvacErrorDecoderAudio({
|
|
396
|
-
code: ERR_CODES.STREAM_INDEX_OUT_OF_BOUNDS,
|
|
397
|
-
adds: streamIndex
|
|
398
|
-
})
|
|
337
|
+
// Process the stream and generate decoded output
|
|
338
|
+
this._processFFmpegStream(format, stream);
|
|
339
|
+
// Calculate final decode time
|
|
340
|
+
this._runtimeStats.decodeTimeMs = Date.now() - startTime;
|
|
341
|
+
this.logger.info("[FFmpegDecoder] Stream processing completed successfully");
|
|
342
|
+
this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`);
|
|
399
343
|
}
|
|
400
|
-
|
|
401
|
-
// Process the stream and generate decoded output
|
|
402
|
-
this._processFFmpegStream(format, format.streams[streamIndex])
|
|
403
|
-
|
|
404
|
-
// Calculate final decode time
|
|
405
|
-
this._runtimeStats.decodeTimeMs = Date.now() - startTime
|
|
406
|
-
|
|
407
|
-
this.logger.info('[FFmpegDecoder] Stream processing completed successfully')
|
|
408
|
-
this.logger.info(`[FFmpegDecoder] Runtime stats: ${JSON.stringify(this._runtimeStats)}`)
|
|
409
|
-
}
|
|
410
344
|
}
|
|
411
|
-
|
|
412
|
-
module.exports = { FFmpegDecoder }
|
|
345
|
+
exports.FFmpegDecoder = FFmpegDecoder;
|