#![allow(dead_code)]
use oxifft::Complex;
const DEFAULT_WINDOW: usize = 1024;
const DEFAULT_HOP: usize = 256;
const TARGET_BIN_LO: usize = 100;
const TARGET_BIN_HI: usize = 110;
const NEIGHBOUR_MARGIN: usize = 20;
const DETECTION_Z_THRESHOLD: f32 = 3.0;
const DEFAULT_INJECT_AMPLITUDE: f32 = 0.001;
#[derive(Debug, Clone)]
pub struct WatermarkResult {
pub confidence: f32,
pub estimated_payload: u64,
pub frequency_band_hz: (f32, f32),
}
pub struct WatermarkDetector {
sample_rate: u32,
window_size: usize,
hop_size: usize,
bin_lo: usize,
bin_hi: usize,
neighbour_margin: usize,
z_threshold: f32,
}
impl WatermarkDetector {
#[must_use]
pub fn new(sample_rate: u32) -> Self {
Self {
sample_rate,
window_size: DEFAULT_WINDOW,
hop_size: DEFAULT_HOP,
bin_lo: TARGET_BIN_LO,
bin_hi: TARGET_BIN_HI,
neighbour_margin: NEIGHBOUR_MARGIN,
z_threshold: DETECTION_Z_THRESHOLD,
}
}
#[must_use]
pub fn with_bins(mut self, bin_lo: usize, bin_hi: usize) -> Self {
self.bin_lo = bin_lo;
self.bin_hi = bin_hi;
self
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn detect(&self, samples: &[f32]) -> Option<WatermarkResult> {
if samples.len() < self.window_size {
return None;
}
let window_size = self.window_size;
let hop = self.hop_size;
let n_bins = window_size / 2 + 1;
if self.bin_lo >= n_bins || self.bin_hi >= n_bins || self.bin_lo > self.bin_hi {
return None;
}
let hann: Vec<f32> = (0..window_size)
.map(|i| {
0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / (window_size - 1) as f32).cos())
})
.collect();
let n_frames = (samples.len().saturating_sub(window_size)) / hop + 1;
let n_target = self.bin_hi - self.bin_lo + 1;
let mut per_frame_z: Vec<f32> = Vec::with_capacity(n_frames);
let mut bin_energy_acc = vec![0.0_f32; n_target];
for frame_idx in 0..n_frames {
let start = frame_idx * hop;
let end = start + window_size;
if end > samples.len() {
break;
}
let windowed: Vec<Complex<f32>> = samples[start..end]
.iter()
.zip(hann.iter())
.map(|(&s, &w)| Complex::new(s * w, 0.0))
.collect();
let spectrum = oxifft::fft(&windowed);
let mag: Vec<f32> = spectrum[..n_bins].iter().map(|c| c.norm()).collect();
let target_slice = &mag[self.bin_lo..=self.bin_hi];
let mean_target: f32 = target_slice.iter().sum::<f32>() / n_target as f32;
for (k, &v) in target_slice.iter().enumerate() {
bin_energy_acc[k] += v;
}
let left_lo = self.bin_lo.saturating_sub(self.neighbour_margin);
let left_hi = self.bin_lo.saturating_sub(1);
let right_lo = (self.bin_hi + 1).min(n_bins - 1);
let right_hi = (self.bin_hi + self.neighbour_margin).min(n_bins - 1);
let mut neighbour_vals: Vec<f32> = Vec::new();
if left_hi >= left_lo {
neighbour_vals.extend_from_slice(&mag[left_lo..=left_hi]);
}
if right_hi >= right_lo {
neighbour_vals.extend_from_slice(&mag[right_lo..=right_hi]);
}
if neighbour_vals.is_empty() {
per_frame_z.push(0.0);
continue;
}
let n_nb = neighbour_vals.len() as f32;
let mean_nb = neighbour_vals.iter().sum::<f32>() / n_nb;
let std_nb = {
let var = neighbour_vals
.iter()
.map(|&v| (v - mean_nb).powi(2))
.sum::<f32>()
/ n_nb;
var.sqrt()
};
let z = if std_nb > 1e-9 {
(mean_target - mean_nb) / std_nb
} else {
0.0
};
per_frame_z.push(z);
}
if per_frame_z.is_empty() {
return None;
}
let mean_z = per_frame_z.iter().sum::<f32>() / per_frame_z.len() as f32;
if mean_z < self.z_threshold {
return None;
}
let confidence = ((mean_z - self.z_threshold) / (self.z_threshold + 1.0)).clamp(0.0, 1.0);
let n_frames_counted = per_frame_z.len() as f32;
let mean_bin_energy: Vec<f32> = bin_energy_acc
.iter()
.map(|&e| e / n_frames_counted)
.collect();
let mut sorted_bin_e = mean_bin_energy.clone();
sorted_bin_e.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_bin_e = sorted_bin_e[sorted_bin_e.len() / 2];
let mut payload: u64 = 0;
for (bit_idx, &e) in mean_bin_energy.iter().enumerate().take(64) {
if e > median_bin_e {
payload |= 1u64 << bit_idx;
}
}
let sr = self.sample_rate as f32;
let band_lo_hz = self.bin_lo as f32 * sr / window_size as f32;
let band_hi_hz = self.bin_hi as f32 * sr / window_size as f32;
Some(WatermarkResult {
confidence,
estimated_payload: payload,
frequency_band_hz: (band_lo_hz, band_hi_hz),
})
}
}
#[allow(clippy::cast_precision_loss)]
pub fn inject_stub_watermark(samples: &mut [f32], payload: u64, sample_rate: u32) {
if samples.is_empty() || sample_rate == 0 {
return;
}
let sr = sample_rate as f32;
let window = DEFAULT_WINDOW as f32;
let amplitude = DEFAULT_INJECT_AMPLITUDE;
for bit_idx in 0..11usize {
if payload & (1u64 << bit_idx) == 0 {
continue;
}
let bin = (TARGET_BIN_LO + bit_idx) as f32;
let freq = bin * sr / window;
for (t, sample) in samples.iter_mut().enumerate() {
*sample += amplitude * (std::f32::consts::TAU * freq * t as f32 / sr).sin();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::TAU;
fn make_sine(freq: f32, sr: u32, secs: f32) -> Vec<f32> {
let n = (sr as f32 * secs) as usize;
(0..n)
.map(|i| (TAU * freq * i as f32 / sr as f32).sin())
.collect()
}
#[test]
fn test_detector_creation() {
let detector = WatermarkDetector::new(44100);
assert_eq!(detector.sample_rate, 44100);
assert_eq!(detector.window_size, DEFAULT_WINDOW);
}
#[test]
fn test_detect_short_signal_returns_none() {
let detector = WatermarkDetector::new(44100);
let short = vec![0.0f32; 100];
assert!(detector.detect(&short).is_none());
}
#[test]
fn test_detect_silence_returns_none() {
let detector = WatermarkDetector::new(44100);
let silence = vec![0.0f32; 44100];
assert!(detector.detect(&silence).is_none());
}
#[test]
fn test_detect_pure_sine_returns_none() {
let detector = WatermarkDetector::new(44100);
let sig = make_sine(440.0, 44100, 1.0);
let result = detector.detect(&sig);
let _ = result;
}
#[test]
fn test_inject_modifies_signal() {
let mut sig = make_sine(440.0, 44100, 1.0);
let original = sig.clone();
inject_stub_watermark(&mut sig, 0b111, 44100);
let modified = sig
.iter()
.zip(original.iter())
.any(|(&a, &b)| (a - b).abs() > 1e-7);
assert!(modified, "Signal should be modified after injection");
}
#[test]
fn test_inject_zero_payload_no_change() {
let mut sig = make_sine(440.0, 44100, 0.5);
let original = sig.clone();
inject_stub_watermark(&mut sig, 0, 44100);
assert_eq!(sig, original);
}
#[test]
fn test_inject_empty_signal_no_panic() {
let mut empty: Vec<f32> = Vec::new();
inject_stub_watermark(&mut empty, 0xFF, 44100);
assert!(empty.is_empty());
}
#[test]
fn test_inject_then_detect_roundtrip() {
let sr = 44100u32;
let detector = WatermarkDetector::new(sr);
let mut sig = make_sine(440.0, sr, 2.0);
let payload = 0b0111_1111_1111_u64; inject_stub_watermark(&mut sig, payload, sr);
if let Some(result) = detector.detect(&sig) {
assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
let (lo, hi) = result.frequency_band_hz;
assert!(lo <= hi);
assert!(lo >= 0.0);
}
}
#[test]
fn test_watermark_result_frequency_band() {
let sr = 44100u32;
let detector = WatermarkDetector::new(sr);
let mut sig = vec![0.1f32; 44100 * 2]; inject_stub_watermark(&mut sig, 0b0111_1111_1111_u64, sr);
if let Some(result) = detector.detect(&sig) {
let (lo, hi) = result.frequency_band_hz;
let expected_lo = 100.0 * 44100.0 / 1024.0;
let expected_hi = 110.0 * 44100.0 / 1024.0;
assert!((lo - expected_lo).abs() < 10.0);
assert!((hi - expected_hi).abs() < 10.0);
}
}
#[test]
fn test_with_bins_customisation() {
let detector = WatermarkDetector::new(44100).with_bins(50, 60);
assert_eq!(detector.bin_lo, 50);
assert_eq!(detector.bin_hi, 60);
}
}