1use std::path::Path;
7
8pub 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 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 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); }
47
48 let lufs = -0.691 + 10.0 * mean_sq.log10();
50 Some((lufs * 10.0).round() / 10.0) }
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 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 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 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 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 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 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}