mux/mp4: DTS audio (dtsc/dtsh + ddts)
Parse the DTS core header (SFREQ/AMODE/RATE/LFF/NBLKS/FSIZE) → a ddts box (sample rate, channel layout mask, core size, computed bitrate, LFE); whole access units (core + DTS-HD extension substreams) pass through, so an HD decoder finds the extension. dtsh when an extension sync follows the core, else dtsc. Fit oracle now carries DTS / DTS-HD MA / DTS-HD HR. Validated on 300 (real DTS-HD MA 7.1): freemkv's mp4 DTS track is byte-identical to ffmpeg -c copy under ffprobe (dts / 48000 / 8ch / 7.1) and decodes clean (exit 0). Channel layout correct.
This commit is contained in:
@@ -10,6 +10,12 @@
|
|||||||
(`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). Round-trip is
|
(`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). Round-trip is
|
||||||
frame-exact (verified `iso:// mp4://` → `mp4:// mkv://` preserves every video
|
frame-exact (verified `iso:// mp4://` → `mp4:// mkv://` preserves every video
|
||||||
and audio packet). Progressive MP4 only; fragmented (`moof`) is future work.
|
and audio packet). Progressive MP4 only; fragmented (`moof`) is future work.
|
||||||
|
- **MP4 output is faststart by default** — `moov` is written *before* `mdat`, so
|
||||||
|
the file plays over HTTP without pre-fetching the end. Done with zero extra I/O:
|
||||||
|
a `moov`-sized hole (`round_up_4MB(16 B/sample × est_samples) + 4 MB`) is
|
||||||
|
reserved up front, so sample offsets are fixed and `moov` is dropped into the
|
||||||
|
hole at finish with a `free` box for the slack (falls back to moov-at-end only
|
||||||
|
if the estimate is blown).
|
||||||
- **Native MP4 output (`mp4://`)** — a progressive ISO-BMFF muxer
|
- **Native MP4 output (`mp4://`)** — a progressive ISO-BMFF muxer
|
||||||
(`ftyp`+`mdat`+`moov`), so a disc goes straight to a play-everywhere `.mp4` in
|
(`ftyp`+`mdat`+`moov`), so a disc goes straight to a play-everywhere `.mp4` in
|
||||||
one decrypt pass, no ffmpeg round-trip. Carries HEVC / H.264 video (HDR10 colour
|
one decrypt pass, no ffmpeg round-trip. Carries HEVC / H.264 video (HDR10 colour
|
||||||
|
|||||||
+210
-21
@@ -1,11 +1,13 @@
|
|||||||
//! MP4 audio sample entries and codec-config boxes for the `mp4://` muxer.
|
//! MP4 audio sample entries and codec-config boxes for the `mp4://` muxer.
|
||||||
//!
|
//!
|
||||||
//! Covers the Dolby family that maps cleanly into MP4 and plays widely:
|
//! Covers the codecs that map cleanly into MP4 and play widely: **AC-3**
|
||||||
//! **AC-3** (`ac-3` + `dac3`) and **E-AC-3 / Dolby Digital Plus** (`ec-3` +
|
//! (`ac-3` + `dac3`), **E-AC-3 / Dolby Digital Plus** (`ec-3` + `dec3`, incl.
|
||||||
//! `dec3`, which also carries Atmos-in-DD+ JOC). The config boxes are derived
|
//! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing
|
||||||
//! from the first audio frame's bitstream (ISO/IEC 14496-12 amendments; ETSI TS
|
//! the core with whole access units passed through so an HD decoder finds the
|
||||||
//! 102 366 Annexes F/G). Codecs with no clean MP4 mapping (TrueHD, DTS family,
|
//! extension). Config boxes are derived from the first audio frame's bitstream
|
||||||
//! LPCM, bitmap subtitles) are handled by the fit oracle in the sink, not here.
|
//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114). Codecs with no
|
||||||
|
//! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit
|
||||||
|
//! oracle in the sink.
|
||||||
|
|
||||||
use super::boxes::bx;
|
use super::boxes::bx;
|
||||||
use crate::disc::Codec;
|
use crate::disc::Codec;
|
||||||
@@ -228,29 +230,188 @@ pub(super) fn audio_sample_entry(
|
|||||||
bx(fourcc, &e)
|
bx(fourcc, &e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The MP4 fourcc + config box for a Dolby audio frame, or `None` if the codec
|
// ── DTS (dtsc/dtsh + ddts) ───────────────────────────────────────────────────
|
||||||
/// has no MP4 mapping here. Together with [`audio_fits`] this is the fit oracle
|
|
||||||
/// for audio: only what returns `Some` is muxable.
|
/// DTS core `SFREQ` (4-bit) → sample rate (Hz). Reserved indices → 48 kHz.
|
||||||
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
|
const DTS_SFREQ: [u32; 16] = [
|
||||||
let c = parse_dolby(first_frame)?;
|
48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000,
|
||||||
let (fourcc, config): (&[u8; 4], Vec<u8>) = match codec {
|
24_000, 48_000, 96_000, 192_000,
|
||||||
Codec::Ac3 => (b"ac-3", dac3_box(&c)),
|
];
|
||||||
Codec::Ac3Plus => (b"ec-3", dec3_box(&c)),
|
/// DTS core base channel count per `AMODE` (0..=9); higher AMODEs are rare on disc.
|
||||||
_ => return None,
|
const DTS_AMODE_CH: [u8; 10] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5];
|
||||||
|
|
||||||
|
/// Decoded DTS core parameters needed for the `ddts` box.
|
||||||
|
struct DtsConfig {
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
amode: u8,
|
||||||
|
lfe: bool,
|
||||||
|
core_size: u32,
|
||||||
|
/// Samples per frame ((NBLKS+1)·32).
|
||||||
|
frame_samples: u32,
|
||||||
|
/// Whether a DTS-HD extension substream follows the core.
|
||||||
|
has_extension: bool,
|
||||||
|
channel_layout: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the DTS core header (ETSI TS 102 114 §5.3.1), starting at the
|
||||||
|
/// 0x7FFE8001 big-endian core sync. Returns `None` if too short / no sync.
|
||||||
|
fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
|
||||||
|
let start = (0..frame.len().saturating_sub(3)).find(|&i| {
|
||||||
|
frame[i] == 0x7F && frame[i + 1] == 0xFE && frame[i + 2] == 0x80 && frame[i + 3] == 0x01
|
||||||
|
})?;
|
||||||
|
let f = &frame[start..];
|
||||||
|
if f.len() < 11 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Bit fields after the 32-bit sync (MSB-first):
|
||||||
|
// FTYPE1 SHORT5 CPF1 NBLKS7 FSIZE14 AMODE6 SFREQ4 RATE5 ...
|
||||||
|
let nblks = (((f[4] & 0x01) as u32) << 6) | ((f[5] >> 2) as u32 & 0x3F);
|
||||||
|
let fsize = (((f[5] & 0x03) as u32) << 12) | ((f[6] as u32) << 4) | ((f[7] >> 4) as u32 & 0x0F);
|
||||||
|
let amode = (((f[7] & 0x0F) << 2) | ((f[8] >> 6) & 0x03)) as usize;
|
||||||
|
let sfreq = ((f[8] >> 2) & 0x0F) as usize;
|
||||||
|
// LFF is 2 bits at bit offset 85 → byte10 bits 2-1.
|
||||||
|
let lff = (f[10] >> 1) & 0x03;
|
||||||
|
let lfe = lff == 1 || lff == 2;
|
||||||
|
|
||||||
|
let sample_rate = DTS_SFREQ[sfreq];
|
||||||
|
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
|
||||||
|
let channels = base_ch as u16 + lfe as u16;
|
||||||
|
let channel_layout = dts_channel_layout(amode, lfe);
|
||||||
|
// DTS-HD extension substream sync (0x64582025) after the core frame.
|
||||||
|
let ext_sync = [0x64, 0x58, 0x20, 0x25];
|
||||||
|
let has_extension = f
|
||||||
|
.windows(4)
|
||||||
|
.skip((fsize as usize + 1).min(f.len()).saturating_sub(4))
|
||||||
|
.any(|w| w == ext_sync)
|
||||||
|
|| f.windows(4).any(|w| w == ext_sync);
|
||||||
|
|
||||||
|
Some(DtsConfig {
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
amode: amode as u8,
|
||||||
|
lfe,
|
||||||
|
core_size: fsize + 1,
|
||||||
|
frame_samples: (nblks + 1) * 32,
|
||||||
|
has_extension,
|
||||||
|
channel_layout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ddts` ChannelLayout (16-bit speaker mask) for the common core layouts.
|
||||||
|
/// bit0=C, bit1=L/R, bit2=Ls/Rs, bit3=LFE.
|
||||||
|
fn dts_channel_layout(amode: usize, lfe: bool) -> u16 {
|
||||||
|
let mut m = match amode {
|
||||||
|
0 => 0x0001, // C (mono)
|
||||||
|
1..=4 => 0x0002, // L/R
|
||||||
|
5 => 0x0003, // C + L/R
|
||||||
|
6 | 8 => 0x0006, // L/R + Ls/Rs (no centre)
|
||||||
|
_ => 0x0007, // C + L/R + surround (amode 7, 9, …)
|
||||||
};
|
};
|
||||||
|
if lfe {
|
||||||
|
m |= 0x0008;
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
|
||||||
|
/// Describes the DTS core; whole access units (core + any extension) are passed
|
||||||
|
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
|
||||||
|
fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
||||||
|
// avg/max bitrate: computed from the core frame size × frame rate (the core
|
||||||
|
// RATE field reads "open/variable" for lossless, so it's not usable directly).
|
||||||
|
let frames_per_sec = if c.frame_samples > 0 {
|
||||||
|
c.sample_rate as u64 / c.frame_samples as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let bitrate = (c.core_size as u64 * 8 * frames_per_sec) as u32;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency
|
||||||
|
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
|
||||||
|
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
|
||||||
|
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
|
||||||
|
// Bit-packed tail (56 bits):
|
||||||
|
// FrameDuration2 StreamConstruction5 CoreLFEPresent1 CoreLayout6 CoreSize14
|
||||||
|
// StereoDownmix1 RepresentationType3 ChannelLayout16 MultiAssetFlag1
|
||||||
|
// LBRDurationMod1 ReservedBoxPresent1 Reserved5
|
||||||
|
let frame_duration = match c.frame_samples {
|
||||||
|
0..=512 => 0,
|
||||||
|
513..=1024 => 1,
|
||||||
|
1025..=2048 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
// StreamConstruction: 1 = DTS core present. Whole-AU passthrough means an
|
||||||
|
// HD decoder still parses the extension substreams from the stream itself.
|
||||||
|
let stream_construction = 1u128;
|
||||||
|
let mut v: u128 = 0;
|
||||||
|
let mut push = |val: u128, bits: u32| v = (v << bits) | (val & ((1u128 << bits) - 1));
|
||||||
|
push(frame_duration as u128, 2);
|
||||||
|
push(stream_construction, 5);
|
||||||
|
push(c.lfe as u128, 1);
|
||||||
|
push(c.amode as u128, 6);
|
||||||
|
push(c.core_size as u128, 14);
|
||||||
|
push(0, 1); // StereoDownmix
|
||||||
|
push(0, 3); // RepresentationType
|
||||||
|
push(c.channel_layout as u128, 16);
|
||||||
|
push(c.has_extension as u128, 1); // MultiAssetFlag
|
||||||
|
push(0, 1); // LBRDurationMod
|
||||||
|
push(0, 1); // ReservedBoxPresent
|
||||||
|
push(0, 5); // Reserved
|
||||||
|
// 56 bits → the low 7 bytes of the big-endian u128.
|
||||||
|
let b = v.to_be_bytes();
|
||||||
|
out.extend_from_slice(&b[9..16]);
|
||||||
|
bx(b"ddts", &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
|
||||||
|
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
|
||||||
|
/// audio: only what returns `Some` is muxable.
|
||||||
|
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
match codec {
|
||||||
|
Codec::Ac3 => {
|
||||||
|
let c = parse_dolby(first_frame)?;
|
||||||
|
Some(audio_sample_entry(
|
||||||
|
b"ac-3",
|
||||||
|
c.channels,
|
||||||
|
c.sample_rate,
|
||||||
|
&dac3_box(&c),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Codec::Ac3Plus => {
|
||||||
|
let c = parse_dolby(first_frame)?;
|
||||||
|
Some(audio_sample_entry(
|
||||||
|
b"ec-3",
|
||||||
|
c.channels,
|
||||||
|
c.sample_rate,
|
||||||
|
&dec3_box(&c),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => {
|
||||||
|
let c = parse_dts(first_frame)?;
|
||||||
|
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
|
||||||
|
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
|
||||||
Some(audio_sample_entry(
|
Some(audio_sample_entry(
|
||||||
fourcc,
|
fourcc,
|
||||||
c.channels,
|
c.channels,
|
||||||
c.sample_rate,
|
c.sample_rate,
|
||||||
&config,
|
&ddts_box(&c),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fit oracle for an audio codec: does `mp4://` currently carry it? M2 covers
|
/// Fit oracle for an audio codec: does `mp4://` currently carry it? Covers the
|
||||||
/// the Dolby family (AC-3 / E-AC-3). TrueHD, DTS(-HD), LPCM, AAC are not yet
|
/// Dolby family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA — the core
|
||||||
|
/// is described, whole access units pass through). TrueHD, LPCM, AAC are not yet
|
||||||
/// mapped and are skipped with a loud report (never silently dropped).
|
/// mapped and are skipped with a loud report (never silently dropped).
|
||||||
pub(super) fn audio_fits(codec: Codec) -> bool {
|
pub(super) fn audio_fits(codec: Codec) -> bool {
|
||||||
matches!(codec, Codec::Ac3 | Codec::Ac3Plus)
|
matches!(
|
||||||
|
codec,
|
||||||
|
Codec::Ac3 | Codec::Ac3Plus | Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -327,11 +488,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_oracle_dolby_only() {
|
fn fit_oracle_covers_dolby_and_dts() {
|
||||||
assert!(audio_fits(Codec::Ac3));
|
assert!(audio_fits(Codec::Ac3));
|
||||||
assert!(audio_fits(Codec::Ac3Plus));
|
assert!(audio_fits(Codec::Ac3Plus));
|
||||||
|
assert!(audio_fits(Codec::Dts));
|
||||||
|
assert!(audio_fits(Codec::DtsHdMa));
|
||||||
assert!(!audio_fits(Codec::TrueHd));
|
assert!(!audio_fits(Codec::TrueHd));
|
||||||
assert!(!audio_fits(Codec::DtsHdMa));
|
|
||||||
assert!(!audio_fits(Codec::Lpcm));
|
assert!(!audio_fits(Codec::Lpcm));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_core_5_1_and_ddts() {
|
||||||
|
// Synthetic DTS core: SFREQ=13 (48k), AMODE=9 (5ch), LFF=1 (LFE) → 5.1.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("dts core parsed");
|
||||||
|
assert_eq!(c.sample_rate, 48_000);
|
||||||
|
assert_eq!(c.amode, 9);
|
||||||
|
assert!(c.lfe);
|
||||||
|
assert_eq!(c.channels, 6, "5 core + LFE = 5.1");
|
||||||
|
assert_eq!(c.channel_layout, 0x000F, "C + L/R + Ls/Rs + LFE");
|
||||||
|
assert_eq!(c.core_size, 96);
|
||||||
|
assert_eq!(c.frame_samples, 512);
|
||||||
|
|
||||||
|
let ddts = ddts_box(&c);
|
||||||
|
assert_eq!(&ddts[4..8], b"ddts");
|
||||||
|
// DTSSamplingFrequency (first field) = 48000.
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_be_bytes([ddts[8], ddts[9], ddts[10], ddts[11]]),
|
||||||
|
48_000
|
||||||
|
);
|
||||||
|
// Sample entry uses dtsc (no extension in this synthetic frame).
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,10 @@
|
|||||||
//! ## Track model
|
//! ## Track model
|
||||||
//!
|
//!
|
||||||
//! One video track (HEVC / H.264) plus every audio track whose codec has a clean
|
//! One video track (HEVC / H.264) plus every audio track whose codec has a clean
|
||||||
//! MP4 mapping (AC-3 → `ac-3`/`dac3`, E-AC-3 → `ec-3`/`dec3`). This is the fit
|
//! MP4 mapping (AC-3 → `ac-3`/`dac3`, E-AC-3 → `ec-3`/`dec3`, DTS/DTS-HD →
|
||||||
//! oracle: a codec MP4 can't carry (TrueHD, DTS, LPCM) or that has no sample
|
//! `dtsc`/`dtsh`/`ddts`). This is the fit oracle: a codec MP4 can't carry
|
||||||
//! entry here is **excluded, never silently dropped** — [`fit_report`] lets the
|
//! (TrueHD, LPCM) or that has no sample entry here is **excluded, never silently
|
||||||
|
//! dropped** — [`fit_report`] lets the
|
||||||
//! CLI enumerate exactly what was left out and why. Video NALs pass through
|
//! CLI enumerate exactly what was left out and why. Video NALs pass through
|
||||||
//! unchanged (the demux hands us length-prefixed hvcC/avcC framing — already
|
//! unchanged (the demux hands us length-prefixed hvcC/avcC framing — already
|
||||||
//! MP4's form). Decode timestamps are derived (the pipeline carries presentation
|
//! MP4's form). Decode timestamps are derived (the pipeline carries presentation
|
||||||
|
|||||||
Reference in New Issue
Block a user