use alloc::vec::Vec;
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 PanakoHash {
pub hash: u32,
pub t_anchor: u32,
pub t_b: u32,
pub t_c: u32,
}
#[derive(Clone, Debug)]
pub struct PanakoFingerprint {
pub hashes: Vec<PanakoHash>,
pub frames_per_sec: f32,
}
#[derive(Clone, Debug)]
pub struct PanakoConfig {
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 PanakoConfig {
fn default() -> Self {
Self {
fan_out: 5,
target_zone_t: 96,
target_zone_f: 96,
peaks_per_sec: 30,
min_anchor_mag_db: -50.0,
max_input_samples: Some(30 * 60 * PANAKO_SR as usize),
max_hashes: Some(500_000),
max_pending_anchors: None,
max_push_samples: None,
}
}
}
const PANAKO_N_FFT: usize = 1024;
const PANAKO_HOP: usize = 128;
const PANAKO_SR: u32 = 8_000;
const PANAKO_FRAMES_PER_SEC: f32 = PANAKO_SR as f32 / PANAKO_HOP as f32;
const PANAKO_PEAK_NEIGHBOURHOOD: usize = 15;
const PANAKO_LOG_FLOOR: f32 = 1e-6;
const PANAKO_LOG_FLOOR_POWER: f32 = PANAKO_LOG_FLOOR * PANAKO_LOG_FLOOR;
use crate::dsp::DB_LOG2_FACTOR;
pub struct Panako {
cfg: PanakoConfig,
stft: ShortTimeFFT,
picker: PeakPicker,
log_spec: Vec<f32>,
}
impl Default for Panako {
fn default() -> Self {
Self::new(PanakoConfig::default())
}
}
impl Panako {
#[must_use]
pub fn new(mut cfg: PanakoConfig) -> Self {
cfg.target_zone_t = cfg.target_zone_t.clamp(1, 512);
cfg.fan_out = cfg.fan_out.clamp(1, 64);
cfg.peaks_per_sec = cfg.peaks_per_sec.min(500);
if cfg.max_input_samples == Some(0) {
cfg.max_input_samples = Some(1);
}
if cfg.max_hashes == Some(0) {
cfg.max_hashes = Some(1);
}
if cfg.max_pending_anchors == Some(0) {
cfg.max_pending_anchors = Some(1);
}
cfg.target_zone_f = cfg.target_zone_f.clamp(1, 512);
cfg.min_anchor_mag_db = cfg.min_anchor_mag_db.clamp(-200.0, 0.0);
let stft = ShortTimeFFT::new(StftConfig {
n_fft: PANAKO_N_FFT,
hop: PANAKO_HOP,
window: WindowKind::Hann,
center: false,
});
let picker = PeakPicker::new(PeakPickerConfig {
neighborhood_t: PANAKO_PEAK_NEIGHBOURHOOD,
neighborhood_f: PANAKO_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 Panako {
type Output = PanakoFingerprint;
type Config = PanakoConfig;
fn name(&self) -> &'static str {
"panako-v2"
}
fn config(&self) -> &Self::Config {
&self.cfg
}
fn required_sample_rate(&self) -> u32 {
PANAKO_SR
}
fn min_samples(&self) -> usize {
PANAKO_SR as usize * 2
}
fn extract(&mut self, audio: AudioBuffer<'_>) -> Result<Self::Output> {
crate::pcm::reject_non_finite(audio.samples)?;
if let Some(limit) = self.cfg.max_input_samples
&& audio.samples.len() > limit
{
return Err(AfpError::InputTooLarge {
limit,
provided: audio.samples.len(),
});
}
if audio.rate.hz() != PANAKO_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 (n_frames, n_bins) = self.stft.power_flat_into(audio.samples, &mut self.log_spec);
if n_frames == 0 {
return Ok(PanakoFingerprint {
hashes: Vec::new(),
frames_per_sec: PANAKO_FRAMES_PER_SEC,
});
}
for v in self.log_spec.iter_mut() {
*v = DB_LOG2_FACTOR * v.max(PANAKO_LOG_FLOOR_POWER).log2();
}
let peaks = self
.picker
.pick(&self.log_spec, n_frames, n_bins, PANAKO_FRAMES_PER_SEC);
let mut hashes = build_triplet_hashes(&peaks, &self.cfg);
hashes.sort_unstable_by_key(|h| (h.t_anchor, h.t_b, h.t_c, h.hash));
if let Some(limit) = self.cfg.max_hashes
&& hashes.len() > limit
{
return Err(AfpError::InputTooLarge {
limit,
provided: hashes.len(),
});
}
Ok(PanakoFingerprint {
hashes,
frames_per_sec: PANAKO_FRAMES_PER_SEC,
})
}
}
#[derive(Copy, Clone)]
struct MinByScoreOwned {
b: Peak,
c: Peak,
score: f32,
}
impl MinByScoreOwned {
fn new(b: &Peak, c: &Peak, score: f32) -> Self {
Self {
b: *b,
c: *c,
score,
}
}
}
impl PartialEq for MinByScoreOwned {
fn eq(&self, o: &Self) -> bool {
self.score == o.score
&& (self.b.t_frame, self.b.f_bin) == (o.b.t_frame, o.b.f_bin)
&& (self.c.t_frame, self.c.f_bin) == (o.c.t_frame, o.c.f_bin)
}
}
impl Eq for MinByScoreOwned {}
impl PartialOrd for MinByScoreOwned {
fn partial_cmp(&self, o: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for MinByScoreOwned {
fn cmp(&self, o: &Self) -> core::cmp::Ordering {
o.score
.partial_cmp(&self.score)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (o.b.t_frame, o.b.f_bin).cmp(&(self.b.t_frame, self.b.f_bin)))
.then_with(|| (o.c.t_frame, o.c.f_bin).cmp(&(self.c.t_frame, self.c.f_bin)))
}
}
fn build_triplet_hashes(peaks: &[Peak], cfg: &PanakoConfig) -> Vec<PanakoHash> {
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 hashes = Vec::with_capacity(peaks.len() * fan_out);
let mut targets: Vec<&Peak> = Vec::with_capacity(64);
let mut heap: alloc::collections::BinaryHeap<MinByScoreOwned> =
alloc::collections::BinaryHeap::with_capacity(fan_out + 1);
let mut triplets: Vec<(Peak, Peak, f32)> = Vec::with_capacity(fan_out);
let mut suffix_max: Vec<f32> = Vec::with_capacity(64);
for (i, anchor) in peaks.iter().enumerate() {
let zone_limit = anchor.t_frame.saturating_add(target_zone_t as u32 - 1);
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 as i32 - anchor.t_frame as i32;
if dt < 1 {
continue;
}
let df = target.f_bin as i32 - anchor.f_bin as i32;
if df.abs() >= target_zone_f {
continue;
}
targets.push(target);
}
let targets_len = targets.len();
if suffix_max.len() < targets_len + 1 {
suffix_max.resize(targets_len + 1, 0.0_f32);
} else {
suffix_max.truncate(targets_len + 1);
suffix_max[targets_len] = 0.0_f32;
}
for j in (0..targets_len).rev() {
let m = targets[j].mag;
suffix_max[j] = if m > suffix_max[j + 1] {
m
} else {
suffix_max[j + 1]
};
}
heap.clear();
for (j, b) in targets.iter().enumerate() {
if heap.len() >= fan_out
&& heap
.peek()
.is_some_and(|min| b.mag + suffix_max[j + 1] < min.score)
{
continue;
}
for c in &targets[j + 1..] {
let score = b.mag + c.mag;
heap.push(MinByScoreOwned::new(b, c, score));
if heap.len() > fan_out {
heap.pop();
}
}
}
triplets.clear();
triplets.extend(heap.drain().map(|w| (w.b, w.c, w.score)));
triplets.sort_unstable_by(|x, y| {
y.2.partial_cmp(&x.2)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (x.0.t_frame, x.0.f_bin).cmp(&(y.0.t_frame, y.0.f_bin)))
.then_with(|| (x.1.t_frame, x.1.f_bin).cmp(&(y.1.t_frame, y.1.f_bin)))
});
for (b, c, _) in &triplets {
let hash = pack_triplet(anchor, b, c);
hashes.push(PanakoHash {
hash,
t_anchor: anchor.t_frame,
t_b: b.t_frame,
t_c: c.t_frame,
});
}
}
hashes
}
#[inline]
fn pack_triplet(a: &Peak, b: &Peak, c: &Peak) -> u32 {
let f_a = a.f_bin as i32;
let f_b = b.f_bin as i32;
let f_c = c.f_bin as i32;
let df_ab = (f_b - f_a).clamp(-127, 127);
let df_bc = (f_c - f_b).clamp(-127, 127);
let sign: u32 = ((f_b >= f_a) as u32) | (((f_c >= f_b) as u32) << 1);
let mag_order: u32 = if a.mag >= b.mag && a.mag >= c.mag {
0
} else if b.mag >= c.mag {
1
} else {
2
};
let dt_ac = (c.t_frame - a.t_frame).max(1) as f32;
let dt_bc = (c.t_frame - b.t_frame) as f32;
let beta = ((dt_bc / dt_ac * 31.0 + 0.5) as i32).clamp(0, 31) as u32;
let dab_u = (df_ab as i8 as u8) as u32;
let dbc_u = (df_bc as i8 as u8) as u32;
((sign & 0x3) << 30)
| ((mag_order & 0x3) << 28)
| ((beta & 0x1F) << 23)
| ((dab_u & 0xFF) << 15)
| ((dbc_u & 0xFF) << 7)
}
struct PendingAnchorPanako {
peak: Peak,
targets: alloc::vec::Vec<Peak>,
}
pub struct StreamingPanako {
cfg: PanakoConfig,
stft: ShortTimeFFT,
sample_carry: Vec<f32>,
spec: Vec<f32>,
spec_n_rows: usize,
spec_n_bins: usize,
spec_first_frame: u32,
n_frames_total: u32,
last_pd_frame: i32,
peak_det: crate::dsp::peaks::IncrementalPeakDetector,
peak_row_max: Vec<f32>,
frame_scratch: Vec<f32>,
bucket_pending: Vec<(u32, Vec<Peak>)>,
last_finalized_bucket: i32,
pending_anchors: alloc::collections::VecDeque<PendingAnchorPanako>,
to_finalize: Vec<u32>,
triplet_scratch: Vec<(Peak, Peak, f32)>,
emitted: Vec<(TimestampMs, PanakoHash)>,
}
impl Default for StreamingPanako {
fn default() -> Self {
Self::new(PanakoConfig::default())
}
}
impl StreamingPanako {
#[must_use]
pub fn new(mut cfg: PanakoConfig) -> Self {
cfg.target_zone_t = cfg.target_zone_t.clamp(1, 512);
cfg.fan_out = cfg.fan_out.clamp(1, 64);
cfg.peaks_per_sec = cfg.peaks_per_sec.min(500);
if cfg.max_input_samples == Some(0) {
cfg.max_input_samples = Some(1);
}
if cfg.max_hashes == Some(0) {
cfg.max_hashes = Some(1);
}
if cfg.max_pending_anchors == Some(0) {
cfg.max_pending_anchors = Some(1);
}
cfg.target_zone_f = cfg.target_zone_f.clamp(1, 512);
cfg.min_anchor_mag_db = cfg.min_anchor_mag_db.clamp(-200.0, 0.0);
let stft = ShortTimeFFT::new(StftConfig {
n_fft: PANAKO_N_FFT,
hop: PANAKO_HOP,
window: WindowKind::Hann,
center: false,
});
let n_bins = stft.n_bins();
let window_capacity = 2 * PANAKO_PEAK_NEIGHBOURHOOD + 1;
Self {
cfg,
stft,
sample_carry: 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,
peak_det: crate::dsp::peaks::IncrementalPeakDetector::new(
PANAKO_PEAK_NEIGHBOURHOOD,
PANAKO_PEAK_NEIGHBOURHOOD,
n_bins,
),
peak_row_max: alloc::vec![0.0_f32; n_bins],
frame_scratch: alloc::vec![0.0_f32; n_bins],
bucket_pending: Vec::new(),
last_finalized_bucket: -1,
pending_anchors: alloc::collections::VecDeque::new(),
to_finalize: Vec::new(),
triplet_scratch: Vec::new(),
emitted: Vec::new(),
}
}
#[must_use]
pub fn config(&self) -> &PanakoConfig {
&self.cfg
}
pub fn reset(&mut self) {
self.sample_carry.clear();
self.peak_det.reset();
self.spec_n_rows = 0;
self.spec_first_frame = 0;
self.n_frames_total = 0;
self.last_pd_frame = -1;
self.bucket_pending.clear();
self.last_finalized_bucket = -1;
self.pending_anchors.clear();
self.to_finalize.clear();
self.emitted.clear();
}
fn lookahead_frames(&self) -> u32 {
self.cfg.target_zone_t as u32
+ PANAKO_PEAK_NEIGHBOURHOOD as u32
+ PANAKO_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 * PANAKO_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_bins = self.spec_n_bins;
for row in from_row..=to_row {
if row >= self.spec_n_rows {
break;
}
let abs_f = self.spec_first_frame + row as u32;
let bucket = (abs_f as f32 / PANAKO_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.peak_row_max[bin] {
let peak = Peak {
t_frame: abs_f,
f_bin: bin as u16,
_pad: 0,
mag: v,
};
match self.bucket_pending.binary_search_by_key(&bucket, |e| e.0) {
Ok(idx) => self.bucket_pending[idx].1.push(peak),
Err(idx) => self.bucket_pending.insert(idx, (bucket, alloc::vec![peak])),
}
}
}
}
}
fn finalize_bucket(&mut self, bucket: u32) {
let mut peaks = match self.bucket_pending.binary_search_by_key(&bucket, |e| e.0) {
Ok(idx) => self.bucket_pending.remove(idx).1,
Err(_) => return,
};
peaks.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)))
});
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;
let target_cap = 2 * fan_out;
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(peak);
if anchor.targets.len() > target_cap {
let min_idx = anchor
.targets
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
a.mag
.partial_cmp(&b.mag)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (b.t_frame, b.f_bin).cmp(&(a.t_frame, a.f_bin)))
})
.map(|(i, _)| i)
.unwrap();
anchor.targets.swap_remove(min_idx);
}
}
if let Some(limit) = self.cfg.max_pending_anchors {
while self.pending_anchors.len() >= limit {
self.pending_anchors.pop_front();
}
}
self.pending_anchors.push_back(PendingAnchorPanako {
peak,
targets: Vec::new(),
});
}
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 / PANAKO_FRAMES_PER_SEC) as i32;
self.to_finalize.clear();
self.to_finalize.extend(
self.bucket_pending.iter().map(|e| e.0).filter(|&b| {
(b as i32) > self.last_finalized_bucket && (b as i32) < current_bucket
}),
);
let n = self.to_finalize.len();
for i in 0..n {
let bucket = self.to_finalize[i];
self.finalize_bucket(bucket);
}
self.to_finalize.clear();
}
fn emit_finalized_anchors(&mut self) {
let last_dt = self.cfg.target_zone_t as u32 - 1;
let mut emitted = core::mem::take(&mut self.emitted);
while let Some(anchor) = self.pending_anchors.pop_front() {
let last_target_frame = anchor.peak.t_frame + last_dt;
let last_target_bucket = (last_target_frame as f32 / PANAKO_FRAMES_PER_SEC) as i32;
if self.last_finalized_bucket < last_target_bucket {
self.pending_anchors.push_front(anchor);
break;
}
self.build_triplets_for_anchor(anchor, &mut emitted);
}
self.emitted = emitted;
}
fn build_triplets_for_anchor(
&mut self,
mut anchor: PendingAnchorPanako,
out: &mut Vec<(TimestampMs, PanakoHash)>,
) {
let fan_out = self.cfg.fan_out as usize;
anchor
.targets
.sort_unstable_by_key(|p| (p.t_frame, p.f_bin));
let mut heap: alloc::collections::BinaryHeap<MinByScoreOwned> =
alloc::collections::BinaryHeap::with_capacity(fan_out + 1);
for (j, b) in anchor.targets.iter().enumerate() {
for c in &anchor.targets[j + 1..] {
let score = b.mag + c.mag;
heap.push(MinByScoreOwned::new(b, c, score));
if heap.len() > fan_out {
heap.pop();
}
}
}
self.triplet_scratch.clear();
self.triplet_scratch
.extend(heap.drain().map(|w| (w.b, w.c, w.score)));
self.triplet_scratch.sort_unstable_by(|x, y| {
y.2.partial_cmp(&x.2)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| (x.0.t_frame, x.0.f_bin).cmp(&(y.0.t_frame, y.0.f_bin)))
.then_with(|| (x.1.t_frame, x.1.f_bin).cmp(&(y.1.t_frame, y.1.f_bin)))
});
for (b, c, _) in &self.triplet_scratch {
let hash = pack_triplet(&anchor.peak, b, c);
let t_ms = (anchor.peak.t_frame as u64 * PANAKO_HOP as u64 * 1000) / PANAKO_SR as u64;
out.push((
TimestampMs(t_ms),
PanakoHash {
hash,
t_anchor: anchor.peak.t_frame,
t_b: b.t_frame,
t_c: c.t_frame,
},
));
}
}
fn process_push_samples(&mut self, samples: &[f32]) {
let samples = crate::pcm::truncate_push(samples, self.cfg.max_push_samples);
crate::pcm::extend_sanitized(&mut self.sample_carry, samples);
let mut off = 0usize;
while self.sample_carry.len() - off >= PANAKO_N_FFT {
self.stft.process_frame_power(
&self.sample_carry[off..off + PANAKO_N_FFT],
&mut self.frame_scratch,
);
for v in self.frame_scratch.iter_mut() {
*v = DB_LOG2_FACTOR * v.max(PANAKO_LOG_FLOOR_POWER).log2();
}
self.append_frame_scratch_row();
self.n_frames_total += 1;
off += PANAKO_HOP;
if let Some(ripe_abs) = self
.peak_det
.push_row(&self.frame_scratch, &mut self.peak_row_max)
{
let row_idx = (ripe_abs - self.spec_first_frame) as usize;
self.detect_rows(row_idx, row_idx);
self.last_pd_frame = ripe_abs as i32;
}
}
if off > 0 {
self.sample_carry.drain(0..off);
}
self.finalize_buckets();
self.emit_finalized_anchors();
}
fn process_flush(&mut self) {
let n_bins = self.spec_n_bins;
let min_mag = self.cfg.min_anchor_mag_db;
let spec = &self.spec;
let spec_first_frame = self.spec_first_frame;
let bucket_pending = &mut self.bucket_pending;
let last_pd = &mut self.last_pd_frame;
self.peak_det
.flush(&mut self.peak_row_max, |ripe_abs, max_row| {
let row_idx = (ripe_abs - spec_first_frame) as usize;
let bucket = (ripe_abs as f32 / PANAKO_FRAMES_PER_SEC) as u32;
for (bin, &row_max) in max_row.iter().enumerate().take(n_bins) {
let idx = row_idx * n_bins + bin;
let v = spec[idx];
if v > min_mag && v >= row_max {
let peak = Peak {
t_frame: ripe_abs,
f_bin: bin as u16,
_pad: 0,
mag: v,
};
match bucket_pending.binary_search_by_key(&bucket, |e| e.0) {
Ok(idx) => bucket_pending[idx].1.push(peak),
Err(idx) => bucket_pending.insert(idx, (bucket, alloc::vec![peak])),
}
}
}
*last_pd = ripe_abs as i32;
});
self.to_finalize.clear();
self.to_finalize
.extend(self.bucket_pending.iter().map(|e| e.0));
let n = self.to_finalize.len();
for i in 0..n {
let bucket = self.to_finalize[i];
self.finalize_bucket(bucket);
}
self.to_finalize.clear();
let mut emitted = core::mem::take(&mut self.emitted);
while let Some(anchor) = self.pending_anchors.pop_front() {
self.build_triplets_for_anchor(anchor, &mut emitted);
}
self.emitted = emitted;
}
}
impl StreamingFingerprinter for StreamingPanako {
type Frame = PanakoHash;
fn required_sample_rate(&self) -> u32 {
PANAKO_SR
}
fn push(&mut self, samples: &[f32]) -> Vec<(TimestampMs, Self::Frame)> {
self.emitted.clear();
self.process_push_samples(samples);
let mut out = Vec::new();
out.append(&mut self.emitted);
out
}
fn push_with<F>(&mut self, samples: &[f32], mut callback: F) -> usize
where
F: FnMut(TimestampMs, &Self::Frame),
{
self.emitted.clear();
self.process_push_samples(samples);
let mut n = 0usize;
for (t, frame) in self.emitted.drain(..) {
callback(t, &frame);
n += 1;
}
self.emitted.clear();
n
}
fn flush(&mut self) -> Vec<(TimestampMs, Self::Frame)> {
self.emitted.clear();
self.process_flush();
let mut out = Vec::new();
out.append(&mut self.emitted);
out
}
fn flush_with<F>(&mut self, mut callback: F) -> usize
where
F: FnMut(TimestampMs, &Self::Frame),
{
self.emitted.clear();
self.process_flush();
let mut n = 0usize;
for (t, frame) in self.emitted.drain(..) {
callback(t, &frame);
n += 1;
}
self.emitted.clear();
n
}
fn latency_ms(&self) -> u32 {
(self.lookahead_frames() * PANAKO_HOP as u32 * 1000) / PANAKO_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
}
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 rejects_wrong_sample_rate() {
let mut fp = Panako::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, got {other:?}"),
}
}
#[test]
fn rejects_short_audio() {
let mut fp = Panako::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 = Panako::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 = Panako::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!(
(500..=900).contains(&fpr.hashes.len()),
"expected 500..=900 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() > 400,
"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].t_b, w[0].t_c) <= (w[1].t_anchor, w[1].t_b, w[1].t_c));
}
}
#[test]
fn synthetic_signal_is_deterministic() {
let samples = synthetic_audio(0xBEEF, 8_000 * 3);
let mut a = Panako::default();
let mut b = Panako::default();
let fa = a
.extract(AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
})
.unwrap();
let fb = b
.extract(AudioBuffer {
samples: &samples,
rate: 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 = Panako::default();
let f1 = fp1
.extract(AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
})
.unwrap();
let mut fp2 = Panako::default();
let f2 = fp2
.extract(AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
})
.unwrap();
assert_eq!(f1.hashes, f2.hashes);
}
#[test]
fn different_signals_diverge() {
let a = synthetic_audio(0x1111, 8_000 * 3);
let b = synthetic_audio(0x2222, 8_000 * 3);
let mut fp = Panako::default();
let fa = fp
.extract(AudioBuffer {
samples: &a,
rate: SampleRate::HZ_8000,
})
.unwrap();
let fb = fp
.extract(AudioBuffer {
samples: &b,
rate: SampleRate::HZ_8000,
})
.unwrap();
assert_ne!(fa.hashes, fb.hashes);
}
#[test]
fn pack_triplet_decodes_correctly() {
let a = Peak {
t_frame: 100,
f_bin: 50,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 110,
f_bin: 70,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 130,
f_bin: 60,
_pad: 0,
mag: 0.0,
};
let h = pack_triplet(&a, &b, &c);
let sign = (h >> 30) & 0x3;
let mag_order = (h >> 28) & 0x3;
let beta = (h >> 23) & 0x1F;
let dab = ((h >> 15) & 0xFF) as u8 as i8;
let dbc = ((h >> 7) & 0xFF) as u8 as i8;
assert_eq!(sign, 0b01);
assert_eq!(mag_order, 0);
assert_eq!(beta, 21);
assert_eq!(dab as i32, 20);
assert_eq!(dbc as i32, -10);
assert_eq!(h & 0x7F, 0);
}
#[test]
fn pack_triplet_clamps_large_freq_diffs() {
let a = Peak {
t_frame: 0,
f_bin: 0,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 5,
f_bin: 400,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 10,
f_bin: 0,
_pad: 0,
mag: 0.0,
};
let h = pack_triplet(&a, &b, &c);
let dab = ((h >> 15) & 0xFF) as u8 as i8;
let dbc = ((h >> 7) & 0xFF) as u8 as i8;
assert_eq!(dab as i32, 127); assert_eq!(dbc as i32, -127); }
#[test]
fn streaming_latency_matches_lookahead() {
let s = StreamingPanako::default();
assert_eq!(s.latency_ms(), 2_784);
}
#[test]
fn streaming_silence_emits_nothing() {
let mut s = StreamingPanako::default();
let zeros = vec![0.0_f32; 8_000 * 4];
assert!(s.push(&zeros).is_empty());
assert!(s.flush().is_empty());
}
#[test]
fn mag_order_picks_largest_of_three() {
let a = Peak {
t_frame: 0,
f_bin: 10,
_pad: 0,
mag: 1.0,
};
let b = Peak {
t_frame: 5,
f_bin: 20,
_pad: 0,
mag: 5.0,
};
let c = Peak {
t_frame: 10,
f_bin: 15,
_pad: 0,
mag: 3.0,
};
let h = pack_triplet(&a, &b, &c);
assert_eq!((h >> 28) & 0x3, 1);
let a = Peak {
t_frame: 0,
f_bin: 10,
_pad: 0,
mag: 1.0,
};
let b = Peak {
t_frame: 5,
f_bin: 20,
_pad: 0,
mag: 2.0,
};
let c = Peak {
t_frame: 10,
f_bin: 15,
_pad: 0,
mag: 9.0,
};
let h = pack_triplet(&a, &b, &c);
assert_eq!((h >> 28) & 0x3, 2);
let a = Peak {
t_frame: 0,
f_bin: 10,
_pad: 0,
mag: 9.0,
};
let b = Peak {
t_frame: 5,
f_bin: 20,
_pad: 0,
mag: 2.0,
};
let c = Peak {
t_frame: 10,
f_bin: 15,
_pad: 0,
mag: 3.0,
};
let h = pack_triplet(&a, &b, &c);
assert_eq!((h >> 28) & 0x3, 0);
}
#[test]
fn sign_bit_combinations() {
let a = Peak {
t_frame: 0,
f_bin: 100,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 5,
f_bin: 80,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 10,
f_bin: 60,
_pad: 0,
mag: 0.0,
};
assert_eq!((pack_triplet(&a, &b, &c) >> 30) & 0x3, 0b00);
let a = Peak {
t_frame: 0,
f_bin: 100,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 5,
f_bin: 120,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 10,
f_bin: 140,
_pad: 0,
mag: 0.0,
};
assert_eq!((pack_triplet(&a, &b, &c) >> 30) & 0x3, 0b11);
}
#[test]
fn beta_saturates_near_extremes() {
let a = Peak {
t_frame: 0,
f_bin: 0,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 1,
f_bin: 5,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 95,
f_bin: 8,
_pad: 0,
mag: 0.0,
};
let h = pack_triplet(&a, &b, &c);
let beta = (h >> 23) & 0x1F;
assert!(beta >= 30, "beta should saturate near 31, got {beta}");
let a = Peak {
t_frame: 0,
f_bin: 0,
_pad: 0,
mag: 0.0,
};
let b = Peak {
t_frame: 90,
f_bin: 5,
_pad: 0,
mag: 0.0,
};
let c = Peak {
t_frame: 91,
f_bin: 8,
_pad: 0,
mag: 0.0,
};
let h = pack_triplet(&a, &b, &c);
let beta = (h >> 23) & 0x1F;
assert!(beta <= 1, "beta should saturate near 0, got {beta}");
}
#[test]
fn streaming_offline_equivalence() {
let samples = synthetic_audio(0xBEEF, 8_000 * 6);
let mut offline = Panako::default();
let off = offline
.extract(AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
})
.unwrap();
let mut streaming = StreamingPanako::default();
let mut online: Vec<PanakoHash> = 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 = off.hashes;
let mut b = online;
a.sort_unstable_by_key(|h| (h.t_anchor, h.t_b, h.t_c, h.hash));
b.sort_unstable_by_key(|h| (h.t_anchor, h.t_b, h.t_c, h.hash));
assert_eq!(a.len(), b.len(), "hash count mismatch");
assert_eq!(a, b, "hash sequences differ");
}
#[test]
fn streaming_state_stays_bounded_under_long_input() {
let secs = 30usize;
let samples = synthetic_audio(11, PANAKO_SR as usize * secs);
let chunk = 256usize;
let mut s = StreamingPanako::default();
let max_spec_rows = 2 * PANAKO_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() < PANAKO_N_FFT);
assert!(s.spec_n_rows <= max_spec_rows);
start = end;
}
assert_eq!(peak_spec_rows, max_spec_rows);
assert!(peak_carry < PANAKO_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 <= 60,
"pending_anchors peaked at {peak_anchors} (expected ≤ 60)",
);
let _ = s.flush();
assert_eq!(s.bucket_pending.len(), 0);
assert_eq!(s.pending_anchors.len(), 0);
}
fn panako_anchor_with_target(
t_frame: u32,
f_bin: u16,
target_t: u32,
target_f: u16,
) -> PendingAnchorPanako {
PendingAnchorPanako {
peak: Peak {
t_frame,
f_bin,
_pad: 0,
mag: 1.0,
},
targets: vec![
Peak {
t_frame: target_t,
f_bin: target_f,
_pad: 0,
mag: 0.9,
},
Peak {
t_frame: target_t + 1,
f_bin: target_f + 1,
_pad: 0,
mag: 0.8,
},
],
}
}
fn panako_bucket_of(t_frame: u32) -> i32 {
(t_frame as f32 / PANAKO_FRAMES_PER_SEC) as i32
}
#[test]
fn panako_emit_finalized_anchors_emits_all_when_zones_covered() {
let mut s = StreamingPanako::default();
s.pending_anchors
.push_back(panako_anchor_with_target(0, 10, 10, 12));
s.pending_anchors
.push_back(panako_anchor_with_target(5, 20, 15, 22));
s.pending_anchors
.push_back(panako_anchor_with_target(100, 30, 110, 32));
s.last_finalized_bucket = panako_bucket_of(195);
s.emitted.clear();
s.emit_finalized_anchors();
assert_eq!(s.emitted.len(), 3);
assert!(s.pending_anchors.is_empty());
}
#[test]
fn panako_emit_finalized_anchors_re_queues_unfinalised() {
let mut s = StreamingPanako::default();
s.pending_anchors
.push_back(panako_anchor_with_target(0, 10, 10, 12));
s.pending_anchors
.push_back(panako_anchor_with_target(100, 30, 110, 32));
s.last_finalized_bucket = 1;
s.emitted.clear();
s.emit_finalized_anchors();
assert_eq!(s.emitted.len(), 1);
assert_eq!(s.pending_anchors.len(), 1);
assert_eq!(s.pending_anchors.front().unwrap().peak.t_frame, 100);
}
#[test]
fn panako_emit_finalized_anchors_idempotent_under_repeated_calls() {
let mut s = StreamingPanako::default();
s.pending_anchors
.push_back(panako_anchor_with_target(0, 10, 10, 12));
s.last_finalized_bucket = panako_bucket_of(95);
s.emitted.clear();
s.emit_finalized_anchors();
let first_len = s.emitted.len();
s.emitted.clear();
s.emit_finalized_anchors();
let second_len = s.emitted.len();
assert_eq!(first_len, 1);
assert_eq!(second_len, 0);
assert!(s.pending_anchors.is_empty());
}
#[test]
fn public_api_name_and_config_match_documented_values() {
let fp = Panako::default();
assert_eq!(fp.name(), "panako-v2");
assert_eq!(fp.required_sample_rate(), 8_000);
assert_eq!(fp.min_samples(), 16_000);
let s = StreamingPanako::default();
assert_eq!(s.latency_ms(), 2_784);
}
#[test]
fn default_config_is_unchanged_by_guard_clamps() {
let fp = Panako::default();
assert_eq!(fp.config().fan_out, 5);
assert_eq!(fp.config().target_zone_t, 96);
assert_eq!(fp.config().peaks_per_sec, 30);
}
#[test]
fn zero_target_zone_is_clamped_to_one_not_underflow() {
let cfg = PanakoConfig {
target_zone_t: 0,
fan_out: 0,
..PanakoConfig::default()
};
let fp = Panako::new(cfg);
assert_eq!(fp.config().target_zone_t, 1);
assert_eq!(fp.config().fan_out, 1);
}
#[test]
fn extreme_config_is_clamped_within_safe_bounds() {
let cfg = PanakoConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..PanakoConfig::default()
};
let fp = Panako::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 = PanakoConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..PanakoConfig::default()
};
let mut fp = Panako::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 3);
let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let fpr = fp.extract(buf).unwrap();
assert!(!fpr.hashes.is_empty());
}
#[test]
fn streaming_default_config_is_unchanged_by_guard_clamps() {
let s = StreamingPanako::default();
let cfg = s.config();
assert_eq!(cfg.fan_out, 5);
assert_eq!(cfg.target_zone_t, 96);
assert_eq!(cfg.peaks_per_sec, 30);
}
#[test]
fn streaming_extreme_config_is_clamped_within_safe_bounds() {
let cfg = PanakoConfig {
fan_out: u16::MAX,
target_zone_t: u16::MAX,
peaks_per_sec: u16::MAX,
..PanakoConfig::default()
};
let s = StreamingPanako::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 = StreamingPanako::default();
let samples = synthetic_audio(0xFEED, 8_000 * 5);
let before = s.push(&samples);
assert!(!before.is_empty(), "should produce hashes");
s.reset();
assert!(s.push(&[]).is_empty(), "reset should clear state");
let after_reset = s.push(&samples);
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 = StreamingPanako::default();
let mut b = StreamingPanako::default();
let samples = synthetic_audio(0xABCD, 8_000 * 5);
let via_push = a.push(&samples);
let mut via_cb: Vec<(TimestampMs, PanakoHash)> = Vec::new();
let n = b.push_with(&samples, |t, f| via_cb.push((t, *f)));
let via_flush = b.flush();
let flush_len = via_flush.len();
via_cb.extend(via_flush);
let mut all_via_push = via_push;
all_via_push.extend(a.flush());
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 = StreamingPanako::default();
let mut b = StreamingPanako::default();
let samples = synthetic_audio(0xF00D, 8_000 * 5);
let _ = a.push(&samples);
let _ = b.push(&samples);
let via_flush = a.flush();
let mut via_cb: Vec<(TimestampMs, PanakoHash)> = Vec::new();
let n = b.flush_with(|t, f| via_cb.push((t, *f)));
assert_eq!(n, via_flush.len());
assert_eq!(via_cb, via_flush);
}
#[test]
fn input_larger_than_max_is_rejected() {
let cfg = PanakoConfig {
max_input_samples: Some(1_000),
..PanakoConfig::default()
};
let mut fp = Panako::new(cfg);
let samples = vec![0.0_f32; 2_000];
let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let err = fp.extract(buf).unwrap_err();
assert!(matches!(err, AfpError::InputTooLarge { .. }));
}
#[test]
fn none_disables_max_input_check() {
let cfg = PanakoConfig {
max_input_samples: None,
..PanakoConfig::default()
};
let mut fp = Panako::new(cfg);
let samples = vec![0.0_f32; 16_000];
let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
fp.extract(buf).unwrap();
}
#[test]
fn max_hashes_enforced_rejects_too_many() {
let cfg = PanakoConfig {
max_hashes: Some(10),
..PanakoConfig::default()
};
let mut fp = Panako::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 5);
let buf = AudioBuffer {
samples: &samples,
rate: SampleRate::HZ_8000,
};
let err = fp.extract(buf).unwrap_err();
assert!(matches!(err, AfpError::InputTooLarge { .. }));
}
#[test]
fn max_pending_anchors_evicts_oldest() {
let cfg = PanakoConfig {
max_pending_anchors: Some(100),
..PanakoConfig::default()
};
let mut s = StreamingPanako::new(cfg);
let samples = synthetic_audio(0xCAFE, 8_000 * 20);
let mut hashes = s.push(&samples);
hashes.extend(s.flush());
assert!(s.config().max_pending_anchors.is_some());
assert!(!hashes.is_empty(), "should produce hashes with cap=100");
}
#[test]
fn max_push_samples_truncates_hostile_chunk() {
let cfg = PanakoConfig {
max_push_samples: Some(512),
..PanakoConfig::default()
};
let mut s = StreamingPanako::new(cfg);
let samples = synthetic_audio(0xBEEF, 8_000 * 5);
let _ = s.push(&samples);
let _ = s.flush();
assert_eq!(s.config().max_push_samples, Some(512));
}
}