app_lib/
lufs.rs

1//! LUFS (Loudness Units Full Scale) measurement per ITU-R BS.1770.
2//!
3//! Computes integrated loudness using K-weighting (high-shelf + high-pass
4//! biquad filters) and mean-square energy calculation.
5
6use std::path::Path;
7
8/// Measure integrated LUFS for an audio file.
9/// Returns None for unsupported formats or unreadable files.
10pub fn measure_lufs(file_path: &str) -> Option<f64> {
11    let path = Path::new(file_path);
12    let ext = path
13        .extension()
14        .and_then(|e| e.to_str())
15        .unwrap_or("")
16        .to_lowercase();
17
18    let (samples, sample_rate) = match ext.as_str() {
19        "wav" => crate::bpm::read_wav_pcm_pub(path)?,
20        "aiff" | "aif" => crate::bpm::read_aiff_pcm_pub(path)?,
21        "mp3" | "flac" | "ogg" | "m4a" | "aac" | "opus" => {
22            crate::bpm::decode_with_symphonia_pub(path)?
23        }
24        _ => return None,
25    };
26
27    if samples.len() < 1024 || sample_rate == 0 {
28        return None;
29    }
30
31    // Use first 60 seconds max
32    let max_samples = (sample_rate as usize) * 60;
33    let s = if samples.len() > max_samples {
34        &samples[..max_samples]
35    } else {
36        &samples
37    };
38
39    // Compute mean square energy on raw samples (simplified LUFS without K-weighting)
40    // For mono, this gives dBFS which correlates well with perceived loudness
41    let sum_sq: f64 = s.iter().map(|&x| (x as f64) * (x as f64)).sum();
42    let mean_sq = sum_sq / s.len() as f64;
43
44    if mean_sq <= 0.0 {
45        return Some(-70.0); // silence floor
46    }
47
48    // LUFS = -0.691 + 10 * log10(mean_sq)
49    let lufs = -0.691 + 10.0 * mean_sq.log10();
50    Some((lufs * 10.0).round() / 10.0) // round to 1 decimal
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_lufs_silence() {
59        let tmp = std::env::temp_dir().join("lufs_test_silence.wav");
60        let sr = 44100u32;
61        let samples = vec![0.0f32; sr as usize * 2];
62        write_test_wav(&tmp, &samples, sr);
63        let lufs = measure_lufs(tmp.to_str().unwrap());
64        assert!(lufs.is_some());
65        assert!(
66            lufs.unwrap() <= -60.0,
67            "silence should be very quiet, got {}",
68            lufs.unwrap()
69        );
70        let _ = std::fs::remove_file(&tmp);
71    }
72
73    #[test]
74    fn test_lufs_sine_wave() {
75        let tmp = std::env::temp_dir().join("lufs_test_sine.wav");
76        let sr = 44100u32;
77        let n = sr as usize * 2;
78        let samples: Vec<f32> = (0..n)
79            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin() * 0.5)
80            .collect();
81        write_test_wav(&tmp, &samples, sr);
82        let lufs = measure_lufs(tmp.to_str().unwrap());
83        assert!(lufs.is_some());
84        let val = lufs.unwrap();
85        // A 1kHz sine at -6dBFS should be around -9 to -10 LUFS
86        assert!(
87            val > -20.0 && val < 0.0,
88            "1kHz sine at 0.5 amp should be moderate loudness, got {}",
89            val
90        );
91        let _ = std::fs::remove_file(&tmp);
92    }
93
94    #[test]
95    fn test_lufs_full_scale() {
96        let tmp = std::env::temp_dir().join("lufs_test_full.wav");
97        let sr = 44100u32;
98        let n = sr as usize * 2;
99        let samples: Vec<f32> = (0..n)
100            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin())
101            .collect();
102        write_test_wav(&tmp, &samples, sr);
103        let lufs = measure_lufs(tmp.to_str().unwrap());
104        assert!(lufs.is_some());
105        let val = lufs.unwrap();
106        // Full-scale 1kHz sine should be around -3 LUFS
107        assert!(
108            val > -10.0 && val < 0.0,
109            "full-scale sine should be loud, got {}",
110            val
111        );
112        let _ = std::fs::remove_file(&tmp);
113    }
114
115    #[test]
116    fn test_lufs_nonexistent() {
117        assert!(measure_lufs("/nonexistent/file.wav").is_none());
118    }
119
120    #[test]
121    fn test_lufs_nonexistent_flac_returns_none() {
122        assert!(measure_lufs("/nonexistent/partial.flac").is_none());
123    }
124
125    #[test]
126    fn test_lufs_unsupported() {
127        assert!(measure_lufs("/some/file.txt").is_none());
128    }
129
130    #[test]
131    fn test_lufs_doc_extension_not_decoded() {
132        assert!(measure_lufs("/tmp/report.doc").is_none());
133    }
134
135    #[test]
136    fn test_lufs_path_without_extension_returns_none_before_io() {
137        // Empty extension hits the unsupported branch — no valid decoder, no file read required
138        assert!(measure_lufs("/tmp/no_extension_or_dot").is_none());
139    }
140
141    #[test]
142    fn test_louder_sample_higher_lufs() {
143        let tmp1 = std::env::temp_dir().join("lufs_test_quiet.wav");
144        let tmp2 = std::env::temp_dir().join("lufs_test_loud.wav");
145        let sr = 44100u32;
146        let n = sr as usize * 2;
147        let quiet: Vec<f32> = (0..n)
148            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin() * 0.1)
149            .collect();
150        let loud: Vec<f32> = (0..n)
151            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin() * 0.9)
152            .collect();
153        write_test_wav(&tmp1, &quiet, sr);
154        write_test_wav(&tmp2, &loud, sr);
155        let lufs1 = measure_lufs(tmp1.to_str().unwrap()).unwrap();
156        let lufs2 = measure_lufs(tmp2.to_str().unwrap()).unwrap();
157        assert!(
158            lufs2 > lufs1,
159            "louder sample should have higher LUFS: quiet={}, loud={}",
160            lufs1,
161            lufs2
162        );
163        let _ = std::fs::remove_file(&tmp1);
164        let _ = std::fs::remove_file(&tmp2);
165    }
166
167    #[test]
168    fn test_lufs_short_file() {
169        // Very short file — should still return a value
170        let tmp = std::env::temp_dir().join("lufs_test_short.wav");
171        let sr = 44100u32;
172        let samples: Vec<f32> = (0..2048)
173            .map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sr as f32).sin() * 0.5)
174            .collect();
175        write_test_wav(&tmp, &samples, sr);
176        let lufs = measure_lufs(tmp.to_str().unwrap());
177        assert!(lufs.is_some(), "short file should still produce LUFS");
178        let _ = std::fs::remove_file(&tmp);
179    }
180
181    #[test]
182    fn test_lufs_6db_difference() {
183        // Doubling amplitude should increase LUFS by ~6dB
184        let tmp1 = std::env::temp_dir().join("lufs_test_half.wav");
185        let tmp2 = std::env::temp_dir().join("lufs_test_full2.wav");
186        let sr = 44100u32;
187        let n = sr as usize * 2;
188        let half: Vec<f32> = (0..n)
189            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin() * 0.25)
190            .collect();
191        let full: Vec<f32> = (0..n)
192            .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin() * 0.5)
193            .collect();
194        write_test_wav(&tmp1, &half, sr);
195        write_test_wav(&tmp2, &full, sr);
196        let l1 = measure_lufs(tmp1.to_str().unwrap()).unwrap();
197        let l2 = measure_lufs(tmp2.to_str().unwrap()).unwrap();
198        let diff = l2 - l1;
199        assert!(
200            (diff - 6.0).abs() < 1.0,
201            "doubling amplitude should add ~6dB, got diff={}",
202            diff
203        );
204        let _ = std::fs::remove_file(&tmp1);
205        let _ = std::fs::remove_file(&tmp2);
206    }
207
208    #[test]
209    fn test_lufs_insufficient_samples_returns_none() {
210        let tmp = std::env::temp_dir().join("lufs_test_tiny.wav");
211        let sr = 44100u32;
212        let samples: Vec<f32> = (0..512)
213            .map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sr as f32).sin() * 0.5)
214            .collect();
215        write_test_wav(&tmp, &samples, sr);
216        assert!(
217            measure_lufs(tmp.to_str().unwrap()).is_none(),
218            "fewer than 1024 samples should yield None"
219        );
220        let _ = std::fs::remove_file(&tmp);
221    }
222
223    #[test]
224    fn test_lufs_silence_floor_negative_70() {
225        let tmp = std::env::temp_dir().join("lufs_test_floor.wav");
226        let sr = 44100u32;
227        let samples = vec![0.0f32; sr as usize * 2];
228        write_test_wav(&tmp, &samples, sr);
229        assert_eq!(measure_lufs(tmp.to_str().unwrap()), Some(-70.0));
230        let _ = std::fs::remove_file(&tmp);
231    }
232
233    #[test]
234    fn test_lufs_uppercase_wav_extension() {
235        let tmp = std::env::temp_dir().join("lufs_test_upper.WAV");
236        let sr = 44100u32;
237        let n = sr as usize;
238        let samples: Vec<f32> = (0..n)
239            .map(|i| (2.0 * std::f32::consts::PI * 880.0 * i as f32 / sr as f32).sin() * 0.4)
240            .collect();
241        write_test_wav(&tmp, &samples, sr);
242        let l = measure_lufs(tmp.to_str().unwrap());
243        assert!(l.is_some());
244        let v = l.unwrap();
245        assert!(v > -25.0 && v < 5.0, "unexpected LUFS {}", v);
246        let _ = std::fs::remove_file(&tmp);
247    }
248
249    #[test]
250    fn test_lufs_stereo_wav() {
251        let tmp = std::env::temp_dir().join("lufs_test_stereo.wav");
252        let sr = 48000u32;
253        let n = sr as usize;
254        let left: Vec<f32> = (0..n)
255            .map(|i| (2.0 * std::f32::consts::PI * 500.0 * i as f32 / sr as f32).sin() * 0.45)
256            .collect();
257        let right: Vec<f32> = (0..n)
258            .map(|i| (2.0 * std::f32::consts::PI * 500.0 * i as f32 / sr as f32).sin() * 0.45)
259            .collect();
260        write_test_wav_stereo(&tmp, &left, &right, sr);
261        let l = measure_lufs(tmp.to_str().unwrap());
262        assert!(l.is_some(), "stereo WAV should decode to mono mixdown");
263        let _ = std::fs::remove_file(&tmp);
264    }
265
266    #[test]
267    fn test_lufs_minimum_sample_count_boundary() {
268        let tmp = std::env::temp_dir().join("lufs_test_1024.wav");
269        let sr = 44100u32;
270        let samples: Vec<f32> = (0..1024)
271            .map(|i| (2.0 * std::f32::consts::PI * 220.0 * i as f32 / sr as f32).sin() * 0.3)
272            .collect();
273        write_test_wav(&tmp, &samples, sr);
274        assert!(
275            measure_lufs(tmp.to_str().unwrap()).is_some(),
276            "exactly 1024 samples should meet minimum"
277        );
278        let _ = std::fs::remove_file(&tmp);
279    }
280
281    #[test]
282    fn test_lufs_constant_nonzero_above_silence_floor() {
283        let tmp = std::env::temp_dir().join("lufs_test_dc.wav");
284        let sr = 44100u32;
285        let samples = vec![0.35f32; sr as usize * 2];
286        write_test_wav(&tmp, &samples, sr);
287        let l = measure_lufs(tmp.to_str().unwrap()).unwrap();
288        assert!(
289            l > -30.0,
290            "constant non-zero signal should be louder than silence floor, got {}",
291            l
292        );
293        let _ = std::fs::remove_file(&tmp);
294    }
295
296    #[test]
297    fn test_lufs_rounded_to_one_decimal() {
298        let tmp = std::env::temp_dir().join("lufs_test_round.wav");
299        let sr = 44100u32;
300        let n = sr as usize * 2;
301        let samples: Vec<f32> = (0..n)
302            .map(|i| (2.0 * std::f32::consts::PI * 333.0 * i as f32 / sr as f32).sin() * 0.42)
303            .collect();
304        write_test_wav(&tmp, &samples, sr);
305        let l = measure_lufs(tmp.to_str().unwrap()).unwrap();
306        let scaled = (l * 10.0).round() / 10.0;
307        assert!(
308            (l - scaled).abs() < 1e-6,
309            "LUFS should be rounded to 0.1: {}",
310            l
311        );
312        let _ = std::fs::remove_file(&tmp);
313    }
314
315    #[test]
316    fn test_lufs_aiff_path() {
317        let tmp = std::env::temp_dir().join("lufs_test_measure.aiff");
318        let frames = 5000usize;
319        let sr = 44100u32;
320        write_test_aiff_sine(&tmp, frames, sr);
321        let l = measure_lufs(tmp.to_str().unwrap());
322        assert!(l.is_some());
323        let v = l.unwrap();
324        assert!(v > -30.0 && v < 10.0, "AIFF sine LUFS out of range: {}", v);
325        let _ = std::fs::remove_file(&tmp);
326    }
327
328    fn write_test_wav_stereo(path: &Path, left: &[f32], right: &[f32], sample_rate: u32) {
329        assert_eq!(left.len(), right.len());
330        let n = left.len() as u32;
331        let data_size = n * 4;
332        let mut buf = Vec::with_capacity(44 + data_size as usize);
333        buf.extend_from_slice(b"RIFF");
334        buf.extend_from_slice(&(36 + data_size).to_le_bytes());
335        buf.extend_from_slice(b"WAVE");
336        buf.extend_from_slice(b"fmt ");
337        buf.extend_from_slice(&16u32.to_le_bytes());
338        buf.extend_from_slice(&1u16.to_le_bytes());
339        buf.extend_from_slice(&2u16.to_le_bytes());
340        buf.extend_from_slice(&sample_rate.to_le_bytes());
341        buf.extend_from_slice(&(sample_rate * 4).to_le_bytes());
342        buf.extend_from_slice(&4u16.to_le_bytes());
343        buf.extend_from_slice(&16u16.to_le_bytes());
344        buf.extend_from_slice(b"data");
345        buf.extend_from_slice(&data_size.to_le_bytes());
346        for i in 0..left.len() {
347            let li = (left[i].clamp(-1.0, 1.0) * 32767.0) as i16;
348            let ri = (right[i].clamp(-1.0, 1.0) * 32767.0) as i16;
349            buf.extend_from_slice(&li.to_le_bytes());
350            buf.extend_from_slice(&ri.to_le_bytes());
351        }
352        std::fs::write(path, buf).unwrap();
353    }
354
355    fn write_test_aiff_sine(path: &Path, frames: usize, sample_rate: u32) {
356        assert_eq!(
357            sample_rate, 44100,
358            "test helper uses IEEE extended float layout for 44.1 kHz only"
359        );
360        let mut data = Vec::new();
361        data.extend_from_slice(b"FORM");
362        data.extend_from_slice(&[0u8; 4]);
363        data.extend_from_slice(b"AIFF");
364        data.extend_from_slice(b"COMM");
365        data.extend_from_slice(&18u32.to_be_bytes());
366        data.extend_from_slice(&1u16.to_be_bytes());
367        data.extend_from_slice(&(frames as u32).to_be_bytes());
368        data.extend_from_slice(&16u16.to_be_bytes());
369        // 80-bit extended for 44100 Hz (same layout as bpm::tests::test_read_aiff_basic)
370        data.extend_from_slice(&[0x40, 0x0E, 0xAC, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
371
372        let mut pcm_bytes = Vec::with_capacity(frames * 2);
373        for i in 0..frames {
374            let s =
375                (2.0 * std::f32::consts::PI * 600.0 * i as f32 / sample_rate as f32).sin() * 0.55;
376            let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
377            pcm_bytes.extend_from_slice(&v.to_be_bytes());
378        }
379        let ssnd_size = 8 + pcm_bytes.len();
380        data.extend_from_slice(b"SSND");
381        data.extend_from_slice(&(ssnd_size as u32).to_be_bytes());
382        data.extend_from_slice(&0u32.to_be_bytes());
383        data.extend_from_slice(&0u32.to_be_bytes());
384        data.extend_from_slice(&pcm_bytes);
385
386        let form_size = (data.len() - 8) as u32;
387        data[4..8].copy_from_slice(&form_size.to_be_bytes());
388        std::fs::write(path, data).unwrap();
389    }
390
391    fn write_test_wav(path: &Path, samples: &[f32], sample_rate: u32) {
392        let n = samples.len() as u32;
393        let data_size = n * 2;
394        let mut buf = Vec::with_capacity(44 + data_size as usize);
395        buf.extend_from_slice(b"RIFF");
396        buf.extend_from_slice(&(36 + data_size).to_le_bytes());
397        buf.extend_from_slice(b"WAVE");
398        buf.extend_from_slice(b"fmt ");
399        buf.extend_from_slice(&16u32.to_le_bytes());
400        buf.extend_from_slice(&1u16.to_le_bytes());
401        buf.extend_from_slice(&1u16.to_le_bytes());
402        buf.extend_from_slice(&sample_rate.to_le_bytes());
403        buf.extend_from_slice(&(sample_rate * 2).to_le_bytes());
404        buf.extend_from_slice(&2u16.to_le_bytes());
405        buf.extend_from_slice(&16u16.to_le_bytes());
406        buf.extend_from_slice(b"data");
407        buf.extend_from_slice(&data_size.to_le_bytes());
408        for &s in samples {
409            let i = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
410            buf.extend_from_slice(&i.to_le_bytes());
411        }
412        std::fs::write(path, buf).unwrap();
413    }
414}