use crate::error::StretchError;
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const PREANALYSIS_VERSION: u32 = 2;
fn default_artifact_version() -> u32 {
1
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PreAnalysisArtifact {
#[serde(default = "default_artifact_version")]
pub version: u32,
pub sample_rate: u32,
pub bpm: f64,
pub downbeat_offset_samples: usize,
pub confidence: f32,
#[serde(default)]
pub beat_positions: Vec<usize>,
#[serde(default)]
pub transient_onsets: Vec<usize>,
#[serde(default)]
pub transient_strengths: Vec<f32>,
#[serde(default)]
pub onset_band_flux: Vec<[f32; 4]>,
#[serde(default)]
pub analysis_hop_size: usize,
#[serde(default)]
pub source_len_samples: usize,
#[serde(default)]
pub content_hash: u64,
}
impl PreAnalysisArtifact {
#[inline]
pub fn is_confident(&self, threshold: f32) -> bool {
self.confidence >= threshold.clamp(0.0, 1.0)
}
#[inline]
pub fn is_usable(&self, sample_rate: u32, confidence_threshold: f32) -> bool {
self.sample_rate == sample_rate
&& self.is_confident(confidence_threshold)
&& (!self.beat_positions.is_empty() || !self.transient_onsets.is_empty())
}
pub fn matches_source(&self, samples: &[f32], sample_rate: u32) -> bool {
if self.sample_rate != sample_rate {
return false;
}
if self.source_len_samples != 0 && self.source_len_samples != samples.len() {
return false;
}
if self.content_hash != 0 && self.content_hash != hash_samples(samples) {
return false;
}
true
}
#[inline]
pub fn strength_at(&self, idx: usize) -> f32 {
self.transient_strengths.get(idx).copied().unwrap_or(1.0)
}
}
pub fn hash_samples(samples: &[f32]) -> u64 {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET;
for sample in samples {
for byte in sample.to_bits().to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
}
hash
}
pub fn write_preanalysis_json(
path: &Path,
artifact: &PreAnalysisArtifact,
) -> Result<(), StretchError> {
let json = serde_json::to_string_pretty(artifact).map_err(|e| {
StretchError::InvalidFormat(format!("failed to serialize pre-analysis artifact: {}", e))
})?;
std::fs::write(path, json)?;
Ok(())
}
pub fn read_preanalysis_json(path: &Path) -> Result<PreAnalysisArtifact, StretchError> {
let data = std::fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| {
StretchError::InvalidFormat(format!(
"failed to parse pre-analysis artifact from {}: {}",
path.display(),
e
))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_artifact() -> PreAnalysisArtifact {
PreAnalysisArtifact {
version: PREANALYSIS_VERSION,
sample_rate: 44100,
bpm: 128.0,
downbeat_offset_samples: 100,
confidence: 0.8,
beat_positions: vec![0, 22050],
transient_onsets: vec![0, 22050],
transient_strengths: vec![1.0, 0.5],
onset_band_flux: vec![[1.0, 0.5, 0.2, 0.1], [0.2, 0.3, 0.4, 0.5]],
analysis_hop_size: 512,
source_len_samples: 44100,
content_hash: 0,
}
}
#[test]
fn test_preanalysis_confidence_threshold() {
let artifact = test_artifact();
assert!(artifact.is_confident(0.5));
assert!(!artifact.is_confident(0.9));
}
#[test]
fn test_is_usable_gates() {
let artifact = test_artifact();
assert!(artifact.is_usable(44100, 0.5));
assert!(!artifact.is_usable(48000, 0.5), "sample-rate mismatch");
assert!(!artifact.is_usable(44100, 0.9), "confidence too low");
let empty = PreAnalysisArtifact {
beat_positions: Vec::new(),
transient_onsets: Vec::new(),
..test_artifact()
};
assert!(!empty.is_usable(44100, 0.5), "no positions at all");
}
#[test]
fn test_matches_source_binding() {
let samples: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin()).collect();
let mut artifact = test_artifact();
artifact.source_len_samples = samples.len();
artifact.content_hash = hash_samples(&samples);
assert!(artifact.matches_source(&samples, 44100));
assert!(!artifact.matches_source(&samples, 48000), "rate mismatch");
assert!(
!artifact.matches_source(&samples[..999], 44100),
"length mismatch"
);
let mut altered = samples.clone();
altered[500] += 0.25;
assert!(!artifact.matches_source(&altered, 44100), "hash mismatch");
artifact.source_len_samples = 0;
artifact.content_hash = 0;
assert!(artifact.matches_source(&altered, 44100));
}
#[test]
fn test_strength_at_v1_default() {
let mut artifact = test_artifact();
assert_eq!(artifact.strength_at(1), 0.5);
artifact.transient_strengths.clear();
assert_eq!(artifact.strength_at(0), 1.0);
assert_eq!(artifact.strength_at(999), 1.0);
}
#[test]
fn test_v1_json_parses_with_defaults() {
let v1_json = r#"{
"sample_rate": 44100,
"bpm": 128.0,
"downbeat_offset_samples": 100,
"confidence": 0.8,
"beat_positions": [0, 22050],
"transient_onsets": [0, 22050]
}"#;
let artifact: PreAnalysisArtifact =
serde_json::from_str(v1_json).expect("v1 JSON should parse");
assert_eq!(artifact.version, 1);
assert!(artifact.transient_strengths.is_empty());
assert!(artifact.onset_band_flux.is_empty());
assert_eq!(artifact.analysis_hop_size, 0);
assert_eq!(artifact.source_len_samples, 0);
assert_eq!(artifact.content_hash, 0);
assert!(artifact.is_usable(44100, 0.5));
}
}