@mediabunny/server 1.45.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/LICENSE +373 -0
- package/README.md +276 -0
- package/dist/bundles/mediabunny-server.cjs +3361 -0
- package/dist/bundles/mediabunny-server.min.cjs +10 -0
- package/dist/bundles/mediabunny-server.min.mjs +9 -0
- package/dist/bundles/mediabunny-server.mjs +3334 -0
- package/dist/mediabunny-server.d.ts +105 -0
- package/dist/modules/src/audio-decoder.d.ts +21 -0
- package/dist/modules/src/audio-decoder.d.ts.map +1 -0
- package/dist/modules/src/audio-decoder.js +132 -0
- package/dist/modules/src/audio-encoder.d.ts +35 -0
- package/dist/modules/src/audio-encoder.d.ts.map +1 -0
- package/dist/modules/src/audio-encoder.js +329 -0
- package/dist/modules/src/audio-sample.d.ts +38 -0
- package/dist/modules/src/audio-sample.d.ts.map +1 -0
- package/dist/modules/src/audio-sample.js +119 -0
- package/dist/modules/src/index.d.ts +39 -0
- package/dist/modules/src/index.d.ts.map +1 -0
- package/dist/modules/src/index.js +132 -0
- package/dist/modules/src/misc.d.ts +26 -0
- package/dist/modules/src/misc.d.ts.map +1 -0
- package/dist/modules/src/misc.js +255 -0
- package/dist/modules/src/video-decoder.d.ts +30 -0
- package/dist/modules/src/video-decoder.d.ts.map +1 -0
- package/dist/modules/src/video-decoder.js +214 -0
- package/dist/modules/src/video-encoder.d.ts +35 -0
- package/dist/modules/src/video-encoder.d.ts.map +1 -0
- package/dist/modules/src/video-encoder.js +474 -0
- package/dist/modules/src/video-sample.d.ts +45 -0
- package/dist/modules/src/video-sample.d.ts.map +1 -0
- package/dist/modules/src/video-sample.js +276 -0
- package/dist/modules/tsconfig.tsbuildinfo +1 -0
- package/package.json +59 -0
- package/src/audio-decoder.ts +120 -0
- package/src/audio-encoder.ts +396 -0
- package/src/audio-sample.ts +101 -0
- package/src/index.ts +104 -0
- package/src/misc.ts +242 -0
- package/src/video-decoder.ts +224 -0
- package/src/video-encoder.ts +568 -0
- package/src/video-sample.ts +313 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
3
|
+
*
|
|
4
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AudioCodec, AudioSample, CustomAudioDecoder, EncodedPacket, type MaybePromise } from 'mediabunny';
|
|
10
|
+
import * as NodeAv from 'node-av';
|
|
11
|
+
import { CODEC_TO_CODEC_ID, getChannelLayout } from './misc';
|
|
12
|
+
import { assert, toUint8Array } from '../../../src/misc';
|
|
13
|
+
import { AvFrameAudioSampleResource } from './audio-sample';
|
|
14
|
+
|
|
15
|
+
export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
|
16
|
+
frame!: NodeAv.Frame;
|
|
17
|
+
packet!: NodeAv.Packet;
|
|
18
|
+
codecContext!: NodeAv.CodecContext;
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
21
|
+
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
|
|
22
|
+
return codec === 'aac'
|
|
23
|
+
|| codec === 'opus'
|
|
24
|
+
|| codec === 'mp3'
|
|
25
|
+
|| codec === 'vorbis'
|
|
26
|
+
|| codec === 'flac'
|
|
27
|
+
|| codec === 'ac3'
|
|
28
|
+
|| codec === 'eac3';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async init(): Promise<void> {
|
|
32
|
+
this.frame = new NodeAv.Frame();
|
|
33
|
+
this.frame.alloc();
|
|
34
|
+
this.packet = new NodeAv.Packet();
|
|
35
|
+
this.packet.alloc();
|
|
36
|
+
|
|
37
|
+
const codecId = CODEC_TO_CODEC_ID[this.codec];
|
|
38
|
+
assert(codecId !== undefined);
|
|
39
|
+
|
|
40
|
+
const codec = NodeAv.Codec.findDecoder(codecId);
|
|
41
|
+
if (codec === null) {
|
|
42
|
+
throw new Error(`Unable to obtain libav codec for '${this.codec}'.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const codecContext = new NodeAv.CodecContext();
|
|
46
|
+
codecContext.allocContext3(codec);
|
|
47
|
+
|
|
48
|
+
codecContext.sampleRate = this.config.sampleRate;
|
|
49
|
+
codecContext.channelLayout = getChannelLayout(this.config.numberOfChannels);
|
|
50
|
+
codecContext.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
|
|
51
|
+
codecContext.codecType = NodeAv.AVMEDIA_TYPE_AUDIO;
|
|
52
|
+
codecContext.codecId = codecId;
|
|
53
|
+
codecContext.extraData = this.config.description
|
|
54
|
+
? Buffer.from(toUint8Array(this.config.description))
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
const ret = await codecContext.open2();
|
|
58
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
|
|
59
|
+
|
|
60
|
+
this.codecContext = codecContext;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async decode(packet: EncodedPacket): Promise<void> {
|
|
64
|
+
this.packet.isKeyframe = packet.type === 'key';
|
|
65
|
+
this.packet.data = Buffer.from(packet.data);
|
|
66
|
+
this.packet.timeBase = { num: 1, den: this.config.sampleRate };
|
|
67
|
+
this.packet.pts = BigInt(Math.round(packet.timestamp * this.config.sampleRate));
|
|
68
|
+
this.packet.dts = NodeAv.AV_NOPTS_VALUE;
|
|
69
|
+
this.packet.duration = BigInt(Math.round(packet.duration * this.config.sampleRate));
|
|
70
|
+
|
|
71
|
+
const ret = await this.codecContext.sendPacket(this.packet);
|
|
72
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
|
76
|
+
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.receiveFrame(receiveRet);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
receiveFrame(ret: number) {
|
|
85
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Receive frame');
|
|
86
|
+
|
|
87
|
+
const clone = this.frame.clone();
|
|
88
|
+
if (!clone) {
|
|
89
|
+
throw new Error('Allocation failure during frame clone.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
clone.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
|
|
93
|
+
this.onSample(new AudioSample(new AvFrameAudioSampleResource(clone)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async flush(): Promise<void> {
|
|
97
|
+
// Send null packet to signal flush
|
|
98
|
+
const ret = await this.codecContext.sendPacket(null);
|
|
99
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder');
|
|
100
|
+
|
|
101
|
+
// Keep receiving frames until no more are available
|
|
102
|
+
while (true) {
|
|
103
|
+
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
|
104
|
+
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
|
105
|
+
// No more frames available
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.receiveFrame(receiveRet);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.codecContext.flushBuffers();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
close(): MaybePromise<void> {
|
|
116
|
+
this.codecContext.freeContext();
|
|
117
|
+
this.frame.free();
|
|
118
|
+
this.packet.free();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
3
|
+
*
|
|
4
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
AudioCodec,
|
|
11
|
+
AudioSample,
|
|
12
|
+
CustomAudioEncoder,
|
|
13
|
+
type MaybePromise,
|
|
14
|
+
QUALITY_MEDIUM,
|
|
15
|
+
EncodedPacket,
|
|
16
|
+
} from 'mediabunny';
|
|
17
|
+
import * as NodeAv from 'node-av';
|
|
18
|
+
import { CODEC_TO_CODEC_ID, getChannelLayout } from './misc';
|
|
19
|
+
import { assert, toUint8Array } from '../../../src/misc';
|
|
20
|
+
import { copyAudioSampleToAvFrame, AvFrameAudioSampleResource } from './audio-sample';
|
|
21
|
+
import {
|
|
22
|
+
AdtsHeaderTemplate,
|
|
23
|
+
buildAdtsHeaderTemplate,
|
|
24
|
+
parseAacAudioSpecificConfig,
|
|
25
|
+
} from '../../../shared/aac-misc';
|
|
26
|
+
|
|
27
|
+
const AAC_SAMPLE_RATES
|
|
28
|
+
= [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
29
|
+
const OPUS_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000];
|
|
30
|
+
const MP3_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000];
|
|
31
|
+
const AC3_SAMPLE_RATES = [32000, 44100, 48000];
|
|
32
|
+
|
|
33
|
+
const FRAME_SIZE_FALLBACK = 1024; // Just 'cause
|
|
34
|
+
|
|
35
|
+
export class NodeAvAudioEncoder extends CustomAudioEncoder {
|
|
36
|
+
frame!: NodeAv.Frame;
|
|
37
|
+
packet!: NodeAv.Packet;
|
|
38
|
+
avCodec!: NodeAv.Codec;
|
|
39
|
+
codecContext: NodeAv.CodecContext | null = null;
|
|
40
|
+
firstExpectedTimestamp: number | null = null;
|
|
41
|
+
outputTimestampOffset = 0;
|
|
42
|
+
|
|
43
|
+
resampler: NodeAv.SoftwareResampleContext | null = null;
|
|
44
|
+
inputParametersKey: string | null = null;
|
|
45
|
+
resamplerInputSampleRate: number | null = null;
|
|
46
|
+
nextResamplerPts: bigint | null = null;
|
|
47
|
+
dstFrame: NodeAv.Frame | null = null;
|
|
48
|
+
packetEmitted = false;
|
|
49
|
+
adtsHeaderTemplate: AdtsHeaderTemplate | null = null;
|
|
50
|
+
|
|
51
|
+
static override supports(codec: AudioCodec, config: AudioEncoderConfig): boolean {
|
|
52
|
+
const { numberOfChannels, sampleRate } = config;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
codec === 'aac' && numberOfChannels >= 1 && numberOfChannels <= 48
|
|
56
|
+
&& AAC_SAMPLE_RATES.includes(sampleRate)
|
|
57
|
+
) || (
|
|
58
|
+
codec === 'opus' && numberOfChannels >= 1 && numberOfChannels <= 255
|
|
59
|
+
&& OPUS_SAMPLE_RATES.includes(sampleRate)
|
|
60
|
+
) || (
|
|
61
|
+
codec === 'mp3' && numberOfChannels >= 1 && numberOfChannels <= 2
|
|
62
|
+
&& MP3_SAMPLE_RATES.includes(sampleRate)
|
|
63
|
+
) || (
|
|
64
|
+
codec === 'vorbis' && numberOfChannels >= 1 && numberOfChannels <= 255
|
|
65
|
+
&& sampleRate <= 200000
|
|
66
|
+
) || (
|
|
67
|
+
codec === 'flac' && numberOfChannels >= 1 && numberOfChannels <= 8
|
|
68
|
+
&& sampleRate <= 655350
|
|
69
|
+
) || (
|
|
70
|
+
codec === 'ac3' && numberOfChannels >= 1 && numberOfChannels <= 6
|
|
71
|
+
&& AC3_SAMPLE_RATES.includes(sampleRate)
|
|
72
|
+
) || (
|
|
73
|
+
codec === 'eac3' && numberOfChannels >= 1 && numberOfChannels <= 16
|
|
74
|
+
&& AC3_SAMPLE_RATES.includes(sampleRate)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async init(): Promise<void> {
|
|
79
|
+
this.frame = new NodeAv.Frame();
|
|
80
|
+
this.frame.alloc();
|
|
81
|
+
this.packet = new NodeAv.Packet();
|
|
82
|
+
this.packet.alloc();
|
|
83
|
+
|
|
84
|
+
const codecId = CODEC_TO_CODEC_ID[this.codec];
|
|
85
|
+
assert(codecId !== undefined);
|
|
86
|
+
|
|
87
|
+
const codec = NodeAv.Codec.findEncoder(codecId);
|
|
88
|
+
if (!codec) {
|
|
89
|
+
throw new Error(`Unable to obtain libav codec for '${this.codec}'.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.avCodec = codec;
|
|
93
|
+
|
|
94
|
+
await this.createCodecContext();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async createCodecContext() {
|
|
98
|
+
assert(this.codecContext === null);
|
|
99
|
+
|
|
100
|
+
const codecContext = new NodeAv.CodecContext();
|
|
101
|
+
codecContext.allocContext3(this.avCodec);
|
|
102
|
+
|
|
103
|
+
let sampleFormat = NodeAv.AV_SAMPLE_FMT_FLTP;
|
|
104
|
+
if (this.avCodec.sampleFormats && !this.avCodec.sampleFormats.includes(NodeAv.AV_SAMPLE_FMT_FLTP)) {
|
|
105
|
+
// Use a format that's supported
|
|
106
|
+
sampleFormat = this.avCodec.sampleFormats[0]!;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
codecContext.sampleRate = this.config.sampleRate;
|
|
110
|
+
codecContext.channelLayout = getChannelLayout(this.config.numberOfChannels);
|
|
111
|
+
codecContext.codecType = NodeAv.AVMEDIA_TYPE_AUDIO;
|
|
112
|
+
codecContext.codecId = CODEC_TO_CODEC_ID[this.codec]!;
|
|
113
|
+
codecContext.sampleFormat = sampleFormat;
|
|
114
|
+
codecContext.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
|
|
115
|
+
codecContext.bitRate = BigInt(this.config.bitrate ?? QUALITY_MEDIUM._toAudioBitrate(this.codec) ?? 0);
|
|
116
|
+
|
|
117
|
+
if (this.config.bitrateMode === 'constant') {
|
|
118
|
+
codecContext.rcMinRate = codecContext.bitRate;
|
|
119
|
+
codecContext.rcMaxRate = codecContext.bitRate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ret = await codecContext.open2();
|
|
123
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
|
|
124
|
+
|
|
125
|
+
this.codecContext = codecContext;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async encode(audioSample: AudioSample): Promise<void> {
|
|
129
|
+
if (this.codecContext === null) {
|
|
130
|
+
await this.createCodecContext();
|
|
131
|
+
assert(this.codecContext);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.firstExpectedTimestamp ??= audioSample.timestamp;
|
|
135
|
+
|
|
136
|
+
if (audioSample._data instanceof AvFrameAudioSampleResource) {
|
|
137
|
+
this.frame.ref(audioSample._data.frame);
|
|
138
|
+
} else {
|
|
139
|
+
copyAudioSampleToAvFrame(audioSample, this.frame);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.frame.pts = BigInt(Math.round(audioSample.timestamp * this.config.sampleRate));
|
|
143
|
+
this.frame.duration = BigInt(Math.round(audioSample.duration * this.config.sampleRate));
|
|
144
|
+
this.frame.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
|
|
145
|
+
|
|
146
|
+
const key = `${this.frame.sampleRate}:${this.frame.channels}:${this.frame.format}`;
|
|
147
|
+
if (this.inputParametersKey !== null && this.inputParametersKey !== key) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'Input audio parameters changed. For this audio encoder, you cannot change the input audio'
|
|
150
|
+
+ ' parameters over time.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
this.inputParametersKey = key;
|
|
154
|
+
|
|
155
|
+
// We need the resampler when:
|
|
156
|
+
// 1. Format conversion is needed (sample format, sample rate, or channel count differs)
|
|
157
|
+
// 2. The codec requires fixed frame sizes
|
|
158
|
+
const requiresResampler
|
|
159
|
+
= this.codecContext.frameSize > 0
|
|
160
|
+
|| this.codecContext.sampleFormat !== this.frame.format
|
|
161
|
+
|| this.codecContext.sampleRate !== this.frame.sampleRate
|
|
162
|
+
|| this.codecContext.channels !== this.frame.channels;
|
|
163
|
+
|
|
164
|
+
if (requiresResampler) {
|
|
165
|
+
if (!this.resampler) {
|
|
166
|
+
this.resampler = new NodeAv.SoftwareResampleContext();
|
|
167
|
+
this.resamplerInputSampleRate = this.frame.sampleRate;
|
|
168
|
+
|
|
169
|
+
const outLayout = getChannelLayout(this.codecContext.channels);
|
|
170
|
+
const inLayout = getChannelLayout(this.frame.channels);
|
|
171
|
+
|
|
172
|
+
const ret = this.resampler.allocSetOpts2(
|
|
173
|
+
outLayout, this.codecContext.sampleFormat, this.codecContext.sampleRate,
|
|
174
|
+
inLayout, this.frame.format as NodeAv.AVSampleFormat, this.frame.sampleRate,
|
|
175
|
+
);
|
|
176
|
+
NodeAv.FFmpegError.throwIfError(ret, 'allocSetOpts2');
|
|
177
|
+
|
|
178
|
+
const ret2 = this.resampler.init();
|
|
179
|
+
NodeAv.FFmpegError.throwIfError(ret2, 'init');
|
|
180
|
+
|
|
181
|
+
this.dstFrame = new NodeAv.Frame();
|
|
182
|
+
this.dstFrame.alloc();
|
|
183
|
+
this.dstFrame.channelLayout = outLayout;
|
|
184
|
+
this.dstFrame.sampleRate = this.codecContext.sampleRate;
|
|
185
|
+
this.dstFrame.format = this.codecContext.sampleFormat;
|
|
186
|
+
this.dstFrame.nbSamples = this.codecContext.frameSize || FRAME_SIZE_FALLBACK;
|
|
187
|
+
this.dstFrame.duration = BigInt(this.dstFrame.nbSamples);
|
|
188
|
+
this.dstFrame.allocBuffer();
|
|
189
|
+
|
|
190
|
+
this.nextResamplerPts = this.frame.pts;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const inputBuffers = this.frame.data;
|
|
194
|
+
if (!inputBuffers) {
|
|
195
|
+
throw new DOMException('Frame has no data', 'EncodingError');
|
|
196
|
+
}
|
|
197
|
+
await this.resampler.convert(null, 0, inputBuffers, this.frame.nbSamples);
|
|
198
|
+
|
|
199
|
+
await this.pullResampledFrames();
|
|
200
|
+
} else {
|
|
201
|
+
await this.sendFrameAndReceivePackets(this.frame);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async pullResampledFrames() {
|
|
206
|
+
assert(this.codecContext);
|
|
207
|
+
assert(this.resampler);
|
|
208
|
+
assert(this.dstFrame);
|
|
209
|
+
assert(this.nextResamplerPts !== null);
|
|
210
|
+
|
|
211
|
+
const frameSize = this.codecContext.frameSize || FRAME_SIZE_FALLBACK;
|
|
212
|
+
|
|
213
|
+
while (true) {
|
|
214
|
+
const available = this.resampler.getOutSamples(0);
|
|
215
|
+
if (available < frameSize) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await this.resampler.convert(this.dstFrame.data, frameSize, null, 0);
|
|
220
|
+
|
|
221
|
+
this.dstFrame.pts = this.nextResamplerPts;
|
|
222
|
+
|
|
223
|
+
await this.sendFrameAndReceivePackets(this.dstFrame);
|
|
224
|
+
this.nextResamplerPts += BigInt(frameSize);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async sendFrameAndReceivePackets(frame: NodeAv.Frame | null) {
|
|
229
|
+
assert(this.codecContext);
|
|
230
|
+
|
|
231
|
+
const ret = await this.codecContext.sendFrame(frame);
|
|
232
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Send frame');
|
|
233
|
+
|
|
234
|
+
while (true) {
|
|
235
|
+
const receiveRet = await this.codecContext.receivePacket(this.packet);
|
|
236
|
+
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.receivePacket(receiveRet);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
receivePacket(ret: number) {
|
|
245
|
+
assert(this.codecContext);
|
|
246
|
+
assert(this.firstExpectedTimestamp !== null);
|
|
247
|
+
NodeAv.FFmpegError.throwIfError(ret, 'Receive packet');
|
|
248
|
+
|
|
249
|
+
if (!this.packet.data) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let timestamp = Number(this.packet.pts) / this.codecContext.sampleRate;
|
|
254
|
+
const duration = Number(this.packet.duration) / this.codecContext.sampleRate;
|
|
255
|
+
|
|
256
|
+
let data: Uint8Array = this.packet.data;
|
|
257
|
+
|
|
258
|
+
let metadata: EncodedAudioChunkMetadata | undefined;
|
|
259
|
+
if (this.packetEmitted) {
|
|
260
|
+
metadata = {};
|
|
261
|
+
} else {
|
|
262
|
+
// To compensate for any negative timestamp things that FFmpeg might do. It does these for a reason, to
|
|
263
|
+
// indicate encoder delay, but the notion of this is not yet supported in Mediabunny. Yes, this technically
|
|
264
|
+
// introduces audio sync drift.
|
|
265
|
+
this.outputTimestampOffset = Math.max(this.firstExpectedTimestamp - timestamp, 0);
|
|
266
|
+
|
|
267
|
+
const codecString = this.config.codec;
|
|
268
|
+
let description = this.codecContext.extraData
|
|
269
|
+
? toUint8Array(this.codecContext.extraData)
|
|
270
|
+
: undefined;
|
|
271
|
+
|
|
272
|
+
if (this.codec === 'aac') {
|
|
273
|
+
if (!description) {
|
|
274
|
+
throw new Error('Extradata expected for AAC.');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// eslint-disable-next-line @stylistic/max-len
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
279
|
+
const isAdts = (this.config as any).aac?.format === 'adts';
|
|
280
|
+
|
|
281
|
+
if (isAdts) {
|
|
282
|
+
const parsedConfig = parseAacAudioSpecificConfig(description);
|
|
283
|
+
this.adtsHeaderTemplate = buildAdtsHeaderTemplate(parsedConfig);
|
|
284
|
+
description = undefined; // Not used with 'adts' format
|
|
285
|
+
}
|
|
286
|
+
} else if (this.codec === 'opus') {
|
|
287
|
+
if (!description) {
|
|
288
|
+
// Technically not required by the WebCodecs/Mediabunny Codec Registry, but we strive to be better
|
|
289
|
+
throw new Error('Extradata expected for Opus.');
|
|
290
|
+
}
|
|
291
|
+
} else if (this.codec === 'vorbis') {
|
|
292
|
+
if (!description) {
|
|
293
|
+
throw new Error('Extradata expected for Vorbis.');
|
|
294
|
+
}
|
|
295
|
+
} else if (this.codec === 'flac') {
|
|
296
|
+
if (!description) {
|
|
297
|
+
throw new Error('Extradata expected for FLAC.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// FFmpeg uses the STREAMINFO block as the extradata, but WebCodecs wants a different format:
|
|
301
|
+
// 1. The bytes 0x66 0x4C 0x61 0x43 ("fLaC" in ASCII)
|
|
302
|
+
// 2. A metadata block (called the STREAMINFO block) as described in section 7 of [FLAC]
|
|
303
|
+
// 3. Other optional metadata blocks (not included here, because, well, they're optional)
|
|
304
|
+
description = new Uint8Array([
|
|
305
|
+
0x66, 0x4c, 0x61, 0x43, // 'fLaC'
|
|
306
|
+
128, 0, 0, description.byteLength,
|
|
307
|
+
...description,
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
metadata = {
|
|
312
|
+
decoderConfig: {
|
|
313
|
+
codec: codecString,
|
|
314
|
+
sampleRate: this.codecContext.sampleRate,
|
|
315
|
+
numberOfChannels: this.codecContext.channels,
|
|
316
|
+
description,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (this.adtsHeaderTemplate) {
|
|
322
|
+
const frameLength = data.byteLength + this.adtsHeaderTemplate.header.byteLength;
|
|
323
|
+
this.adtsHeaderTemplate.bitstream.pos = 30;
|
|
324
|
+
this.adtsHeaderTemplate.bitstream.writeBits(13, frameLength);
|
|
325
|
+
|
|
326
|
+
const final = new Uint8Array(this.adtsHeaderTemplate.header.byteLength + data.byteLength);
|
|
327
|
+
final.set(this.adtsHeaderTemplate.header, 0);
|
|
328
|
+
final.set(data, this.adtsHeaderTemplate.header.byteLength);
|
|
329
|
+
|
|
330
|
+
data = final;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
timestamp += this.outputTimestampOffset;
|
|
334
|
+
|
|
335
|
+
const packet = new EncodedPacket(
|
|
336
|
+
data,
|
|
337
|
+
'key',
|
|
338
|
+
timestamp,
|
|
339
|
+
duration,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
this.packetEmitted = true;
|
|
343
|
+
this.onPacket(packet, metadata);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async flush(): Promise<void> {
|
|
347
|
+
if (!this.codecContext) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
outer:
|
|
352
|
+
if (this.resampler) {
|
|
353
|
+
assert(this.resamplerInputSampleRate !== null);
|
|
354
|
+
|
|
355
|
+
const currentOutSamples = this.resampler.getOutSamples(0);
|
|
356
|
+
if (currentOutSamples === 0) {
|
|
357
|
+
break outer; // Clean cut-off point
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const frameSize = this.codecContext.frameSize || FRAME_SIZE_FALLBACK;
|
|
361
|
+
assert(currentOutSamples < frameSize); // Because if it's more, it would've already been retrieved
|
|
362
|
+
|
|
363
|
+
const inputSamplesNeeded = Math.ceil(
|
|
364
|
+
((frameSize - currentOutSamples) / this.codecContext.sampleRate) * this.resamplerInputSampleRate,
|
|
365
|
+
);
|
|
366
|
+
this.resampler.injectSilence(inputSamplesNeeded);
|
|
367
|
+
|
|
368
|
+
await this.pullResampledFrames();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await this.sendFrameAndReceivePackets(null);
|
|
372
|
+
|
|
373
|
+
this.codecContext.freeContext();
|
|
374
|
+
this.codecContext = null;
|
|
375
|
+
this.packetEmitted = false;
|
|
376
|
+
this.firstExpectedTimestamp = null;
|
|
377
|
+
this.outputTimestampOffset = 0;
|
|
378
|
+
this.adtsHeaderTemplate = null;
|
|
379
|
+
|
|
380
|
+
this.resampler?.free();
|
|
381
|
+
this.resampler = null;
|
|
382
|
+
this.inputParametersKey = null;
|
|
383
|
+
this.resamplerInputSampleRate = null;
|
|
384
|
+
this.nextResamplerPts = null;
|
|
385
|
+
this.dstFrame?.free();
|
|
386
|
+
this.dstFrame = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
close(): MaybePromise<void> {
|
|
390
|
+
this.codecContext?.freeContext();
|
|
391
|
+
this.frame.free();
|
|
392
|
+
this.packet.free();
|
|
393
|
+
this.dstFrame?.free();
|
|
394
|
+
this.resampler?.free();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
3
|
+
*
|
|
4
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AudioSample, AudioSampleResource } from 'mediabunny';
|
|
10
|
+
import * as NodeAv from 'node-av';
|
|
11
|
+
import { fromAudioSampleFormat, getChannelLayout, toAudioSampleFormat } from './misc';
|
|
12
|
+
import { assert, toUint8Array } from '../../../src/misc';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A custom `AudioSampleResource` backed by NodeAV's
|
|
16
|
+
* [`Frame`](https://seydx.github.io/node-av/api/lib/classes/Frame.html), which in turn is backed by FFmpeg's
|
|
17
|
+
* [`AVFrame`](https://ffmpeg.org/doxygen/2.7/structAVFrame.html). You can use this resource to create `AudioSample`
|
|
18
|
+
* instances that are directly backed by FFmpeg's `AVFrame` without data having to be copied.
|
|
19
|
+
*
|
|
20
|
+
* When passed, the `Frame` is now owned by resource, meaning it takes care of closing the frame later. If you want to
|
|
21
|
+
* keep a copy for your own use, clone the frame first.
|
|
22
|
+
*
|
|
23
|
+
* @group \@mediabunny/server
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export class AvFrameAudioSampleResource extends AudioSampleResource {
|
|
27
|
+
/** @internal */
|
|
28
|
+
_frame: NodeAv.Frame | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The NodeAV [`Frame`](https://seydx.github.io/node-av/api/lib/classes/Frame.html) instance backing this resource.
|
|
32
|
+
* Access throws if the resource has already been closed.
|
|
33
|
+
*/
|
|
34
|
+
get frame() {
|
|
35
|
+
if (!this._frame) {
|
|
36
|
+
throw new Error('AvFrameAudioSampleResource has been closed.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return this._frame;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
constructor(frame: NodeAv.Frame) {
|
|
43
|
+
super();
|
|
44
|
+
|
|
45
|
+
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_AUDIO) {
|
|
46
|
+
throw new Error('AvFrameAudioSampleResource must be initialized with an audio frame.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this._frame = frame;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getFormat(): AudioSampleFormat {
|
|
53
|
+
const result = toAudioSampleFormat(this.frame.format as NodeAv.AVSampleFormat);
|
|
54
|
+
if (result === null) {
|
|
55
|
+
const name = NodeAv.avGetSampleFmtName(this.frame.format as NodeAv.AVSampleFormat);
|
|
56
|
+
throw new TypeError(`Unsupported audio sample format: ${name} (${this.frame.format})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getSampleRate(): number {
|
|
63
|
+
return this.frame.sampleRate;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getNumberOfChannels(): number {
|
|
67
|
+
return this.frame.channels;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getNumberOfFrames(): number {
|
|
71
|
+
return this.frame.nbSamples;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getTimestamp(): number {
|
|
75
|
+
return Number(this.frame.pts) / this.frame.timeBase.den;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
close(): void {
|
|
79
|
+
this.frame.free();
|
|
80
|
+
this._frame = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getDataPlane(planeIndex: number): Uint8Array {
|
|
84
|
+
assert(this.frame.data && planeIndex < this.frame.data.length);
|
|
85
|
+
return toUint8Array(this.frame.data[planeIndex]!);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const copyAudioSampleToAvFrame = (sample: AudioSample, frame: NodeAv.Frame) => {
|
|
90
|
+
frame.format = fromAudioSampleFormat(sample.format);
|
|
91
|
+
frame.nbSamples = sample.numberOfFrames;
|
|
92
|
+
frame.sampleRate = sample.sampleRate;
|
|
93
|
+
frame.channelLayout = getChannelLayout(sample.numberOfChannels);
|
|
94
|
+
|
|
95
|
+
frame.allocBuffer();
|
|
96
|
+
assert(frame.data);
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < frame.data.length; i++) {
|
|
99
|
+
sample.copyTo(frame.data[i]!, { planeIndex: i });
|
|
100
|
+
}
|
|
101
|
+
};
|