commit - c48439d62522304863233832e3e47f3e79ee79f1
commit + fb2b2da90482b058e04b4bc97f5f4684db247c25
blob - 1077c7c24adcced912e621d9a0733d8163a2b7f1
blob + de775fb02eb1f796790bb0a37957abbbb9176510
--- src/audio_processing.rs
+++ src/audio_processing.rs
// A flat chroma has zero variance, so correlation is defined as 0.
let v = ndarray::arr1(&[2.0; CHROMA_BINS]);
let (mean, stddev) = *MAJOR_PROFILE_STATS;
- assert_eq!(pearson_correlation(&v, &MAJOR_ROTATIONS[0], mean, stddev), 0.0);
+ assert_eq!(
+ pearson_correlation(&v, &MAJOR_ROTATIONS[0], mean, stddev),
+ 0.0
+ );
}
#[test]
blob - cbf0262be8cfcc237989c5e8c1ef0153f915c8d6
blob + 78b15a03638e0d3f6c0be7944515113de4dde540
--- src/db.rs
+++ src/db.rs
key: Some("A Minor".to_string()),
timestamp: Some(1700),
};
- assert_eq!(full.to_string(), "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor");
+ assert_eq!(
+ full.to_string(),
+ "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor"
+ );
let empty = TrackReport {
artist: None,
let hits = db.find_by_artist("aphex", 0).unwrap();
assert_eq!(hits.len(), 2);
- assert!(hits.iter().all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin")));
+ assert!(
+ hits.iter()
+ .all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin"))
+ );
assert_eq!(db.find_by_artist("APHEX TWIN", 0).unwrap().len(), 2);
assert!(db.find_by_artist("nobody", 0).unwrap().is_empty());
db.save(&k2, &r2).unwrap();
// Match on the primary key / artist.
- assert_eq!(db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(), 1);
+ assert_eq!(
+ db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(),
+ 1
+ );
// Match on track title.
- assert_eq!(db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(), 1);
+ assert_eq!(
+ db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(),
+ 1
+ );
// Match on formatted BPM (one decimal place).
assert_eq!(db.search(&Regex::new("95.5").unwrap(), 0).unwrap().len(), 1);
// No match.
- assert!(db.search(&Regex::new("zzz").unwrap(), 0).unwrap().is_empty());
+ assert!(
+ db.search(&Regex::new("zzz").unwrap(), 0)
+ .unwrap()
+ .is_empty()
+ );
// Limit caps the number of results.
let both = db.search(&Regex::new(".").unwrap(), 1).unwrap();
blob - cfe14004f404fc46b2b375ed936b3854940147e7
blob + 622057dfe054da84a927f84a6be99322a0039ccb
--- src/file_io.rs
+++ src/file_io.rs
-use anyhow::Result;
-use lofty::config::WriteOptions;
+use anyhow::{Context, Result};
+use lofty::config::{ParseOptions, WriteOptions};
use lofty::prelude::*;
use lofty::probe::Probe;
use lofty::tag::{ItemKey, Tag};
use std::path::Path;
+const IFF_FORM_SIZE: usize = 4;
+const IFF_HEADER_LEN: usize = 8;
+const IFF_CONTAINER_HEADER: usize = 12;
+
use crate::audio_processing::{calculate_bpm, calculate_key};
use crate::types::TrackInfo;
use bliss_audio::decoder::Decoder as DecoderTrait;
}
fn read_tags(path: &Path) -> (FileMetadata, Option<(f32, String)>) {
- match Probe::open(path).and_then(Probe::read) {
+ let options = ParseOptions::new().read_properties(false);
+ match Probe::open(path)
+ .map(|probe| probe.options(options))
+ .and_then(Probe::read)
+ {
Ok(tagged_file) => {
let tag = tagged_file
.primary_tag()
track: t.get_string(ItemKey::TrackTitle).map(String::from),
};
- let existing_tags = t
- .get_string(ItemKey::Bpm)
- .zip(t.get_string(ItemKey::InitialKey))
- .and_then(|(bpm_str, key)| {
- bpm_str
- .parse::<f32>()
- .ok()
- .map(|bpm| (bpm, key.to_string()))
- });
+ let bpm_str = t
+ .get_string(ItemKey::IntegerBpm)
+ .or_else(|| t.get_string(ItemKey::Bpm));
+ let existing_tags =
+ bpm_str
+ .zip(t.get_string(ItemKey::InitialKey))
+ .and_then(|(bpm_str, key)| {
+ bpm_str
+ .parse::<f32>()
+ .ok()
+ .map(|bpm| (bpm, key.to_string()))
+ });
(metadata, existing_tags)
}
}
/// Write BPM and key metadata to a file.
-///
-/// # Errors
-///
-/// Returns an error if the file cannot be opened, read, or written.
-///
-/// # Panics
-///
-/// Panics if there are no tags and a new tag cannot be created.
pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> {
- let mut tagged_file = Probe::open(path)?.read()?;
- let tag = match tagged_file.primary_tag_mut() {
- Some(primary_tag) => primary_tag,
- None => {
- if let Some(first_tag) = tagged_file.first_tag_mut() {
- first_tag
- } else {
- let tag_type = tagged_file.primary_tag_type();
- tagged_file.insert_tag(Tag::new(tag_type));
- tagged_file
- .primary_tag_mut()
- .expect("Failed to create new tag")
- }
- }
+ let is_aiff = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .map(|e| {
+ matches!(
+ e.to_ascii_lowercase().as_str(),
+ "aiff" | "aif" | "aifc" | "afc"
+ )
+ })
+ .unwrap_or(false);
+
+ if is_aiff {
+ write_aiff_id3(path, bpm, key)
+ } else {
+ write_lofty(path, bpm, key)
+ }
+}
+
+fn write_lofty(path: &Path, bpm: f32, key: &str) -> Result<()> {
+ let mut tagged_file = Probe::open(path)?
+ .options(ParseOptions::new().read_properties(false))
+ .read()?;
+
+ let tag = if let Some(primary_tag) = tagged_file.primary_tag_mut() {
+ primary_tag
+ } else {
+ tagged_file.insert_tag(Tag::new(tagged_file.file_type().primary_tag_type()));
+ tagged_file
+ .primary_tag_mut()
+ .expect("failed to create new tag")
};
- tag.insert_text(ItemKey::Bpm, bpm.round().to_string());
+ tag.insert_text(ItemKey::IntegerBpm, bpm.round().to_string());
tag.insert_text(ItemKey::InitialKey, key.to_string());
if let Some(tkey) = ItemKey::from_key(tag.tag_type(), "TKEY") {
tag.insert_text(tkey, key.to_string());
}
- tag.save_to_path(path, WriteOptions::default())?;
+ tagged_file.save_to_path(path, WriteOptions::default())?;
Ok(())
}
+fn write_aiff_id3(path: &Path, bpm: f32, key: &str) -> Result<()> {
+ let bpm_str = bpm.round().to_string();
+ let id3 = id3_tag(&bpm_str, key);
+
+ let mut file_bytes =
+ std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
+
+ if file_bytes.len() < IFF_CONTAINER_HEADER || &file_bytes[..IFF_FORM_SIZE] != b"FORM" {
+ anyhow::bail!("not a valid IFF/AIFF file");
+ }
+
+ for fourcc in [b"ID3 ", b"id3 "] {
+ strip_chunks(&mut file_bytes, fourcc);
+ }
+
+ let pos = comm_end(&file_bytes).unwrap_or(IFF_CONTAINER_HEADER);
+ let chunk = id3_chunk(&id3);
+
+ let new_size = u32::try_from(file_bytes.len() - IFF_CONTAINER_HEADER + chunk.len())
+ .with_context(|| "AIFF file too large")?;
+ file_bytes.splice(pos..pos, chunk);
+ file_bytes[4..8].copy_from_slice(&new_size.to_be_bytes());
+
+ std::fs::write(path, file_bytes)
+ .with_context(|| format!("failed to write {}", path.display()))?;
+
+ Ok(())
+}
+
+fn pad(size: usize) -> usize {
+ size + (size % 2)
+}
+
+fn id3_chunk(id3: &[u8]) -> Vec<u8> {
+ let mut chunk = Vec::with_capacity(IFF_HEADER_LEN + pad(id3.len()));
+ chunk.extend_from_slice(b"ID3 ");
+ chunk.extend_from_slice(
+ &u32::try_from(id3.len())
+ .expect("ID3 tag too large")
+ .to_be_bytes(),
+ );
+ chunk.extend_from_slice(id3);
+ if pad(id3.len()) != id3.len() {
+ chunk.push(0);
+ }
+ chunk
+}
+
+fn id3_tag(bpm: &str, key: &str) -> Vec<u8> {
+ const ID3_VERSION_MAJOR: u8 = 3;
+ const ID3_VERSION_MINOR: u8 = 0;
+ const ID3_FLAGS: u8 = 0;
+
+ let bpm_frame = text_frame("TBPM", bpm);
+ let key_frame = text_frame("TKEY", key);
+ let size = syncsafe(bpm_frame.len() + key_frame.len());
+
+ let mut tag = Vec::with_capacity(10 + bpm_frame.len() + key_frame.len());
+ tag.extend_from_slice(b"ID3");
+ tag.push(ID3_VERSION_MAJOR);
+ tag.push(ID3_VERSION_MINOR);
+ tag.push(ID3_FLAGS);
+ tag.extend_from_slice(&size.to_be_bytes());
+ tag.extend_from_slice(&bpm_frame);
+ tag.extend_from_slice(&key_frame);
+ tag
+}
+
+fn text_frame(id: &str, value: &str) -> Vec<u8> {
+ const TEXT_FLAGS: [u8; 2] = [0, 0];
+ const TEXT_ENCODING_LATIN1: u8 = 0;
+
+ let mut frame = Vec::with_capacity(10 + value.len());
+ frame.extend_from_slice(id.as_bytes());
+ frame.extend_from_slice(&((1 + value.len()) as u32).to_be_bytes());
+ frame.extend_from_slice(&TEXT_FLAGS);
+ frame.push(TEXT_ENCODING_LATIN1);
+ frame.extend_from_slice(value.as_bytes());
+ frame
+}
+
+fn syncsafe(n: usize) -> u32 {
+ (((n & 0x0FE00000) << 3) | ((n & 0x001FC000) << 2) | ((n & 0x00003F80) << 1) | (n & 0x0000007F))
+ as u32
+}
+
+fn comm_end(bytes: &[u8]) -> Option<usize> {
+ for (off, fourcc, size) in iff_chunks(bytes) {
+ if fourcc == b"COMM" {
+ return Some(off + IFF_HEADER_LEN + pad(size));
+ }
+ }
+ None
+}
+
+fn strip_chunks(bytes: &mut Vec<u8>, fourcc: &[u8; 4]) {
+ let ranges: Vec<_> = iff_chunks(bytes)
+ .filter(|(_, id, _)| *id == fourcc)
+ .map(|(off, _, size)| (off, off + IFF_HEADER_LEN + pad(size)))
+ .collect();
+ for (s, e) in ranges.into_iter().rev() {
+ bytes.drain(s..e);
+ }
+}
+
+fn iff_chunks(bytes: &[u8]) -> impl Iterator<Item = (usize, &[u8; 4], usize)> + use<'_> {
+ let total = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
+ let mut off = IFF_CONTAINER_HEADER;
+ std::iter::from_fn(move || {
+ if off + IFF_HEADER_LEN > bytes.len() || off - IFF_CONTAINER_HEADER >= total {
+ return None;
+ }
+ let id: &[u8; 4] = bytes[off..off + 4].try_into().ok()?;
+ let size = u32::from_be_bytes([
+ bytes[off + 4],
+ bytes[off + 5],
+ bytes[off + 6],
+ bytes[off + 7],
+ ]) as usize;
+ let nxt = off + IFF_HEADER_LEN + pad(size);
+ if nxt <= off {
+ return None;
+ }
+ let cur = (off, id, size);
+ off = nxt;
+ Some(cur)
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
artist: Some("Aphex Twin".to_string()),
track: Some("Xtal".to_string()),
};
- let info = build_track_info(Path::new("/music/xtal.flac"), metadata, 120.5, "A Minor".to_string());
+ let info = build_track_info(
+ Path::new("/music/xtal.flac"),
+ metadata,
+ 120.5,
+ "A Minor".to_string(),
+ );
assert_eq!(info.path, Path::new("/music/xtal.flac"));
assert_eq!(info.artist.as_deref(), Some("Aphex Twin"));
blob - 13f638d6a5925110b089e9f72a3b9c807a9482e9
blob + fa043d2e77b4c38e39ac5a9e7b4c2c1ce0cbd812
--- src/utils.rs
+++ src/utils.rs
"Aphex Twin - Xtal"
);
assert_eq!(format_primary_key(None, Some("Xtal")), "Unknown - Xtal");
- assert_eq!(format_primary_key(Some("Aphex Twin"), None), "Aphex Twin - Unknown");
+ assert_eq!(
+ format_primary_key(Some("Aphex Twin"), None),
+ "Aphex Twin - Unknown"
+ );
assert_eq!(format_primary_key(None, None), "Unknown - Unknown");
}
}