Something went wrong. Try again.
An extensible music server written in Rust , single binary, zero dependency
scrobble audio atproto radio streaming jellyfin subsonic navidrome
Something went wrong. Try again.
9.0 kB · 240 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241//! What a track sounds like, as numbers.//!//! Four things, from one decode pass: a **waveform** to draw, **loudness** to//! level tracks against each other, a **tempo**, and a **mood** as a point in//! valence/arousal space. Together they are enough to answer "what should play//! after this?" without knowing anything about genres or what anyone has//! listened to.//!//! Analysis is expensive and its inputs never change — the same bytes always//! give the same answer — so results are stored and a track is analysed once.//! That belongs to the caller; this crate only computes.
mod decode;mod features;pub mod fingerprint;pub mod key;pub mod tags;mod waveform;
pub use decode::{decode, Decoded};pub use fingerprint::{fingerprint, fingerprint_file, Fingerprint, FINGERPRINT_SECONDS};
use anyhow::Result;use serde::{Deserialize, Serialize};
/// How many bars a stored waveform has.////// Wide enough to look like the track rather than a smear, small enough that/// storing one per track is unremarkable — 400 bytes, against several megabytes/// of audio.pub const WAVEFORM_BINS: usize = 400;
/// Everything analysis knows about one track.#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]pub struct Analysis { /// Peak amplitude per bar, 0–255, left to right. Drawn as-is: the shaping /// that makes a waveform readable is already applied. pub waveform: Vec<u8>, /// Beats per minute. pub bpm: Option<f32>, /// How much to believe the bpm, 0–1. Tempo detection is confident on a /// four-to-the-floor track and much less so on something rubato, and a /// caller matching tracks by tempo needs to know which it has. pub bpm_confidence: Option<f32>, /// Pleasantness, -1 (dark) to 1 (bright). pub valence: Option<f32>, /// Energy, 0 (calm) to 1 (driving). pub arousal: Option<f32>, /// Mood labels with confidences, as the detector named them. pub moods: Vec<(String, f32)>, /// The musical key in traditional notation, e.g. `"Fm"` — what every other /// tool in a user's library shows. [`key::Key`] converts to Camelot when /// the wheel position is what is wanted. pub key: Option<String>, /// How much to believe the key, 0–1. pub key_confidence: Option<f32>, /// Integrated loudness, LUFS. The EBU R128 measure — what "as loud as" means /// when comparing two tracks. pub lufs: Option<f32>, /// True peak, in dBTP. Needed alongside loudness: a track can be quiet on /// average and still clip when gained up. pub true_peak_db: Option<f32>, /// Seconds, as decoded rather than as the tags claim. pub duration: f32,}
impl Analysis { /// Whether there is enough here to match tracks against each other. /// /// A waveform alone is worth storing — it is drawn — but it says nothing /// about what would sound good next. pub fn is_useful_for_matching(&self) -> bool { self.bpm.is_some() || (self.valence.is_some() && self.arousal.is_some()) }
/// The gain, in dB, that would bring this track to a target loudness. /// /// Limited so the true peak stays a decibel below clipping: turning a quiet /// master up to match a loud one is exactly when a track starts to clip. pub fn gain_for_target(&self, target_lufs: f32) -> Option<f32> { let lufs = self.lufs?; let mut gain = target_lufs - lufs; if let Some(peak) = self.true_peak_db { gain = gain.min(-1.0 - peak); } Some(gain.clamp(-24.0, 24.0)) }}
/// Analyse audio.////// `extension_hint` is the file extension or codec name when it is known, to/// help the prober; a wrong hint costs nothing.////// Feature extraction that fails does not fail the analysis. A track whose/// tempo cannot be found still has a waveform worth drawing and a loudness/// worth knowing, and returning nothing would mean re-attempting all of it/// every time.pub fn analyze(bytes: &[u8], extension_hint: Option<&str>) -> Result<Analysis> { let decoded = decode(bytes, extension_hint)?;
let mut analysis = Analysis { waveform: waveform::bins(&decoded.chunk_peaks, WAVEFORM_BINS), duration: decoded.duration, ..Default::default() };
match waveform::loudness(&decoded) { Ok((lufs, true_peak_db)) => { analysis.lufs = Some(lufs); analysis.true_peak_db = Some(true_peak_db); } Err(cause) => tracing::debug!(%cause, "no loudness"), }
// Same again for tempo, and for the same reason — though detection does // rather better here, so this mostly just saves the work. match decoded.tags.bpm { Some(bpm) => { analysis.bpm = Some(bpm); analysis.bpm_confidence = Some(0.95); } None => match features::tempo(&decoded) { Ok((bpm, confidence)) => { analysis.bpm = Some(bpm); analysis.bpm_confidence = Some(confidence); } Err(cause) => tracing::debug!(%cause, "no tempo"), }, }
// The file's own key, when it has one. Detection only fills the gap. // // Not merely a shortcut: correlation-based detection picks the tonic well // and the *mode* poorly, and mode is not a small error — F major is 7B and // F minor is 4A, at opposite ends of the wheel. A tag written by a DJ tool // says which outright, and is what every other tool in the user's library // is already showing. match decoded.tags.key.clone() { Some(key) => { analysis.key = Some(key); // Stated rather than estimated. Not 1.0: a tag can be wrong too, // and a caller weighing this against a measurement should not be // told it is beyond question. analysis.key_confidence = Some(0.95); } None => match features::key(&decoded) { Ok((key, confidence)) => { analysis.key = Some(key); analysis.key_confidence = Some(confidence); } Err(cause) => tracing::debug!(%cause, "no key"), }, }
match features::mood(&decoded) { Ok(mood) => { analysis.valence = Some(mood.valence); analysis.arousal = Some(mood.arousal); analysis.moods = mood.labels; } Err(cause) => tracing::debug!(%cause, "no mood"), }
Ok(analysis)}
#[cfg(test)]mod tests { use super::*;
fn analysed(lufs: Option<f32>, peak: Option<f32>) -> Analysis { Analysis { lufs, true_peak_db: peak, ..Default::default() } }
/// The gain is the difference to the target, until the peak says otherwise. #[test] fn the_target_gain_never_lets_a_track_clip() { // Quiet master, plenty of headroom: the full correction applies. let quiet = analysed(Some(-20.0), Some(-6.0)); assert_eq!(quiet.gain_for_target(-14.0), Some(5.0));
// Same loudness, but already peaking near full scale. Turning it up by // 6 dB would clip, so it is held to a decibel below the peak. let hot = analysed(Some(-20.0), Some(-0.5)); assert_eq!(hot.gain_for_target(-14.0), Some(-0.5));
// Turning a loud track *down* is never limited by its peak. let loud = analysed(Some(-6.0), Some(-0.1)); assert_eq!(loud.gain_for_target(-14.0), Some(-8.0)); }
#[test] fn a_track_with_no_loudness_has_no_gain() { assert_eq!(analysed(None, None).gain_for_target(-14.0), None); // No peak measurement is not a reason to refuse: it only means the // clipping limit cannot be applied. assert_eq!( analysed(Some(-20.0), None).gain_for_target(-14.0), Some(6.0) ); }
/// A waveform is worth storing on its own, but it cannot pick the next /// track — that needs a tempo or a mood. #[test] fn matching_needs_more_than_a_waveform() { let mut analysis = Analysis { waveform: vec![7; WAVEFORM_BINS], ..Default::default() }; assert!(!analysis.is_useful_for_matching());
analysis.bpm = Some(128.0); assert!(analysis.is_useful_for_matching());
// Mood alone is enough too, but only when both halves are present: // valence without arousal is not a point in the space. let mood_only = Analysis { valence: Some(0.4), ..Default::default() }; assert!(!mood_only.is_useful_for_matching()); let mood_both = Analysis { valence: Some(0.4), arousal: Some(0.7), ..Default::default() }; assert!(mood_both.is_useful_for_matching()); }
#[test] fn nothing_is_not_a_track() { assert!(analyze(&[], None).is_err()); }}