pub mod constellation;
pub mod database;
pub mod extract;
pub mod hash;
#[allow(clippy::module_inception)]
pub mod matching;
use crate::error::AudioResult;
use crate::AudioFrame;
pub use constellation::{ConstellationMap, Peak as ConstellationPeak};
pub use database::{FingerprintDatabase, Match};
pub use extract::SpectrogramExtractor;
pub use hash::{Hash, HashGenerator};
pub use matching::FingerprintMatcher;
#[derive(Clone, Debug)]
pub struct Fingerprint {
pub hashes: Vec<(Hash, f64)>,
pub sample_rate: u32,
pub duration: f64,
pub config: FingerprintConfig,
}
impl Fingerprint {
#[must_use]
pub fn new(
hashes: Vec<(Hash, f64)>,
sample_rate: u32,
duration: f64,
config: FingerprintConfig,
) -> Self {
Self {
hashes,
sample_rate,
duration,
config,
}
}
#[must_use]
pub fn hash_count(&self) -> usize {
self.hashes.len()
}
#[must_use]
pub fn density(&self) -> f64 {
if self.duration > 0.0 {
self.hash_count() as f64 / self.duration
} else {
0.0
}
}
#[must_use]
pub fn is_valid(&self) -> bool {
!self.hashes.is_empty() && self.sample_rate > 0 && self.duration > 0.0
}
#[must_use]
pub fn time_range(&self) -> (f64, f64) {
if self.hashes.is_empty() {
return (0.0, 0.0);
}
let min_time = self
.hashes
.iter()
.map(|(_, t)| t)
.copied()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(0.0);
let max_time = self
.hashes
.iter()
.map(|(_, t)| t)
.copied()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(0.0);
(min_time, max_time)
}
#[must_use]
pub fn merge(fingerprints: &[Self]) -> Option<Self> {
if fingerprints.is_empty() {
return None;
}
let first = &fingerprints[0];
let mut merged_hashes = Vec::new();
let mut total_duration = 0.0;
for fp in fingerprints {
let time_offset = total_duration;
for (hash, time) in &fp.hashes {
merged_hashes.push((*hash, time + time_offset));
}
total_duration += fp.duration;
}
Some(Self {
hashes: merged_hashes,
sample_rate: first.sample_rate,
duration: total_duration,
config: first.config.clone(),
})
}
}
#[derive(Clone, Debug)]
pub struct FingerprintConfig {
pub fft_size: usize,
pub hop_size: usize,
pub freq_range: (f64, f64),
pub num_bands: usize,
pub peak_threshold: f64,
pub max_peaks_per_frame: usize,
pub target_zone_size: usize,
pub target_zone_offset: usize,
pub num_targets_per_anchor: usize,
pub min_time_delta: f64,
pub max_time_delta: f64,
}
impl FingerprintConfig {
#[must_use]
pub fn music() -> Self {
Self {
fft_size: 4096,
hop_size: 2048,
freq_range: (40.0, 8000.0),
num_bands: 32,
peak_threshold: 0.7,
max_peaks_per_frame: 5,
target_zone_size: 32,
target_zone_offset: 1,
num_targets_per_anchor: 5,
min_time_delta: 0.0,
max_time_delta: 2.0,
}
}
#[must_use]
pub fn speech() -> Self {
Self {
fft_size: 2048,
hop_size: 512,
freq_range: (80.0, 4000.0),
num_bands: 24,
peak_threshold: 0.6,
max_peaks_per_frame: 4,
target_zone_size: 24,
target_zone_offset: 1,
num_targets_per_anchor: 4,
min_time_delta: 0.0,
max_time_delta: 1.5,
}
}
#[must_use]
pub fn high_sensitivity() -> Self {
Self {
fft_size: 4096,
hop_size: 1024,
freq_range: (20.0, 10000.0),
num_bands: 48,
peak_threshold: 0.5,
max_peaks_per_frame: 8,
target_zone_size: 48,
target_zone_offset: 1,
num_targets_per_anchor: 8,
min_time_delta: 0.0,
max_time_delta: 3.0,
}
}
#[must_use]
pub fn fast() -> Self {
Self {
fft_size: 2048,
hop_size: 2048,
freq_range: (100.0, 5000.0),
num_bands: 16,
peak_threshold: 0.8,
max_peaks_per_frame: 3,
target_zone_size: 16,
target_zone_offset: 2,
num_targets_per_anchor: 3,
min_time_delta: 0.1,
max_time_delta: 1.0,
}
}
pub fn validate(&self) -> AudioResult<()> {
use crate::error::AudioError;
if !self.fft_size.is_power_of_two() {
return Err(AudioError::InvalidParameter(
"FFT size must be power of 2".to_string(),
));
}
if self.hop_size == 0 || self.hop_size > self.fft_size {
return Err(AudioError::InvalidParameter("Invalid hop size".to_string()));
}
if self.freq_range.0 >= self.freq_range.1 {
return Err(AudioError::InvalidParameter(
"Invalid frequency range".to_string(),
));
}
if self.num_bands == 0 {
return Err(AudioError::InvalidParameter(
"Number of bands must be > 0".to_string(),
));
}
if !(0.0..=1.0).contains(&self.peak_threshold) {
return Err(AudioError::InvalidParameter(
"Peak threshold must be in range [0, 1]".to_string(),
));
}
Ok(())
}
}
impl Default for FingerprintConfig {
fn default() -> Self {
Self::music()
}
}
pub struct Fingerprinter {
config: FingerprintConfig,
extractor: SpectrogramExtractor,
hash_generator: HashGenerator,
}
impl Fingerprinter {
pub fn new(config: FingerprintConfig) -> AudioResult<Self> {
config.validate()?;
let extractor = SpectrogramExtractor::new(
config.fft_size,
config.hop_size,
config.freq_range,
config.num_bands,
);
let hash_generator = HashGenerator::new(
config.target_zone_size,
config.target_zone_offset,
config.num_targets_per_anchor,
);
Ok(Self {
config,
extractor,
hash_generator,
})
}
pub fn generate(&self, audio: &AudioFrame) -> AudioResult<Fingerprint> {
let constellation = self.extractor.extract(audio, &self.config)?;
let hashes = self.hash_generator.generate(&constellation);
Ok(Fingerprint::new(
hashes,
audio.sample_rate,
audio.duration_seconds(),
self.config.clone(),
))
}
pub fn generate_streaming(&self, frames: &[AudioFrame]) -> AudioResult<Fingerprint> {
let fingerprints: Result<Vec<_>, _> =
frames.iter().map(|frame| self.generate(frame)).collect();
let fingerprints = fingerprints?;
Fingerprint::merge(&fingerprints).ok_or_else(|| {
crate::error::AudioError::InvalidData("No frames to fingerprint".to_string())
})
}
#[must_use]
pub const fn config(&self) -> &FingerprintConfig {
&self.config
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn estimate_processing_time(&self, duration_seconds: f64) -> f64 {
duration_seconds / 15.0
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn estimate_hash_count(&self, duration_seconds: f64, sample_rate: u32) -> usize {
let num_frames = (duration_seconds * f64::from(sample_rate)) / self.config.hop_size as f64;
let hashes_per_frame = self.config.max_peaks_per_frame * self.config.num_targets_per_anchor;
(num_frames * hashes_per_frame as f64) as usize
}
}