use crate::error::StretchError;
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const PREANALYSIS_VERSION: u32 = 7;
const MIN_COMPATIBLE_VERSION: u32 = 4;
fn default_artifact_version() -> u32 {
1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyMode {
Major,
Minor,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct KeyEstimate {
pub root: u8,
pub mode: KeyMode,
pub confidence: f32,
}
impl KeyEstimate {
const NOTE_NAMES: [&'static str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
pub fn name(&self) -> String {
let mode = match self.mode {
KeyMode::Major => "major",
KeyMode::Minor => "minor",
};
format!("{} {}", Self::NOTE_NAMES[usize::from(self.root) % 12], mode)
}
pub fn camelot(&self) -> String {
let fifth = (usize::from(self.root) * 7) % 12;
let (number, letter) = match self.mode {
KeyMode::Major => ((fifth + 7) % 12 + 1, 'B'),
KeyMode::Minor => ((fifth + 4) % 12 + 1, 'A'),
};
format!("{number}{letter}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LoudnessMeasurement {
pub integrated_lufs: f64,
pub true_peak_dbtp: f64,
pub loudness_range_lu: f64,
}
impl LoudnessMeasurement {
#[inline]
pub fn gain_db_to(&self, target_lufs: f64) -> f64 {
target_lufs - self.integrated_lufs
}
#[inline]
pub fn gain_linear_to(&self, target_lufs: f64) -> f64 {
10f64.powf(self.gain_db_to(target_lufs) / 20.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TempoCandidate {
pub bpm: f64,
pub salience: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TempoSegment {
pub start_beat: usize,
pub bpm: f64,
}
#[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 beat_positions_fractional: Vec<f64>,
#[serde(default)]
pub downbeat_beat_indices: Vec<usize>,
#[serde(default)]
pub tempo_segments: Vec<TempoSegment>,
#[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,
#[serde(default)]
pub key: Option<KeyEstimate>,
#[serde(default)]
pub loudness: Option<LoudnessMeasurement>,
#[serde(default)]
pub tempo_candidates: Vec<TempoCandidate>,
}
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 {
let hash = if self.content_hash != 0 {
hash_samples(samples)
} else {
0
};
self.matches_identity(sample_rate, samples.len(), hash)
}
pub fn matches_identity(
&self,
sample_rate: u32,
source_len_samples: usize,
content_hash: u64,
) -> bool {
if self.version < MIN_COMPATIBLE_VERSION {
return false;
}
if self.sample_rate != sample_rate {
return false;
}
if self.source_len_samples != 0 && self.source_len_samples != source_len_samples {
return false;
}
if self.content_hash != 0 && self.content_hash != content_hash {
return false;
}
true
}
#[inline]
pub fn strength_at(&self, idx: usize) -> f32 {
self.transient_strengths.get(idx).copied().unwrap_or(1.0)
}
pub fn resample_to(&self, sample_rate: u32) -> Self {
if sample_rate == self.sample_rate || self.sample_rate == 0 {
return self.clone();
}
let ratio = sample_rate as f64 / self.sample_rate as f64;
let scale = |v: usize| (v as f64 * ratio).round() as usize;
Self {
sample_rate,
downbeat_offset_samples: scale(self.downbeat_offset_samples),
beat_positions: self.beat_positions.iter().map(|&p| scale(p)).collect(),
beat_positions_fractional: self
.beat_positions_fractional
.iter()
.map(|&p| p * ratio)
.collect(),
transient_onsets: self.transient_onsets.iter().map(|&p| scale(p)).collect(),
analysis_hop_size: scale(self.analysis_hop_size),
source_len_samples: 0,
content_hash: 0,
..self.clone()
}
}
}
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
}
#[deprecated(
since = "0.11.0",
note = "use `write_analysis_file` with the `.tsa` container (`crate::io::tsa`), which also carries waveform peaks"
)]
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(())
}
#[deprecated(
since = "0.11.0",
note = "use `read_analysis_file` / `read_analysis_file_validated` on the `.tsa` container (`crate::io::tsa`)"
)]
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],
beat_positions_fractional: vec![0.0, 22050.0],
downbeat_beat_indices: vec![0],
tempo_segments: vec![TempoSegment {
start_beat: 0,
bpm: 128.0,
}],
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,
key: Some(KeyEstimate {
root: 9,
mode: KeyMode::Minor,
confidence: 0.4,
}),
loudness: Some(LoudnessMeasurement {
integrated_lufs: -9.5,
true_peak_dbtp: -0.2,
loudness_range_lu: 4.0,
}),
tempo_candidates: vec![
TempoCandidate {
bpm: 128.0,
salience: 0.9,
},
TempoCandidate {
bpm: 64.0,
salience: 0.5,
},
],
}
}
#[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));
artifact.version = 3;
assert!(
!artifact.matches_source(&altered, 44100),
"stale version must be regenerated"
);
}
#[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_resample_to_scales_positions() {
let artifact = test_artifact(); let resampled = artifact.resample_to(88_200);
assert_eq!(resampled.sample_rate, 88_200);
assert_eq!(resampled.beat_positions, vec![0, 44_100]);
assert_eq!(resampled.beat_positions_fractional, vec![0.0, 44_100.0]);
assert_eq!(resampled.transient_onsets, vec![0, 44_100]);
assert_eq!(resampled.downbeat_offset_samples, 200);
assert_eq!(resampled.analysis_hop_size, 1024);
assert_eq!(resampled.bpm, artifact.bpm);
assert_eq!(resampled.confidence, artifact.confidence);
assert_eq!(
resampled.downbeat_beat_indices,
artifact.downbeat_beat_indices
);
assert_eq!(resampled.tempo_segments, artifact.tempo_segments);
assert_eq!(resampled.transient_strengths, artifact.transient_strengths);
assert_eq!(resampled.onset_band_flux, artifact.onset_band_flux);
assert_eq!(resampled.version, artifact.version);
assert_eq!(resampled.key, artifact.key);
assert_eq!(resampled.loudness, artifact.loudness);
assert_eq!(resampled.tempo_candidates, artifact.tempo_candidates);
}
#[test]
fn test_resample_to_clears_content_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);
let resampled = artifact.resample_to(48_000);
assert_eq!(resampled.source_len_samples, 0);
assert_eq!(resampled.content_hash, 0);
let same = artifact.resample_to(44_100);
assert_eq!(same.content_hash, artifact.content_hash);
assert_eq!(same.source_len_samples, artifact.source_len_samples);
}
#[test]
fn test_resample_round_trip_is_close() {
let artifact = test_artifact();
let round = artifact.resample_to(48_000).resample_to(44_100);
for (a, b) in round.beat_positions.iter().zip(&artifact.beat_positions) {
assert!((*a as i64 - *b as i64).abs() <= 1, "{a} vs {b}");
}
}
#[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));
}
#[test]
fn test_v4_json_without_key_parses_as_none() {
let mut artifact = test_artifact();
artifact.version = 4;
artifact.key = None;
artifact.loudness = None;
let json = serde_json::to_string(&artifact).unwrap();
let parsed: PreAnalysisArtifact = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.key, None);
assert_eq!(parsed.loudness, None);
assert_eq!(parsed.version, 4);
let round: PreAnalysisArtifact =
serde_json::from_str(&serde_json::to_string(&test_artifact()).unwrap()).unwrap();
assert_eq!(round.key, test_artifact().key);
assert_eq!(round.loudness, test_artifact().loudness);
}
#[test]
fn test_v5_json_without_loudness_parses_as_none() {
let mut artifact = test_artifact();
artifact.version = 5;
artifact.loudness = None;
artifact.tempo_candidates = Vec::new();
let json = serde_json::to_string(&artifact).unwrap();
let parsed: PreAnalysisArtifact = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.loudness, None);
assert!(parsed.tempo_candidates.is_empty());
assert_eq!(parsed.key, test_artifact().key);
assert!(parsed.version >= MIN_COMPATIBLE_VERSION);
}
#[test]
fn test_v6_json_without_candidates_parses_as_empty() {
let mut artifact = test_artifact();
artifact.version = 6;
artifact.tempo_candidates = Vec::new();
let json = serde_json::to_string(&artifact).unwrap();
let parsed: PreAnalysisArtifact = serde_json::from_str(&json).unwrap();
assert!(parsed.tempo_candidates.is_empty());
assert_eq!(parsed.loudness, test_artifact().loudness);
let round: PreAnalysisArtifact =
serde_json::from_str(&serde_json::to_string(&test_artifact()).unwrap()).unwrap();
assert_eq!(round.tempo_candidates, test_artifact().tempo_candidates);
}
#[test]
fn test_key_names_and_camelot() {
let key = |root, mode| KeyEstimate {
root,
mode,
confidence: 1.0,
};
assert_eq!(key(0, KeyMode::Major).name(), "C major");
assert_eq!(key(9, KeyMode::Minor).name(), "A minor");
assert_eq!(key(6, KeyMode::Major).name(), "F# major");
assert_eq!(key(0, KeyMode::Major).camelot(), "8B"); assert_eq!(key(9, KeyMode::Minor).camelot(), "8A"); assert_eq!(key(7, KeyMode::Major).camelot(), "9B"); assert_eq!(key(11, KeyMode::Major).camelot(), "1B"); assert_eq!(key(8, KeyMode::Minor).camelot(), "1A"); assert_eq!(key(5, KeyMode::Major).camelot(), "7B"); assert_eq!(key(2, KeyMode::Minor).camelot(), "7A"); }
}