use alloc::vec::Vec;
use crate::classical::stream;
use crate::dsp::peaks::{Peak, PeakPicker, PeakPickerConfig};
use crate::dsp::stft::{ShortTimeFFT, StftConfig};
use crate::dsp::windows::WindowKind;
use crate::{AfpError, Fingerprinter, Result, SampleRate, StreamingFingerprinter, TimestampMs};
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WangHash {
pub hash: u32,
pub t_anchor: u32,
}
#[derive(Clone, Debug)]
pub struct WangFingerprint {
pub hashes: Vec<WangHash>,
pub frames_per_sec: f32,
}
#[derive(Clone, Debug)]
pub struct WangConfig {
pub fan_out: u16,
pub target_zone_t: u16,
pub target_zone_f: u16,
pub peaks_per_sec: u16,
pub min_anchor_mag_db: f32,
pub max_input_samples: Option<usize>,
pub max_hashes: Option<usize>,
pub max_pending_anchors: Option<usize>,
pub max_push_samples: Option<usize>,
}
impl Default for WangConfig {
fn default() -> Self {
Self {
fan_out: 10,
target_zone_t: 63,
target_zone_f: 64,
peaks_per_sec: 30,
min_anchor_mag_db: -50.0,
max_input_samples: Some(30 * 60 * WANG_SR as usize),
max_hashes: Some(500_000),
max_pending_anchors: None,
max_push_samples: None,
}
}
}
const WANG_N_FFT: usize = 1024;
const WANG_HOP: usize = 128;
const WANG_SR: u32 = 8_000;
const WANG_FRAMES_PER_SEC: f32 = WANG_SR as f32 / WANG_HOP as f32;
const WANG_FREQ_BUCKETS: u32 = 512;
const WANG_PEAK_NEIGHBOURHOOD: usize = 15;
const WANG_LOG_FLOOR: f32 = 1e-6;
const WANG_LOG_FLOOR_POWER: f32 = WANG_LOG_FLOOR * WANG_LOG_FLOOR;
use crate::dsp::power_to_db_wide;
pub struct Wang {
cfg: WangConfig,
stft: ShortTimeFFT,
picker: PeakPicker,
log_spec: Vec<f32>,
}
impl Default for Wang {
fn default() -> Self {
Self::new(WangConfig::default())
}
}
impl Wang {
#[must_use]
pub fn new(mut cfg: WangConfig) -> Self {
crate::classical::sanitize_cfg!(cfg);
let stft = ShortTimeFFT::new(StftConfig {
n_fft: WANG_N_FFT,
hop: WANG_HOP,
window: WindowKind::Hann,
center: false,
});
let picker = PeakPicker::new(PeakPickerConfig {
neighborhood_t: WANG_PEAK_NEIGHBOURHOOD,
neighborhood_f: WANG_PEAK_NEIGHBOURHOOD,
min_magnitude_db: cfg.min_anchor_mag_db,
min_magnitude_linear: None,
target_per_sec: cfg.peaks_per_sec as usize,
});
Self {
cfg,
stft,
picker,
log_spec: Vec::new(),
}
}
}
const WANG_PROGRESS_INTERVAL: usize = 32;
impl Wang {
pub fn extract_with_progress<F: FnMut(f32)>(
&mut self,
samples: &[f32],
rate: SampleRate,
mut progress: F,
) -> Result<WangFingerprint> {
crate::pcm::reject_non_finite(samples)?;
if let Some(limit) = self.cfg.max_input_samples
&& samples.len() > limit
{
return Err(AfpError::InputTooLarge {
limit,
provided: samples.len(),
});
}
if rate.hz() != WANG_SR {
return Err(AfpError::UnsupportedSampleRate(rate.hz()));
}
if samples.len() < self.min_samples() {
return Err(AfpError::AudioTooShort {
needed: self.min_samples(),
got: samples.len(),
});
}
progress(0.0);
let (n_frames, n_bins) = self.stft.power_flat_into(samples, &mut self.log_spec);
if n_frames == 0 {
progress(1.0);
return Ok(WangFingerprint {
hashes: Vec::new(),
frames_per_sec: WANG_FRAMES_PER_SEC,
});
}
let total_frames = n_frames;
let stft_weight = 0.7_f32;
let interval = WANG_PROGRESS_INTERVAL;
{
let mut reported = 0usize;
while reported + interval < total_frames {
reported += interval;
progress(stft_weight * (reported as f32 / total_frames as f32));
}
}
progress(stft_weight);
power_to_db_wide(&mut self.log_spec, WANG_LOG_FLOOR_POWER);
progress(0.80);
let peaks = self
.picker
.pick(&self.log_spec, n_frames, n_bins, WANG_FRAMES_PER_SEC);
progress(0.90);
let mut hashes = build_hashes(&peaks, &self.cfg);
hashes.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
if let Some(limit) = self.cfg.max_hashes
&& hashes.len() > limit
{
return Err(AfpError::InputTooLarge {
limit,
provided: hashes.len(),
});
}
progress(1.0);
Ok(WangFingerprint {
hashes,
frames_per_sec: WANG_FRAMES_PER_SEC,
})
}
}
impl Fingerprinter for Wang {
type Output = WangFingerprint;
type Config = WangConfig;
fn name(&self) -> &'static str {
"wang-v1"
}
fn config(&self) -> &Self::Config {
&self.cfg
}
fn required_sample_rate(&self) -> SampleRate {
SampleRate::new(WANG_SR).expect("WANG_SR is non-zero")
}
fn min_samples(&self) -> usize {
WANG_SR as usize * 2
}
fn extract(&mut self, samples: &[f32], rate: SampleRate) -> Result<Self::Output> {
self.extract_with_progress(samples, rate, |_| {})
}
}
fn build_hashes(peaks: &[Peak], cfg: &WangConfig) -> Vec<WangHash> {
let mut hashes = Vec::with_capacity(peaks.len() * cfg.fan_out as usize);
let target_zone_t = cfg.target_zone_t as u32;
let target_zone_f = cfg.target_zone_f as i32;
let fan_out = cfg.fan_out as usize;
let mut targets: Vec<Peak> = Vec::with_capacity(fan_out);
for (i, anchor) in peaks.iter().enumerate() {
let zone_limit = anchor.t_frame.saturating_add(target_zone_t);
let zone_end = peaks[i + 1..].partition_point(|p| p.t_frame <= zone_limit);
targets.clear();
for target in &peaks[i + 1..i + 1 + zone_end] {
let dt = target.t_frame - anchor.t_frame;
if dt < 1 {
continue;
}
let df = target.f_bin as i32 - anchor.f_bin as i32;
if df.abs() > target_zone_f {
continue;
}
insert_top_target(&mut targets, *target, fan_out);
}
let f_a_q = quantise_freq(anchor.f_bin);
for target in &targets {
let f_b_q = quantise_freq(target.f_bin);
let dt = (target.t_frame - anchor.t_frame).clamp(1, 0x3FFF);
let hash = ((f_a_q & 0x1FF) << 23) | ((f_b_q & 0x1FF) << 14) | dt;
hashes.push(WangHash {
hash,
t_anchor: anchor.t_frame,
});
}
}
hashes
}
#[inline]
fn insert_top_target(targets: &mut Vec<Peak>, target: Peak, fan_out: usize) {
if targets.len() < fan_out {
let pos = targets.partition_point(|p| {
p.mag > target.mag
|| (p.mag == target.mag && (p.t_frame, p.f_bin) <= (target.t_frame, target.f_bin))
});
targets.insert(pos, target);
} else if let Some(last) = targets.last()
&& (target.mag > last.mag
|| (target.mag == last.mag
&& (target.t_frame, target.f_bin) < (last.t_frame, last.f_bin)))
{
let pos = targets.partition_point(|p| {
p.mag > target.mag
|| (p.mag == target.mag && (p.t_frame, p.f_bin) <= (target.t_frame, target.f_bin))
});
targets.insert(pos, target);
targets.pop();
}
}
#[inline]
fn quantise_freq(bin: u16) -> u32 {
(bin as u32 * WANG_FREQ_BUCKETS) / 513
}
pub struct StreamingWang {
cfg: WangConfig,
core: stream::StreamCore<WangHash>,
}
impl Default for StreamingWang {
fn default() -> Self {
Self::new(WangConfig::default())
}
}
impl StreamingWang {
#[must_use]
pub fn new(mut cfg: WangConfig) -> Self {
crate::classical::sanitize_cfg!(cfg);
Self {
cfg,
core: stream::StreamCore::new(
WANG_N_FFT,
WANG_HOP,
WANG_SR,
WANG_PEAK_NEIGHBOURHOOD,
WANG_LOG_FLOOR_POWER,
stream::Zone::Inclusive,
),
}
}
#[must_use]
pub fn config(&self) -> &WangConfig {
&self.cfg
}
pub fn reset(&mut self) {
self.core.reset();
}
fn lookahead_frames(&self) -> u32 {
self.cfg.target_zone_t as u32
+ WANG_PEAK_NEIGHBOURHOOD as u32
+ WANG_FRAMES_PER_SEC.ceil() as u32
}
fn peak_cfg(&self) -> stream::PeakCfg {
stream::PeakCfg {
min_anchor_mag_db: self.cfg.min_anchor_mag_db,
target_zone_t: self.cfg.target_zone_t as i32,
target_zone_f: self.cfg.target_zone_f as i32,
fan_out: self.cfg.fan_out as usize,
peaks_per_sec: self.cfg.peaks_per_sec as usize,
max_pending_anchors: self.cfg.max_pending_anchors,
max_push_samples: self.cfg.max_push_samples,
}
}
fn add_target(targets: &mut Vec<Peak>, target: Peak, _dt: i32, _df: i32, cfg: stream::PeakCfg) {
crate::classical::wang::insert_top_target(targets, target, cfg.fan_out);
}
fn emit_anchor(
anchor: stream::PendingAnchor,
_cfg: stream::PeakCfg,
out: &mut alloc::vec::Vec<(TimestampMs, WangHash)>,
) {
let f_a_q = quantise_freq(anchor.peak.f_bin);
for target in &anchor.targets {
let f_b_q = quantise_freq(target.f_bin);
let dt = (target.t_frame - anchor.peak.t_frame).clamp(1, 0x3FFF);
let hash = ((f_a_q & 0x1FF) << 23) | ((f_b_q & 0x1FF) << 14) | dt;
let t_ms = (anchor.peak.t_frame as u64 * WANG_HOP as u64 * 1000) / WANG_SR as u64;
out.push((
TimestampMs(t_ms),
WangHash {
hash,
t_anchor: anchor.peak.t_frame,
},
));
}
}
}
impl StreamingFingerprinter for StreamingWang {
type Frame = WangHash;
fn required_sample_rate(&self) -> u32 {
WANG_SR
}
fn push(&mut self, samples: &[f32]) -> Result<alloc::vec::Vec<(TimestampMs, Self::Frame)>> {
self.core.emitted.clear();
let cfg = self.peak_cfg();
self.core
.process_push_samples(samples, cfg, Self::add_target, Self::emit_anchor);
Ok(core::mem::take(&mut self.core.emitted))
}
fn push_with<F>(&mut self, samples: &[f32], mut callback: F) -> Result<usize>
where
F: FnMut(TimestampMs, &Self::Frame),
{
self.core.emitted.clear();
let cfg = self.peak_cfg();
self.core
.process_push_samples(samples, cfg, Self::add_target, Self::emit_anchor);
let mut n = 0usize;
for (t, frame) in self.core.emitted.drain(..) {
callback(t, &frame);
n += 1;
}
Ok(n)
}
fn flush(&mut self) -> Result<alloc::vec::Vec<(TimestampMs, Self::Frame)>> {
self.core.emitted.clear();
let cfg = self.peak_cfg();
self.core
.process_flush(cfg, Self::add_target, Self::emit_anchor);
Ok(core::mem::take(&mut self.core.emitted))
}
fn flush_with<F>(&mut self, mut callback: F) -> Result<usize>
where
F: FnMut(TimestampMs, &Self::Frame),
{
self.core.emitted.clear();
let cfg = self.peak_cfg();
self.core
.process_flush(cfg, Self::add_target, Self::emit_anchor);
let mut n = 0usize;
for (t, frame) in self.core.emitted.drain(..) {
callback(t, &frame);
n += 1;
}
Ok(n)
}
fn latency_ms(&self) -> u32 {
(self.lookahead_frames() * WANG_HOP as u32 * 1000) / WANG_SR
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleRate;
use alloc::vec;
use core::f32::consts::PI;
fn synthetic_audio(seed: u32, len: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(len);
let mut x: u32 = seed.max(1);
for n in 0..len {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
let noise = ((x as i32 as f32) / (i32::MAX as f32)) * 0.05;
let t = n as f32 / 8_000.0;
let s = 0.5 * libm::sinf(2.0 * PI * 880.0 * t)
+ 0.3 * libm::sinf(2.0 * PI * 1320.0 * t)
+ noise;
out.push(s);
}
out
}
#[test]
fn rejects_wrong_sample_rate() {
let mut fp = Wang::default();
let samples = vec![0.0_f32; 16_000];
match fp.extract(&samples, SampleRate::HZ_16000) {
Err(AfpError::UnsupportedSampleRate(16_000)) => {}
other => panic!("expected UnsupportedSampleRate, got {other:?}"),
}
}
#[test]
fn rejects_short_audio() {
let mut fp = Wang::default();
let samples = vec![0.0_f32; 8_000];
match fp.extract(&samples, SampleRate::HZ_8000) {
Err(AfpError::AudioTooShort {
needed: 16_000,
got: 8_000,
}) => {}
other => panic!("expected AudioTooShort, got {other:?}"),
}
}
#[test]
fn silence_gives_empty_fingerprint() {
let mut fp = Wang::default();
let samples = vec![0.0_f32; 8_000 * 3];
let fpr = fp.extract(&samples, SampleRate::HZ_8000).unwrap();
assert_eq!(fpr.frames_per_sec, 62.5);
assert!(fpr.hashes.is_empty());
}
#[test]
fn synthetic_signal_produces_hashes() {
let mut fp = Wang::default();
let samples = synthetic_audio(0xC0FFEE, 8_000 * 5);
let fpr = fp.extract(&samples, SampleRate::HZ_8000).unwrap();
assert!(
(650..=1100).contains(&fpr.hashes.len()),
"expected 650..=1100 hashes from a 5s tone, got {}",
fpr.hashes.len(),
);
let distinct: alloc::collections::BTreeSet<u32> =
fpr.hashes.iter().map(|h| h.hash).collect();
assert!(
distinct.len() > 500,
"expected most hashes to be distinct, got {} distinct of {}",
distinct.len(),
fpr.hashes.len(),
);
for w in fpr.hashes.windows(2) {
assert!((w[0].t_anchor, w[0].hash) <= (w[1].t_anchor, w[1].hash));
}
}
#[test]
fn synthetic_signal_is_deterministic() {
let samples = synthetic_audio(0xBEEF, 8_000 * 3);
let mut a = Wang::default();
let mut b = Wang::default();
let fa = a.extract(&samples, SampleRate::HZ_8000).unwrap();
let fb = b.extract(&samples, SampleRate::HZ_8000).unwrap();
assert_eq!(fa.hashes, fb.hashes);
}
#[test]
fn extraction_is_deterministic() {
let samples = synthetic_audio(0xDEAD, 8_000 * 4);
let mut fp1 = Wang::default();
let f1 = fp1.extract(&samples, SampleRate::HZ_8000).unwrap();
let mut fp2 = Wang::default();
let f2 = fp2.extract(&samples, SampleRate::HZ_8000).unwrap();
assert_eq!(f1.hashes.len(), f2.hashes.len());
for (a, b) in f1.hashes.iter().zip(f2.hashes.iter()) {
assert_eq!(a, b);
}
}
#[test]
fn different_signals_diverge() {
let samples_a = synthetic_audio(0x1111, 8_000 * 3);
let samples_b = synthetic_audio(0x2222, 8_000 * 3);
let mut fp = Wang::default();
let fa = fp.extract(&samples_a, SampleRate::HZ_8000).unwrap();
let fb = fp.extract(&samples_b, SampleRate::HZ_8000).unwrap();
assert_ne!(fa.hashes, fb.hashes);
}
#[test]
fn hash_packing_round_trips() {
let peaks = alloc::vec![
Peak {
t_frame: 100,
f_bin: 50,
_pad: 0,
mag: -10.0
},
Peak {
t_frame: 110,
f_bin: 70,
_pad: 0,
mag: -12.0
},
];
let cfg = WangConfig::default();
let hashes = build_hashes(&peaks, &cfg);
assert_eq!(hashes.len(), 1);
let h = hashes[0].hash;
let f_a_q = (h >> 23) & 0x1FF;
let f_b_q = (h >> 14) & 0x1FF;
let dt = h & 0x3FFF;
assert_eq!(f_a_q, quantise_freq(50));
assert_eq!(f_b_q, quantise_freq(70));
assert_eq!(dt, 10);
let ta = hashes[0].t_anchor;
assert_eq!(ta, 100);
}
#[test]
fn dt_field_clamps_to_14_bit_ceiling_not_wraparound() {
let peaks = alloc::vec![
Peak {
t_frame: 0,
f_bin: 50,
_pad: 0,
mag: -10.0
},
Peak {
t_frame: 20_000,
f_bin: 70,
_pad: 0,
mag: -12.0
},
];
let cfg = WangConfig {
target_zone_t: u16::MAX,
target_zone_f: u16::MAX,
..WangConfig::default()
};
let hashes = build_hashes(&peaks, &cfg);
assert_eq!(hashes.len(), 1);
let dt = hashes[0].hash & 0x3FFF;
assert_eq!(dt, 0x3FFF, "Δt must clamp to 14-bit max, got {dt}");
}
#[test]
fn streaming_latency_matches_lookahead() {
let s = StreamingWang::default();
assert_eq!(s.latency_ms(), 2_256);
}
#[test]
fn streaming_empty_push_is_empty() {
let mut s = StreamingWang::default();
assert!(s.push(&[]).unwrap().is_empty());
assert!(s.flush().unwrap().is_empty());
}
#[test]
fn streaming_flush_is_idempotent() {
let mut s = StreamingWang::default();
let samples = synthetic_audio(0x1D1D, 8_000 * 3);
let _ = s.push(&samples).unwrap();
let first = s.flush().unwrap();
let second = s.flush().unwrap();
assert!(
second.is_empty(),
"second flush returned {} frames after {} in the first",
second.len(),
first.len(),
);
}
#[test]
fn streaming_push_after_flush_does_not_reemit_old_rows() {
let a = synthetic_audio(0x0AF1, 8_000 * 3);
let b = synthetic_audio(0x0AF2, 8_000 * 2);
let mut s = StreamingWang::default();
let _ = s.push(&a).unwrap();
let _ = s.flush().unwrap();
let mut after: Vec<(TimestampMs, WangHash)> = s.push(&b).unwrap();
after.extend(s.flush().unwrap());
assert!(!after.is_empty(), "continuation should emit hashes");
let first_new_frame = 1 + (a.len().saturating_sub(WANG_N_FFT)) / WANG_HOP;
for (t, h) in &after {
assert!(
h.t_anchor as usize >= first_new_frame,
"hash anchored in pre-flush audio re-emitted: anchor frame {} (first new frame {first_new_frame}), t={} ms",
h.t_anchor,
t.0,
);
}
}
#[test]
fn streaming_silence_emits_nothing() {
let mut s = StreamingWang::default();
let zeros = vec![0.0_f32; 8_000 * 4];
assert!(s.push(&zeros).unwrap().is_empty());
assert!(s.flush().unwrap().is_empty());
}
fn chunk_sizes(seed: u32, total: usize, max_chunk: usize) -> Vec<usize> {
let mut x = seed.max(1);
let mut out = Vec::new();
let mut remaining = total;
while remaining > 0 {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
let n = ((x as usize) % max_chunk).max(1).min(remaining);
out.push(n);
remaining -= n;
}
out
}
#[test]
fn streaming_chunk_size_invariant() {
let samples = synthetic_audio(0xFACE, 8_000 * 4);
let collect = |chunk_size: usize| -> Vec<WangHash> {
let mut s = StreamingWang::default();
let mut out = Vec::new();
for chunk in samples.chunks(chunk_size) {
out.extend(s.push(chunk).unwrap().into_iter().map(|(_, h)| h));
}
out.extend(s.flush().unwrap().into_iter().map(|(_, h)| h));
out.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
out
};
let baseline = collect(8_000); for chunk_size in [128, 1024, 4321, 16_000] {
assert_eq!(
collect(chunk_size),
baseline,
"chunk_size = {chunk_size} produced different hashes than 8000",
);
}
}
#[test]
fn streaming_offline_equivalence() {
let samples = synthetic_audio(0xBEEF, 8_000 * 6);
let mut offline = Wang::default();
let off = offline.extract(&samples, SampleRate::HZ_8000).unwrap();
let mut streaming = StreamingWang::default();
let mut online = Vec::new();
let mut cursor = 0;
for n in chunk_sizes(0xCAFE, samples.len(), 4_000) {
let end = cursor + n;
online.extend(
streaming
.push(&samples[cursor..end])
.unwrap()
.into_iter()
.map(|(_, h)| h),
);
cursor = end;
}
online.extend(streaming.flush().unwrap().into_iter().map(|(_, h)| h));
let mut a: Vec<WangHash> = off.hashes;
let mut b: Vec<WangHash> = online;
a.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
b.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
assert_eq!(a.len(), b.len(), "hash count mismatch");
assert_eq!(a, b, "hash sequences differ");
}
#[test]
fn smaller_fan_out_yields_fewer_hashes() {
let samples = synthetic_audio(0xFEED, 8_000 * 4);
let mut wide = Wang::new(WangConfig {
fan_out: 10,
..WangConfig::default()
});
let mut narrow = Wang::new(WangConfig {
fan_out: 3,
..WangConfig::default()
});
let f_wide = wide.extract(&samples, SampleRate::HZ_8000).unwrap();
let f_narrow = narrow.extract(&samples, SampleRate::HZ_8000).unwrap();
assert!(
f_narrow.hashes.len() < f_wide.hashes.len(),
"narrow={} wide={}",
f_narrow.hashes.len(),
f_wide.hashes.len(),
);
}
#[test]
fn quantise_freq_covers_full_range() {
assert_eq!(quantise_freq(0), 0);
assert!(quantise_freq(512) < WANG_FREQ_BUCKETS);
let mut prev = 0;
for b in 0..513_u16 {
let q = quantise_freq(b);
assert!(q >= prev);
assert!(q < WANG_FREQ_BUCKETS);
prev = q;
}
}
#[test]
fn streaming_with_one_sample_chunks_still_matches_offline() {
let samples = synthetic_audio(0xABCD, 8_000 * 3);
let mut offline = Wang::default();
let off = offline.extract(&samples, SampleRate::HZ_8000).unwrap();
let mut s = StreamingWang::default();
let mut online = Vec::new();
for &sample in &samples {
online.extend(s.push(&[sample]).unwrap().into_iter().map(|(_, h)| h));
}
online.extend(s.flush().unwrap().into_iter().map(|(_, h)| h));
let mut a = off.hashes;
let mut b = online;
a.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
b.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
assert_eq!(a, b);
}
#[test]
fn streaming_state_stays_bounded_under_long_input() {
let secs = 30usize;
let samples = synthetic_audio(7, WANG_SR as usize * secs);
let chunk = 256usize;
let mut s = StreamingWang::default();
let max_spec_rows = 2 * WANG_PEAK_NEIGHBOURHOOD + 1;
let mut peak_carry = 0usize;
let mut peak_spec_rows = 0usize;
let mut peak_bucket_pending = 0usize;
let mut peak_anchors = 0usize;
let mut start = 0usize;
while start < samples.len() {
let end = (start + chunk).min(samples.len());
let _ = s.push(&samples[start..end]).unwrap();
peak_carry = peak_carry.max(s.core.sample_carry.len());
peak_spec_rows = peak_spec_rows.max(s.core.spec_n_rows);
peak_bucket_pending = peak_bucket_pending.max(s.core.bucket_pending.len());
peak_anchors = peak_anchors.max(s.core.pending_anchors.len());
assert!(s.core.sample_carry.len() < WANG_N_FFT);
assert!(s.core.spec_n_rows <= max_spec_rows);
start = end;
}
assert_eq!(
peak_spec_rows, max_spec_rows,
"spec window should fill once the stream is long enough",
);
assert!(peak_carry < WANG_N_FFT, "peak_carry {peak_carry}");
assert!(
peak_bucket_pending <= 3,
"bucket_pending peaked at {peak_bucket_pending} (steady state should be ≤ 2)",
);
assert!(
peak_anchors <= 40,
"pending_anchors peaked at {peak_anchors} (expected ≤ 40)",
);
let _ = s.flush().unwrap();
assert_eq!(s.core.bucket_pending.len(), 0);
assert_eq!(s.core.pending_anchors.len(), 0);
}
#[test]
fn target_zone_filters_far_peaks() {
let peaks = alloc::vec![
Peak {
t_frame: 0,
f_bin: 100,
_pad: 0,
mag: 0.0
},
Peak {
t_frame: 0,
f_bin: 200,
_pad: 0,
mag: 0.0
},
Peak {
t_frame: 70,
f_bin: 100,
_pad: 0,
mag: 0.0
},
Peak {
t_frame: 5,
f_bin: 110,
_pad: 0,
mag: 0.0
},
Peak {
t_frame: 5,
f_bin: 300,
_pad: 0,
mag: 0.0
},
];
let mut sorted = peaks;
sorted.sort_unstable_by_key(|p| (p.t_frame, p.f_bin));
let cfg = WangConfig::default();
let hashes = build_hashes(&sorted, &cfg);
assert_eq!(hashes.len(), 1);
let ta = hashes[0].t_anchor;
assert_eq!(ta, 0);
}
fn anchor_with_target(
t_frame: u32,
f_bin: u16,
target_t: u32,
target_f: u16,
target_mag: f32,
) -> stream::PendingAnchor {
let target = Peak {
t_frame: target_t,
f_bin: target_f,
_pad: 0,
mag: target_mag,
};
let mut targets = Vec::with_capacity(4);
insert_top_target(&mut targets, target, 4);
stream::PendingAnchor {
peak: Peak {
t_frame,
f_bin,
_pad: 0,
mag: 1.0,
},
targets,
}
}
fn wang_bucket_of(t_frame: u32) -> i32 {
(t_frame as f32 / WANG_FRAMES_PER_SEC) as i32
}
#[test]
fn wang_emit_finalized_anchors_emits_all_when_zones_covered() {
let mut s = StreamingWang::default();
s.core
.pending_anchors
.push_back(anchor_with_target(0, 10, 10, 12, 0.9));
s.core
.pending_anchors
.push_back(anchor_with_target(5, 20, 15, 22, 0.8));
s.core
.pending_anchors
.push_back(anchor_with_target(100, 30, 110, 32, 0.7));
s.core.last_finalized_bucket = wang_bucket_of(163);
s.core.emitted.clear();
s.core
.emit_finalized_anchors(s.peak_cfg(), StreamingWang::emit_anchor);
assert_eq!(s.core.emitted.len(), 3);
assert!(s.core.pending_anchors.is_empty());
}
#[test]
fn wang_emit_finalized_anchors_re_queues_unfinalised() {
let mut s = StreamingWang::default();
s.core
.pending_anchors
.push_back(anchor_with_target(0, 10, 10, 12, 0.9));
s.core
.pending_anchors
.push_back(anchor_with_target(100, 30, 110, 32, 0.7));
s.core.last_finalized_bucket = 1;
s.core.emitted.clear();
s.core
.emit_finalized_anchors(s.peak_cfg(), StreamingWang::emit_anchor);
assert_eq!(s.core.emitted.len(), 1);
assert_eq!(s.core.pending_anchors.len(), 1);
assert_eq!(s.core.pending_anchors.front().unwrap().peak.t_frame, 100);
}
#[test]
fn wang_emit_finalized_anchors_idempotent_under_repeated_calls() {
let mut s = StreamingWang::default();
s.core
.pending_anchors
.push_back(anchor_with_target(0, 10, 10, 12, 0.9));
s.core.last_finalized_bucket = wang_bucket_of(63);
s.core.emitted.clear();
s.core
.emit_finalized_anchors(s.peak_cfg(), StreamingWang::emit_anchor);
let first_len = s.core.emitted.len();
s.core.emitted.clear();
s.core
.emit_finalized_anchors(s.peak_cfg(), StreamingWang::emit_anchor);
let second_len = s.core.emitted.len();
assert_eq!(first_len, 1);
assert_eq!(second_len, 0);
assert!(s.core.pending_anchors.is_empty());
}
#[test]
fn public_api_name_and_config_match_documented_values() {
let fp = Wang::default();
assert_eq!(fp.name(), "wang-v1");
assert_eq!(fp.required_sample_rate(), SampleRate::HZ_8000);
assert_eq!(fp.min_samples(), 16_000);
let s = StreamingWang::default();
assert_eq!(s.latency_ms(), 2_256);
}
#[test]
fn default_config_is_unchanged_by_guard_clamps() {
let fp = Wang::default();
assert_eq!(fp.config().fan_out, 10);
assert_eq!(fp.config().target_zone_t, 63);
assert_eq!(fp.config().peaks_per_sec, 30);
}
#[test]
fn zero_target_zone_is_clamped_to_one_not_underflow() {
let cfg = WangConfig {
target_zone_t: 0,
..WangConfig::default()
};
let fp = Wang::new(cfg);
assert_eq!(fp.config().target_zone_t, 1);
}
#[test]
fn extreme_config_is_clamped_within_safe_bounds() {
let cfg = WangConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..WangConfig::default()
};
let fp = Wang::new(cfg);
assert_eq!(fp.config().fan_out, 64);
assert_eq!(fp.config().target_zone_t, 512);
assert_eq!(fp.config().peaks_per_sec, 500);
}
#[test]
fn clamped_config_still_produces_valid_hashes() {
let cfg = WangConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..WangConfig::default()
};
let mut fp = Wang::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 3);
let fpr = fp.extract(&samples, SampleRate::HZ_8000).unwrap();
assert!(!fpr.hashes.is_empty());
}
#[test]
fn streaming_default_config_is_unchanged_by_guard_clamps() {
let s = StreamingWang::default();
let cfg = s.config();
assert_eq!(cfg.fan_out, 10);
assert_eq!(cfg.target_zone_t, 63);
assert_eq!(cfg.peaks_per_sec, 30);
}
#[test]
fn streaming_extreme_config_is_clamped_within_safe_bounds() {
let cfg = WangConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..WangConfig::default()
};
let s = StreamingWang::new(cfg);
assert_eq!(s.config().fan_out, 64);
assert_eq!(s.config().target_zone_t, 512);
assert_eq!(s.config().peaks_per_sec, 500);
}
#[test]
fn streaming_reset_clears_all_state() {
let mut s = StreamingWang::default();
let samples = synthetic_audio(0xFEED, 8_000 * 4);
let before = s.push(&samples).unwrap();
assert!(!before.is_empty(), "should produce hashes");
s.reset();
assert!(s.push(&[]).unwrap().is_empty(), "reset should clear state");
let after_reset = s.push(&samples).unwrap();
assert!(!after_reset.is_empty());
assert_eq!(
before, after_reset,
"reset+replay must produce identical hashes"
);
}
#[test]
fn push_with_matches_push_output_count() {
let mut a = StreamingWang::default();
let mut b = StreamingWang::default();
let samples = synthetic_audio(0xABCD, 8_000 * 4);
let via_push = a.push(&samples).unwrap();
let mut via_cb: Vec<(TimestampMs, WangHash)> = Vec::new();
let n = b.push_with(&samples, |t, f| via_cb.push((t, *f))).unwrap();
let via_flush = b.flush().unwrap();
let flush_len = via_flush.len();
via_cb.extend(via_flush);
let mut all_via_push = via_push;
all_via_push.extend(a.flush().unwrap());
assert_eq!(n + flush_len, all_via_push.len());
assert_eq!(
via_cb, all_via_push,
"push_with must emit exactly what push+flush emits"
);
}
#[test]
fn flush_with_matches_flush_output() {
let mut a = StreamingWang::default();
let mut b = StreamingWang::default();
let samples = synthetic_audio(0xF00D, 8_000 * 4);
let _ = a.push(&samples).unwrap();
let _ = b.push(&samples).unwrap();
let via_flush = a.flush().unwrap();
let mut via_cb: Vec<(TimestampMs, WangHash)> = Vec::new();
let n = b.flush_with(|t, f| via_cb.push((t, *f))).unwrap();
assert_eq!(n, via_flush.len());
assert_eq!(via_cb, via_flush);
}
#[test]
fn default_max_input_samples_is_set() {
let fp = Wang::default();
assert!(fp.config().max_input_samples.is_some());
}
#[test]
fn input_larger_than_max_is_rejected() {
let cfg = WangConfig {
max_input_samples: Some(1_000),
..WangConfig::default()
};
let mut fp = Wang::new(cfg);
let samples = vec![0.0_f32; 2_000];
let err = fp.extract(&samples, SampleRate::HZ_8000).unwrap_err();
match err {
AfpError::InputTooLarge { limit, provided } => {
assert_eq!(limit, 1_000);
assert_eq!(provided, 2_000);
}
other => panic!("expected InputTooLarge, got {other:?}"),
}
}
#[test]
fn none_disables_max_input_check() {
let cfg = WangConfig {
max_input_samples: None,
..WangConfig::default()
};
let mut fp = Wang::new(cfg);
let samples = vec![0.0_f32; 16_000];
fp.extract(&samples, SampleRate::HZ_8000).unwrap();
}
#[test]
fn valid_input_under_limit_passes() {
let cfg = WangConfig {
max_input_samples: Some(100_000),
..WangConfig::default()
};
let mut fp = Wang::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 3);
fp.extract(&samples, SampleRate::HZ_8000).unwrap();
}
#[test]
fn max_hashes_enforced_rejects_too_many() {
let cfg = WangConfig {
max_hashes: Some(10),
..WangConfig::default()
};
let mut fp = Wang::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 5);
let err = fp.extract(&samples, SampleRate::HZ_8000).unwrap_err();
assert!(matches!(err, AfpError::InputTooLarge { .. }));
}
#[test]
fn max_pending_anchors_evicts_oldest() {
let cfg = WangConfig {
max_pending_anchors: Some(100),
..WangConfig::default()
};
let mut s = StreamingWang::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 20);
let mut hashes = s.push(&samples).unwrap();
hashes.extend(s.flush().unwrap());
assert!(s.config().max_pending_anchors.is_some());
assert!(!hashes.is_empty(), "should produce hashes with cap=100");
}
#[test]
fn extract_rejects_nan_pcm() {
let mut fp = Wang::default();
let mut samples = vec![0.0_f32; 8_000 * 3];
samples[100] = f32::NAN;
let err = fp.extract(&samples, SampleRate::HZ_8000).unwrap_err();
assert!(matches!(err, AfpError::NonFiniteSample { index: 100 }));
}
#[test]
fn max_push_samples_truncates_hostile_chunk() {
let cfg = WangConfig {
max_push_samples: Some(512),
..WangConfig::default()
};
let mut s = StreamingWang::new(cfg);
let samples = synthetic_audio(0xBEEF, 8_000 * 5);
let _ = s.push(&samples).unwrap();
let _ = s.flush().unwrap();
assert_eq!(s.config().max_push_samples, Some(512));
}
#[test]
fn push_sanitizes_nan_to_zero() {
let mut clean = StreamingWang::default();
let mut dirty = StreamingWang::default();
let mut samples = synthetic_audio(0xABCD, 8_000 * 3);
let a = clean.push(&samples);
samples[10] = f32::NAN;
samples[20] = f32::INFINITY;
let b = dirty.push(&samples);
let _ = (a, b);
let _ = dirty.flush();
}
#[test]
fn extract_with_progress_is_called_and_monotonic() {
let mut fp = Wang::default();
let samples = synthetic_audio(0xCAFE, 8_000 * 5);
let mut values: Vec<f32> = Vec::new();
let result = fp.extract_with_progress(&samples, SampleRate::HZ_8000, |v| values.push(v));
assert!(result.is_ok());
assert!(
values.len() >= 3,
"expected at least 3 progress calls, got {}",
values.len()
);
assert_eq!(values[0], 0.0);
assert_eq!(*values.last().unwrap(), 1.0);
for w in values.windows(2) {
assert!(w[1] >= w[0], "progress went backwards: {} → {}", w[0], w[1]);
}
for &v in &values {
assert!((0.0..=1.0).contains(&v), "progress out of range: {v}");
}
}
#[test]
fn extract_with_progress_matches_extract_output() {
let samples = synthetic_audio(0xDEAD, 8_000 * 4);
let mut fp1 = Wang::default();
let result1 = fp1.extract(&samples, SampleRate::HZ_8000).unwrap();
let mut fp2 = Wang::default();
let result2 = fp2
.extract_with_progress(&samples, SampleRate::HZ_8000, |_| {})
.unwrap();
assert_eq!(result1.hashes, result2.hashes);
assert_eq!(result1.frames_per_sec, result2.frames_per_sec);
}
#[test]
fn extract_with_progress_short_audio_still_reports_0_and_1() {
let mut fp = Wang::default();
let samples = synthetic_audio(0xFACE, 8_000 * 2);
let mut values: Vec<f32> = Vec::new();
let _ = fp.extract_with_progress(&samples, SampleRate::HZ_8000, |v| values.push(v));
assert_eq!(values[0], 0.0);
assert_eq!(*values.last().unwrap(), 1.0);
}
#[test]
fn max_push_samples_zero_is_bumped_to_one() {
let cfg = WangConfig {
max_push_samples: Some(0),
..WangConfig::default()
};
let s = StreamingWang::new(cfg);
assert_eq!(s.config().max_push_samples, Some(1));
}
}