Skip to main content

ferraus_analysis/
mastering.rs

1//! Measurements the mastering crate decides from: dynamics (crest factor, PLR, transient
2//! density, loudness sections), the "already mastered" heuristic, and a one-call
3//! [`mastering_analysis`] that bundles loudness, tonal balance, dynamics, stereo,
4//! problems, and tempo.
5
6use crate::loudness::{self, LoudnessReport, LoudnessSection};
7use crate::problems::{self, ProblemsReport};
8use crate::spectrum::{self, BandLevel, ToneBalance};
9use crate::stereo::{self, StereoReport};
10use crate::tempo::{self, TempoOptions};
11use ferraus_core::AudioBuffer;
12use serde::Serialize;
13
14/// Whether the input already looks mastered (limited, loud, little headroom).
15#[derive(Clone, Debug, Serialize)]
16pub struct MasteredHeuristic {
17    pub looks_mastered: bool,
18    /// 0..1 share of the mastered indicators that apply.
19    pub score: f64,
20    pub reasons: Vec<String>,
21}
22
23/// Dynamics measurements.
24#[derive(Clone, Debug, Serialize)]
25pub struct DynamicsReport {
26    /// Peak minus RMS (dB).
27    pub crest_factor_db: f64,
28    /// True peak minus integrated loudness (dB).
29    pub plr_db: f64,
30    pub loudness_range_lu: f64,
31    /// Onsets per second.
32    pub transient_density_per_s: f64,
33    /// Distance from the true peak to 0 dBTP (dB).
34    pub headroom_db: f64,
35    /// Sections more than 6 LU louder or quieter than the whole.
36    pub sections: Vec<LoudnessSection>,
37    pub mastered: MasteredHeuristic,
38}
39
40/// Peak-to-loudness ratio: true peak (dBTP) minus integrated loudness (LUFS).
41pub fn plr_db(buf: &AudioBuffer) -> f64 {
42    let r = loudness::loudness(buf);
43    r.plr_db
44}
45
46/// Score the "already mastered" indicators from a loudness report and crest factor.
47pub fn looks_mastered(l: &LoudnessReport, crest_factor_db: f64) -> MasteredHeuristic {
48    let mut reasons = Vec::new();
49    let mut hits = 0.0;
50    let total = 5.0;
51    if l.integrated_lufs.is_finite() && l.integrated_lufs > -14.0 {
52        hits += 1.0;
53        reasons.push(format!("loud: {:.1} LUFS integrated", l.integrated_lufs));
54    }
55    if l.true_peak_dbtp > -1.5 {
56        hits += 1.0;
57        reasons.push(format!("little headroom: true peak {:.1} dBTP", l.true_peak_dbtp));
58    }
59    if l.plr_db.is_finite() && l.plr_db < 11.0 {
60        hits += 1.0;
61        reasons.push(format!("low peak-to-loudness ratio: {:.1} dB", l.plr_db));
62    }
63    if crest_factor_db < 10.0 {
64        hits += 1.0;
65        reasons.push(format!("low crest factor: {crest_factor_db:.1} dB (limited)"));
66    }
67    if l.loudness_range_lu < 6.0 && l.duration_s >= 10.0 {
68        hits += 1.0;
69        reasons.push(format!("narrow loudness range: {:.1} LU", l.loudness_range_lu));
70    }
71    let score = hits / total;
72    MasteredHeuristic {
73        looks_mastered: score >= 0.6 && l.integrated_lufs.is_finite() && l.integrated_lufs > -16.0,
74        score,
75        reasons,
76    }
77}
78
79/// Dynamics report of a buffer.
80pub fn dynamics(buf: &AudioBuffer) -> DynamicsReport {
81    let l = loudness::loudness(buf);
82    dynamics_with(buf, &l)
83}
84
85fn dynamics_with(buf: &AudioBuffer, l: &LoudnessReport) -> DynamicsReport {
86    let crest = crate::levels::crest_factor_db(buf);
87    DynamicsReport {
88        crest_factor_db: crest,
89        plr_db: l.plr_db,
90        loudness_range_lu: l.loudness_range_lu,
91        transient_density_per_s: tempo::transient_density(buf),
92        headroom_db: -l.true_peak_dbtp,
93        sections: loudness::loudness_outliers(buf, 6.0),
94        mastered: looks_mastered(l, crest),
95    }
96}
97
98/// Context numbers for a mastering report.
99#[derive(Clone, Debug, Serialize)]
100pub struct MasteringContext {
101    pub duration_s: f64,
102    pub sample_rate: u32,
103    pub channels: usize,
104    pub tempo_bpm: f64,
105    pub tempo_confidence: f64,
106}
107
108/// Everything `master analyze` reports that this crate can measure (key comes from the
109/// pitch crate, bit depth from the decoder).
110#[derive(Clone, Debug, Serialize)]
111pub struct MasteringAnalysis {
112    pub loudness: LoudnessReport,
113    pub third_octave: Vec<BandLevel>,
114    /// Present when a reference was given.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub tone_balance: Option<ToneBalance>,
117    pub dynamics: DynamicsReport,
118    pub stereo: StereoReport,
119    pub problems: ProblemsReport,
120    pub context: MasteringContext,
121}
122
123/// One-call analysis for mastering. `reference` adds a tonal-balance comparison.
124pub fn mastering_analysis(buf: &AudioBuffer, reference: Option<&AudioBuffer>) -> MasteringAnalysis {
125    let l = loudness::loudness(buf);
126    let bands = spectrum::third_octave_levels(buf);
127    let tone_balance = reference.map(|r| spectrum::tone_balance(&bands, &spectrum::third_octave_levels(r)));
128    let t = tempo::detect_tempo(buf, &TempoOptions::default());
129    MasteringAnalysis {
130        dynamics: dynamics_with(buf, &l),
131        loudness: l,
132        third_octave: bands,
133        tone_balance,
134        stereo: stereo::stereo_analysis(buf),
135        problems: problems::diagnose(buf),
136        context: MasteringContext {
137            duration_s: buf.duration_s(),
138            sample_rate: buf.sample_rate,
139            channels: buf.channels(),
140            tempo_bpm: t.bpm,
141            tempo_confidence: t.confidence,
142        },
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use ferraus_core::testing::{noise, sine, stereo};
150
151    #[test]
152    fn heuristic_flags_loud_limited_audio() {
153        // hard-limited loud noise: loud, no headroom, low crest
154        let mut b = noise(48000, 12.0, 1.4, 1);
155        b.data[0].iter_mut().for_each(|v| *v = v.clamp(-0.98, 0.98));
156        let d = dynamics(&stereo(b));
157        assert!(d.mastered.looks_mastered, "{:?}", d.mastered);
158        // quiet sine with lots of headroom
159        let q = stereo(sine(48000, 440.0, 12.0, 0.05));
160        let d = dynamics(&q);
161        assert!(!d.mastered.looks_mastered, "{:?}", d.mastered);
162        assert!(d.headroom_db > 20.0);
163    }
164
165    #[test]
166    fn bundle_with_reference() {
167        let a = stereo(sine(48000, 440.0, 3.0, 0.3));
168        let r = stereo(noise(48000, 3.0, 0.2, 2));
169        let m = mastering_analysis(&a, Some(&r));
170        assert!(m.tone_balance.is_some());
171        assert!(m.loudness.integrated_lufs.is_finite());
172        assert_eq!(m.context.channels, 2);
173        let v = serde_json::to_value(&m).unwrap();
174        assert!(v["stereo"]["correlation"].as_f64().unwrap() > 0.99);
175    }
176}