commit fb2b2da90482b058e04b4bc97f5f4684db247c25 from: mtmn date: Sun Jul 26 13:38:38 2026 UTC fix aiff tagging commit - c48439d62522304863233832e3e47f3e79ee79f1 commit + fb2b2da90482b058e04b4bc97f5f4684db247c25 blob - 1077c7c24adcced912e621d9a0733d8163a2b7f1 blob + de775fb02eb1f796790bb0a37957abbbb9176510 --- src/audio_processing.rs +++ src/audio_processing.rs @@ -251,7 +251,10 @@ mod tests { // 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 @@ -515,7 +515,10 @@ mod tests { 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, @@ -573,7 +576,10 @@ mod tests { 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()); @@ -709,13 +715,23 @@ mod tests { 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 @@ -1,10 +1,14 @@ -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; @@ -60,7 +64,11 @@ pub fn process_file(path: &Path, write_tags: bool, for } 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() @@ -77,15 +85,18 @@ fn read_tags(path: &Path) -> (FileMetadata, Option<(f3 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::() - .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::() + .ok() + .map(|bpm| (bpm, key.to_string())) + }); (metadata, existing_tags) } @@ -94,41 +105,178 @@ fn read_tags(path: &Path) -> (FileMetadata, Option<(f3 } /// 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 { + 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 { + 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 { + 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 { + 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, 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 + 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::*; @@ -139,7 +287,12 @@ mod tests { 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 @@ -82,7 +82,10 @@ mod tests { "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"); } }