1use crate::{DedupError, DedupResult};
20
21const DCT_SIZE: usize = 32;
27const HASH_BLOCK: usize = 8;
29const HASH_BITS: u32 = (HASH_BLOCK * HASH_BLOCK) as u32;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct PHash(u64);
35
36impl PHash {
37 #[must_use]
39 pub const fn from_bits(bits: u64) -> Self {
40 Self(bits)
41 }
42
43 #[must_use]
45 pub const fn bits(self) -> u64 {
46 self.0
47 }
48
49 #[must_use]
51 pub fn hamming_distance(self, other: Self) -> u32 {
52 (self.0 ^ other.0).count_ones()
53 }
54
55 #[must_use]
59 pub fn similarity(self, other: Self) -> f64 {
60 1.0 - (f64::from(self.hamming_distance(other)) / f64::from(HASH_BITS))
61 }
62
63 #[must_use]
67 pub fn is_near_duplicate(self, other: Self) -> bool {
68 self.hamming_distance(other) <= 10
69 }
70
71 #[must_use]
73 pub fn within_distance(self, other: Self, max_distance: u32) -> bool {
74 self.hamming_distance(other) <= max_distance
75 }
76
77 #[must_use]
79 pub fn to_hex(self) -> String {
80 format!("{:016x}", self.0)
81 }
82
83 pub fn from_hex(s: &str) -> DedupResult<Self> {
89 u64::from_str_radix(s, 16)
90 .map(Self)
91 .map_err(|e| DedupError::Hash(format!("Invalid pHash hex '{s}': {e}")))
92 }
93}
94
95impl std::fmt::Display for PHash {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 write!(f, "{}", self.to_hex())
98 }
99}
100
101#[derive(Debug, Clone)]
109pub struct GrayFrame {
110 pub width: usize,
112 pub height: usize,
114 pub data: Vec<u8>,
116}
117
118impl GrayFrame {
119 pub fn new(width: usize, height: usize, data: Vec<u8>) -> DedupResult<Self> {
125 if data.len() != width * height {
126 return Err(DedupError::Visual(format!(
127 "GrayFrame: expected {} pixels, got {}",
128 width * height,
129 data.len()
130 )));
131 }
132 Ok(Self {
133 width,
134 height,
135 data,
136 })
137 }
138
139 pub fn from_rgb(width: usize, height: usize, rgb: &[u8], channels: usize) -> DedupResult<Self> {
147 let expected = width * height * channels;
148 if rgb.len() != expected {
149 return Err(DedupError::Visual(format!(
150 "from_rgb: expected {expected} bytes, got {}",
151 rgb.len()
152 )));
153 }
154 if channels < 3 {
155 return Err(DedupError::Visual(
156 "from_rgb: need at least 3 channels".to_string(),
157 ));
158 }
159 let mut data = Vec::with_capacity(width * height);
160 for i in 0..width * height {
161 let r = f32::from(rgb[i * channels]);
162 let g = f32::from(rgb[i * channels + 1]);
163 let b = f32::from(rgb[i * channels + 2]);
164 data.push((0.299 * r + 0.587 * g + 0.114 * b) as u8);
165 }
166 Ok(Self {
167 width,
168 height,
169 data,
170 })
171 }
172
173 #[must_use]
175 pub fn resize(&self, new_width: usize, new_height: usize) -> Self {
176 let x_ratio = self.width as f32 / new_width as f32;
177 let y_ratio = self.height as f32 / new_height as f32;
178 let mut data = Vec::with_capacity(new_width * new_height);
179 for ny in 0..new_height {
180 let sy = (ny as f32 * y_ratio) as usize;
181 for nx in 0..new_width {
182 let sx = (nx as f32 * x_ratio) as usize;
183 data.push(self.data[sy * self.width + sx]);
184 }
185 }
186 Self {
187 width: new_width,
188 height: new_height,
189 data,
190 }
191 }
192}
193
194fn dct1d(input: &[f64]) -> Vec<f64> {
202 let n = input.len();
203 let mut out = vec![0.0; n];
204 let pi_over_2n = std::f64::consts::PI / (2.0 * n as f64);
205 for (k, ok) in out.iter_mut().enumerate() {
206 let scale = if k == 0 {
207 (1.0 / n as f64).sqrt()
208 } else {
209 (2.0 / n as f64).sqrt()
210 };
211 let mut s = 0.0;
212 for (i, xi) in input.iter().enumerate() {
213 s += xi * ((2 * i + 1) as f64 * k as f64 * pi_over_2n).cos();
214 }
215 *ok = scale * s;
216 }
217 out
218}
219
220fn dct2d(input: &[f64], rows: usize, cols: usize) -> Vec<f64> {
222 let mut tmp = vec![0.0f64; rows * cols];
224 for r in 0..rows {
225 let row: Vec<f64> = input[r * cols..(r + 1) * cols].to_vec();
226 let d = dct1d(&row);
227 tmp[r * cols..(r + 1) * cols].copy_from_slice(&d);
228 }
229 let mut out = vec![0.0f64; rows * cols];
231 for c in 0..cols {
232 let col: Vec<f64> = (0..rows).map(|r| tmp[r * cols + c]).collect();
233 let d = dct1d(&col);
234 for r in 0..rows {
235 out[r * cols + c] = d[r];
236 }
237 }
238 out
239}
240
241#[must_use]
251pub fn compute_phash(frame: &GrayFrame) -> PHash {
252 let resized = frame.resize(DCT_SIZE, DCT_SIZE);
253
254 let input: Vec<f64> = resized.data.iter().map(|&v| f64::from(v)).collect();
256 let dct = dct2d(&input, DCT_SIZE, DCT_SIZE);
257
258 let mut low: Vec<f64> = Vec::with_capacity(HASH_BLOCK * HASH_BLOCK);
260 for r in 0..HASH_BLOCK {
261 for c in 0..HASH_BLOCK {
262 low.push(dct[r * DCT_SIZE + c]);
263 }
264 }
265
266 let mut sorted = low.clone();
268 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
269 let median = sorted[sorted.len() / 2];
270
271 let mut hash = 0u64;
273 for (i, &v) in low.iter().enumerate() {
274 if v > median {
275 hash |= 1u64 << i;
276 }
277 }
278 PHash(hash)
279}
280
281pub fn compute_phash_rgb(
289 width: usize,
290 height: usize,
291 rgb: &[u8],
292 channels: usize,
293) -> DedupResult<PHash> {
294 let frame = GrayFrame::from_rgb(width, height, rgb, channels)?;
295 Ok(compute_phash(&frame))
296}
297
298#[derive(Debug, Clone, PartialEq)]
304pub struct FrameMatch {
305 pub frame_a: usize,
307 pub frame_b: usize,
309 pub hamming_distance: u32,
311 pub similarity: f64,
313}
314
315#[derive(Debug, Clone)]
317pub struct SlidingWindowConfig {
318 pub window_size: usize,
320 pub max_distance: u32,
322 pub min_gap: usize,
325}
326
327impl Default for SlidingWindowConfig {
328 fn default() -> Self {
329 Self {
330 window_size: 30, max_distance: 10,
332 min_gap: 1,
333 }
334 }
335}
336
337#[must_use]
344pub fn sliding_window_detect(hashes: &[PHash], config: &SlidingWindowConfig) -> Vec<FrameMatch> {
345 let mut matches = Vec::new();
346
347 for i in 0..hashes.len() {
348 let end = (i + 1 + config.window_size).min(hashes.len());
349 for j in (i + config.min_gap + 1).min(end)..end {
350 let dist = hashes[i].hamming_distance(hashes[j]);
351 if dist <= config.max_distance {
352 matches.push(FrameMatch {
353 frame_a: i,
354 frame_b: j,
355 hamming_distance: dist,
356 similarity: hashes[i].similarity(hashes[j]),
357 });
358 }
359 }
360 }
361
362 matches
363}
364
365#[must_use]
369pub fn detect_frozen_segments(hashes: &[PHash], max_distance: u32) -> Vec<(usize, usize)> {
370 if hashes.len() < 2 {
371 return Vec::new();
372 }
373
374 let mut segments = Vec::new();
375 let mut run_start = 0usize;
376 let mut in_run = false;
377
378 for i in 1..hashes.len() {
379 let dist = hashes[i - 1].hamming_distance(hashes[i]);
380 if dist <= max_distance {
381 if !in_run {
382 run_start = i - 1;
383 in_run = true;
384 }
385 } else if in_run {
386 let run_len = i - run_start;
387 if run_len >= 2 {
388 segments.push((run_start, run_len));
389 }
390 in_run = false;
391 }
392 }
393 if in_run {
394 let run_len = hashes.len() - run_start;
395 if run_len >= 2 {
396 segments.push((run_start, run_len));
397 }
398 }
399 segments
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct AudioSegmentFingerprint {
417 pub bytes: Vec<u8>,
419 pub sample_count: usize,
421 pub sample_rate: u32,
423}
424
425impl AudioSegmentFingerprint {
426 #[must_use]
428 pub fn hamming_distance(&self, other: &Self) -> usize {
429 let min_len = self.bytes.len().min(other.bytes.len());
430 let len_penalty =
431 (self.bytes.len() as i64 - other.bytes.len() as i64).unsigned_abs() as usize * 8;
432 let bit_diff: usize = self.bytes[..min_len]
433 .iter()
434 .zip(other.bytes[..min_len].iter())
435 .map(|(a, b)| (a ^ b).count_ones() as usize)
436 .sum();
437 bit_diff + len_penalty
438 }
439
440 #[must_use]
442 pub fn similarity(&self, other: &Self) -> f64 {
443 let max_bits = self.bytes.len().max(other.bytes.len()) * 8;
444 if max_bits == 0 {
445 return 1.0;
446 }
447 let dist = self.hamming_distance(other);
448 (1.0 - dist as f64 / max_bits as f64).clamp(0.0, 1.0)
449 }
450
451 #[must_use]
453 pub fn is_similar(&self, other: &Self, threshold: f64) -> bool {
454 self.similarity(other) >= threshold
455 }
456}
457
458const N_BANDS: usize = 8;
460const FRAME_SAMPLES: usize = 128;
462
463#[must_use]
469pub fn compute_audio_fingerprint(samples: &[f32], sample_rate: u32) -> AudioSegmentFingerprint {
470 if samples.is_empty() {
471 return AudioSegmentFingerprint {
472 bytes: Vec::new(),
473 sample_count: 0,
474 sample_rate,
475 };
476 }
477
478 let stride = (sample_rate as usize / 11025).max(1);
480 let downsampled: Vec<f32> = samples.iter().step_by(stride).copied().collect();
481
482 let mut fingerprint_bytes = Vec::new();
483
484 let num_frames = downsampled.len() / FRAME_SAMPLES;
485 for frame_idx in 0..num_frames {
486 let start = frame_idx * FRAME_SAMPLES;
487 let frame = &downsampled[start..start + FRAME_SAMPLES];
488
489 let band_energies = compute_subband_energies(frame);
494
495 let mut byte = 0u8;
497 for b in 0..N_BANDS - 1 {
498 if band_energies[b] > band_energies[b + 1] {
500 byte |= 1u8 << b;
501 }
502 }
503 if band_energies[0] > band_energies[N_BANDS - 1] {
505 byte |= 1u8 << (N_BANDS - 1);
506 }
507 fingerprint_bytes.push(byte);
508 }
509
510 AudioSegmentFingerprint {
511 bytes: fingerprint_bytes,
512 sample_count: samples.len(),
513 sample_rate,
514 }
515}
516
517fn compute_subband_energies(frame: &[f32]) -> [f64; N_BANDS] {
522 let len = frame.len();
523 let half = len / 2;
524
525 let segment_size = (len / N_BANDS).max(1);
527 let mut energies = [0.0f64; N_BANDS];
528
529 for (band, energy) in energies.iter_mut().enumerate() {
530 let start = band * segment_size;
531 let end = ((band + 1) * segment_size).min(len);
532 if start >= end {
533 break;
534 }
535 let rms: f64 = frame[start..end]
536 .iter()
537 .map(|&s| (s as f64).powi(2))
538 .sum::<f64>()
539 / (end - start) as f64;
540 *energy = rms;
541 }
542
543 let orig = energies;
546 for b in 1..N_BANDS - 1 {
547 energies[b] = 0.25 * orig[b - 1] + 0.5 * orig[b] + 0.25 * orig[b + 1];
548 }
549 let _ = half; energies
552}
553
554#[derive(Debug, Clone)]
556pub struct FingerprintComparison {
557 pub hamming_distance: usize,
559 pub similarity: f64,
561 pub is_duplicate: bool,
563 pub threshold: f64,
565}
566
567#[must_use]
569pub fn compare_fingerprints(
570 fp1: &AudioSegmentFingerprint,
571 fp2: &AudioSegmentFingerprint,
572 threshold: f64,
573) -> FingerprintComparison {
574 let hamming = fp1.hamming_distance(fp2);
575 let similarity = fp1.similarity(fp2);
576 FingerprintComparison {
577 hamming_distance: hamming,
578 similarity,
579 is_duplicate: similarity >= threshold,
580 threshold,
581 }
582}
583
584#[cfg(test)]
589mod tests {
590 use super::*;
591
592 fn solid_frame(val: u8, w: usize, h: usize) -> GrayFrame {
595 GrayFrame::new(w, h, vec![val; w * h]).expect("operation should succeed")
596 }
597
598 fn gradient_frame(w: usize, h: usize) -> GrayFrame {
599 let data = (0..w * h).map(|i| (i % 256) as u8).collect();
600 GrayFrame::new(w, h, data).expect("operation should succeed")
601 }
602
603 #[test]
604 fn test_phash_identical_frames_zero_distance() {
605 let frame = gradient_frame(64, 64);
606 let h1 = compute_phash(&frame);
607 let h2 = compute_phash(&frame);
608 assert_eq!(h1.hamming_distance(h2), 0);
609 assert_eq!(h1.similarity(h2), 1.0);
610 }
611
612 #[test]
613 fn test_phash_different_frames_nonzero_distance() {
614 let f1 = solid_frame(0, 64, 64);
615 let f2 = solid_frame(255, 64, 64);
616 let h1 = compute_phash(&f1);
617 let h2 = compute_phash(&f2);
618 assert!(h1.hamming_distance(h2) > 0);
620 }
621
622 #[test]
623 fn test_phash_similarity_range() {
624 let f1 = gradient_frame(64, 64);
625 let f2 = gradient_frame(32, 32);
626 let h1 = compute_phash(&f1);
627 let h2 = compute_phash(&f2);
628 let sim = h1.similarity(h2);
629 assert!((0.0..=1.0).contains(&sim));
630 }
631
632 #[test]
633 fn test_phash_hex_roundtrip() {
634 let frame = gradient_frame(64, 64);
635 let h = compute_phash(&frame);
636 let hex = h.to_hex();
637 assert_eq!(hex.len(), 16);
638 let h2 = PHash::from_hex(&hex).expect("operation should succeed");
639 assert_eq!(h, h2);
640 }
641
642 #[test]
643 fn test_phash_invalid_hex() {
644 assert!(PHash::from_hex("not_a_hex!").is_err());
645 }
646
647 #[test]
648 fn test_phash_within_distance() {
649 let h1 = PHash::from_bits(0xFFFF_FFFF_FFFF_FFFF);
650 let h2 = PHash::from_bits(0xFFFF_FFFF_FFFF_FFFE); assert!(h1.within_distance(h2, 1));
652 assert!(!h1.within_distance(h2, 0));
653 }
654
655 #[test]
656 fn test_phash_near_duplicate() {
657 let h1 = PHash::from_bits(0xFFFF_FFFF_FFFF_FFFF);
658 let h2 = PHash::from_bits(0xFFFF_FFFF_FFFF_FF00); assert!(h1.is_near_duplicate(h2)); let h3 = PHash::from_bits(0x0000_FFFF_FFFF_FFFF); assert!(!h1.is_near_duplicate(h3));
662 }
663
664 #[test]
665 fn test_compute_phash_rgb() {
666 let rgb: Vec<u8> = (0..32 * 32 * 3).map(|i| (i % 256) as u8).collect();
667 assert!(compute_phash_rgb(32, 32, &rgb, 3).is_ok());
668 }
669
670 #[test]
671 fn test_compute_phash_rgb_wrong_size() {
672 let rgb = vec![0u8; 10];
673 assert!(compute_phash_rgb(32, 32, &rgb, 3).is_err());
674 }
675
676 #[test]
677 fn test_gray_frame_from_rgb() {
678 let rgb: Vec<u8> = (0..8 * 8 * 3).map(|i| (i % 256) as u8).collect();
679 let frame = GrayFrame::from_rgb(8, 8, &rgb, 3).expect("operation should succeed");
680 assert_eq!(frame.data.len(), 64);
681 }
682
683 fn seq_hashes(n: usize) -> Vec<PHash> {
686 (0..n).map(|i| PHash::from_bits(i as u64)).collect()
687 }
688
689 #[test]
690 fn test_sliding_window_no_duplicates() {
691 let hashes = seq_hashes(10);
692 let config = SlidingWindowConfig {
693 max_distance: 2,
694 ..Default::default()
695 };
696 let matches = sliding_window_detect(&hashes, &config);
698 assert!(matches.len() < hashes.len() * 10);
701 }
702
703 #[test]
704 fn test_sliding_window_identical_sequence() {
705 let hashes = vec![PHash::from_bits(0xABCD_EF12_3456_7890); 20];
706 let config = SlidingWindowConfig {
707 window_size: 5,
708 max_distance: 0,
709 min_gap: 1,
710 };
711 let matches = sliding_window_detect(&hashes, &config);
712 assert!(!matches.is_empty(), "Identical hashes should all match");
713 for m in &matches {
715 assert_eq!(m.hamming_distance, 0);
716 assert_eq!(m.similarity, 1.0);
717 }
718 }
719
720 #[test]
721 fn test_sliding_window_empty() {
722 let hashes: Vec<PHash> = vec![];
723 let matches = sliding_window_detect(&hashes, &SlidingWindowConfig::default());
724 assert!(matches.is_empty());
725 }
726
727 #[test]
728 fn test_sliding_window_single() {
729 let hashes = vec![PHash::from_bits(42)];
730 let matches = sliding_window_detect(&hashes, &SlidingWindowConfig::default());
731 assert!(matches.is_empty());
732 }
733
734 #[test]
735 fn test_detect_frozen_segments() {
736 let h = PHash::from_bits(0xDEAD_BEEF_DEAD_BEEF);
737 let hashes: Vec<PHash> = vec![
738 PHash::from_bits(1), h,
740 h,
741 h,
742 h, PHash::from_bits(99), ];
745 let segs = detect_frozen_segments(&hashes, 0);
746 assert_eq!(segs.len(), 1);
747 assert_eq!(segs[0].0, 1); assert_eq!(segs[0].1, 4); }
750
751 #[test]
752 fn test_detect_frozen_empty() {
753 let segs = detect_frozen_segments(&[], 0);
754 assert!(segs.is_empty());
755 }
756
757 fn sine_samples(freq_hz: f32, duration_s: f32, sr: u32) -> Vec<f32> {
760 let n = (duration_s * sr as f32) as usize;
761 (0..n)
762 .map(|i| (2.0 * std::f32::consts::PI * freq_hz * i as f32 / sr as f32).sin())
763 .collect()
764 }
765
766 #[test]
767 fn test_audio_fingerprint_empty() {
768 let fp = compute_audio_fingerprint(&[], 44100);
769 assert!(fp.bytes.is_empty());
770 }
771
772 #[test]
773 fn test_audio_fingerprint_non_empty() {
774 let samples = sine_samples(440.0, 1.0, 44100);
775 let fp = compute_audio_fingerprint(&samples, 44100);
776 assert!(!fp.bytes.is_empty());
777 }
778
779 #[test]
780 fn test_audio_fingerprint_same_signal_high_similarity() {
781 let samples = sine_samples(440.0, 2.0, 44100);
782 let fp1 = compute_audio_fingerprint(&samples, 44100);
783 let fp2 = compute_audio_fingerprint(&samples, 44100);
784 assert_eq!(fp1.similarity(&fp2), 1.0, "Same signal should be identical");
785 }
786
787 #[test]
788 fn test_audio_fingerprint_different_signals_lower_similarity() {
789 let s1 = sine_samples(440.0, 2.0, 44100);
790 let s2 = sine_samples(880.0, 2.0, 44100); let fp1 = compute_audio_fingerprint(&s1, 44100);
792 let fp2 = compute_audio_fingerprint(&s2, 44100);
793 let sim = fp1.similarity(&fp2);
794 assert!(sim < 1.0, "Different signals should have < 1.0 similarity");
795 assert!((0.0..=1.0).contains(&sim));
796 }
797
798 #[test]
799 fn test_fingerprint_comparison() {
800 let s = sine_samples(440.0, 1.0, 44100);
801 let fp1 = compute_audio_fingerprint(&s, 44100);
802 let fp2 = compute_audio_fingerprint(&s, 44100);
803 let cmp = compare_fingerprints(&fp1, &fp2, 0.9);
804 assert!(cmp.is_duplicate);
805 assert_eq!(cmp.hamming_distance, 0);
806 }
807
808 #[test]
809 fn test_audio_fingerprint_hamming_symmetry() {
810 let s1 = sine_samples(220.0, 1.0, 44100);
811 let s2 = sine_samples(880.0, 1.0, 44100);
812 let fp1 = compute_audio_fingerprint(&s1, 44100);
813 let fp2 = compute_audio_fingerprint(&s2, 44100);
814 assert_eq!(fp1.hamming_distance(&fp2), fp2.hamming_distance(&fp1));
815 }
816
817 #[test]
818 fn test_audio_fingerprint_similarity_symmetric() {
819 let s1 = sine_samples(330.0, 1.0, 44100);
820 let s2 = sine_samples(660.0, 1.0, 44100);
821 let fp1 = compute_audio_fingerprint(&s1, 44100);
822 let fp2 = compute_audio_fingerprint(&s2, 44100);
823 let sim12 = fp1.similarity(&fp2);
824 let sim21 = fp2.similarity(&fp1);
825 assert!((sim12 - sim21).abs() < 1e-9);
826 }
827}