diff --git a/Cargo.lock b/Cargo.lock index 934ac3085..3ae707320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b9dd38079..a0daa93e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 025b7964f..0651fe490 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -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()))), } diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 3aadb1809..b6e2a7269 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -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"]); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 23427d0b8..bbc3c6049 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1363,7 +1363,7 @@ fn build() -> Vec { "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 ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 060681aeb..3e74d3759 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -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", diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 4732fd8cf..044c6329e 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -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"))?; } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index d058e4e60..3732c7fb3 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -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 } diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 1154f99ff..5c26ea529 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -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() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index d6a26863f..17858c27d 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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), +} + /// An export's output file: on disk, or in memory for an [`OutputSink`]. enum Out { File(std::io::BufWriter), @@ -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, 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 { + None + } } pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option>>; @@ -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 { + 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]) -> Result>> { + let ints: Vec> = 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>> { + Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect()) + } + fn flush_timed(&mut self) -> Result, 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 { + Some(SampleEntry::flac(&self.enc.streaminfo())) + } +} + fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option>> { // 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, 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, 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> = 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); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 03b88a74d..7601886f0 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -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 } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 6e6b9ddd8..58f60d6ef 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -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 { "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)), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 1a427302d..70831e9ca 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -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 { diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 25b712308..1def9d1e9 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -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| 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"); diff --git a/crates/flac/Cargo.toml b/crates/flac/Cargo.toml new file mode 100644 index 000000000..8d6d05095 --- /dev/null +++ b/crates/flac/Cargo.toml @@ -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 } diff --git a/crates/flac/README.md b/crates/flac/README.md new file mode 100644 index 000000000..3909ab793 --- /dev/null +++ b/crates/flac/README.md @@ -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> = 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. diff --git a/crates/flac/src/crc.rs b/crates/flac/src/crc.rs new file mode 100644 index 000000000..0cc4869b5 --- /dev/null +++ b/crates/flac/src/crc.rs @@ -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); + } +} diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs new file mode 100644 index 000000000..6ccd8a50f --- /dev/null +++ b/crates/flac/src/frame.rs @@ -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) { + 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, + /// 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 { + 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]); + } +} diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs new file mode 100644 index 000000000..5e278365b --- /dev/null +++ b/crates/flac/src/lib.rs @@ -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 = (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>, + frame_number: u64, + total_samples: u64, + min_frame: u32, + max_frame: u32, + md5: md5::Md5, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + 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 { + 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, 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>, 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> = 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 { + if self.pending.first().is_none_or(Vec::is_empty) { + return Vec::new(); + } + let block: Vec> = self.pending.iter_mut().map(std::mem::take).collect(); + self.frame(&block) + } + + fn frame(&mut self, block: &[Vec]) -> Vec { + 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 = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); + let mid: Vec = 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 + } +} diff --git a/crates/flac/src/md5.rs b/crates/flac/src/md5.rs new file mode 100644 index 000000000..ca99c9e32 --- /dev/null +++ b/crates/flac/src/md5.rs @@ -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"); + } +} diff --git a/crates/flac/src/predict.rs b/crates/flac/src/predict.rs new file mode 100644 index 000000000..f5c681dee --- /dev/null +++ b/crates/flac/src/predict.rs @@ -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, + 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, 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 = 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> { + let x: Vec = 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> { + 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 { + 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> { + let n = x.len(); + if n <= max { + return Vec::new(); + } + let w = window(n); + let s: Vec = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect(); + let r: Vec = (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 = 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, 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::>(); + 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 = (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 = (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 = (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 = 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 = (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 = (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)); + } +} diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs new file mode 100644 index 000000000..80a0c7538 --- /dev/null +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -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 { + 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 { + 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], cfg: EncoderConfig, chunk: usize) -> Result, 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, 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], cfg: EncoderConfig, chunk: usize) -> Option { + 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 = 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> = (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"); +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index 7050f74c8..1fbcff9ce 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1320,6 +1320,16 @@ impl SampleEntry { pub fn aac(asc: Vec, 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) diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index 0ea814779..880d49a2c 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -541,6 +541,14 @@ impl Mp4Writer { 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 { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9ecd4a5ec..90f7005bf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ff8afbc2f..8647bb7a6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 20cd07683..32f716c5f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -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 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 0bf753885..ce800f141 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -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) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9bb1853c7..925c0b579 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -53,6 +53,7 @@ const LAYERS: &[(&str, u8)] = &[ ("apv", 0), ("av1", 0), ("aac", 0), + ("flac", 0), ("opus", 0), ("frame", 1), ("media", 1),