use alloc::vec::Vec;
use libm::log10f;
use crate::dsp::peaks::{Peak, PeakPicker, PeakPickerConfig};
use crate::dsp::stft::{ShortTimeFFT, StftConfig};
use crate::dsp::windows::WindowKind;
use crate::{AfpError, AudioBuffer, Fingerprinter, Result, 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,
}
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,
}
}
}
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;
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(cfg: WangConfig) -> Self {
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: cfg.min_anchor_mag_db,
target_per_sec: cfg.peaks_per_sec as usize,
});
Self {
cfg,
stft,
picker,
log_spec: Vec::new(),
}
}
}
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) -> u32 {
WANG_SR
}
fn min_samples(&self) -> usize {
WANG_SR as usize * 2
}
fn extract(&mut self, audio: AudioBuffer<'_>) -> Result<Self::Output> {
if audio.rate.hz() != WANG_SR {
return Err(AfpError::UnsupportedSampleRate(audio.rate.hz()));
}
if audio.samples.len() < self.min_samples() {
return Err(AfpError::AudioTooShort {
needed: self.min_samples(),
got: audio.samples.len(),
});
}
let (power_flat, n_frames, n_bins) = self.stft.power_flat(audio.samples);
if n_frames == 0 {
return Ok(WangFingerprint {
hashes: Vec::new(),
frames_per_sec: WANG_FRAMES_PER_SEC,
});
}
self.log_spec.clear();
self.log_spec.resize(power_flat.len(), 0.0);
for (i, &p) in power_flat.iter().enumerate() {
self.log_spec[i] = 10.0 * log10f(p.max(WANG_LOG_FLOOR_POWER));
}
let peaks = self
.picker
.pick(&self.log_spec, n_frames, n_bins, WANG_FRAMES_PER_SEC);
let mut hashes = build_hashes(&peaks, &self.cfg);
hashes.sort_unstable_by_key(|h| (h.t_anchor, h.hash));
Ok(WangFingerprint {
hashes,
frames_per_sec: WANG_FRAMES_PER_SEC,
})
}
}
#[derive(Copy, Clone)]
struct MinByMag<'a>(&'a Peak);
impl PartialEq for MinByMag<'_> {
fn eq(&self, o: &Self) -> bool {
self.0.mag == o.0.mag && self.0.t_frame == o.0.t_frame && self.0.f_bin == o.0.f_bin
}
}
impl Eq for MinByMag<'_> {}
impl PartialOrd for MinByMag<'_> {
fn partial_cmp(&self, o: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for MinByMag<'_> {
fn cmp(&self, o: &Self) -> core::cmp::Ordering {
o.0.mag
.partial_cmp(&self.0.mag)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (o.0.t_frame, o.0.f_bin).cmp(&(self.0.t_frame, self.0.f_bin)))
}
}
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 i32;
let target_zone_f = cfg.target_zone_f as i32;
let fan_out = cfg.fan_out as usize;
let mut heap: alloc::collections::BinaryHeap<MinByMag> =
alloc::collections::BinaryHeap::with_capacity(fan_out + 1);
let mut targets: Vec<&Peak> = Vec::with_capacity(fan_out);
for (i, anchor) in peaks.iter().enumerate() {
heap.clear();
for target in &peaks[i + 1..] {
let dt = target.t_frame as i32 - anchor.t_frame as i32;
if dt < 1 {
continue;
}
if dt > target_zone_t {
break;
}
let df = target.f_bin as i32 - anchor.f_bin as i32;
if df.abs() > target_zone_f {
continue;
}
heap.push(MinByMag(target));
if heap.len() > fan_out {
heap.pop();
}
}
targets.clear();
targets.extend(heap.drain().map(|w| w.0));
targets.sort_unstable_by(|a, b| {
b.mag
.partial_cmp(&a.mag)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (a.t_frame, a.f_bin).cmp(&(b.t_frame, b.f_bin)))
});
for target in &targets {
let f_a_q = quantise_freq(anchor.f_bin);
let f_b_q = quantise_freq(target.f_bin);
let dt = ((target.t_frame - anchor.t_frame) & 0x3FFF).max(1);
let hash = ((f_a_q & 0x1FF) << 23) | ((f_b_q & 0x1FF) << 14) | (dt & 0x3FFF);
hashes.push(WangHash {
hash,
t_anchor: anchor.t_frame,
});
}
}
hashes
}
#[inline]
fn quantise_freq(bin: u16) -> u32 {
(bin as u32 * WANG_FREQ_BUCKETS) / 513
}
#[derive(Copy, Clone)]
struct MinByMagOwned(Peak);
impl PartialEq for MinByMagOwned {
fn eq(&self, o: &Self) -> bool {
self.0.mag == o.0.mag && self.0.t_frame == o.0.t_frame && self.0.f_bin == o.0.f_bin
}
}
impl Eq for MinByMagOwned {}
impl PartialOrd for MinByMagOwned {
fn partial_cmp(&self, o: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for MinByMagOwned {
fn cmp(&self, o: &Self) -> core::cmp::Ordering {
o.0.mag
.partial_cmp(&self.0.mag)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (o.0.t_frame, o.0.f_bin).cmp(&(self.0.t_frame, self.0.f_bin)))
}
}
struct PendingAnchor {
peak: Peak,
targets: alloc::collections::BinaryHeap<MinByMagOwned>,
}
pub struct StreamingWang {
cfg: WangConfig,
stft: ShortTimeFFT,
sample_carry: alloc::vec::Vec<f32>,
spec: alloc::vec::Vec<f32>,
spec_n_rows: usize,
spec_n_bins: usize,
spec_first_frame: u32,
n_frames_total: u32,
last_pd_frame: i32,
pd_max: alloc::vec::Vec<f32>,
pd_temp: alloc::vec::Vec<f32>,
pd_col_in: alloc::vec::Vec<f32>,
pd_col_out: alloc::vec::Vec<f32>,
frame_scratch: alloc::vec::Vec<f32>,
bucket_pending: alloc::collections::BTreeMap<u32, alloc::vec::Vec<Peak>>,
last_finalized_bucket: i32,
pending_anchors: alloc::collections::VecDeque<PendingAnchor>,
}
impl Default for StreamingWang {
fn default() -> Self {
Self::new(WangConfig::default())
}
}
impl StreamingWang {
#[must_use]
pub fn new(cfg: WangConfig) -> Self {
let stft = ShortTimeFFT::new(StftConfig {
n_fft: WANG_N_FFT,
hop: WANG_HOP,
window: WindowKind::Hann,
center: false,
});
let n_bins = stft.n_bins();
let window_capacity = 2 * WANG_PEAK_NEIGHBOURHOOD + 1;
Self {
cfg,
stft,
sample_carry: alloc::vec::Vec::new(),
spec: alloc::vec![0.0_f32; window_capacity * n_bins],
spec_n_rows: 0,
spec_n_bins: n_bins,
spec_first_frame: 0,
n_frames_total: 0,
last_pd_frame: -1,
pd_max: alloc::vec::Vec::new(),
pd_temp: alloc::vec::Vec::new(),
pd_col_in: alloc::vec::Vec::new(),
pd_col_out: alloc::vec::Vec::new(),
frame_scratch: alloc::vec![0.0_f32; n_bins],
bucket_pending: alloc::collections::BTreeMap::new(),
last_finalized_bucket: -1,
pending_anchors: alloc::collections::VecDeque::new(),
}
}
#[must_use]
pub fn config(&self) -> &WangConfig {
&self.cfg
}
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 append_frame_scratch_row(&mut self) {
debug_assert_eq!(self.frame_scratch.len(), self.spec_n_bins);
let cap = 2 * WANG_PEAK_NEIGHBOURHOOD + 1;
if self.spec_n_rows == cap {
self.spec.copy_within(self.spec_n_bins.., 0);
self.spec_first_frame += 1;
self.spec_n_rows -= 1;
}
let dst_start = self.spec_n_rows * self.spec_n_bins;
let n_bins = self.spec_n_bins;
self.spec[dst_start..dst_start + n_bins].copy_from_slice(&self.frame_scratch);
self.spec_n_rows += 1;
}
fn detect_rows(&mut self, from_row: usize, to_row: usize) {
if self.spec_n_rows == 0 || from_row > to_row {
return;
}
let n_rows = self.spec_n_rows;
let n_bins = self.spec_n_bins;
let used = n_rows * n_bins;
self.pd_max.clear();
self.pd_max.resize(used, 0.0);
self.pd_temp.clear();
self.pd_temp.resize(used, 0.0);
self.pd_col_in.clear();
self.pd_col_in.resize(n_rows, 0.0);
self.pd_col_out.clear();
self.pd_col_out.resize(n_rows, 0.0);
crate::dsp::peaks::rolling_max_2d_pooled(
&self.spec[..used],
n_rows,
n_bins,
WANG_PEAK_NEIGHBOURHOOD,
WANG_PEAK_NEIGHBOURHOOD,
&mut self.pd_max,
&mut self.pd_temp,
&mut self.pd_col_in,
&mut self.pd_col_out,
);
for row in from_row..=to_row {
if row >= n_rows {
break;
}
let abs_f = self.spec_first_frame + row as u32;
let bucket = (abs_f as f32 / WANG_FRAMES_PER_SEC) as u32;
for bin in 0..n_bins {
let idx = row * n_bins + bin;
let v = self.spec[idx];
if v > self.cfg.min_anchor_mag_db && v >= self.pd_max[idx] {
let peak = Peak {
t_frame: abs_f,
f_bin: bin as u16,
_pad: 0,
mag: v,
};
self.bucket_pending.entry(bucket).or_default().push(peak);
}
}
}
}
fn finalize_bucket(&mut self, bucket: u32) {
let mut peaks = match self.bucket_pending.remove(&bucket) {
Some(p) => p,
None => return,
};
peaks.sort_unstable_by(|a, b| {
b.mag
.partial_cmp(&a.mag)
.unwrap_or(core::cmp::Ordering::Equal)
});
peaks.truncate(self.cfg.peaks_per_sec as usize);
peaks.sort_unstable_by_key(|p| (p.t_frame, p.f_bin));
let target_zone_t = self.cfg.target_zone_t as i32;
let target_zone_f = self.cfg.target_zone_f as i32;
let fan_out = self.cfg.fan_out as usize;
for peak in peaks {
for anchor in self.pending_anchors.iter_mut() {
let dt = peak.t_frame as i32 - anchor.peak.t_frame as i32;
if dt < 1 || dt > target_zone_t {
continue;
}
let df = peak.f_bin as i32 - anchor.peak.f_bin as i32;
if df.abs() > target_zone_f {
continue;
}
anchor.targets.push(MinByMagOwned(peak));
if anchor.targets.len() > fan_out {
anchor.targets.pop();
}
}
self.pending_anchors.push_back(PendingAnchor {
peak,
targets: alloc::collections::BinaryHeap::with_capacity(fan_out + 1),
});
}
self.last_finalized_bucket = bucket as i32;
}
fn finalize_buckets(&mut self) {
if self.last_pd_frame < 0 {
return;
}
let current_bucket = (self.last_pd_frame as f32 / WANG_FRAMES_PER_SEC) as i32;
let to_finalize: alloc::vec::Vec<u32> = self
.bucket_pending
.keys()
.filter(|&&b| (b as i32) > self.last_finalized_bucket && (b as i32) < current_bucket)
.cloned()
.collect();
for bucket in to_finalize {
self.finalize_bucket(bucket);
}
}
fn emit_finalized_anchors(&mut self) -> alloc::vec::Vec<(TimestampMs, WangHash)> {
let mut emitted = alloc::vec::Vec::new();
while let Some(front) = self.pending_anchors.front() {
let last_target_frame = front.peak.t_frame + self.cfg.target_zone_t as u32;
let last_target_bucket = (last_target_frame as f32 / WANG_FRAMES_PER_SEC) as i32;
if self.last_finalized_bucket < last_target_bucket {
break;
}
let anchor = self.pending_anchors.pop_front().unwrap();
self.build_hashes_for_anchor(anchor, &mut emitted);
}
emitted
}
fn build_hashes_for_anchor(
&self,
anchor: PendingAnchor,
out: &mut alloc::vec::Vec<(TimestampMs, WangHash)>,
) {
let mut targets: alloc::vec::Vec<Peak> = anchor.targets.into_iter().map(|w| w.0).collect();
targets.sort_unstable_by(|a, b| {
b.mag
.partial_cmp(&a.mag)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (a.t_frame, a.f_bin).cmp(&(b.t_frame, b.f_bin)))
});
for target in &targets {
let f_a_q = quantise_freq(anchor.peak.f_bin);
let f_b_q = quantise_freq(target.f_bin);
let dt = ((target.t_frame - anchor.peak.t_frame) & 0x3FFF).max(1);
let hash = ((f_a_q & 0x1FF) << 23) | ((f_b_q & 0x1FF) << 14) | (dt & 0x3FFF);
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 push(&mut self, samples: &[f32]) -> alloc::vec::Vec<(TimestampMs, Self::Frame)> {
self.sample_carry.extend_from_slice(samples);
let nbht = WANG_PEAK_NEIGHBOURHOOD as u32;
let mut off = 0usize;
while self.sample_carry.len() - off >= WANG_N_FFT {
self.stft.process_frame_power(
&self.sample_carry[off..off + WANG_N_FFT],
&mut self.frame_scratch,
);
for v in self.frame_scratch.iter_mut() {
*v = 10.0 * libm::log10f(v.max(WANG_LOG_FLOOR_POWER));
}
self.append_frame_scratch_row();
let frame_idx = self.n_frames_total;
self.n_frames_total += 1;
off += WANG_HOP;
if frame_idx >= nbht {
let abs_ripe = frame_idx - nbht;
let row_idx = (abs_ripe - self.spec_first_frame) as usize;
self.detect_rows(row_idx, row_idx);
self.last_pd_frame = abs_ripe as i32;
}
}
if off > 0 {
self.sample_carry.drain(0..off);
}
self.finalize_buckets();
self.emit_finalized_anchors()
}
fn flush(&mut self) -> alloc::vec::Vec<(TimestampMs, Self::Frame)> {
if self.spec_n_rows > 0 && self.n_frames_total > 0 {
let detect_to_abs = self.n_frames_total as i32 - 1;
if detect_to_abs > self.last_pd_frame {
let from_abs = (self.last_pd_frame + 1).max(self.spec_first_frame as i32) as u32;
let to_abs = detect_to_abs as u32;
let from_row = (from_abs - self.spec_first_frame) as usize;
let to_row = (to_abs - self.spec_first_frame) as usize;
self.detect_rows(from_row, to_row);
self.last_pd_frame = detect_to_abs;
}
}
let buckets: alloc::vec::Vec<u32> = self.bucket_pending.keys().cloned().collect();
for bucket in buckets {
self.finalize_bucket(bucket);
}
let mut emitted = alloc::vec::Vec::new();
while let Some(anchor) = self.pending_anchors.pop_front() {
self.build_hashes_for_anchor(anchor, &mut emitted);
}
emitted
}
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];
let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_16000,
};
match fp.extract(buf) {
Err(AfpError::UnsupportedSampleRate(16_000)) => {}
other => panic!("expected UnsupportedSampleRate(16000), got {other:?}"),
}
}
#[test]
fn rejects_short_audio() {
let mut fp = Wang::default();
let samples = vec![0.0_f32; 8_000]; let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
match fp.extract(buf) {
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 buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let fpr = fp.extract(buf).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 buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let fpr = fp.extract(buf).unwrap();
assert!(!fpr.hashes.is_empty(), "expected hashes from a 5s tone");
for w in fpr.hashes.windows(2) {
assert!((w[0].t_anchor, w[0].hash) <= (w[1].t_anchor, w[1].hash));
}
}
#[test]
fn extraction_is_deterministic() {
let samples = synthetic_audio(0xDEAD, 8_000 * 4);
let mut fp1 = Wang::default();
let buf1 = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let f1 = fp1.extract(buf1).unwrap();
let mut fp2 = Wang::default();
let buf2 = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let f2 = fp2.extract(buf2).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(AudioBuffer {
samples: &samples_a,
rate: SampleRate::HZ_8000,
})
.unwrap();
let fb = fp
.extract(AudioBuffer {
samples: &samples_b,
rate: 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);
assert_eq!(hashes[0].t_anchor, 100);
}
#[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(&[]).is_empty());
assert!(s.flush().is_empty());
}
#[test]
fn streaming_silence_emits_nothing() {
let mut s = StreamingWang::default();
let zeros = vec![0.0_f32; 8_000 * 4];
assert!(s.push(&zeros).is_empty());
assert!(s.flush().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).into_iter().map(|(_, h)| h));
}
out.extend(s.flush().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(AudioBuffer {
samples: &samples,
rate: 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])
.into_iter()
.map(|(_, h)| h),
);
cursor = end;
}
online.extend(streaming.flush().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 buf_a = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let buf_b = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
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(buf_a).unwrap();
let f_narrow = narrow.extract(buf_b).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(AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
})
.unwrap();
let mut s = StreamingWang::default();
let mut online = Vec::new();
for &sample in &samples {
online.extend(s.push(&[sample]).into_iter().map(|(_, h)| h));
}
online.extend(s.flush().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]);
peak_carry = peak_carry.max(s.sample_carry.len());
peak_spec_rows = peak_spec_rows.max(s.spec_n_rows);
peak_bucket_pending = peak_bucket_pending.max(s.bucket_pending.len());
peak_anchors = peak_anchors.max(s.pending_anchors.len());
assert!(s.sample_carry.len() < WANG_N_FFT);
assert!(s.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();
assert_eq!(s.bucket_pending.len(), 0);
assert_eq!(s.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);
assert_eq!(hashes[0].t_anchor, 0);
}
}