mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 11:17:01 -04:00
Merge pull request #613 from JordyUSA/feat/flac-export
Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV)
This commit is contained in:
commit
8ec1a36b9f
29 files changed
+1569
-29
No files matched your search
Generated
+10
@@ -1923,6 +1923,7 @@ dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
"filmcraft-flac",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-h264enc",
|
||||
@@ -1943,6 +1944,15 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-flac"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -33,6 +33,7 @@ filmcraft-dnx = { path = "crates/dnx" }
|
||||
filmcraft-apv = { path = "crates/apv" }
|
||||
filmcraft-av1 = { path = "crates/av1" }
|
||||
filmcraft-aac = { path = "crates/aac" }
|
||||
filmcraft-flac = { path = "crates/flac" }
|
||||
filmcraft-opus = { path = "crates/opus" }
|
||||
filmcraft-h264enc = { path = "crates/h264enc" }
|
||||
filmcraft-audio-dsp = { path = "crates/audio-dsp" }
|
||||
@@ -116,6 +117,9 @@ opt-level = 3
|
||||
[profile.dev.package.filmcraft-aac]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-flac]
|
||||
opt-level = 3
|
||||
|
||||
# Audio effects: realtime-factor tests and long convolution / spectral runs.
|
||||
[profile.dev.package.filmcraft-audio-dsp]
|
||||
opt-level = 3
|
||||
|
||||
@@ -178,7 +178,8 @@ impl PacketDecoder {
|
||||
C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)),
|
||||
C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(),
|
||||
C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())),
|
||||
C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None),
|
||||
// the decoder needs the STREAMINFO body: `dfLa` holds it after the 4-byte block header
|
||||
C::Flac(f) => Self::new(CODEC_TYPE_FLAC, rate, f.metadata_blocks.get(4..38).map(<[u8]>::to_vec)),
|
||||
C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
|
||||
other => Err(CodecError::Unsupported(format!("{} audio", other.name()))),
|
||||
}
|
||||
|
||||
@@ -224,6 +224,18 @@ fn matroska_hevc_flac() {
|
||||
check_mkv("blue_hevc_flac.mkv", &["-c:v", "libx265", "-x265-params", "log-level=error", "-pix_fmt", "yuv420p"], &["-c:a", "flac"]);
|
||||
}
|
||||
|
||||
/// FLAC in MP4 (`fLaC` + `dfLa`): the decoder gets the STREAMINFO from `dfLa` (it was given
|
||||
/// nothing, and every FLAC track in an MP4 failed with "missing extra data").
|
||||
#[test]
|
||||
fn mp4_flac_audio() {
|
||||
let args = ["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=2", "-c:a", "flac", "-strict", "-2"];
|
||||
let Some(b) = fixture("tone_flac.mp4", &args) else { return };
|
||||
let src = crate::open_bytes("tone_flac.mp4", b).expect("open");
|
||||
let a = src.audio(24_000, 4800, 48_000).expect("audio");
|
||||
let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs()));
|
||||
assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_prores_pcm() {
|
||||
check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]);
|
||||
|
||||
@@ -1363,7 +1363,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
|
||||
@@ -163,10 +163,11 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
||||
let Some(j) = probe(&out) else { continue };
|
||||
let fmt_name = j["format"]["format_name"].as_str().unwrap();
|
||||
match fmt.as_str() {
|
||||
"wav" | "aiff" => {
|
||||
"wav" | "aiff" | "flac" => {
|
||||
let a = stream(&j, "audio").unwrap();
|
||||
let bits = settings["audio"]["bits"].as_u64().unwrap();
|
||||
let codec = match (fmt.as_str(), bits) {
|
||||
("flac", _) => "flac",
|
||||
("wav", 24) => "pcm_s24le",
|
||||
("wav", _) => "pcm_s16le",
|
||||
(_, 24) => "pcm_s24be",
|
||||
|
||||
@@ -286,6 +286,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
||||
if let Some(v) = str_p(p, "apvProfile") {
|
||||
settings.apv_profile = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "audioCodec") {
|
||||
settings.audio.codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "audioCodec: auto | aac | pcm | flac"))?;
|
||||
}
|
||||
if let Some(v) = u64_p(p, "flacLevel") {
|
||||
settings.audio.flac_level = v.min(8) as u8;
|
||||
}
|
||||
if let Some(v) = str_p(p, "mxfVideoCodec") {
|
||||
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ filmcraft-dnx = { workspace = true }
|
||||
filmcraft-apv = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-flac = { workspace = true }
|
||||
filmcraft-h264enc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -260,6 +260,7 @@ impl Exporter {
|
||||
audio_factories().read().unwrap_or_else(|e| e.into_inner()).iter().find_map(|f| f(settings.format, a.sr, a.channels as u32, &settings));
|
||||
Some(found.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??)
|
||||
}
|
||||
(Some(a), AudioCodec::Flac) => Some(Box::new(crate::FlacAudio::new(a.sr, a.channels as u32, &settings)?)),
|
||||
_ => None,
|
||||
};
|
||||
let measured = !settings.effects.loudness.enabled || audio.is_none();
|
||||
@@ -487,11 +488,12 @@ impl Exporter {
|
||||
self.write_video(tail)?;
|
||||
self.write_audio(mixed)?;
|
||||
if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) {
|
||||
let fs = a.frame_size();
|
||||
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
|
||||
for au in a.flush()? {
|
||||
mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
for (au, duration) in a.flush_timed()? {
|
||||
mux.write_sample(at, WriteSample { data: &au, duration, composition_offset: 0, is_sync: true }).map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
if let Some(entry) = a.final_sample_entry() {
|
||||
mux.set_sample_entry(at, entry).map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let (bytes, extra_files) = match self.mxf.take() {
|
||||
|
||||
+108
-8
@@ -120,6 +120,9 @@ pub enum Format {
|
||||
/// the path ends in `.aac`.
|
||||
#[serde(rename = "aac", alias = "Aac")]
|
||||
Aac,
|
||||
/// FLAC (RFC 9639): lossless compressed audio.
|
||||
#[serde(rename = "flac", alias = "Flac")]
|
||||
Flac,
|
||||
/// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
|
||||
#[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
|
||||
MxfOp1a,
|
||||
@@ -153,6 +156,7 @@ impl Format {
|
||||
"wav" | "waveform" | "waveformaudio" => Format::Wav,
|
||||
"aif" | "aiff" | "aifc" => Format::Aiff,
|
||||
"aac" | "aacaudio" | "m4a" | "adts" => Format::Aac,
|
||||
"flac" | "fla" => Format::Flac,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -177,6 +181,7 @@ impl Format {
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aiff",
|
||||
Format::Aac => "aac",
|
||||
Format::Flac => "flac",
|
||||
Format::MxfOp1a => "mxf-op1a",
|
||||
Format::MxfOpAtom => "mxf-opatom",
|
||||
}
|
||||
@@ -196,6 +201,7 @@ impl Format {
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aif",
|
||||
Format::Aac => "m4a",
|
||||
Format::Flac => "flac",
|
||||
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
|
||||
}
|
||||
}
|
||||
@@ -219,6 +225,7 @@ impl Format {
|
||||
Format::Wav => "Waveform Audio",
|
||||
Format::Aiff => "AIFF",
|
||||
Format::Aac => "AAC Audio",
|
||||
Format::Flac => "FLAC",
|
||||
Format::MxfOp1a => "MXF OP1a",
|
||||
Format::MxfOpAtom => "MXF OP-Atom",
|
||||
}
|
||||
@@ -245,9 +252,9 @@ impl Format {
|
||||
pub fn has_builtin_encoder(self) -> bool {
|
||||
!matches!(self, Format::Hevc | Format::Av1)
|
||||
}
|
||||
/// Sound without picture: Waveform Audio, AIFF and AAC Audio.
|
||||
/// Sound without picture: Waveform Audio, AIFF, AAC Audio and FLAC.
|
||||
pub fn is_audio_only(self) -> bool {
|
||||
matches!(self, Format::Wav | Format::Aiff | Format::Aac)
|
||||
matches!(self, Format::Wav | Format::Aiff | Format::Aac | Format::Flac)
|
||||
}
|
||||
/// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls.
|
||||
pub fn is_h26x(self) -> bool {
|
||||
@@ -261,7 +268,7 @@ impl Format {
|
||||
pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
|
||||
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
|
||||
}
|
||||
pub const ALL: [Format; 20] = [
|
||||
pub const ALL: [Format; 21] = [
|
||||
Format::H264,
|
||||
Format::Hevc,
|
||||
Format::Av1,
|
||||
@@ -280,6 +287,7 @@ impl Format {
|
||||
Format::Wav,
|
||||
Format::Aiff,
|
||||
Format::Aac,
|
||||
Format::Flac,
|
||||
Format::MxfOp1a,
|
||||
Format::MxfOpAtom,
|
||||
];
|
||||
@@ -406,6 +414,12 @@ impl std::fmt::Debug for OutputSink {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an audio-only export codes its samples.
|
||||
enum AudioWriter {
|
||||
Pcm(pcm::PcmContainer),
|
||||
Flac(Box<filmcraft_flac::Encoder>),
|
||||
}
|
||||
|
||||
/// An export's output file: on disk, or in memory for an [`OutputSink`].
|
||||
enum Out {
|
||||
File(std::io::BufWriter<std::fs::File>),
|
||||
@@ -733,6 +747,17 @@ pub trait AudioEncoder: Send {
|
||||
fn priming(&self) -> u32;
|
||||
/// Samples per access unit (1024 for AAC).
|
||||
fn frame_size(&self) -> u32;
|
||||
/// [`AudioEncoder::flush`] with each access unit's duration, for codecs whose last unit is
|
||||
/// shorter than [`AudioEncoder::frame_size`] (FLAC); by default every unit is a full frame.
|
||||
fn flush_timed(&mut self) -> Result<Vec<(Vec<u8>, u32)>> {
|
||||
let n = self.frame_size();
|
||||
Ok(self.flush()?.into_iter().map(|au| (au, n)).collect())
|
||||
}
|
||||
/// The sample entry once everything is encoded, when it differs from the first one (the FLAC
|
||||
/// STREAMINFO learns its frame sizes and MD5 at the end).
|
||||
fn final_sample_entry(&self) -> Option<SampleEntry> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>>;
|
||||
@@ -1075,6 +1100,53 @@ impl AudioEncoder for AacEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
|
||||
pub(crate) struct FlacAudio {
|
||||
enc: filmcraft_flac::Encoder,
|
||||
bits: u16,
|
||||
/// Samples of the frame `flush` returns (shorter than a full block).
|
||||
tail: u32,
|
||||
}
|
||||
|
||||
impl FlacAudio {
|
||||
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
|
||||
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
|
||||
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
|
||||
cfg.level = s.audio.flac_level.min(8);
|
||||
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
|
||||
Ok(FlacAudio { enc, bits, tail: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioEncoder for FlacAudio {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
SampleEntry::flac(&self.enc.streaminfo())
|
||||
}
|
||||
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Vec<u8>>> {
|
||||
Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect())
|
||||
}
|
||||
fn flush_timed(&mut self) -> Result<Vec<(Vec<u8>, u32)>> {
|
||||
self.tail = self.enc.pending_samples() as u32;
|
||||
let last = self.enc.finish();
|
||||
Ok(if last.is_empty() { Vec::new() } else { vec![(last, self.tail)] })
|
||||
}
|
||||
fn priming(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn frame_size(&self) -> u32 {
|
||||
4096
|
||||
}
|
||||
fn final_sample_entry(&self) -> Option<SampleEntry> {
|
||||
Some(SampleEntry::flac(&self.enc.streaminfo()))
|
||||
}
|
||||
}
|
||||
|
||||
fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option<Result<Box<dyn AudioEncoder>>> {
|
||||
// AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so
|
||||
// 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused
|
||||
@@ -1767,13 +1839,24 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
let batch = rayon::current_num_threads().clamp(2, 16) as i64;
|
||||
let (bytes, nframes) = match settings.format {
|
||||
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
|
||||
Format::Wav | Format::Aiff => {
|
||||
Format::Wav | Format::Aiff | Format::Flac => {
|
||||
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
|
||||
let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff };
|
||||
let frames = a.remaining();
|
||||
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
|
||||
let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?;
|
||||
let mut writer = match settings.format {
|
||||
Format::Flac => {
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
|
||||
cfg.level = settings.audio.flac_level.min(8);
|
||||
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
|
||||
}
|
||||
Format::Wav => AudioWriter::Pcm(pcm::PcmContainer::Wav),
|
||||
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
|
||||
};
|
||||
let head = match &writer {
|
||||
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
|
||||
AudioWriter::Flac(enc) => enc.header(),
|
||||
};
|
||||
// one second per chunk: memory stays flat however long the range is
|
||||
let chunk = i64::from(sr.max(1));
|
||||
if !settings.part_of_batch {
|
||||
@@ -1791,13 +1874,30 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
return Err(ExportError::Cancelled);
|
||||
}
|
||||
buf.clear();
|
||||
pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf);
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
|
||||
AudioWriter::Flac(enc) => {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
|
||||
}
|
||||
}
|
||||
out.write_all(&buf).map_err(io)?;
|
||||
if !settings.part_of_batch {
|
||||
progress.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?;
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
|
||||
AudioWriter::Flac(enc) => {
|
||||
out.write_all(&enc.finish()).map_err(io)?;
|
||||
// the frame sizes and MD5 are known now: fill them into STREAMINFO
|
||||
use std::io::{Seek, SeekFrom};
|
||||
out.seek(SeekFrom::Start(filmcraft_flac::STREAMINFO_OFFSET)).map_err(io)?;
|
||||
out.write_all(&enc.streaminfo()).map_err(io)?;
|
||||
out.seek(SeekFrom::End(0)).map_err(io)?;
|
||||
}
|
||||
}
|
||||
let n = out.finish(settings)?;
|
||||
if settings.part_of_batch {
|
||||
progress.done.store(1, Ordering::Relaxed);
|
||||
|
||||
@@ -13,7 +13,7 @@ pub enum PcmContainer {
|
||||
}
|
||||
|
||||
/// Quantise one sample to a signed integer of `bits` (16 or 24).
|
||||
fn quantise(s: f32, bits: u16) -> i32 {
|
||||
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
|
||||
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
|
||||
(s.clamp(-1.0, 1.0) * max).round() as i32
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ fn h264(size: Option<(u32, u32)>, kbps: u32, max_kbps: u32) -> ExportSettings {
|
||||
bitrate_kbps: kbps,
|
||||
max_bitrate_kbps: Some(max_kbps),
|
||||
bitrate_mode: BitrateMode::Vbr1Pass,
|
||||
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 },
|
||||
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ fn adaptive(bpp: f32) -> ExportSettings {
|
||||
fn mov(format: Format, profile: &str) -> ExportSettings {
|
||||
let mut s = ExportSettings {
|
||||
format,
|
||||
audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24 },
|
||||
audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24, ..Default::default() },
|
||||
render_at_max_depth: true,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -82,7 +82,7 @@ fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings {
|
||||
fn audio_only(format: Format, bits: u16) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format,
|
||||
audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits },
|
||||
audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits, ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -181,10 +181,11 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
||||
"MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps",
|
||||
ExportSettings {
|
||||
format: Format::Aac,
|
||||
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 },
|
||||
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
p("FLAC 48 kHz 24-bit", "Audio Only", "FLAC, stereo 48 kHz 24-bit, lossless", audio_only(Format::Flac, 24)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,8 @@ impl MxfVideoCodec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF.
|
||||
/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF, FLAC in FLAC.
|
||||
/// MP4 and QuickTime can also carry FLAC; MXF is always PCM.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AudioCodec {
|
||||
@@ -168,6 +169,8 @@ pub enum AudioCodec {
|
||||
Auto,
|
||||
Aac,
|
||||
Pcm,
|
||||
/// Lossless compression (the FLAC format).
|
||||
Flac,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -180,13 +183,15 @@ pub struct AudioSettings {
|
||||
pub channels: u32,
|
||||
/// AAC bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
/// PCM sample size: 16 or 24.
|
||||
/// PCM and FLAC sample size: 16 or 24.
|
||||
pub bits: u16,
|
||||
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
|
||||
pub flac_level: u8,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16 }
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,8 +436,11 @@ impl ExportSettings {
|
||||
/// The audio codec actually used.
|
||||
pub fn audio_codec(&self) -> AudioCodec {
|
||||
match (self.format, self.audio.codec) {
|
||||
(Format::Flac, _) => AudioCodec::Flac,
|
||||
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
|
||||
(Format::Aac, _) => AudioCodec::Aac,
|
||||
// MP4 and QuickTime both carry FLAC (`fLaC` + `dfLa`)
|
||||
(_, AudioCodec::Flac) => AudioCodec::Flac,
|
||||
(f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) => AudioCodec::Pcm,
|
||||
@@ -494,12 +502,15 @@ impl ExportSettings {
|
||||
// two bytes per half-float channel
|
||||
Format::ExrSequence => px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps,
|
||||
Format::Gif => px * 0.6 * 8.0 * fps,
|
||||
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
|
||||
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
|
||||
};
|
||||
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
|
||||
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
|
||||
_ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 },
|
||||
// typical of music and dialogue; noise compresses less, silence far more
|
||||
AudioCodec::Flac => pcm * 0.6,
|
||||
_ => pcm,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
@@ -589,6 +600,12 @@ impl ExportSettings {
|
||||
};
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
|
||||
AudioCodec::Flac => format!(
|
||||
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
|
||||
if self.audio.bits >= 24 { 24 } else { 16 },
|
||||
self.audio.flac_level.min(8),
|
||||
r.sample_rate
|
||||
),
|
||||
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -402,6 +402,126 @@ fn aac_export_rejects_unsupported_explicit_sample_rates() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_is_lossless_against_wav() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
|
||||
let dir = Scratch::new("flac");
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2)));
|
||||
let audio = AudioSettings { channels: 2, bits: 24, ..Default::default() };
|
||||
let (wav, flac) = (dir.path("a.wav"), dir.path("a.flac"));
|
||||
let w = ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() };
|
||||
export(&p, seq, &w, &m, &Progress::default()).unwrap();
|
||||
let f = ExportSettings { format: Format::Flac, path: flac.clone(), range, audio, ..Default::default() };
|
||||
let prog = Progress::default();
|
||||
let r = export(&p, seq, &f, &m, &prog).unwrap();
|
||||
assert_eq!(r.frames, 72_000, "1.5 s at 48 kHz");
|
||||
// one progress step per second of audio
|
||||
assert_eq!((prog.total.load(Ordering::Relaxed), prog.done.load(Ordering::Relaxed)), (2, 2));
|
||||
let (wb, fb) = (std::fs::read(&wav).unwrap(), std::fs::read(&flac).unwrap());
|
||||
assert_eq!(&fb[..4], b"fLaC");
|
||||
assert!(fb.len() * 2 < wb.len(), "a tone compresses: FLAC {} bytes, WAV {}", fb.len(), wb.len());
|
||||
// STREAMINFO was filled in at the end: total samples
|
||||
let si = &fb[8..42];
|
||||
assert_eq!((u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes(si[14..18].try_into().unwrap())), 72_000);
|
||||
|
||||
// the same samples through FilmCraft's own readers
|
||||
let decode = |name: &str, bytes: Vec<u8>| filmcraft_codecs::open_bytes(name, bytes.into()).unwrap().audio(0, 72_000, 48_000).unwrap();
|
||||
let (a, b) = (decode("a.wav", wb), decode("a.flac", fb));
|
||||
assert_eq!(a.channels.len(), 2);
|
||||
for (ca, cb) in a.channels.iter().zip(&b.channels) {
|
||||
assert_eq!(ca.len(), cb.len());
|
||||
let worst = ca.iter().zip(cb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-6, "differs by {worst}");
|
||||
assert!(ca.iter().any(|v| v.abs() > 0.1), "the tone is there");
|
||||
}
|
||||
// and bit for bit through ffmpeg
|
||||
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac export") {
|
||||
let raw = |path: &str| {
|
||||
let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-i", path, "-f", "s32le", "-c:a", "pcm_s32le", "-"]).output().unwrap();
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout
|
||||
};
|
||||
let (a, b) = (raw(&wav), raw(&flac));
|
||||
assert_eq!(a.len(), 72_000 * 2 * 4);
|
||||
assert!(a == b, "FLAC must decode to the WAV's samples exactly");
|
||||
}
|
||||
if let Some(j) = ffprobe_json(&["-show_streams"], &flac) {
|
||||
let st = &j["streams"][0];
|
||||
assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["bits_per_raw_sample"].as_str()), (Some("flac"), Some("48000"), Some("24")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_audio_in_mp4_and_mov() {
|
||||
let (p, seq, m) = matte([0.2, 0.4, 0.6, 1.0], 64, 36, Some(-6.0));
|
||||
let dir = Scratch::new("flac-in");
|
||||
// 1.5 s at 48 kHz: 17 full FLAC frames of 4096 samples and a last one of 2368
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2)));
|
||||
let audio = AudioSettings { codec: AudioCodec::Flac, bits: 24, ..Default::default() };
|
||||
let wav = dir.path("ref.wav");
|
||||
export(&p, seq, &ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }, &m, &Progress::default())
|
||||
.unwrap();
|
||||
for (format, mux, name) in
|
||||
[(Format::H264, Multiplexer::Mp4, "a.mp4"), (Format::H264, Multiplexer::Mov, "b.mov"), (Format::ProRes, Multiplexer::Mp4, "c.mov")]
|
||||
{
|
||||
let path = dir.path(name);
|
||||
let s = ExportSettings { format, multiplexer: mux, path: path.clone(), range, audio: audio.clone(), ..Default::default() };
|
||||
assert_eq!(s.audio_codec(), AudioCodec::Flac, "{name}");
|
||||
assert!(s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio.starts_with("FLAC (lossless) 24-bit"), "{name}");
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
// `dfLa` was rewritten at the end: STREAMINFO with frame sizes, total samples and the MD5
|
||||
let at = bytes.windows(4).position(|w| w == b"dfLa").unwrap() + 4 + 4 + 4;
|
||||
let si = &bytes[at..at + 34];
|
||||
assert_eq!(bytes[at - 4], 0x80, "{name}: STREAMINFO is the last metadata block");
|
||||
assert!(si[4..10].iter().any(|&b| b != 0), "{name}: frame sizes");
|
||||
assert_eq!(u32::from_be_bytes(si[14..18].try_into().unwrap()), 72_000, "{name}: total samples");
|
||||
assert!(si[18..34].iter().any(|&b| b != 0), "{name}: MD5");
|
||||
// FilmCraft's own importer reads the FLAC track back, sample-accurately long
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap();
|
||||
let a = src.audio(0, 72_000, 48_000).unwrap();
|
||||
assert!(a.channels.iter().all(|c| c.len() == 72_000) && a.channels[0].iter().any(|v| v.abs() > 0.1), "{name}");
|
||||
if let Some(j) = ffprobe_json(&["-show_streams", "-count_packets"], &path) {
|
||||
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
|
||||
assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["channels"].as_u64()), (Some("flac"), Some("48000"), Some(2)), "{name}");
|
||||
assert_eq!(st["nb_read_packets"].as_str(), Some("18"), "{name}: one FLAC frame per packet");
|
||||
assert_eq!(st["duration_ts"].as_u64(), Some(72_000), "{name}: the short last frame is timed exactly");
|
||||
}
|
||||
// the audio decodes to the WAV export's samples bit for bit
|
||||
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac in containers") {
|
||||
let raw = |path: &str| {
|
||||
let out = std::process::Command::new(&ffmpeg)
|
||||
.args(["-v", "error", "-i", path, "-map", "0:a", "-f", "s32le", "-c:a", "pcm_s32le", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout
|
||||
};
|
||||
assert!(raw(&path) == raw(&wav), "{name}: FLAC must decode to the WAV's samples exactly");
|
||||
}
|
||||
}
|
||||
// MXF stays PCM whatever is asked for
|
||||
let mxf = ExportSettings { format: Format::MxfOp1a, audio: audio.clone(), ..Default::default() };
|
||||
assert_eq!(mxf.audio_codec(), AudioCodec::Pcm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_settings_round_trip_and_summary() {
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "flac"})).unwrap();
|
||||
assert_eq!((s.format, s.audio_codec(), s.audio.flac_level, s.has_video()), (Format::Flac, AudioCodec::Flac, 5, false));
|
||||
assert_eq!((s.extension(), Format::from_name("FLAC"), Format::Flac.id()), ("flac", Some(Format::Flac), "flac"));
|
||||
// settings saved before FLAC read the default level
|
||||
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
|
||||
assert_eq!(old.flac_level, 5);
|
||||
let sum = ExportSettings { format: Format::Flac, ..Default::default() }.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND));
|
||||
assert!(sum.audio.starts_with("FLAC (lossless) 16-bit, level 5, 48000 Hz"), "{}", sum.audio);
|
||||
assert_eq!(sum.video, "No video");
|
||||
// estimated below PCM
|
||||
let pcm = ExportSettings { format: Format::Wav, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND));
|
||||
let flac = ExportSettings { format: Format::Flac, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND));
|
||||
assert!(flac < pcm, "{flac} < {pcm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_export_allocations_are_rejected_before_rendering() {
|
||||
let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None);
|
||||
@@ -798,7 +918,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
|
||||
}
|
||||
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
|
||||
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
|
||||
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 20);
|
||||
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 21);
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
|
||||
assert_eq!(s.format, Format::Hevc);
|
||||
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "filmcraft-flac"
|
||||
description = "Clean-room FLAC encoder (RFC 9639)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
filmcraft-bitstream = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
@@ -0,0 +1,62 @@
|
||||
# filmcraft-flac
|
||||
|
||||
A clean-room FLAC encoder (Free Lossless Audio Codec, [RFC 9639](https://www.rfc-editor.org/rfc/rfc9639)),
|
||||
written from the RFC. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, has no
|
||||
`unsafe`, and builds for `wasm32-unknown-unknown`.
|
||||
|
||||
No libFLAC, FFmpeg or other FLAC implementation was read or ported. ffmpeg is used only as an
|
||||
external **test oracle** (decoding our streams). Decoding FLAC in FilmCraft is still done by the
|
||||
symphonia bootstrap decoder in `filmcraft-codecs`.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
|
||||
|
||||
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
|
||||
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
|
||||
let mut enc = Encoder::new(cfg)?;
|
||||
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
|
||||
file.extend(enc.encode(&[&left, &right])?); // planar i32 samples, any chunk size → whole frames
|
||||
file.extend(enc.finish()); // the last, shorter frame
|
||||
// frame sizes, total samples and MD5 are only known now: patch STREAMINFO (or seek back in a file)
|
||||
let at = STREAMINFO_OFFSET as usize;
|
||||
file[at..at + 34].copy_from_slice(&enc.streaminfo());
|
||||
|
||||
// MP4 / QuickTime (`fLaC` + `dfLa`): one frame per sample, the STREAMINFO body in `dfLa`
|
||||
let frames: Vec<Vec<u8>> = enc.encode_frames(&[&left, &right])?; // each a full block
|
||||
let last_len = enc.pending_samples(); // duration of the frame finish() writes
|
||||
```
|
||||
|
||||
## What it writes
|
||||
|
||||
| Part | |
|
||||
|---|---|
|
||||
| Metadata | STREAMINFO (block size, frame sizes, rate, channels, sample size, total samples, MD5 of the audio) and a VORBIS_COMMENT with the vendor string |
|
||||
| Frames | fixed block size (4096 by default), frame numbers, sample rate and size codes, CRC-8 header and CRC-16 frame checksums |
|
||||
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
|
||||
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
|
||||
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
|
||||
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
|
||||
|
||||
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
|
||||
seek tables.
|
||||
|
||||
| Level | LPC order | Rice partition order | Stereo decorrelation |
|
||||
|---|---|---|---|
|
||||
| 0 | fixed only | ≤ 3 | no |
|
||||
| 1–2 | fixed only | ≤ 3 | yes |
|
||||
| 3 | ≤ 6 | ≤ 4 | no |
|
||||
| 4 | ≤ 8 | ≤ 4 | yes |
|
||||
| 5 (default) | ≤ 8 | ≤ 5 | yes |
|
||||
| 6 | ≤ 8 | ≤ 6 | yes |
|
||||
| 7–8 | ≤ 12 | ≤ 6 | yes |
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test -p filmcraft-flac`: CRC check values, the RFC 1321 MD5 suite, frame-number coding,
|
||||
predictor round trips, and `tests/ffmpeg_oracle.rs`, which decodes every stream with ffmpeg and
|
||||
requires every sample back exactly, no decoder errors, and the STREAMINFO MD5 equal to ffmpeg's
|
||||
hash of the decoded audio. It covers music-like, sweep, noise, full-scale square, silence and click
|
||||
signals; 8/16/20/24-bit; mono, stereo and 5.1; common and odd sample rates; block sizes 16 to 65535;
|
||||
and every level. Skipped (with a `SKIPPED` line) without ffmpeg.
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The two frame checksums of RFC 9639 §9.1.8 and §9.3: CRC-8 (polynomial x⁸+x²+x+1) over the
|
||||
//! frame header and CRC-16 (x¹⁶+x¹⁵+x²+1) over the whole frame, both MSB-first with a zero start.
|
||||
|
||||
const fn table8() -> [u8; 256] {
|
||||
let mut t = [0u8; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut c = i as u8;
|
||||
let mut b = 0;
|
||||
while b < 8 {
|
||||
c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 };
|
||||
b += 1;
|
||||
}
|
||||
t[i] = c;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
const fn table16() -> [u16; 256] {
|
||||
let mut t = [0u16; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut c = (i as u16) << 8;
|
||||
let mut b = 0;
|
||||
while b < 8 {
|
||||
c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 };
|
||||
b += 1;
|
||||
}
|
||||
t[i] = c;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
static T8: [u8; 256] = table8();
|
||||
static T16: [u16; 256] = table16();
|
||||
|
||||
pub fn crc8(data: &[u8]) -> u8 {
|
||||
data.iter().fold(0u8, |c, &b| T8[usize::from(c ^ b)])
|
||||
}
|
||||
|
||||
pub fn crc16(data: &[u8]) -> u16 {
|
||||
data.iter().fold(0u16, |c, &b| (c << 8) ^ T16[usize::from((c >> 8) as u8 ^ b)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_values() {
|
||||
// the standard check value of each polynomial over "123456789"
|
||||
assert_eq!(crc8(b"123456789"), 0xF4);
|
||||
assert_eq!(crc16(b"123456789"), 0xFEE8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Writing one frame (RFC 9639 §9): header with CRC-8, one subframe per channel, zero padding to a
|
||||
//! byte, CRC-16.
|
||||
|
||||
use filmcraft_bitstream::BitWriter;
|
||||
|
||||
use crate::crc::{crc8, crc16};
|
||||
use crate::predict::{Residual, Subframe};
|
||||
|
||||
/// Inter-channel decorrelation of a stereo frame (§9.1.3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stereo {
|
||||
Independent,
|
||||
/// Left, then side (left − right).
|
||||
LeftSide,
|
||||
/// Side, then right.
|
||||
SideRight,
|
||||
/// Mid ((left + right) >> 1), then side.
|
||||
MidSide,
|
||||
}
|
||||
|
||||
/// Frame header block size bits: 4096 has its own code, others are stored after the header.
|
||||
fn block_size_code(n: u16) -> (u8, Option<(u32, u32)>) {
|
||||
match n {
|
||||
4096 => (0b1100, None),
|
||||
1..=256 => (0b0110, Some((u32::from(n - 1), 8))),
|
||||
_ => (0b0111, Some((u32::from(n.saturating_sub(1)), 16))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame header sample rate code; 0 refers to STREAMINFO.
|
||||
fn sample_rate_code(rate: u32) -> u8 {
|
||||
match rate {
|
||||
88_200 => 1,
|
||||
176_400 => 2,
|
||||
192_000 => 3,
|
||||
8_000 => 4,
|
||||
16_000 => 5,
|
||||
22_050 => 6,
|
||||
24_000 => 7,
|
||||
32_000 => 8,
|
||||
44_100 => 9,
|
||||
48_000 => 10,
|
||||
96_000 => 11,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame header sample size code; 0 refers to STREAMINFO.
|
||||
fn sample_size_code(bps: u8) -> u8 {
|
||||
match bps {
|
||||
8 => 1,
|
||||
12 => 2,
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame number in the UTF-8-like coding of §9.1.5 (up to 36 bits).
|
||||
fn coded_number(v: u64, out: &mut Vec<u8>) {
|
||||
if v < 0x80 {
|
||||
out.push(v as u8);
|
||||
return;
|
||||
}
|
||||
let tail = match v {
|
||||
0..0x800 => 1,
|
||||
0x800..0x1_0000 => 2,
|
||||
0x1_0000..0x20_0000 => 3,
|
||||
0x20_0000..0x400_0000 => 4,
|
||||
0x400_0000..0x8000_0000 => 5,
|
||||
_ => 6,
|
||||
};
|
||||
let lead_mask: u8 = !(0xFFu8 >> (tail + 1));
|
||||
out.push(lead_mask | (v >> (6 * tail)) as u8 & (0x7F >> (tail + 1)));
|
||||
for i in (0..tail).rev() {
|
||||
out.push(0x80 | ((v >> (6 * i)) & 0x3F) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FrameSpec<'a> {
|
||||
pub number: u64,
|
||||
pub sample_rate: u32,
|
||||
pub bps: u8,
|
||||
pub stereo: Option<Stereo>,
|
||||
/// The signals as coded (mid/side already formed), with their subframes.
|
||||
pub channels: &'a [(&'a [i32], Subframe, u8)],
|
||||
}
|
||||
|
||||
fn write_residual(w: &mut BitWriter, residual: &Residual, r: &[i64], block: usize, order: usize) {
|
||||
let wide = residual.wide();
|
||||
w.write_bits(u32::from(wide), 2);
|
||||
w.write_bits(u32::from(residual.partition_order), 4);
|
||||
let parts = 1usize << residual.partition_order;
|
||||
let len = block / parts;
|
||||
let mut start = 0usize;
|
||||
for (p, &k) in residual.params.iter().enumerate() {
|
||||
w.write_bits(u32::from(k), if wide { 5 } else { 4 });
|
||||
let n = if p == 0 { len.saturating_sub(order) } else { len };
|
||||
for &v in r.get(start..start + n).unwrap_or_default() {
|
||||
let u = if v >= 0 { (v as u64) << 1 } else { ((-v - 1) as u64) << 1 | 1 };
|
||||
let mut q = u >> k;
|
||||
while q >= 32 {
|
||||
w.write_bits(0, 32);
|
||||
q -= 32;
|
||||
}
|
||||
w.write_bits(1, q as u32 + 1);
|
||||
w.write_bits((u & ((1u64 << k) - 1)) as u32, u32::from(k));
|
||||
}
|
||||
start += n;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subframe(w: &mut BitWriter, x: &[i32], sub: &Subframe, bps: u8) {
|
||||
let b = u32::from(bps);
|
||||
let sample = |w: &mut BitWriter, v: i32| w.write_bits(v as u32, b);
|
||||
w.write_bit(false);
|
||||
match sub {
|
||||
Subframe::Constant(v) => {
|
||||
w.write_bits(0, 6);
|
||||
w.write_bit(false);
|
||||
sample(w, *v);
|
||||
}
|
||||
Subframe::Verbatim => {
|
||||
w.write_bits(1, 6);
|
||||
w.write_bit(false);
|
||||
x.iter().for_each(|&v| sample(w, v));
|
||||
}
|
||||
Subframe::Fixed { order, residual } => {
|
||||
w.write_bits(0b001000 | u32::from(*order), 6);
|
||||
w.write_bit(false);
|
||||
let o = usize::from(*order);
|
||||
x.iter().take(o).for_each(|&v| sample(w, v));
|
||||
let r = crate::predict::fixed_residual(x, o).unwrap_or_default();
|
||||
write_residual(w, residual, &r, x.len(), o);
|
||||
}
|
||||
Subframe::Lpc { coefs, precision, shift, residual } => {
|
||||
let o = coefs.len();
|
||||
w.write_bits(0b100000 | (o as u32 - 1), 6);
|
||||
w.write_bit(false);
|
||||
x.iter().take(o).for_each(|&v| sample(w, v));
|
||||
w.write_bits(u32::from(*precision) - 1, 4);
|
||||
w.write_bits(u32::from(*shift), 5);
|
||||
coefs.iter().for_each(|&c| w.write_bits(c as u32, u32::from(*precision)));
|
||||
let r = crate::predict::lpc_residual(x, coefs, *shift).unwrap_or_default();
|
||||
write_residual(w, residual, &r, x.len(), o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One complete frame.
|
||||
pub fn write(spec: &FrameSpec) -> Vec<u8> {
|
||||
let block = spec.channels.first().map_or(0, |c| c.0.len());
|
||||
let (bs_code, bs_extra) = block_size_code(u16::try_from(block).unwrap_or(u16::MAX));
|
||||
let channel_code = match spec.stereo {
|
||||
Some(Stereo::LeftSide) => 8,
|
||||
Some(Stereo::SideRight) => 9,
|
||||
Some(Stereo::MidSide) => 10,
|
||||
_ => spec.channels.len().saturating_sub(1) as u8,
|
||||
};
|
||||
let mut h = vec![0xFF, 0xF8, bs_code << 4 | sample_rate_code(spec.sample_rate), channel_code << 4 | sample_size_code(spec.bps) << 1];
|
||||
coded_number(spec.number, &mut h);
|
||||
if let Some((v, n)) = bs_extra {
|
||||
if n == 8 {
|
||||
h.push(v as u8);
|
||||
} else {
|
||||
h.extend_from_slice(&(v as u16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
h.push(crc8(&h));
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bytes(&h);
|
||||
for (x, sub, bps) in spec.channels {
|
||||
write_subframe(&mut w, x, sub, *bps);
|
||||
}
|
||||
let mut out = w.finish();
|
||||
let crc = crc16(&out);
|
||||
out.extend_from_slice(&crc.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coded_numbers() {
|
||||
let code = |v| {
|
||||
let mut o = Vec::new();
|
||||
coded_number(v, &mut o);
|
||||
o
|
||||
};
|
||||
assert_eq!(code(0), [0]);
|
||||
assert_eq!(code(0x7F), [0x7F]);
|
||||
assert_eq!(code(0x80), [0xC2, 0x80]);
|
||||
assert_eq!(code(0x7FF), [0xDF, 0xBF]);
|
||||
assert_eq!(code(0x800), [0xE0, 0xA0, 0x80]);
|
||||
assert_eq!(code(0xFFFF), [0xEF, 0xBF, 0xBF]);
|
||||
assert_eq!(code(0x1_0000), [0xF0, 0x90, 0x80, 0x80]);
|
||||
assert_eq!(code(0x7FFF_FFFF), [0xFD, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]);
|
||||
assert_eq!(code(0xF_FFFF_FFFF), [0xFE, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
|
||||
//!
|
||||
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
|
||||
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
|
||||
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
|
||||
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
|
||||
//! stereo frames also try left/side, side/right and mid/side.
|
||||
//! - The STREAMINFO sizes and MD5 are only known at the end: write the header first, then overwrite
|
||||
//! [`STREAMINFO_OFFSET`] with [`Encoder::streaminfo`] once [`Encoder::finish`] has run (or keep
|
||||
//! the placeholder, whose zeros mean "unknown" and decode fine).
|
||||
//!
|
||||
//! Lossless at every compression level; the level (0–8) only trades encoding time for size.
|
||||
//!
|
||||
//! Layer L0: depends only on `filmcraft-bitstream` (+ `thiserror`); no `unsafe`; builds for
|
||||
//! `wasm32-unknown-unknown`.
|
||||
//!
|
||||
//! ```
|
||||
//! use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
|
||||
//!
|
||||
//! let left: Vec<i32> = (0..48_000).map(|i| ((i as f64 * 0.05).sin() * 20_000.0) as i32).collect();
|
||||
//! let right = left.clone();
|
||||
//! let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16))?;
|
||||
//! let mut file = enc.header();
|
||||
//! file.extend(enc.encode(&[&left, &right])?);
|
||||
//! file.extend(enc.finish());
|
||||
//! let at = STREAMINFO_OFFSET as usize;
|
||||
//! file[at..at + 34].copy_from_slice(&enc.streaminfo());
|
||||
//! assert_eq!(&file[..4], b"fLaC");
|
||||
//! assert!(file.len() < 48_000 * 2 * 2 / 2); // well under half of 16-bit PCM
|
||||
//! # Ok::<(), filmcraft_flac::Error>(())
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod crc;
|
||||
mod frame;
|
||||
mod md5;
|
||||
mod predict;
|
||||
|
||||
use frame::{FrameSpec, Stereo};
|
||||
use predict::{Effort, Subframe};
|
||||
|
||||
/// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`].
|
||||
pub const STREAMINFO_OFFSET: u64 = 8;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid configuration: {0}")]
|
||||
InvalidConfig(&'static str),
|
||||
#[error("expected {expected} channels, got {got}")]
|
||||
Channels { expected: usize, got: usize },
|
||||
#[error("channels of unequal length")]
|
||||
UnequalChannels,
|
||||
#[error("sample {0} does not fit the configured sample size")]
|
||||
SampleRange(i32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EncoderConfig {
|
||||
/// 1 to 1 048 575 Hz.
|
||||
pub sample_rate: u32,
|
||||
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
|
||||
pub channels: u8,
|
||||
/// 8–24.
|
||||
pub bits_per_sample: u8,
|
||||
/// Samples per channel per frame, 16–65535 (4096 by default).
|
||||
pub block_size: u16,
|
||||
/// 0 (fastest) to 8 (smallest); 5 by default.
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
pub fn new(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Self {
|
||||
EncoderConfig { sample_rate, channels, bits_per_sample, block_size: 4096, level: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
effort: Effort,
|
||||
/// Samples waiting for a full block, per channel.
|
||||
pending: Vec<Vec<i32>>,
|
||||
frame_number: u64,
|
||||
total_samples: u64,
|
||||
min_frame: u32,
|
||||
max_frame: u32,
|
||||
md5: md5::Md5,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(cfg: EncoderConfig) -> Result<Self, Error> {
|
||||
if !(1..=8).contains(&cfg.channels) {
|
||||
return Err(Error::InvalidConfig("1 to 8 channels"));
|
||||
}
|
||||
if !(8..=24).contains(&cfg.bits_per_sample) {
|
||||
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
|
||||
}
|
||||
if !(1..=1_048_575).contains(&cfg.sample_rate) {
|
||||
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
|
||||
}
|
||||
if cfg.block_size < 16 {
|
||||
return Err(Error::InvalidConfig("block size 16 to 65535"));
|
||||
}
|
||||
let effort = Effort::level(cfg.level);
|
||||
Ok(Encoder {
|
||||
pending: vec![Vec::new(); usize::from(cfg.channels)],
|
||||
cfg,
|
||||
effort,
|
||||
frame_number: 0,
|
||||
total_samples: 0,
|
||||
min_frame: 0,
|
||||
max_frame: 0,
|
||||
md5: md5::Md5::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `fLaC`, the STREAMINFO block (sizes and MD5 unknown yet: see [`Encoder::streaminfo`]) and a
|
||||
/// VORBIS_COMMENT block with the vendor string.
|
||||
pub fn header(&self) -> Vec<u8> {
|
||||
let mut v = b"fLaC".to_vec();
|
||||
v.extend_from_slice(&[0, 0, 0, 34]);
|
||||
v.extend_from_slice(&self.streaminfo_with(0, 0, 0, [0; 16]));
|
||||
let vendor = concat!("filmcraft-flac ", env!("CARGO_PKG_VERSION"));
|
||||
let len = 4 + vendor.len() + 4;
|
||||
// last metadata block, type 4 (VORBIS_COMMENT), little-endian lengths inside
|
||||
v.push(0x84);
|
||||
v.extend_from_slice(&(len as u32).to_be_bytes()[1..]);
|
||||
v.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
v.extend_from_slice(vendor.as_bytes());
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
/// The final STREAMINFO body: frame sizes, total samples and MD5 of what has been encoded.
|
||||
pub fn streaminfo(&self) -> [u8; 34] {
|
||||
self.streaminfo_with(self.min_frame, self.max_frame, self.total_samples, self.md5.digest())
|
||||
}
|
||||
|
||||
fn streaminfo_with(&self, min_frame: u32, max_frame: u32, total: u64, md5: [u8; 16]) -> [u8; 34] {
|
||||
let mut w = filmcraft_bitstream::BitWriter::new();
|
||||
let bs = u32::from(self.cfg.block_size);
|
||||
w.write_bits(bs, 16);
|
||||
w.write_bits(bs, 16);
|
||||
w.write_bits(min_frame.min(0xFF_FFFF), 24);
|
||||
w.write_bits(max_frame.min(0xFF_FFFF), 24);
|
||||
w.write_bits(self.cfg.sample_rate, 20);
|
||||
w.write_bits(u32::from(self.cfg.channels) - 1, 3);
|
||||
w.write_bits(u32::from(self.cfg.bits_per_sample) - 1, 5);
|
||||
// 36 bits; 0 means unknown, and a count that does not fit is left unknown
|
||||
let total = if total >> 36 == 0 { total } else { 0 };
|
||||
w.write_bits((total >> 32) as u32, 4);
|
||||
w.write_bits(total as u32, 32);
|
||||
w.write_bytes(&md5);
|
||||
let mut out = [0u8; 34];
|
||||
out.copy_from_slice(w.bytes().get(..34).unwrap_or(&[0; 34]));
|
||||
out
|
||||
}
|
||||
|
||||
/// Add samples (one slice per channel, equal lengths); returns the frames completed.
|
||||
pub fn encode(&mut self, planar: &[&[i32]]) -> Result<Vec<u8>, Error> {
|
||||
Ok(self.encode_frames(planar)?.concat())
|
||||
}
|
||||
|
||||
/// [`Encoder::encode`] with each completed frame on its own (one MP4 / QuickTime sample each).
|
||||
pub fn encode_frames(&mut self, planar: &[&[i32]]) -> Result<Vec<Vec<u8>>, Error> {
|
||||
if planar.len() != self.pending.len() {
|
||||
return Err(Error::Channels { expected: self.pending.len(), got: planar.len() });
|
||||
}
|
||||
let n = planar.first().map_or(0, |c| c.len());
|
||||
if planar.iter().any(|c| c.len() != n) {
|
||||
return Err(Error::UnequalChannels);
|
||||
}
|
||||
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
|
||||
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
|
||||
return Err(Error::SampleRange(bad));
|
||||
}
|
||||
for (p, c) in self.pending.iter_mut().zip(planar) {
|
||||
p.extend_from_slice(c);
|
||||
}
|
||||
let bs = usize::from(self.cfg.block_size);
|
||||
let mut out = Vec::new();
|
||||
while self.pending.first().is_some_and(|p| p.len() >= bs) {
|
||||
let block: Vec<Vec<i32>> = self.pending.iter_mut().map(|p| p.drain(..bs).collect()).collect();
|
||||
out.push(self.frame(&block));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Samples per channel waiting for a full block: the length of the frame [`Encoder::finish`]
|
||||
/// would write.
|
||||
pub fn pending_samples(&self) -> usize {
|
||||
self.pending.first().map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
/// Encode what is left as a final, shorter frame.
|
||||
pub fn finish(&mut self) -> Vec<u8> {
|
||||
if self.pending.first().is_none_or(Vec::is_empty) {
|
||||
return Vec::new();
|
||||
}
|
||||
let block: Vec<Vec<i32>> = self.pending.iter_mut().map(std::mem::take).collect();
|
||||
self.frame(&block)
|
||||
}
|
||||
|
||||
fn frame(&mut self, block: &[Vec<i32>]) -> Vec<u8> {
|
||||
let n = block.first().map_or(0, Vec::len);
|
||||
let bps = self.cfg.bits_per_sample;
|
||||
// the MD5 of the unencoded audio: interleaved, little-endian, whole bytes per sample
|
||||
let bytes = usize::from(bps.div_ceil(8));
|
||||
let mut raw = Vec::with_capacity(n * block.len() * bytes);
|
||||
for i in 0..n {
|
||||
for c in block {
|
||||
raw.extend_from_slice(c.get(i).copied().unwrap_or(0).to_le_bytes().get(..bytes).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
self.md5.update(&raw);
|
||||
|
||||
let effort = self.effort;
|
||||
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
|
||||
let data = match block {
|
||||
[l, r] if effort.stereo => {
|
||||
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
|
||||
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
|
||||
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));
|
||||
let options =
|
||||
[(Stereo::Independent, cl.1 + cr.1), (Stereo::LeftSide, cl.1 + cs.1), (Stereo::SideRight, cs.1 + cr.1), (Stereo::MidSide, cm.1 + cs.1)];
|
||||
let best = options.iter().min_by_key(|o| o.1).map_or(Stereo::Independent, |o| o.0);
|
||||
let channels: Vec<(&[i32], Subframe, u8)> = match best {
|
||||
Stereo::Independent => vec![(l, cl.0, bps), (r, cr.0, bps)],
|
||||
Stereo::LeftSide => vec![(l, cl.0, bps), (&side, cs.0, bps + 1)],
|
||||
Stereo::SideRight => vec![(&side, cs.0, bps + 1), (r, cr.0, bps)],
|
||||
Stereo::MidSide => vec![(&mid, cm.0, bps), (&side, cs.0, bps + 1)],
|
||||
};
|
||||
frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: Some(best), channels: &channels })
|
||||
}
|
||||
_ => {
|
||||
let channels: Vec<(&[i32], Subframe, u8)> = block.iter().map(|c| (c.as_slice(), code(c, bps).0, bps)).collect();
|
||||
frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: None, channels: &channels })
|
||||
}
|
||||
};
|
||||
self.frame_number += 1;
|
||||
self.total_samples += n as u64;
|
||||
let size = u32::try_from(data.len()).unwrap_or(u32::MAX);
|
||||
self.min_frame = if self.min_frame == 0 { size } else { self.min_frame.min(size) };
|
||||
self.max_frame = self.max_frame.max(size);
|
||||
data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! MD5 (RFC 1321), for the STREAMINFO signature of the unencoded audio (RFC 9639 §8.2): every
|
||||
//! sample, interleaved, little-endian, in the fewest whole bytes that hold the sample size.
|
||||
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11,
|
||||
16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
/// `floor(abs(sin(i + 1)) × 2³²)`, RFC 1321 §3.4.
|
||||
const K: [u32; 64] = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122,
|
||||
0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6,
|
||||
0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60,
|
||||
0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Md5 {
|
||||
state: [u32; 4],
|
||||
block: [u8; 64],
|
||||
filled: usize,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl Default for Md5 {
|
||||
fn default() -> Self {
|
||||
Md5 { state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], block: [0; 64], filled: 0, len: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Md5 {
|
||||
pub fn update(&mut self, mut data: &[u8]) {
|
||||
self.len = self.len.wrapping_add(data.len() as u64);
|
||||
while !data.is_empty() {
|
||||
let take = (64 - self.filled).min(data.len());
|
||||
let (head, rest) = data.split_at(take);
|
||||
if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) {
|
||||
dst.copy_from_slice(head);
|
||||
}
|
||||
self.filled += take;
|
||||
data = rest;
|
||||
if self.filled == 64 {
|
||||
let block = self.block;
|
||||
self.compress(&block);
|
||||
self.filled = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compress(&mut self, block: &[u8; 64]) {
|
||||
let mut m = [0u32; 16];
|
||||
for (w, c) in m.iter_mut().zip(block.as_chunks::<4>().0) {
|
||||
*w = u32::from_le_bytes(*c);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d] = self.state;
|
||||
for i in 0..64 {
|
||||
let (f, g) = match i / 16 {
|
||||
0 => ((b & c) | (!b & d), i),
|
||||
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
|
||||
2 => (b ^ c ^ d, (3 * i + 5) % 16),
|
||||
_ => (c ^ (b | !d), (7 * i) % 16),
|
||||
};
|
||||
let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = b.wrapping_add(f.rotate_left(S[i]));
|
||||
}
|
||||
for (s, v) in self.state.iter_mut().zip([a, b, c, d]) {
|
||||
*s = s.wrapping_add(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest so far (the state is not consumed: more data may follow).
|
||||
pub fn digest(&self) -> [u8; 16] {
|
||||
let mut h = self.clone();
|
||||
let bits = h.len.wrapping_mul(8);
|
||||
h.update(&[0x80]);
|
||||
while h.filled != 56 {
|
||||
h.update(&[0]);
|
||||
}
|
||||
h.update(&bits.to_le_bytes());
|
||||
let mut out = [0u8; 16];
|
||||
for (o, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(h.state) {
|
||||
o.copy_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex(d: [u8; 16]) -> String {
|
||||
d.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_1321_test_suite() {
|
||||
let md5 = |s: &[u8]| {
|
||||
let mut m = Md5::default();
|
||||
m.update(s);
|
||||
hex(m.digest())
|
||||
};
|
||||
assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
|
||||
assert_eq!(md5(b"message digest"), "f96b697d7cb7938d525a2f31aaf161d0");
|
||||
assert_eq!(md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "57edf4a22be3c955ac49da2e2107b67a");
|
||||
// fed in pieces across block boundaries
|
||||
let mut m = Md5::default();
|
||||
for c in b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".chunks(7) {
|
||||
m.update(c);
|
||||
}
|
||||
assert_eq!(hex(m.digest()), "57edf4a22be3c955ac49da2e2107b67a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Subframe prediction (RFC 9639 §9.2.5–9.2.6) and residual coding (§9.2.7): the fixed polynomial
|
||||
//! predictors, linear prediction from the windowed autocorrelation (Levinson–Durbin), and
|
||||
//! partitioned Rice codes. Everything here only measures and chooses; [`crate::frame`] writes.
|
||||
|
||||
/// Residuals larger than this are not Rice coded (a 5-bit parameter tops out at 30).
|
||||
const MAX_RESIDUAL: i64 = (1 << 30) - 1;
|
||||
|
||||
/// A residual coded with partitioned Rice codes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Residual {
|
||||
/// Rice parameter per partition; `1 << order` partitions.
|
||||
pub params: Vec<u8>,
|
||||
pub partition_order: u8,
|
||||
/// Total bits of the residual section, coding method included.
|
||||
pub bits: u64,
|
||||
}
|
||||
|
||||
impl Residual {
|
||||
/// The 5-bit parameter method is needed when any parameter passes 14 (15 is the escape code).
|
||||
pub fn wide(&self) -> bool {
|
||||
self.params.iter().any(|&k| k > 14)
|
||||
}
|
||||
}
|
||||
|
||||
/// How one channel of one block is coded.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Subframe {
|
||||
Constant(i32),
|
||||
Verbatim,
|
||||
Fixed { order: u8, residual: Residual },
|
||||
Lpc { coefs: Vec<i32>, precision: u8, shift: u8, residual: Residual },
|
||||
}
|
||||
|
||||
/// Folded residual: 0, -1, 1, -2, 2 … → 0, 1, 2, 3, 4 ….
|
||||
fn fold(r: i64) -> u64 {
|
||||
if r >= 0 { (r as u64) << 1 } else { ((-r - 1) as u64) << 1 | 1 }
|
||||
}
|
||||
|
||||
/// Bits of `n` folded values summing to `sum` with Rice parameter `k`: unary quotient, stop bit,
|
||||
/// `k` low bits. The quotient total is approximated by `sum >> k`, exact up to the per-value
|
||||
/// carries; [`rice_bits_exact`] gives the true cost for the chosen parameter.
|
||||
fn rice_estimate(sum: u64, n: u64, k: u32) -> u64 {
|
||||
(sum >> k) + n * (u64::from(k) + 1)
|
||||
}
|
||||
|
||||
fn rice_bits_exact(u: &[u64], k: u32) -> u64 {
|
||||
u.iter().map(|&v| (v >> k) + u64::from(k) + 1).sum()
|
||||
}
|
||||
|
||||
/// The best parameter for a partition of folded values.
|
||||
fn best_param(u: &[u64]) -> (u8, u64) {
|
||||
let n = u.len() as u64;
|
||||
let sum: u64 = u.iter().sum();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let mean = sum / n;
|
||||
let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() };
|
||||
let mut best = (0u32, u64::MAX);
|
||||
for k in guess.saturating_sub(1)..=(guess + 1).min(30) {
|
||||
let b = rice_estimate(sum, n, k);
|
||||
if b < best.1 {
|
||||
best = (k, b);
|
||||
}
|
||||
}
|
||||
(best.0 as u8, rice_bits_exact(u, best.0))
|
||||
}
|
||||
|
||||
/// Choose the partition order and parameters for the residual of a block of `block` samples whose
|
||||
/// first `order` samples are warm-up (`u` holds the folded residual after them).
|
||||
fn code_residual(u: &[u64], block: usize, order: usize, max_partition_order: u8) -> Residual {
|
||||
let mut best: Option<Residual> = None;
|
||||
for po in 0..=max_partition_order {
|
||||
let parts = 1usize << po;
|
||||
if !block.is_multiple_of(parts) {
|
||||
break;
|
||||
}
|
||||
let len = block / parts;
|
||||
if len <= order {
|
||||
break;
|
||||
}
|
||||
let mut params = Vec::with_capacity(parts);
|
||||
let mut bits = 0u64;
|
||||
let mut start = 0usize;
|
||||
for p in 0..parts {
|
||||
let n = if p == 0 { len - order } else { len };
|
||||
let Some(chunk) = u.get(start..start + n) else { break };
|
||||
let (k, b) = best_param(chunk);
|
||||
params.push(k);
|
||||
bits += b;
|
||||
start += n;
|
||||
}
|
||||
let wide = params.iter().any(|&k| k > 14);
|
||||
bits += 2 + 4 + parts as u64 * if wide { 5 } else { 4 };
|
||||
if best.as_ref().is_none_or(|b| bits < b.bits) {
|
||||
best = Some(Residual { params, partition_order: po, bits });
|
||||
}
|
||||
}
|
||||
best.unwrap_or(Residual { params: vec![0], partition_order: 0, bits: u64::MAX })
|
||||
}
|
||||
|
||||
/// The residual of fixed predictor `order` (0–4), or None when it does not fit.
|
||||
pub fn fixed_residual(x: &[i32], order: usize) -> Option<Vec<i64>> {
|
||||
let x: Vec<i64> = x.iter().map(|&v| i64::from(v)).collect();
|
||||
let mut out = Vec::with_capacity(x.len().saturating_sub(order));
|
||||
for i in order..x.len() {
|
||||
let s = |j: usize| x.get(i - j).copied().unwrap_or(0);
|
||||
let r = match order {
|
||||
0 => s(0),
|
||||
1 => s(0) - s(1),
|
||||
2 => s(0) - 2 * s(1) + s(2),
|
||||
3 => s(0) - 3 * s(1) + 3 * s(2) - s(3),
|
||||
_ => s(0) - 4 * s(1) + 6 * s(2) - 4 * s(3) + s(4),
|
||||
};
|
||||
if r.abs() > MAX_RESIDUAL {
|
||||
return None;
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The residual of quantised linear prediction, or None when it does not fit.
|
||||
pub fn lpc_residual(x: &[i32], coefs: &[i32], shift: u8) -> Option<Vec<i64>> {
|
||||
let order = coefs.len();
|
||||
let mut out = Vec::with_capacity(x.len().saturating_sub(order));
|
||||
for i in order..x.len() {
|
||||
let hist = x.get(i - order..i)?;
|
||||
// coefs[0] weighs the previous sample
|
||||
let pred: i64 = coefs.iter().zip(hist.iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum();
|
||||
let r = i64::from(*x.get(i)?) - (pred >> shift);
|
||||
if r.abs() > MAX_RESIDUAL {
|
||||
return None;
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Tukey(0.5) window: flat in the middle half, raised-cosine tapers.
|
||||
fn window(n: usize) -> Vec<f64> {
|
||||
let taper = (n / 4).max(1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let edge = i.min(n - 1 - i);
|
||||
if edge >= taper { 1.0 } else { 0.5 * (1.0 - (std::f64::consts::PI * edge as f64 / taper as f64).cos()) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Linear prediction coefficients of every order 1..=`max` (Levinson–Durbin on the windowed
|
||||
/// autocorrelation); empty for a silent or degenerate block.
|
||||
fn lpc_orders(x: &[i32], max: usize) -> Vec<Vec<f64>> {
|
||||
let n = x.len();
|
||||
if n <= max {
|
||||
return Vec::new();
|
||||
}
|
||||
let w = window(n);
|
||||
let s: Vec<f64> = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect();
|
||||
let r: Vec<f64> = (0..=max).map(|lag| s.iter().zip(s.iter().skip(lag)).map(|(a, b)| a * b).sum()).collect();
|
||||
let Some(&r0) = r.first() else { return Vec::new() };
|
||||
if r0 <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut err = r0 * (1.0 + 1e-9);
|
||||
let mut a: Vec<f64> = Vec::new();
|
||||
let mut all = Vec::with_capacity(max);
|
||||
for i in 0..max {
|
||||
let ri = r.get(i + 1).copied().unwrap_or(0.0);
|
||||
let acc: f64 = a.iter().enumerate().map(|(j, &aj)| aj * r.get(i - j).copied().unwrap_or(0.0)).sum();
|
||||
let k = (ri - acc) / err;
|
||||
if !k.is_finite() {
|
||||
break;
|
||||
}
|
||||
let prev = a.clone();
|
||||
a.push(k);
|
||||
for j in 0..i {
|
||||
if let (Some(aj), Some(&p)) = (a.get_mut(j), prev.get(i - 1 - j)) {
|
||||
*aj -= k * p;
|
||||
}
|
||||
}
|
||||
err *= 1.0 - k * k;
|
||||
all.push(a.clone());
|
||||
if err <= 0.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Quantise coefficients to `precision`-bit integers with a right shift (0–15), carrying the
|
||||
/// rounding error forward so the quantised filter stays close to the real one.
|
||||
fn quantise(coefs: &[f64], precision: u8) -> Option<(Vec<i32>, u8)> {
|
||||
let cmax = coefs.iter().fold(0.0f64, |m, c| m.max(c.abs()));
|
||||
if !(cmax.is_finite() && cmax > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let lim = (1i64 << (precision - 1)) - 1;
|
||||
// the largest shift that keeps the biggest coefficient inside the precision
|
||||
let log = cmax.log2().floor() as i32 + 1;
|
||||
let shift = (i32::from(precision) - 1 - log).clamp(0, 15);
|
||||
let scale = f64::from(1u32 << shift);
|
||||
let mut carry = 0.0;
|
||||
let mut q = Vec::with_capacity(coefs.len());
|
||||
for &c in coefs {
|
||||
carry += c * scale;
|
||||
let v = (carry.round() as i64).clamp(-lim - 1, lim);
|
||||
carry -= v as f64;
|
||||
q.push(v as i32);
|
||||
}
|
||||
Some((q, shift as u8))
|
||||
}
|
||||
|
||||
/// Encoding effort, from the compression level.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Effort {
|
||||
pub max_lpc_order: usize,
|
||||
pub max_partition_order: u8,
|
||||
pub stereo: bool,
|
||||
}
|
||||
|
||||
impl Effort {
|
||||
pub fn level(level: u8) -> Effort {
|
||||
let (max_lpc_order, max_partition_order, stereo) = match level {
|
||||
0 => (0, 3, false),
|
||||
1 | 2 => (0, 3, true),
|
||||
3 => (6, 4, false),
|
||||
4 => (8, 4, true),
|
||||
5 => (8, 5, true),
|
||||
6 => (8, 6, true),
|
||||
_ => (12, 6, true),
|
||||
};
|
||||
Effort { max_lpc_order, max_partition_order, stereo }
|
||||
}
|
||||
}
|
||||
|
||||
/// The cheapest coding of one channel of `bps`-bit samples, and its size in bits.
|
||||
pub fn choose(x: &[i32], bps: u8, effort: Effort) -> (Subframe, u64) {
|
||||
let n = x.len();
|
||||
let b = u64::from(bps);
|
||||
let head = 8; // zero bit, type, wasted-bits flag
|
||||
let first = x.first().copied().unwrap_or(0);
|
||||
if x.iter().all(|&v| v == first) {
|
||||
return (Subframe::Constant(first), head + b);
|
||||
}
|
||||
let mut best = (Subframe::Verbatim, head + n as u64 * b);
|
||||
let mut consider = |sub: Subframe, bits: u64| {
|
||||
if bits < best.1 {
|
||||
best = (sub, bits);
|
||||
}
|
||||
};
|
||||
let folded = |r: &[i64]| r.iter().map(|&v| fold(v)).collect::<Vec<u64>>();
|
||||
for order in 0..=4usize.min(n.saturating_sub(1)) {
|
||||
let Some(r) = fixed_residual(x, order) else { continue };
|
||||
let residual = code_residual(&folded(&r), n, order, effort.max_partition_order);
|
||||
let bits = head + order as u64 * b + residual.bits;
|
||||
consider(Subframe::Fixed { order: order as u8, residual }, bits);
|
||||
}
|
||||
// 15-bit coefficients, fewer for short blocks (their side information weighs more)
|
||||
let precision: u8 = if n <= 1152 { 12 } else { 15 };
|
||||
for coefs in lpc_orders(x, effort.max_lpc_order) {
|
||||
let Some((q, shift)) = quantise(&coefs, precision) else { continue };
|
||||
let order = q.len();
|
||||
let Some(r) = lpc_residual(x, &q, shift) else { continue };
|
||||
let residual = code_residual(&folded(&r), n, order, effort.max_partition_order);
|
||||
let bits = head + order as u64 * (b + u64::from(precision)) + 4 + 5 + residual.bits;
|
||||
consider(Subframe::Lpc { coefs: q, precision, shift, residual }, bits);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folding() {
|
||||
assert_eq!([0, -1, 1, -2, 2].map(fold), [0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictable_signals_beat_verbatim() {
|
||||
// a ramp is exactly predicted by the order-2 fixed predictor
|
||||
let ramp: Vec<i32> = (0..4096).map(|i| i * 3 - 6000).collect();
|
||||
let (sub, bits) = choose(&ramp, 16, Effort::level(5));
|
||||
assert!(matches!(sub, Subframe::Fixed { order: 2, .. } | Subframe::Lpc { .. }), "{sub:?}");
|
||||
assert!(bits < 4096 * 2, "{bits}");
|
||||
// a sine is well predicted by LPC
|
||||
let sine: Vec<i32> = (0..4096).map(|i| ((i as f64 * 0.05).sin() * 20000.0) as i32).collect();
|
||||
let (_, bits) = choose(&sine, 16, Effort::level(5));
|
||||
assert!(bits < 4096 * 6, "{bits}");
|
||||
assert_eq!(choose(&[7; 100], 16, Effort::level(5)), (Subframe::Constant(7), 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lpc_residual_inverts() {
|
||||
let x: Vec<i32> = (0..500).map(|i| ((i as f64 * 0.1).sin() * 1000.0) as i32).collect();
|
||||
let coefs = lpc_orders(&x, 4).pop().unwrap();
|
||||
let (q, shift) = quantise(&coefs, 15).unwrap();
|
||||
let r = lpc_residual(&x, &q, shift).unwrap();
|
||||
// rebuild the signal from the warm-up and the residual, as a decoder would
|
||||
let mut y: Vec<i32> = x[..q.len()].to_vec();
|
||||
for (i, &res) in r.iter().enumerate() {
|
||||
let p: i64 = q.iter().zip(y[i..i + q.len()].iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum();
|
||||
y.push((res + (p >> shift)) as i32);
|
||||
}
|
||||
assert_eq!(y, x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_and_extremes_do_not_panic() {
|
||||
let mut seed = 1u32;
|
||||
let noise: Vec<i32> = (0..4096)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(seed >> 8) as i32 - (1 << 23)
|
||||
})
|
||||
.collect();
|
||||
let (_, bits) = choose(&noise, 24, Effort::level(8));
|
||||
assert!(bits <= 8 + 4096 * 24);
|
||||
let square: Vec<i32> = (0..4096).map(|i| if i % 2 == 0 { (1 << 23) - 1 } else { -(1 << 23) }).collect();
|
||||
choose(&square, 24, Effort::level(8));
|
||||
choose(&[], 16, Effort::level(5));
|
||||
choose(&[1], 16, Effort::level(5));
|
||||
choose(&[1, 2], 16, Effort::level(8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! ffmpeg as an external decoder oracle (skipped when absent): every stream must decode without an
|
||||
//! error to exactly the samples that went in, with the STREAMINFO MD5 matching ffmpeg's hash of the
|
||||
//! decoded audio.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use filmcraft_flac::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("flac oracle")
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
|
||||
/// a full-scale square, silence and a lone click.
|
||||
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
|
||||
let max = f64::from((1i32 << (bps - 1)) - 1);
|
||||
let mut r = Rng(seed | 1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48_000.0;
|
||||
let v = match kind {
|
||||
"music" => 0.5 * (2.0 * std::f64::consts::PI * 220.0 * t).sin() + 0.3 * (2.0 * std::f64::consts::PI * 1375.0 * t).sin() + 0.05 * r.next(),
|
||||
"sweep" => (2.0 * std::f64::consts::PI * (40.0 + 4000.0 * t) * t).sin() * 0.9,
|
||||
"noise" => r.next(),
|
||||
"square" => {
|
||||
if (i / 37) % 2 == 0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
"click" => f64::from(u8::from(i == n / 2)),
|
||||
_ => 0.0,
|
||||
};
|
||||
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode(planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut enc = Encoder::new(cfg)?;
|
||||
let mut file = enc.header();
|
||||
let n = planar.first().map_or(0, Vec::len);
|
||||
let mut at = 0;
|
||||
while at < n {
|
||||
let end = (at + chunk).min(n);
|
||||
let parts: Vec<&[i32]> = planar.iter().map(|c| &c[at..end]).collect();
|
||||
file.extend(enc.encode(&parts)?);
|
||||
at = end;
|
||||
}
|
||||
file.extend(enc.finish());
|
||||
let o = STREAMINFO_OFFSET as usize;
|
||||
file[o..o + 34].copy_from_slice(&enc.streaminfo());
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn run(ffmpeg: &Path, args: &[&str], input: &Path) -> (Vec<u8>, String) {
|
||||
let out = Command::new(ffmpeg).args(["-v", "error", "-i"]).arg(input).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
(out.stdout, String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
|
||||
/// Encode, decode with ffmpeg, compare every sample and the MD5; returns the file size.
|
||||
fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize) -> Option<usize> {
|
||||
let ffmpeg = ffmpeg()?;
|
||||
let bps = cfg.bits_per_sample;
|
||||
let rate = cfg.sample_rate;
|
||||
let file = encode(planar, cfg, chunk).unwrap();
|
||||
let path = filmcraft_testkit::fixtures_dir("flac").join(format!("{name}.flac"));
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
|
||||
// samples: s32 holds each one shifted to the top bits
|
||||
let (raw, err) = run(&ffmpeg, &["-f", "s32le", "-c:a", "pcm_s32le", "-"], &path);
|
||||
assert!(err.is_empty(), "{name}: decoder errors: {err}");
|
||||
let decoded: Vec<i32> = raw.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes(*b) >> (32 - bps)).collect();
|
||||
let n = planar[0].len();
|
||||
assert_eq!(decoded.len(), n * planar.len(), "{name}: sample count");
|
||||
for (i, frame) in decoded.chunks_exact(planar.len()).enumerate() {
|
||||
for (c, &v) in frame.iter().enumerate() {
|
||||
assert_eq!(v, planar[c][i], "{name}: channel {c} sample {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
|
||||
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
|
||||
// and 24 bit compare
|
||||
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
|
||||
let pcm = match bps {
|
||||
8 => Some("s8"),
|
||||
16 => Some("s16le"),
|
||||
24 => Some("s24le"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(pcm) = pcm {
|
||||
let (hash, _) = run(&ffmpeg, &["-c:a", &format!("pcm_{pcm}"), "-f", "hash", "-hash", "md5", "-"], &path);
|
||||
let hash = String::from_utf8_lossy(&hash);
|
||||
let ours: String = si[18..34].iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(hash.trim().strip_prefix("MD5=").unwrap_or(""), ours, "{name}: MD5");
|
||||
}
|
||||
let total = (u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes([si[14], si[15], si[16], si[17]]));
|
||||
assert_eq!(total, n as u64, "{name}: total samples");
|
||||
let si_rate = (u32::from(si[10]) << 12) | (u32::from(si[11]) << 4) | (u32::from(si[12]) >> 4);
|
||||
assert_eq!(si_rate, rate);
|
||||
Some(file.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_signal_round_trips_exactly() {
|
||||
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
|
||||
for bps in [16u8, 24] {
|
||||
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
|
||||
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_layouts_rates_and_sizes() {
|
||||
let cases: [(&str, u8, u8, u32, usize); 6] = [
|
||||
("mono-8bit", 1, 8, 8_000, 3_001),
|
||||
("mono-24-44k1", 1, 24, 44_100, 9_999),
|
||||
("stereo-20bit", 2, 20, 96_000, 10_000),
|
||||
("5.1-24", 6, 24, 48_000, 12_345),
|
||||
("odd-rate", 2, 16, 37_800, 5_000),
|
||||
("tiny", 2, 16, 48_000, 1),
|
||||
];
|
||||
for (name, ch, bps, rate, n) in cases {
|
||||
let planar: Vec<Vec<i32>> = (0..ch).map(|c| signal(if c == 3 { "silence" } else { "music" }, n, bps, u64::from(c) + 7)).collect();
|
||||
if round_trip(name, &planar, EncoderConfig::new(rate, ch, bps), 1000).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_and_block_sizes_trade_time_for_size() {
|
||||
let planar = vec![signal("music", 48_000, 16, 11), signal("sweep", 48_000, 16, 12)];
|
||||
let mut sizes = Vec::new();
|
||||
for level in 0..=8 {
|
||||
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 16) };
|
||||
let Some(size) = round_trip(&format!("level{level}"), &planar, cfg, 48_000) else { return };
|
||||
sizes.push(size);
|
||||
}
|
||||
// the effortful levels are never larger than the fastest, and all beat PCM
|
||||
assert!(sizes[8] <= sizes[0] && sizes[5] <= sizes[0], "{sizes:?}");
|
||||
assert!(sizes.iter().all(|&s| s < 48_000 * 4), "{sizes:?}");
|
||||
for bs in [16u16, 192, 1152, 4608, 65_535] {
|
||||
let cfg = EncoderConfig { block_size: bs, ..EncoderConfig::new(48_000, 2, 16) };
|
||||
round_trip(&format!("block{bs}"), &planar, cfg, 777);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_an_error_not_a_panic() {
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
|
||||
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
|
||||
assert_eq!(enc.encode(&[&[0, 1]]), Err(Error::Channels { expected: 2, got: 1 }));
|
||||
assert_eq!(enc.encode(&[&[0, 1], &[0]]), Err(Error::UnequalChannels));
|
||||
assert_eq!(enc.encode(&[&[40_000], &[0]]), Err(Error::SampleRange(40_000)));
|
||||
assert_eq!(enc.encode(&[&[-32_769], &[0]]), Err(Error::SampleRange(-32_769)));
|
||||
// nothing encoded: no frames, and a header that still says "unknown"
|
||||
assert!(enc.finish().is_empty());
|
||||
assert_eq!(&enc.header()[..4], b"fLaC");
|
||||
}
|
||||
@@ -1320,6 +1320,16 @@ impl SampleEntry {
|
||||
pub fn aac(asc: Vec<u8>, channels: u32, sample_rate: u32) -> Self {
|
||||
Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16)
|
||||
}
|
||||
/// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block.
|
||||
pub fn flac(streaminfo: &[u8; 34]) -> Self {
|
||||
let mut blocks = vec![0x80, 0, 0, 34];
|
||||
blocks.extend_from_slice(streaminfo);
|
||||
let mut full = vec![0; 4];
|
||||
full.extend_from_slice(&blocks);
|
||||
let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() });
|
||||
let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample));
|
||||
Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits)
|
||||
}
|
||||
/// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4).
|
||||
pub fn pcm(cfg: PcmConfig) -> Self {
|
||||
Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits)
|
||||
|
||||
@@ -541,6 +541,14 @@ impl<W: Write + Seek> Mp4Writer<W> {
|
||||
Ok(self.tracks.len() - 1)
|
||||
}
|
||||
|
||||
/// Replace a track's sample entry before the file is finished (the `moov` is written last), for
|
||||
/// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`.
|
||||
pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> {
|
||||
let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?;
|
||||
t.cfg.entry = entry;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start
|
||||
/// frame number) is written at finish, spanning the video track's duration.
|
||||
pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result<usize> {
|
||||
|
||||
@@ -2981,6 +2981,12 @@
|
||||
AIFF AIFF
|
||||
AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits
|
||||
AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits
|
||||
FLAC FLAC
|
||||
FLAC 48 kHz 24-bit FLAC 48 kHz 24 bits
|
||||
FLAC, stereo 48 kHz 24-bit, lossless FLAC, estéreo, 48 kHz y 24 bits, sin pérdida
|
||||
FLAC (lossless) FLAC (sin pérdida)
|
||||
Compression Level Nivel de compresión
|
||||
0 fastest, 8 smallest 0 más rápido, 8 más pequeño
|
||||
APV APV
|
||||
APV 422-10 APV 422-10
|
||||
APV 422-12 APV 422-12
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -2969,6 +2969,12 @@
|
||||
AIFF AIFF
|
||||
AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit
|
||||
AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM
|
||||
FLAC FLAC
|
||||
FLAC 48 kHz 24-bit FLAC 48 kHz 24-bit
|
||||
FLAC, stereo 48 kHz 24-bit, lossless FLAC、ステレオ 48 kHz 24-bit、ロスレス
|
||||
FLAC (lossless) FLAC (ロスレス)
|
||||
Compression Level 圧縮レベル
|
||||
0 fastest, 8 smallest 0 が最速、8 が最小
|
||||
APV APV
|
||||
APV 422-10 APV 422-10
|
||||
APV 422-12 APV 422-12
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -2963,6 +2963,12 @@
|
||||
AIFF AIFF
|
||||
AIFF 48 kHz 16-bit AIFF 48 kHz 16 位
|
||||
AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM
|
||||
FLAC FLAC
|
||||
FLAC 48 kHz 24-bit FLAC 48 kHz 24 位
|
||||
FLAC, stereo 48 kHz 24-bit, lossless FLAC,立体声 48 kHz 24 位,无损
|
||||
FLAC (lossless) FLAC(无损)
|
||||
Compression Level 压缩级别
|
||||
0 fastest, 8 smallest 0 最快,8 最小
|
||||
APV APV
|
||||
APV 422-10 APV 422-10
|
||||
APV 422-12 APV 422-12
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -786,16 +786,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
||||
}
|
||||
}
|
||||
row(ui, t, tl!("Audio Format"), |ui| {
|
||||
let fixed = s.format.is_mp4_with(s.multiplexer) || audio_only || s.format.is_mxf();
|
||||
let fixed = audio_only || s.format.is_mxf();
|
||||
let cur = match s.audio_codec() {
|
||||
AudioCodec::Aac => "AAC",
|
||||
AudioCodec::Flac => tl!("FLAC (lossless)"),
|
||||
_ => tl!("Uncompressed (PCM)"),
|
||||
};
|
||||
let labels = vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !s.format.is_mp4_with(s.multiplexer))];
|
||||
// MP4 takes AAC or FLAC; QuickTime any of the three
|
||||
let labels = vec![
|
||||
("AAC".to_string(), !audio_only),
|
||||
(tl!("Uncompressed (PCM)").to_string(), !s.format.is_mp4_with(s.multiplexer)),
|
||||
(tl!("FLAC (lossless)").to_string(), true),
|
||||
];
|
||||
if fixed {
|
||||
ui.label(cur);
|
||||
} else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) {
|
||||
s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm };
|
||||
s.audio.codec = [AudioCodec::Aac, AudioCodec::Pcm, AudioCodec::Flac][i.min(2)];
|
||||
}
|
||||
});
|
||||
row(ui, t, tl!("Sample Rate"), |ui| {
|
||||
@@ -834,6 +840,16 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
||||
}
|
||||
});
|
||||
}
|
||||
if s.audio_codec() == AudioCodec::Flac {
|
||||
// every level is lossless: higher levels take longer for smaller files
|
||||
row(ui, t, tl!("Compression Level"), |ui| {
|
||||
let mut level = f64::from(s.audio.flac_level.min(8));
|
||||
if drag(ui, reg, "export.audio.flacLevel", &mut level, 0.0..=8.0, 0.05, "", 0) {
|
||||
s.audio.flac_level = level.round() as u8;
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("0 fastest, 8 smallest")).size(12.0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement) {
|
||||
|
||||
@@ -53,6 +53,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
("apv", 0),
|
||||
("av1", 0),
|
||||
("aac", 0),
|
||||
("flac", 0),
|
||||
("opus", 0),
|
||||
("frame", 1),
|
||||
("media", 1),
|
||||
|
||||
Reference in new issue
Block a user