diff --git a/Cargo.lock b/Cargo.lock index 8d5e936..c952e56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.106", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -227,9 +247,20 @@ version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -251,6 +282,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.48" @@ -922,7 +964,7 @@ dependencies = [ "hungarian", "id3", "inquire", - "itertools", + "itertools 0.14.0", "levenshtein", "log", "metaflac", @@ -937,6 +979,7 @@ dependencies = [ "sha2", "similar", "simplelog", + "soundtouch", "symphonia", "tempfile", "thiserror 2.0.16", @@ -1287,6 +1330,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1302,6 +1354,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1330,6 +1391,16 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1409,6 +1480,12 @@ dependencies = [ "hex", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1499,6 +1576,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num" version = "0.4.3" @@ -1836,6 +1923,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + [[package]] name = "primal-check" version = "0.3.4" @@ -2024,6 +2121,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustfft" version = "6.4.0" @@ -2275,6 +2378,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "soundtouch" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132ff7c331f49bdd5c02b79a287fcc583260d9b7e8365a0b05b836f9b734d4ba" +dependencies = [ + "soundtouch-ffi", +] + +[[package]] +name = "soundtouch-ffi" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a157b7c8482ea7218ff1cfbff913b26bad36ae0bdd06255146e22e78116a16" +dependencies = [ + "bindgen", + "cc", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 54ea777..20805d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ num_cpus = "1.17.0" handlebars = "6.3.2" tempfile = "3.22.0" expanduser = "1.2.2" +soundtouch = { version = "0.5.1", default-features = false } [dev-dependencies] paste = "1" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index ba8419b..69c8e48 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -23,10 +23,12 @@ use symphonia::core::probe::Hint; mod chromaprint; mod ebur128; +mod soundtouch_bpm; mod track_length; use chromaprint::ChromaprintFingerprintAnalyzer; use ebur128::EbuR128Analyzer; +use soundtouch_bpm::SoundTouchBpmAnalyzer; use track_length::TrackLengthAnalyzer; pub use ebur128::EbuR128AlbumResult; @@ -94,6 +96,8 @@ enum CompoundAnalyzerItem { ChromaprintFingerprint(Box), /// EBU R 128 Analyzer. EbuR128(Box), + /// SoundTouch BPM Analyzer. + SoundTouchBpm(Box), } impl CompoundAnalyzerItem { @@ -130,6 +134,15 @@ impl CompoundAnalyzerItem { None } }, + AnalyzerType::SoundTouchBpm => { + match SoundTouchBpmAnalyzer::initialize(config, codec_params) { + Ok(analyzer) => Some(Self::SoundTouchBpm(Box::from(analyzer))), + Err(err) => { + result.soundtouch_bpm = Some(Err(err)); + None + } + } + } } } @@ -139,6 +152,7 @@ impl CompoundAnalyzerItem { Self::TrackLength(analyzer) => analyzer.is_complete(), Self::ChromaprintFingerprint(analyzer) => analyzer.is_complete(), Self::EbuR128(analyzer) => analyzer.is_complete(), + Self::SoundTouchBpm(analyzer) => analyzer.is_complete(), } } @@ -171,6 +185,13 @@ impl CompoundAnalyzerItem { false } }, + Self::SoundTouchBpm(analyzer) => match analyzer.feed(samples) { + Ok(()) => true, + Err(err) => { + result.soundtouch_bpm = Some(Err(err)); + false + } + }, } } @@ -189,6 +210,9 @@ impl CompoundAnalyzerItem { Self::EbuR128(analyzer) => { result.ebur128 = Some(analyzer.finalize()); } + Self::SoundTouchBpm(analyzer) => { + result.soundtouch_bpm = Some(analyzer.finalize()); + } } result } @@ -204,6 +228,8 @@ pub struct CompoundAnalyzerResult { Option::Result, AnalyzerError>>, /// Result of the EBU R 128 analysis. pub ebur128: Option::Result, AnalyzerError>>, + /// Result of the SoundTouch BPM analysis. + pub soundtouch_bpm: Option::Result, AnalyzerError>>, } impl Analyzer for CompoundAnalyzer { diff --git a/src/analyzer/soundtouch_bpm.rs b/src/analyzer/soundtouch_bpm.rs new file mode 100644 index 0000000..ea06c61 --- /dev/null +++ b/src/analyzer/soundtouch_bpm.rs @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Jan Holthuis +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy +// of the MPL was not distributed with this file, You can obtain one at +// http://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +//! SoundTouch BPM analysis. +//! +//! Detects the average Beats-per-minute (BPM) of the audio track using the [SoundTouch Audio +//! Processing Library][soundtouch]. +//! +//! [soundtouch]: http://www.surina.net/soundtouch/ + +use super::{Analyzer, AnalyzerError}; +use crate::config::Config; + +use symphonia::core::audio::Channels; +use symphonia::core::codecs::CodecParameters; + +use soundtouch::BPMDetect; + +/// Chromaprint Analyzer. +#[allow(missing_debug_implementations)] +pub struct SoundTouchBpmAnalyzer { + /// The [`BPMDetect`] struct which is doing the actual tempo analysis. + bpm_detect: BPMDetect, +} + +/// Analysis result of the Chromaprint analyzer. +#[allow(missing_copy_implementations)] +#[derive(Debug, Clone)] +pub struct SoundTouchBpmResult { + /// Analyzed Beats per Minute (BPM). + pub bpm: f32, +} + +impl SoundTouchBpmResult { + /// Return the BPM as a string. + pub fn bpm_string(&self) -> String { + format!("{bpm:.2}", bpm = self.bpm) + } +} + +impl Analyzer for SoundTouchBpmAnalyzer { + type Result = SoundTouchBpmResult; + + fn initialize(_config: &Config, codec_params: &CodecParameters) -> Result { + let sample_rate = codec_params + .sample_rate + .ok_or(AnalyzerError::MissingSampleRate)?; + let num_channels = codec_params + .channels + .map(Channels::count) + .and_then(|channel_count| u32::try_from(channel_count).ok()) + .ok_or(AnalyzerError::MissingAudioChannels)?; + + let bpm_detect = BPMDetect::new(num_channels, sample_rate); + let analyzer = Self { bpm_detect }; + Ok(analyzer) + } + + fn feed(&mut self, samples: &[i16]) -> Result<(), AnalyzerError> { + let samples_float = samples + .iter() + .map(|&sample| sample.into()) + .collect::>(); + self.bpm_detect.input_samples(&samples_float); + Ok(()) + } + + fn is_complete(&self) -> bool { + // We need to read the entire file to calculate the average BPM. + false + } + + fn finalize(mut self) -> Result { + let bpm = self.bpm_detect.get_bpm(); + if bpm == 0.0 { + Err(AnalyzerError::Custom("No Beats detected")) + } else { + Ok(Self::Result { bpm }) + } + } +} diff --git a/src/cli/ui/handle_candidate.rs b/src/cli/ui/handle_candidate.rs index 2f552ce..3472dc6 100644 --- a/src/cli/ui/handle_candidate.rs +++ b/src/cli/ui/handle_candidate.rs @@ -505,6 +505,22 @@ pub fn show_candidate( ); } } + + // Print the BPM (if available/different) + if let Some(bpm) = lhs_track.analyzed_metadata().bpm() { + if lhs_track.bpm().is_none_or(|l| l != bpm) { + print_extra_metadata( + lhs_track.bpm(), + Some(bpm), + "", + " (bpm)", + candidate_details_config, + max_width, + candidate_details_config.tracklist_extra_line_limit, + ); + } + } + if let Some(path) = lhs_track.track_path() { let old_path = path; diff --git a/src/config.rs b/src/config.rs index 71fe311..1f031df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -329,6 +329,8 @@ pub enum AnalyzerType { ChromaprintFingerprint, /// EBU R 128 Loudness Analyzer EbuR128, + /// SoundTouch BPM Analyzer + SoundTouchBpm, } /// The path configuration struct. diff --git a/src/default_config.toml b/src/default_config.toml index 2db2a80..f25b4b7 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -1,6 +1,6 @@ [analyzers] num_parallel_jobs = 0 -enabled = ["track_length", "chromaprint_fingerprint", "ebu_r128"] +enabled = ["track_length", "chromaprint_fingerprint", "ebu_r128", "sound_touch_bpm"] [paths] library_path = "~/Music" diff --git a/src/taggedfile.rs b/src/taggedfile.rs index e9925c4..dd8b37c 100644 --- a/src/taggedfile.rs +++ b/src/taggedfile.rs @@ -360,6 +360,11 @@ impl TaggedFile { .replay_gain_track_range() .map(|value| Cow::from(value.to_string())); self.set_tag_value(&TagKey::ReplayGainTrackRange, replay_gain_track_range); + let bpm_analyzed = self + .analyzed_metadata() + .bpm() + .map(|value| Cow::from(value.to_string())); + self.set_tag_value(&TagKey::Bpm, bpm_analyzed); self.set_tag_value(&TagKey::TrackNumber, track.track_number()); self.set_tag_value(&TagKey::TrackTitle, track.track_title()); self.set_tag_value(&TagKey::TrackTitleSortOrder, track.track_title_sort_order()); @@ -688,6 +693,13 @@ impl AnalyzedTrackMetadata for TaggedFileAnalyzedMetadata<'_> { fn replay_gain_track_range(&self) -> Option> { None } + + fn bpm(&self) -> Option> { + self.0 + .and_then(|result| result.soundtouch_bpm.as_ref()) + .and_then(|res| res.as_ref().ok()) + .map(|soundtouch_bpm| Cow::from(soundtouch_bpm.bpm_string())) + } } #[cfg(test)] diff --git a/src/track.rs b/src/track.rs index d331fcf..6d0ef5a 100644 --- a/src/track.rs +++ b/src/track.rs @@ -271,6 +271,9 @@ pub trait AnalyzedTrackMetadata { /// ReplayGain Track Range. fn replay_gain_track_range(&self) -> Option>; + + /// Tempo in Beats per Minute (BPM). + fn bpm(&self) -> Option>; } /// Adds helper methods to the `MusicBrainzReleaseTrack` struct. @@ -760,4 +763,8 @@ impl AnalyzedTrackMetadata for NoAnalyzedTrackMetadata { fn replay_gain_track_range(&self) -> Option> { None } + + fn bpm(&self) -> Option> { + None + } }