aede_core/playback/
loudness.rs

1//! Playback loudness measurements over decoded PCM, independent of the output sink.
2//!
3//! Imported FlacCompagnon values are preferred for a track. Missing values are
4//! measured over decoded PCM by `aede-dsp`. Album measurements combine gated
5//! block energies for the whole programme, never track LUFS averages.
6
7use std::error::Error;
8use std::path::Path;
9
10use aede_dsp::{PcmFormat, loudness::LoudnessProgramme};
11
12use crate::analysis::FileAnalysis;
13
14use super::decoder::FileDecoder;
15
16pub use aede_dsp::loudness::Measurement;
17
18/// One version of a file's measured playback loudness.
19#[derive(Clone, Debug, PartialEq)]
20pub struct CachedTrack {
21    /// File size at measurement time.
22    pub size: u64,
23    /// File modification time at measurement time.
24    pub mtime: u64,
25    /// Nanosecond fraction captured by current measurements.
26    /// Legacy caches without precision cannot match a freshly read identity.
27    pub mtime_subseconds: Option<u32>,
28    /// `None` records silence or an unsupported channel layout.
29    pub measurement: Option<Measurement>,
30}
31
32/// A file's identity within an ordered album programme.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct ProgrammeFile {
35    /// Audio file path retained as supplied by the playback selection.
36    pub path: String,
37    /// File size at measurement time.
38    pub size: u64,
39    /// File modification time at measurement time.
40    pub mtime: u64,
41    /// Nanosecond fraction, absent only in older serialized cache entries.
42    pub mtime_subseconds: Option<u32>,
43}
44
45impl CachedTrack {
46    pub(crate) fn matches(&self, file: &ProgrammeFile) -> bool {
47        self.size == file.size
48            && self.mtime == file.mtime
49            && self.mtime_subseconds == file.mtime_subseconds
50    }
51}
52
53/// One programme loudness measurement, invalidated when any member changes.
54#[derive(Clone, Debug, PartialEq)]
55pub struct CachedProgramme {
56    /// Ordered file identities in the programme.
57    pub files: Vec<ProgrammeFile>,
58    /// `None` records silence or an unsupported layout.
59    pub measurement: Option<Measurement>,
60}
61
62/// Read a regular file's precise identity for playback measurements.
63///
64/// Unavailable and pre-epoch modification times are explicit errors. Imported
65/// analyses retain their original whole-second evidence; no added precision is
66/// inferred for those externally supplied measurements. Paths that cannot be
67/// represented exactly as UTF-8 are refused before accessing the filesystem,
68/// because a lossy persisted spelling could identify another audio file.
69pub fn identity(path: &Path) -> Result<ProgrammeFile, std::io::Error> {
70    let stored_path = path.to_str().ok_or_else(|| {
71        std::io::Error::new(
72            std::io::ErrorKind::InvalidInput,
73            "playback loudness path must be valid UTF-8",
74        )
75    })?;
76    let metadata = std::fs::metadata(path)?;
77    if !metadata.is_file() {
78        return Err(std::io::Error::new(
79            std::io::ErrorKind::InvalidInput,
80            "playback loudness source must be a regular file",
81        ));
82    }
83    let modified = metadata
84        .modified()?
85        .duration_since(std::time::UNIX_EPOCH)
86        .map_err(|_| {
87            std::io::Error::new(
88                std::io::ErrorKind::InvalidData,
89                "playback loudness source timestamp predates the epoch",
90            )
91        })?;
92    Ok(ProgrammeFile {
93        path: stored_path.to_owned(),
94        size: metadata.len(),
95        mtime: modified.as_secs(),
96        mtime_subseconds: Some(modified.subsec_nanos()),
97    })
98}
99
100/// Reuse an attributed analysis while its recorded size/whole-second time match.
101/// A missing true peak stays unknown so the output stage can choose its policy.
102/// A finite imported sample peak floors the playback peak estimate: incomplete
103/// interpolation tails must not justify gain above a known source sample.
104/// The attributed report itself is never changed by this conservative bound.
105pub fn from_flaccompagnon(analyses: &[FileAnalysis], file: &ProgrammeFile) -> Option<Measurement> {
106    analyses.iter().rev().find_map(|analysis| {
107        if analysis.path != file.path
108            || analysis.source != "flaccompagnon"
109            || analysis.error.is_some()
110            || !analysis.still_applies(file.size, file.mtime)
111        {
112            return None;
113        }
114        let loudness = analysis.integrated_lufs?;
115        if !loudness.is_finite() || !(-100.0..=20.0).contains(&loudness) {
116            return None;
117        }
118        let linear_peak = |db: f64| {
119            if !db.is_finite() {
120                return None;
121            }
122            let peak = 10f64.powf(db / 20.0) as f32;
123            peak.is_finite().then_some(peak)
124        };
125        let peak = analysis.true_peak_dbtp.and_then(linear_peak).map(|peak| {
126            analysis
127                .peak_dbfs
128                .and_then(linear_peak)
129                .map_or(peak, |sample_peak| peak.max(sample_peak))
130        });
131        Some(Measurement {
132            integrated_lufs: loudness as f32,
133            true_peak: peak,
134        })
135    })
136}
137
138fn decode_into(
139    decoder: &mut FileDecoder,
140    meter: &mut LoudnessProgramme,
141) -> Result<(), Box<dyn Error>> {
142    let channels = usize::from(decoder.channels());
143    let format = PcmFormat::new(decoder.sample_rate(), decoder.channels())?;
144    let mut samples = vec![0.0; 4096 * channels];
145    loop {
146        let frames = decoder.read_frames(&mut samples)?;
147        if frames == 0 {
148            break;
149        }
150        meter.push(format, &samples[..frames * channels])?;
151    }
152    Ok(())
153}
154
155/// Measure a track once before output, so playback never has to wait mid-track.
156pub fn measure_track(path: &Path) -> Result<Option<Measurement>, Box<dyn Error>> {
157    let mut decoder = FileDecoder::open(path)?;
158    if !matches!(decoder.channels(), 1 | 2) {
159        return Ok(None);
160    }
161    let mut meter = LoudnessProgramme::new();
162    decode_into(&mut decoder, &mut meter)?;
163    Ok(meter.measurement()?)
164}
165
166/// Measure an ordered album programme. One meter spans matching formats;
167/// different formats retain independent meters, then share one gated result.
168pub fn measure_programme(paths: &[&Path]) -> Result<Option<Measurement>, Box<dyn Error>> {
169    if paths.is_empty() {
170        return Ok(None);
171    }
172    let mut meter = LoudnessProgramme::new();
173    for path in paths {
174        let mut decoder = FileDecoder::open(path)?;
175        if !matches!(decoder.channels(), 1 | 2) {
176            return Ok(None);
177        }
178        decode_into(&mut decoder, &mut meter)?;
179    }
180    Ok(meter.measurement()?)
181}
182
183#[cfg(test)]
184#[path = "loudness_tests.rs"]
185mod tests;