Define DTS codec, add muxing and demuxing support across ISOBMFF, Matroska, and MPEG-TS, add DTS encode/decode support to @mediabunny/server

This commit is contained in:
Vanilagy
2026-08-15 16:19:12 +02:00
parent ae66f01566
commit 914907b5db
27 changed files with 1182 additions and 56 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|| codec === 'vorbis'
|| codec === 'flac'
|| codec === 'ac3'
|| codec === 'eac3';
|| codec === 'eac3'
|| codec === 'dts';
}
async init(): Promise<void> {
+45 -7
View File
@@ -15,7 +15,7 @@ import {
EncodedPacket,
} from 'mediabunny';
import * as NodeAv from 'node-av';
import { CODEC_TO_CODEC_ID, getChannelLayout } from './misc';
import { CODEC_TO_CODEC_ID, getChannelLayout, getDtsChannelLayout } from './misc';
import { assert, toUint8Array } from '../../../src/misc';
import { copyAudioSampleToAvFrame, AvFrameAudioSampleResource } from './audio-sample';
import {
@@ -29,6 +29,11 @@ const AAC_SAMPLE_RATES
const OPUS_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000];
const MP3_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000];
const AC3_SAMPLE_RATES = [32000, 44100, 48000];
const DTS_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000];
/** DTS only has layouts for these channel counts; 3 and 7 have no mapping. */
const DTS_CHANNEL_COUNTS = [1, 2, 4, 5, 6];
const DTS_MAX_FRAME_SIZE = 16384;
const FRAME_SIZE_FALLBACK = 1024; // Just 'cause
@@ -72,6 +77,10 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
) || (
codec === 'eac3' && numberOfChannels >= 1 && numberOfChannels <= 16
&& AC3_SAMPLE_RATES.includes(sampleRate)
) || (
codec === 'dts' && DTS_CHANNEL_COUNTS.includes(numberOfChannels)
&& DTS_SAMPLE_RATES.includes(sampleRate)
&& dtsBitrateFits(resolveBitrate(config, codec), sampleRate, numberOfChannels)
);
}
@@ -107,21 +116,26 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
}
codecContext.sampleRate = this.config.sampleRate;
codecContext.channelLayout = getChannelLayout(this.config.numberOfChannels);
codecContext.channelLayout = this.codec === 'dts'
? getDtsChannelLayout(this.config.numberOfChannels)
: getChannelLayout(this.config.numberOfChannels);
codecContext.codecType = NodeAv.AVMEDIA_TYPE_AUDIO;
codecContext.codecId = CODEC_TO_CODEC_ID[this.codec]!;
codecContext.sampleFormat = sampleFormat;
codecContext.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
codecContext.bitRate = BigInt(
this.config.bitrate ?? new Quality('medium')._toAudioBitrate(this.codec) ?? 0,
);
codecContext.bitRate = BigInt(resolveBitrate(this.config, this.codec));
if (this.config.bitrateMode === 'constant') {
codecContext.rcMinRate = codecContext.bitRate;
codecContext.rcMaxRate = codecContext.bitRate;
}
const ret = await codecContext.open2();
// libav's DTS encoder is marked experimental, so it refuses to open at the default compliance level
const options = this.codec === 'dts'
? NodeAv.Dictionary.fromObject({ strict: -2 })
: null;
const ret = await codecContext.open2(this.avCodec, options);
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
this.codecContext = codecContext;
@@ -172,7 +186,9 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
this.resampler = new NodeAv.SoftwareResampleContext();
this.resamplerInputSampleRate = this.frame.sampleRate;
const outLayout = getChannelLayout(this.codecContext.channels);
// Resample straight to whatever layout the encoder was opened with, since not every codec accepts
// the layout that getChannelLayout hands out for a given channel count
const outLayout = this.codecContext.channelLayout;
const inLayout = getChannelLayout(this.frame.channels);
const ret = this.resampler.allocSetOpts2(
@@ -400,3 +416,25 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
this.resampler?.free();
}
}
const resolveBitrate = (config: AudioEncoderConfig, codec: AudioCodec) => {
return config.bitrate ?? new Quality('medium')._toAudioBitrate(codec) ?? 0;
};
/**
* The DTS encoder needs each frame to be big enough to hold the per-channel side info and rejects the whole
* configuration when it isn't, so this mirrors the check from FFmpeg's dcaenc.c.
*/
const dtsBitrateFits = (bitrate: number, sampleRate: number, numberOfChannels: number) => {
if (bitrate < 32000 || bitrate > 3840000) {
return false;
}
const hasLfe = numberOfChannels === 6;
const fullbandChannels = numberOfChannels - (hasLfe ? 1 : 0);
const frameBits = 32 * Math.ceil(Math.ceil(bitrate * 512 / sampleRate) / 32);
const minFrameBits = 132 + (493 + 28 * 32) * fullbandChannels + (hasLfe ? 72 : 0);
return frameBits >= minFrameBits && frameBits <= 8 * DTS_MAX_FRAME_SIZE;
};
+19
View File
@@ -25,6 +25,7 @@ export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> =
flac: NodeAv.AV_CODEC_ID_FLAC,
ac3: NodeAv.AV_CODEC_ID_AC3,
eac3: NodeAv.AV_CODEC_ID_EAC3,
dts: NodeAv.AV_CODEC_ID_DTS,
};
let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined;
@@ -267,3 +268,21 @@ export const getChannelLayout = (numChannels: number): NodeAv.ChannelLayout => {
default: return { nbChannels: numChannels, order: NodeAv.AV_CHANNEL_ORDER_UNSPEC, mask: 0n };
}
};
/** FL + FR + SL + SR, which libav has no constant for. */
const QUAD_SIDE_MASK = 0x603n;
/**
* DTS insists on the side-based surround layouts and rejects the back-based ones that {@link getChannelLayout}
* hands out, so it gets its own mapping.
*/
export const getDtsChannelLayout = (numChannels: number): NodeAv.ChannelLayout => {
switch (numChannels) {
case 1: return NodeAv.AV_CHANNEL_LAYOUT_MONO;
case 2: return NodeAv.AV_CHANNEL_LAYOUT_STEREO;
case 4: return { nbChannels: 4, order: NodeAv.AV_CHANNEL_ORDER_NATIVE, mask: QUAD_SIDE_MASK };
case 5: return NodeAv.AV_CHANNEL_LAYOUT_5POINT0;
case 6: return NodeAv.AV_CHANNEL_LAYOUT_5POINT1;
default: return { nbChannels: numChannels, order: NodeAv.AV_CHANNEL_ORDER_UNSPEC, mask: 0n };
}
};