Skip to main content

mecomp_analysis/
lib.rs

1//! This library contains stuff for song analysis and feature extraction.
2//!
3//! A lot of the code in this library is inspired by, or directly pulled from, [bliss-rs](https://github.com/Polochon-street/bliss-rs).
4//! We don't simply use bliss-rs because I don't want to bring in an ffmpeg dependency, and bliss-rs also has a lot of features that I don't need.
5//! (for example, I don't need to decode tags, process playlists, etc. etc., I'm doing all of that myself already)
6//!
7//! We use rodio to decode the audio file (overkill, but we already have the dependency for audio playback so may as well),
8//! We use rubato to resample the audio file to 22050 Hz.
9
10#![deny(clippy::missing_inline_in_public_items)]
11
12pub mod chroma;
13pub mod clustering;
14pub mod decoder;
15pub mod embeddings;
16pub mod errors;
17pub mod misc;
18pub mod temporal;
19pub mod timbral;
20pub mod utils;
21
22use std::{ops::Index, path::PathBuf};
23
24use likely_stable::LikelyResult;
25use misc::LoudnessDesc;
26use strum::{EnumCount, EnumIter, IntoEnumIterator};
27
28use chroma::ChromaDesc;
29use errors::{AnalysisError, AnalysisResult};
30use temporal::BPMDesc;
31use timbral::{SpectralDesc, ZeroCrossingRateDesc};
32
33pub use crate::embeddings::DIM_EMBEDDING;
34
35/// The resampled audio data used for analysis.
36///
37/// Must be in mono (1 channel), with a sample rate of 22050 Hz.
38#[derive(Debug, Clone)]
39pub struct ResampledAudio {
40    pub path: PathBuf,
41    pub samples: Vec<f32>,
42}
43
44impl TryInto<Analysis> for ResampledAudio {
45    type Error = AnalysisError;
46
47    #[inline]
48    fn try_into(self) -> Result<Analysis, Self::Error> {
49        Analysis::from_samples(&self)
50    }
51}
52
53/// The sampling rate used for the analysis.
54pub const SAMPLE_RATE: u32 = 22050;
55
56#[derive(Debug, EnumIter, EnumCount)]
57/// Indexes different fields of an Analysis.
58///
59/// Prints the tempo value of an analysis.
60///
61/// Note that this should mostly be used for debugging / distance metric
62/// customization purposes.
63pub enum AnalysisIndex {
64    /// The song's tempo.
65    Tempo,
66    /// The song's zero-crossing rate.
67    Zcr,
68    /// The mean of the song's spectral centroid.
69    MeanSpectralCentroid,
70    /// The standard deviation of the song's spectral centroid.
71    StdDeviationSpectralCentroid,
72    /// The mean of the song's spectral rolloff.
73    MeanSpectralRolloff,
74    /// The standard deviation of the song's spectral rolloff.
75    StdDeviationSpectralRolloff,
76    /// The mean of the song's spectral flatness.
77    MeanSpectralFlatness,
78    /// The standard deviation of the song's spectral flatness.
79    StdDeviationSpectralFlatness,
80    /// The mean of the song's loudness.
81    MeanLoudness,
82    /// The standard deviation of the song's loudness.
83    StdDeviationLoudness,
84    /// The proportion of pitch class set 1 (IC1) compared to the 6 other pitch class sets,
85    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
86    Chroma1,
87    /// The proportion of pitch class set 2 (IC2) compared to the 6 other pitch class sets,
88    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
89    Chroma2,
90    /// The proportion of pitch class set 3 (IC3) compared to the 6 other pitch class sets,
91    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
92    Chroma3,
93    /// The proportion of pitch class set 4 (IC4) compared to the 6 other pitch class sets,
94    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
95    Chroma4,
96    /// The proportion of pitch class set 5 (IC5) compared to the 6 other pitch class sets,
97    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
98    Chroma5,
99    /// The proportion of pitch class set 6 (IC6) compared to the 6 other pitch class sets,
100    /// per this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
101    Chroma6,
102    /// The proportion of major triads in the song, compared to the other triads.
103    Chroma7,
104    /// The proportion of minor triads in the song, compared to the other triads.
105    Chroma8,
106    /// The proportion of diminished triads in the song, compared to the other triads.
107    Chroma9,
108    /// The proportion of augmented triads in the song, compared to the other triads.
109    Chroma10,
110    /// The L2-norm of the IC1-6 (see above).
111    Chroma11,
112    /// The L2-norm of the IC7-10 (see above).
113    Chroma12,
114    /// The ratio of the L2-norm of IC7-10 and IC1-6 (proportion of triads vs dyads).
115    Chroma13,
116}
117
118/// The Type of individual features
119pub type Feature = f32;
120/// The number of features used in `Analysis`
121pub const NUMBER_FEATURES: usize = AnalysisIndex::COUNT;
122
123#[derive(Default, PartialEq, Clone, Copy)]
124/// Object holding the results of the song's analysis.
125///
126/// Only use it if you want to have an in-depth look of what is
127/// happening behind the scene, or make a distance metric yourself.
128///
129/// Under the hood, it is just an array of f32 holding different numeric
130/// features.
131///
132/// For more info on the different features, build the
133/// documentation with private items included using
134/// `cargo doc --document-private-items`, and / or read up
135/// [this document](https://lelele.io/thesis.pdf), that contains a description
136/// on most of the features, except the chroma ones, which are documented
137/// directly in this code.
138pub struct Analysis {
139    pub(crate) internal_analysis: [Feature; NUMBER_FEATURES],
140}
141
142impl Index<AnalysisIndex> for Analysis {
143    type Output = Feature;
144
145    #[inline]
146    fn index(&self, index: AnalysisIndex) -> &Feature {
147        &self.internal_analysis[index as usize]
148    }
149}
150
151impl Index<usize> for Analysis {
152    type Output = Feature;
153
154    #[inline]
155    fn index(&self, index: usize) -> &Feature {
156        &self.internal_analysis[index]
157    }
158}
159
160impl std::fmt::Debug for Analysis {
161    #[inline]
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        let mut debug_struct = f.debug_struct("Analysis");
164        for feature in AnalysisIndex::iter() {
165            debug_struct.field(&format!("{feature:?}"), &self[feature]);
166        }
167        debug_struct.finish()?;
168        f.write_str(&format!(" /* {:?} */", &self.as_vec()))
169    }
170}
171
172impl Analysis {
173    /// Create a new Analysis object.
174    ///
175    /// Usually not needed, unless you have already computed and stored
176    /// features somewhere, and need to recreate a Song with an already
177    /// existing Analysis yourself.
178    #[must_use]
179    #[inline]
180    pub const fn new(analysis: [Feature; NUMBER_FEATURES]) -> Self {
181        Self {
182            internal_analysis: analysis,
183        }
184    }
185
186    /// Creates a new `Analysis` object from a `Vec<Feature>`.
187    ///
188    /// invariant: `features.len() == NUMBER_FEATURES`
189    ///
190    /// # Errors
191    ///
192    /// This function will return an error if the length of the features is not equal to `NUMBER_FEATURES`.
193    #[inline]
194    pub fn from_vec(features: Vec<Feature>) -> Result<Self, AnalysisError> {
195        features
196            .try_into()
197            .map_err(|_| AnalysisError::InvalidFeaturesLen)
198            .map(Self::new)
199    }
200
201    /// Return the inner array of the analysis.
202    /// This is mostly useful if you want to store the features somewhere.
203    #[must_use]
204    #[inline]
205    pub const fn inner(&self) -> &[Feature; NUMBER_FEATURES] {
206        &self.internal_analysis
207    }
208
209    /// Return a `Vec<f32>` representing the analysis' features.
210    ///
211    /// Particularly useful if you want iterate through the values to store
212    /// them somewhere.
213    #[must_use]
214    #[inline]
215    pub fn as_vec(&self) -> Vec<Feature> {
216        self.internal_analysis.to_vec()
217    }
218
219    /// Create an `Analysis` object from a `ResampledAudio`.
220    /// This is the main function you should use to create an `Analysis` object.
221    /// It will compute all the features from the audio samples.
222    /// You can get a `ResampledAudio` object by using a `Decoder` to decode an audio file.
223    ///
224    /// This is meant to be run within a rayon thread pool, as it uses rayon to parallelize
225    ///
226    /// # Errors
227    ///
228    /// This function will return an error if the samples are empty or too short.
229    /// Or if there is an error during the analysis.
230    #[allow(clippy::missing_inline_in_public_items)]
231    pub fn from_samples(audio: &ResampledAudio) -> AnalysisResult<Self> {
232        let largest_window = BPMDesc::WINDOW_SIZE
233            .max(ChromaDesc::WINDOW_SIZE)
234            .max(SpectralDesc::WINDOW_SIZE)
235            .max(LoudnessDesc::WINDOW_SIZE);
236
237        if audio.samples.len() < largest_window {
238            return Err(AnalysisError::EmptySamples);
239        }
240
241        // jobs are split in a way that should make it so that each branch takes roughly the same amount of time
242        let (chroma, (spectral, tempo_zcr_loudness)) = rayon::join(
243            || -> AnalysisResult<_> {
244                let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
245                chroma_desc.do_(&audio.samples)?;
246                Ok(chroma_desc.get_value())
247            },
248            || {
249                rayon::join(
250                    || -> AnalysisResult<_> {
251                        let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE)?;
252                        let windows = audio
253                            .samples
254                            .windows(SpectralDesc::WINDOW_SIZE)
255                            .step_by(SpectralDesc::HOP_SIZE);
256
257                        for window in windows {
258                            spectral_desc.do_(window)?;
259                        }
260                        let centroid = spectral_desc.get_centroid();
261                        let rolloff = spectral_desc.get_rolloff();
262                        let flatness = spectral_desc.get_flatness();
263                        Ok((centroid, rolloff, flatness))
264                    },
265                    || -> AnalysisResult<_> {
266                        // BPM
267                        let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?;
268                        let windows = audio
269                            .samples
270                            .windows(BPMDesc::WINDOW_SIZE)
271                            .step_by(BPMDesc::HOP_SIZE);
272                        for window in windows {
273                            tempo_desc.do_(window)?;
274                        }
275                        let tempo = tempo_desc.get_value();
276
277                        // ZCR
278                        let mut zcr_desc = ZeroCrossingRateDesc::default();
279                        zcr_desc.do_(&audio.samples);
280                        let zcr = zcr_desc.get_value();
281
282                        // Loudness
283                        let mut loudness_desc = LoudnessDesc::default();
284                        let windows = audio.samples.chunks(LoudnessDesc::WINDOW_SIZE);
285                        for window in windows {
286                            loudness_desc.do_(window);
287                        }
288                        let loudness = loudness_desc.get_value();
289
290                        Ok((tempo, zcr, loudness))
291                    },
292                )
293            },
294        );
295
296        let chroma = chroma?;
297        let (centroid, rolloff, flatness) = spectral?;
298        let (tempo, zcr, loudness) = tempo_zcr_loudness?;
299
300        let mut result = vec![tempo, zcr];
301        result.extend_from_slice(&centroid);
302        result.extend_from_slice(&rolloff);
303        result.extend_from_slice(&flatness);
304        result.extend_from_slice(&loudness);
305        result.extend_from_slice(&chroma);
306        let array: [Feature; NUMBER_FEATURES] = result
307            .try_into()
308            .map_err_unlikely(|_| AnalysisError::InvalidFeaturesLen)?;
309
310        Ok(Self::new(array))
311    }
312}