Skip to main content

oximedia_dedup/
phash.rs

1//! Perceptual hashing (pHash) and near-duplicate detection for video frames.
2//!
3//! This module provides:
4//! - DCT-based perceptual hash (`PHash`) for images/video frames
5//! - Hamming distance comparison for near-duplicate detection
6//! - Sliding-window duplicate detection over sequences of frames
7//! - Content fingerprinting for audio segments
8//!
9//! # Algorithm
10//!
11//! pHash operates by:
12//! 1. Converting the image to grayscale and resizing to 32×32
13//! 2. Applying a 2D Discrete Cosine Transform (DCT)
14//! 3. Keeping only the 8×8 low-frequency coefficients (top-left block)
15//! 4. Thresholding coefficients at the median → 64-bit hash
16//!
17//! Two images with Hamming distance ≤ 10 are considered near-duplicates.
18
19use crate::{DedupError, DedupResult};
20
21// --------------------------------------------------------------------------
22// Perceptual hash
23// --------------------------------------------------------------------------
24
25/// Size of the DCT input (32×32 pixels).
26const DCT_SIZE: usize = 32;
27/// Size of the low-frequency hash block (8×8 → 64 bits).
28const HASH_BLOCK: usize = 8;
29/// Number of bits in the hash.
30const HASH_BITS: u32 = (HASH_BLOCK * HASH_BLOCK) as u32;
31
32/// A 64-bit perceptual hash derived from DCT coefficients.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct PHash(u64);
35
36impl PHash {
37    /// Create a pHash from a raw 64-bit value.
38    #[must_use]
39    pub const fn from_bits(bits: u64) -> Self {
40        Self(bits)
41    }
42
43    /// Return the raw 64-bit value.
44    #[must_use]
45    pub const fn bits(self) -> u64 {
46        self.0
47    }
48
49    /// Hamming distance between two hashes (number of differing bits).
50    #[must_use]
51    pub fn hamming_distance(self, other: Self) -> u32 {
52        (self.0 ^ other.0).count_ones()
53    }
54
55    /// Similarity in range [0.0, 1.0].
56    ///
57    /// 1.0 = identical, 0.0 = maximally different.
58    #[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    /// Returns `true` if the two hashes are considered near-duplicates.
64    ///
65    /// The default threshold is Hamming distance ≤ 10 (≈84 % similarity).
66    #[must_use]
67    pub fn is_near_duplicate(self, other: Self) -> bool {
68        self.hamming_distance(other) <= 10
69    }
70
71    /// Returns `true` if Hamming distance is within `max_distance`.
72    #[must_use]
73    pub fn within_distance(self, other: Self, max_distance: u32) -> bool {
74        self.hamming_distance(other) <= max_distance
75    }
76
77    /// Hex string representation.
78    #[must_use]
79    pub fn to_hex(self) -> String {
80        format!("{:016x}", self.0)
81    }
82
83    /// Parse from hex string.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the string is not a valid 16-character hex value.
88    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// --------------------------------------------------------------------------
102// Grayscale frame representation
103// --------------------------------------------------------------------------
104
105/// A grayscale image used as input for perceptual hashing.
106///
107/// Pixels are stored in row-major order with values in `[0, 255]`.
108#[derive(Debug, Clone)]
109pub struct GrayFrame {
110    /// Image width in pixels.
111    pub width: usize,
112    /// Image height in pixels.
113    pub height: usize,
114    /// Row-major grayscale pixel values.
115    pub data: Vec<u8>,
116}
117
118impl GrayFrame {
119    /// Create a new frame from raw data.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if `data.len() != width * height`.
124    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    /// Create from an RGB(A) buffer.
140    ///
141    /// Uses ITU-R BT.601 luma: `Y = 0.299R + 0.587G + 0.114B`.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if buffer length doesn't match `width * height * channels`.
146    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    /// Nearest-neighbour resize to `new_width × new_height`.
174    #[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
194// --------------------------------------------------------------------------
195// 1D DCT-II (normalised) – building block for the 2D separable DCT
196// --------------------------------------------------------------------------
197
198/// Compute the 1D DCT-II of `input` in-place.
199///
200/// Uses the naive O(N²) algorithm because N ≤ 32 and this runs infrequently.
201fn 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
220/// 2D separable DCT: row-by-row, then column-by-column.
221fn dct2d(input: &[f64], rows: usize, cols: usize) -> Vec<f64> {
222    // Row transforms
223    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    // Column transforms
230    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// --------------------------------------------------------------------------
242// Public pHash computation
243// --------------------------------------------------------------------------
244
245/// Compute the pHash of a `GrayFrame`.
246///
247/// The frame is first resized to `DCT_SIZE × DCT_SIZE`, then the 2D DCT is
248/// applied and the top-left `HASH_BLOCK × HASH_BLOCK` block is thresholded
249/// at the median to produce a 64-bit hash.
250#[must_use]
251pub fn compute_phash(frame: &GrayFrame) -> PHash {
252    let resized = frame.resize(DCT_SIZE, DCT_SIZE);
253
254    // Convert to f64 for DCT
255    let input: Vec<f64> = resized.data.iter().map(|&v| f64::from(v)).collect();
256    let dct = dct2d(&input, DCT_SIZE, DCT_SIZE);
257
258    // Extract top-left HASH_BLOCK × HASH_BLOCK
259    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    // Median (we skip DC coefficient at [0] which dominates the average)
267    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    // Hash: bit = 1 if coefficient > median
272    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
281/// Compute pHash from a raw RGB pixel buffer.
282///
283/// Convenience wrapper around `compute_phash` + `GrayFrame::from_rgb`.
284///
285/// # Errors
286///
287/// Returns an error if buffer size doesn't match `width * height * channels`.
288pub 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// --------------------------------------------------------------------------
299// Sliding-window duplicate detection
300// --------------------------------------------------------------------------
301
302/// A near-duplicate match found by the sliding window detector.
303#[derive(Debug, Clone, PartialEq)]
304pub struct FrameMatch {
305    /// Index of the first (reference) frame.
306    pub frame_a: usize,
307    /// Index of the second (candidate) frame.
308    pub frame_b: usize,
309    /// Hamming distance between the two hashes.
310    pub hamming_distance: u32,
311    /// Similarity score [0.0, 1.0].
312    pub similarity: f64,
313}
314
315/// Configuration for the sliding-window detector.
316#[derive(Debug, Clone)]
317pub struct SlidingWindowConfig {
318    /// Number of frames to look ahead from each reference frame.
319    pub window_size: usize,
320    /// Maximum Hamming distance to be considered a duplicate.
321    pub max_distance: u32,
322    /// Minimum gap between frame pairs to avoid consecutive-frame matching
323    /// (set to 0 to include all pairs).
324    pub min_gap: usize,
325}
326
327impl Default for SlidingWindowConfig {
328    fn default() -> Self {
329        Self {
330            window_size: 30, // ~1 second at 30 fps
331            max_distance: 10,
332            min_gap: 1,
333        }
334    }
335}
336
337/// Detect near-duplicate frames in a sequence using a sliding window.
338///
339/// Each frame hash is compared against the next `window_size` hashes.
340/// Pairs with Hamming distance ≤ `max_distance` are returned as matches.
341///
342/// The algorithm is O(N × window_size) making it suitable for long sequences.
343#[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/// Find all consecutive frame pairs that are likely frozen (identical or near-identical).
366///
367/// Returns the start indices of frozen runs and the run length.
368#[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// --------------------------------------------------------------------------
403// Audio content fingerprinting
404// --------------------------------------------------------------------------
405
406/// A compact fingerprint for an audio segment based on sub-band energy ratios.
407///
408/// The fingerprint is computed by:
409/// 1. Dividing the audio into short frames (default 64 ms)
410/// 2. Computing the energy in each of 8 frequency sub-bands
411/// 3. Encoding the sign of inter-band energy differences as bits
412///
413/// This produces a bit-string that is robust to small amplitude changes and
414/// can be compared with Hamming distance for near-duplicate detection.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct AudioSegmentFingerprint {
417    /// Raw fingerprint bytes (1 byte per analysis frame).
418    pub bytes: Vec<u8>,
419    /// Number of audio samples analysed.
420    pub sample_count: usize,
421    /// Sample rate used during fingerprinting.
422    pub sample_rate: u32,
423}
424
425impl AudioSegmentFingerprint {
426    /// Hamming distance between two fingerprints (proportional to byte length difference).
427    #[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    /// Similarity in [0.0, 1.0].
441    #[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    /// Returns `true` if similarity ≥ `threshold`.
452    #[must_use]
453    pub fn is_similar(&self, other: &Self, threshold: f64) -> bool {
454        self.similarity(other) >= threshold
455    }
456}
457
458/// Number of sub-bands for the audio fingerprint.
459const N_BANDS: usize = 8;
460/// Frame size in samples at the reference rate (11 025 Hz).
461const FRAME_SAMPLES: usize = 128;
462
463/// Compute an audio segment fingerprint from mono PCM samples.
464///
465/// `samples` should be normalised floats in `[-1.0, 1.0]`.
466/// `sample_rate` is the actual sample rate; samples are logically
467/// downsampled to 11 025 Hz (no actual resampling – we stride).
468#[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    // Stride to approximate 11 025 Hz downsampling
479    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        // Compute sub-band energies using overlapping frequency windows.
490        // Band k contains frequencies proportional to (k..k+1)/N_BANDS of Nyquist.
491        // We use a simplified DFT-free approach: compute energy of frame
492        // segments that approximate frequency bands via the Haar-like decomposition.
493        let band_energies = compute_subband_energies(frame);
494
495        // Encode adjacent band energy differences as bits
496        let mut byte = 0u8;
497        for b in 0..N_BANDS - 1 {
498            // Bit set if band b has more energy than band b+1
499            if band_energies[b] > band_energies[b + 1] {
500                byte |= 1u8 << b;
501            }
502        }
503        // Last bit: compare first and last band (wraps around)
504        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
517/// Compute approximate sub-band energies for a frame using recursive splitting.
518///
519/// Each iteration of the Haar-like wavelet halves the frequency range,
520/// giving `N_BANDS` sub-band energies without needing an FFT.
521fn compute_subband_energies(frame: &[f32]) -> [f64; N_BANDS] {
522    let len = frame.len();
523    let half = len / 2;
524
525    // Split frame into N_BANDS equal segments and compute RMS energy per segment
526    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    // Apply simple Haar-like smoothing: blend adjacent bands
544    // This makes the fingerprint more robust to small spectral shifts
545    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; // suppress unused warning from the half var above
550
551    energies
552}
553
554/// Compare two audio fingerprints and return a similarity report.
555#[derive(Debug, Clone)]
556pub struct FingerprintComparison {
557    /// Hamming distance.
558    pub hamming_distance: usize,
559    /// Similarity score [0.0, 1.0].
560    pub similarity: f64,
561    /// Whether the segments are considered duplicates.
562    pub is_duplicate: bool,
563    /// The threshold used.
564    pub threshold: f64,
565}
566
567/// Compare two audio fingerprints.
568#[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// --------------------------------------------------------------------------
585// Tests
586// --------------------------------------------------------------------------
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    // ----- PHash tests -----
593
594    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        // Fully black and fully white → different hashes
619        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); // 1 bit diff
651        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); // 8 bit diff
659        assert!(h1.is_near_duplicate(h2)); // 8 ≤ 10
660        let h3 = PHash::from_bits(0x0000_FFFF_FFFF_FFFF); // 16 bit diff
661        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    // ----- Sliding window tests -----
684
685    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        // Sequential integers differ in at least 1 bit, many differ in > 2
697        let matches = sliding_window_detect(&hashes, &config);
698        // Adjacent pairs (i, i+1) differ by at most a few bits for small i
699        // Just ensure no panic and result is reasonable
700        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        // All matches should have distance 0
714        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), // 0: different
739            h,
740            h,
741            h,
742            h,                    // 1..4: frozen
743            PHash::from_bits(99), // 5: different
744        ];
745        let segs = detect_frozen_segments(&hashes, 0);
746        assert_eq!(segs.len(), 1);
747        assert_eq!(segs[0].0, 1); // starts at frame 1
748        assert_eq!(segs[0].1, 4); // 4 frames long
749    }
750
751    #[test]
752    fn test_detect_frozen_empty() {
753        let segs = detect_frozen_segments(&[], 0);
754        assert!(segs.is_empty());
755    }
756
757    // ----- Audio fingerprint tests -----
758
759    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); // Octave up
791        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}