Skip to main content

limnifs_write/file_categorizer/
pcm_audio.rs

1//! PCM audio categorizer — routes WAV/AIFF files to FLAC.
2//!
3//! **Status:** DETECTION READY, ROUTING DISABLED.
4//!
5//! Detection parses the WAV/AIFF header and extracts PCM sample
6//! format (sample rate, channels, bits per sample, endianness).
7//! Routing is currently disabled because `omnizip-flac` has not
8//! shipped a real FLAC encoder yet. When it does, flip
9//! `FLAC_ENABLED` to `true` and the categorizer will start
10//! claiming WAV/AIFF files for the FLAC codec (id 0x07).
11//!
12//! See `docs/omnizip-vs-limnifs-boundary.md` for the codec id
13//! allocation plan and `docs/dwarfs-multicodec-investigation.md`
14//! for the rationale (FLAC saves 83% on PCM audio vs ~30% for
15//! general codecs).
16
17use std::path::Path;
18
19use super::{Categorization, FileCategorizer};
20use limnifs_core::codec::CODEC_FLAC;
21
22/// FLAC routing enabled — omnizip-flac 0.10 ships full LPC encoder
23/// (CONSTANT/VERBATIM/FIXED/LPC + Rice residuals). The encoder picks
24/// the cheapest subframe type per block via bit-cost estimation.
25const FLAC_ENABLED: bool = true;
26
27/// Minimum size for a sensible WAV/AIFF file. Smaller than this and
28/// there's no point routing to FLAC — the overhead exceeds the gain.
29const MIN_PCM_AUDIO_SIZE: usize = 64;
30
31/// PCM parameters extracted from the file header. Serialized into
32/// `Categorization::codec_params` for the FLAC codec to consume.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct PcmParams {
35    pub sample_rate: u32,
36    pub channels: u8,
37    pub bits_per_sample: u8,
38    pub endianness: Endianness,
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum Endianness {
43    Little,
44    Big,
45}
46
47impl PcmParams {
48    /// Encode as a compact 6-byte prefix the FLAC codec can decode.
49    /// Wire format owned by the codec layer, not the framework.
50    #[must_use]
51    pub fn encode(&self) -> [u8; 6] {
52        let mut out = [0u8; 6];
53        out[0..4].copy_from_slice(&self.sample_rate.to_le_bytes());
54        out[4] = self.channels;
55        out[5] = (self.bits_per_sample << 1)
56            | match self.endianness {
57                Endianness::Little => 0,
58                Endianness::Big => 1,
59            };
60        out
61    }
62}
63
64/// Categorizer for PCM-audio files (WAV, AIFF).
65pub struct PcmAudioCategorizer;
66
67impl FileCategorizer for PcmAudioCategorizer {
68    fn name(&self) -> &'static str {
69        "pcm-audio"
70    }
71
72    fn categories(&self) -> &'static [&'static str] {
73        &["pcmaudio/waveform"]
74    }
75
76    fn first_byte_hint(&self) -> Option<&'static [u8]> {
77        Some(b"R")
78    }
79
80    fn categorize(&self, _path: &Path, data: &[u8]) -> Option<Categorization> {
81        if !FLAC_ENABLED {
82            return None;
83        }
84        if data.len() < MIN_PCM_AUDIO_SIZE {
85            return None;
86        }
87        // Use omnizip-flac's parsers — they're maintained alongside
88        // the codec and handle WAV/AIFF edge cases the same way the
89        // encoder does. Local parser kept as a fallback if the dep
90        // isn't desired; uncomment to use it instead.
91        let params = omnizip_flac::pcm_header::parse_wav(data)
92            .or_else(|| omnizip_flac::pcm_header::parse_aiff(data))?;
93        Some(Categorization {
94            codec_id: CODEC_FLAC,
95            codec_params: encode_pcm_params(params).to_vec(),
96            category: "pcmaudio/waveform",
97        })
98    }
99}
100
101/// Encode omnizip-flac's `PcmParams` into the compact 6-byte prefix
102/// the `LimniFS` drop record expects.
103fn encode_pcm_params(p: omnizip_flac::PcmParams) -> [u8; 6] {
104    let mut out = [0u8; 6];
105    out[0..4].copy_from_slice(&p.sample_rate.to_le_bytes());
106    out[4] = p.channels;
107    out[5] = (p.bits_per_sample << 1)
108        | match p.endianness {
109            omnizip_flac::Endianness::LittleEndian => 0,
110            omnizip_flac::Endianness::BigEndian => 1,
111        };
112    out
113}
114
115/// Parse a WAV (RIFF/WAVE) header. Returns the PCM parameters if
116/// the file is a vanilla PCM WAV (format tag 1 = `WAVE_FORMAT_PCM`).
117#[must_use]
118#[allow(dead_code)]
119fn parse_wav(data: &[u8]) -> Option<PcmParams> {
120    // RIFF header: "RIFF" + u32 LE size + "WAVE"
121    if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
122        return None;
123    }
124    // Walk chunks looking for fmt.
125    let mut off = 12;
126    while off + 8 <= data.len() {
127        let chunk_id = &data[off..off + 4];
128        let chunk_size =
129            u32::from_le_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]])
130                as usize;
131        let body_off = off + 8;
132        if body_off + chunk_size > data.len() {
133            return None;
134        }
135        if chunk_id == b"fmt " {
136            // PCM fmt chunk: tag(2) + channels(2) + sample_rate(4)
137            // + byte_rate(4) + block_align(2) + bits_per_sample(2)
138            if chunk_size < 16 {
139                return None;
140            }
141            let body = &data[body_off..body_off + 16];
142            let tag = u16::from_le_bytes([body[0], body[1]]);
143            if tag != 1 {
144                return None; // not PCM
145            }
146            let channels = u16::from_le_bytes([body[2], body[3]]);
147            let sample_rate = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
148            let bits_per_sample = u16::from_le_bytes([body[14], body[15]]);
149            return Some(PcmParams {
150                sample_rate,
151                channels: u8::try_from(channels).ok()?,
152                bits_per_sample: u8::try_from(bits_per_sample).ok()?,
153                endianness: Endianness::Little,
154            });
155        }
156        off = body_off + chunk_size + (chunk_size & 1); // chunks are word-aligned
157    }
158    None
159}
160
161/// Parse an AIFF (FORM/AIFF) header. Returns the PCM parameters.
162#[must_use]
163#[allow(dead_code)]
164fn parse_aiff(data: &[u8]) -> Option<PcmParams> {
165    // AIFF header: "FORM" + u32 BE size + "AIFF"
166    if data.len() < 12 || &data[0..4] != b"FORM" || &data[8..12] != b"AIFF" {
167        return None;
168    }
169    let mut off = 12;
170    while off + 8 <= data.len() {
171        let chunk_id = &data[off..off + 4];
172        let chunk_size =
173            u32::from_be_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]])
174                as usize;
175        let body_off = off + 8;
176        if body_off + chunk_size > data.len() {
177            return None;
178        }
179        if chunk_id == b"COMM" {
180            // COMM chunk: channels(2 BE) + numFrames(4 BE)
181            // + sampleSize(2 BE) + sampleRate(80-bit IEEE 754 ext)
182            if chunk_size < 18 {
183                return None;
184            }
185            let body = &data[body_off..body_off + 18];
186            let channels = u16::from_be_bytes([body[0], body[1]]);
187            let bits_per_sample = u16::from_be_bytes([body[6], body[7]]);
188            // sampleRate is 80-bit extended float; we only need
189            // the integer value, which fits in the high bytes for
190            // common rates (8000, 11025, 16000, 22050, 32000,
191            // 44100, 48000, 96000). A real IEEE 754 extended
192            // decoder would go here; for now we return a sentinel
193            // sample_rate of 0 and let the codec derive it from
194            // the data stream. This is good enough to TEST
195            // categorization routing.
196            return Some(PcmParams {
197                sample_rate: 0,
198                channels: u8::try_from(channels).ok()?,
199                bits_per_sample: u8::try_from(bits_per_sample).ok()?,
200                endianness: Endianness::Big,
201            });
202        }
203        off = body_off + chunk_size + (chunk_size & 1);
204    }
205    None
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn disabled_by_default() {
214        let c = PcmAudioCategorizer;
215        // Even with valid WAV magic, returns None because FLAC_ENABLED = false.
216        let wav = make_minimal_wav(44100, 2, 16);
217        assert!(c.categorize(Path::new("/x.wav"), &wav).is_none());
218    }
219
220    #[test]
221    fn wav_header_parsed_correctly() {
222        let wav = make_minimal_wav(48000, 1, 24);
223        let params = parse_wav(&wav).expect("wav parses");
224        assert_eq!(params.sample_rate, 48000);
225        assert_eq!(params.channels, 1);
226        assert_eq!(params.bits_per_sample, 24);
227        assert_eq!(params.endianness, Endianness::Little);
228    }
229
230    #[test]
231    fn rejects_non_pcm_wav() {
232        // WAVE_FORMAT_ADPCM (tag = 2) — not PCM.
233        let mut wav = make_minimal_wav(44100, 2, 16);
234        // Patch the format tag at offset 20 (RIFF[12] + fmt chunk header[8]).
235        wav[20] = 0x02;
236        wav[21] = 0x00;
237        assert!(parse_wav(&wav).is_none());
238    }
239
240    #[test]
241    fn rejects_non_wav_magic() {
242        assert!(parse_wav(b"NOTRIFF____WAVE____").is_none());
243        assert!(parse_wav(b"RIFF\x00\x00\x00\x00NOPE____").is_none());
244    }
245
246    /// Build a minimal valid PCM WAV header for tests.
247    fn make_minimal_wav(sample_rate: u32, channels: u8, bits: u8) -> Vec<u8> {
248        let mut wav = Vec::new();
249        wav.extend_from_slice(b"RIFF");
250        wav.extend_from_slice(&0u32.to_le_bytes()); // size, patched below
251        wav.extend_from_slice(b"WAVE");
252        wav.extend_from_slice(b"fmt ");
253        wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size
254        wav.extend_from_slice(&1u16.to_le_bytes()); // tag = PCM
255        wav.extend_from_slice(&[channels, 0]);
256        wav.extend_from_slice(&sample_rate.to_le_bytes());
257        wav.extend_from_slice(
258            &(sample_rate * u32::from(channels) * u32::from(bits) / 8).to_le_bytes(),
259        );
260        wav.extend_from_slice(&[(channels * bits / 8), 0]); // block align
261        wav.extend_from_slice(&[bits, 0]);
262        // data chunk (empty)
263        wav.extend_from_slice(b"data");
264        wav.extend_from_slice(&0u32.to_le_bytes());
265        // Patch RIFF size
266        let total = u32::try_from(wav.len()).unwrap_or(0) - 8;
267        wav[4..8].copy_from_slice(&total.to_le_bytes());
268        wav
269    }
270}