aede_core/playback/
gain_plan.rs

1//! Normalization policy shared by local CLI and native PCM playback.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::path::{Path, PathBuf};
6
7use crate::conclusions::{self, Conclusions};
8use crate::model::Catalog;
9use crate::playback::loudness::{self, CachedProgramme, CachedTrack, Measurement, ProgrammeFile};
10use crate::playback::normalization::{self, Mode as NormalizationMode, Source};
11use crate::store_lock::StoreLock;
12use crate::tags;
13
14#[path = "gain_plan_ready.rs"]
15mod ready;
16pub use ready::{LoudnessUpdate, ReadyNormalization};
17
18/// The gain chosen before the DSP enforces output headroom.
19#[derive(Clone, Copy)]
20pub struct GainPlan {
21    /// Requested gain before output headroom is applied.
22    pub gain_db: f32,
23    /// Linear peak of the unamplified source, when known.
24    pub source_peak: Option<f32>,
25    /// Origin of the loudness decision.
26    pub label: &'static str,
27    /// Origin of the peak used for headroom.
28    pub peak_label: &'static str,
29}
30
31fn metadata_plan(selection: normalization::Selection) -> GainPlan {
32    let (label, peak_label) = match selection.source {
33        Source::ReplayGainTrack => ("ReplayGain track", "ReplayGain peak"),
34        Source::ReplayGainAlbum => ("ReplayGain album", "ReplayGain peak"),
35        Source::OpusR128Track => ("Opus R128 track", "assumed full-scale peak"),
36        Source::OpusR128Album => ("Opus R128 album", "assumed full-scale peak"),
37    };
38    GainPlan {
39        gain_db: selection.gain_db,
40        source_peak: selection.source_peak,
41        label,
42        peak_label,
43    }
44}
45
46fn measured_plan(measurement: Measurement, album: bool, imported: bool) -> GainPlan {
47    GainPlan {
48        gain_db: normalization::DEFAULT_TARGET_LUFS - measurement.integrated_lufs,
49        source_peak: measurement.true_peak,
50        label: if album {
51            "measured album"
52        } else if imported {
53            "FlacCompagnon track"
54        } else {
55            "measured track"
56        },
57        peak_label: if measurement.true_peak.is_some() {
58            "measured true peak"
59        } else {
60            "assumed full-scale peak"
61        },
62    }
63}
64
65fn is_requested_scope(source: Source, mode: NormalizationMode) -> bool {
66    matches!(
67        (source, mode),
68        (
69            Source::ReplayGainTrack | Source::OpusR128Track,
70            NormalizationMode::Track
71        ) | (
72            Source::ReplayGainAlbum | Source::OpusR128Album,
73            NormalizationMode::Album
74        )
75    )
76}
77
78fn album_groups(paths: &[PathBuf], is_album: bool, catalog: Option<&Catalog>) -> Vec<Vec<usize>> {
79    if !is_album {
80        return vec![(0..paths.len()).collect()];
81    }
82    let mut groups: Vec<Vec<usize>> = Vec::new();
83    let mut previous = None;
84    for (index, path) in paths.iter().enumerate() {
85        let release = catalog.and_then(|catalog| {
86            let path = path.to_str()?;
87            catalog.tracks.iter().find_map(|track| {
88                (catalog.file(track.file_id)?.path == path)
89                    .then_some(track.release_id)
90                    .flatten()
91            })
92        });
93        if groups.is_empty() || (index > 0 && release != previous) {
94            groups.push(Vec::new());
95        }
96        if let Some(group) = groups.last_mut() {
97            group.push(index);
98        }
99        previous = release;
100    }
101    groups
102}
103
104/// Explicitly premeasure and cache an ordered playback selection.
105/// This blocking preparation can decode every missing track or whole album.
106/// Interactive CLI and native PCM playback use [`ReadyNormalization`].
107pub fn plan_normalization(
108    paths: &[PathBuf],
109    is_album: bool,
110    catalog: Option<&Catalog>,
111    directory: &Path,
112    mode: NormalizationMode,
113) -> Result<Vec<Option<GainPlan>>, Box<dyn Error>> {
114    let mut plans = vec![None; paths.len()];
115    if mode == NormalizationMode::Off {
116        return Ok(plans);
117    }
118    let identities = paths
119        .iter()
120        .map(|path| loudness::identity(path))
121        .collect::<Result<Vec<_>, _>>()?;
122    let tags = paths
123        .iter()
124        .map(|path| tags::read(path))
125        .collect::<Result<Vec<_>, _>>()?;
126    let metadata = tags
127        .iter()
128        .map(|raw| normalization::select_raw(raw, mode, normalization::DEFAULT_TARGET_LUFS))
129        .collect::<Result<Vec<_>, _>>()?;
130    let cache_path = conclusions::conclusions_path(directory);
131    let cache = conclusions::load(&cache_path)?.unwrap_or_default();
132    let mut new_tracks: BTreeMap<String, CachedTrack> = BTreeMap::new();
133    let mut new_programmes = Vec::new();
134    if mode == NormalizationMode::Track {
135        for (index, file) in identities.iter().enumerate() {
136            if let Some(gain) = metadata[index].filter(|gain| is_requested_scope(gain.source, mode))
137            {
138                plans[index] = Some(metadata_plan(gain));
139                continue;
140            }
141            let imported = loudness::from_flaccompagnon(&cache.analyses, file).or_else(|| {
142                catalog.and_then(|catalog| loudness::from_flaccompagnon(&catalog.analyses, file))
143            });
144            let measurement = if let Some(measurement) = imported {
145                Some(measurement)
146            } else if let Some(cached) = new_tracks
147                .get(&file.path)
148                .or_else(|| cache.loudness_tracks.get(&file.path))
149                .filter(|cached| cached.matches(file))
150            {
151                cached.measurement
152            } else {
153                let measured = loudness::measure_track(&paths[index])?;
154                new_tracks.insert(
155                    file.path.clone(),
156                    CachedTrack {
157                        size: file.size,
158                        mtime: file.mtime,
159                        mtime_subseconds: file.mtime_subseconds,
160                        measurement: measured,
161                    },
162                );
163                measured
164            };
165            plans[index] = measurement
166                .map(|value| measured_plan(value, false, imported.is_some()))
167                .or_else(|| metadata[index].map(metadata_plan));
168        }
169    } else {
170        for group in album_groups(paths, is_album, catalog) {
171            let has_complete_album_tags = group.iter().all(|&index| {
172                metadata[index].is_some_and(|gain| is_requested_scope(gain.source, mode))
173            });
174            if has_complete_album_tags {
175                for &index in &group {
176                    plans[index] = metadata[index].map(metadata_plan);
177                }
178                continue;
179            }
180            let files = group
181                .iter()
182                .map(|&index| identities[index].clone())
183                .collect::<Vec<ProgrammeFile>>();
184            let measurement = if let Some(cached) = cache
185                .loudness_programmes
186                .iter()
187                .find(|cached| cached.files == files)
188            {
189                cached.measurement
190            } else {
191                let paths = group
192                    .iter()
193                    .map(|&index| paths[index].as_path())
194                    .collect::<Vec<_>>();
195                let measured = loudness::measure_programme(&paths)?;
196                new_programmes.push(CachedProgramme {
197                    files,
198                    measurement: measured,
199                });
200                measured
201            };
202            for &index in &group {
203                plans[index] = measurement
204                    .map(|value| measured_plan(value, true, false))
205                    .or_else(|| metadata[index].map(metadata_plan));
206            }
207        }
208    }
209    if !new_tracks.is_empty() || !new_programmes.is_empty() {
210        let _lock = StoreLock::acquire(directory)?;
211        for (path, cached) in &new_tracks {
212            if !cached.matches(&loudness::identity(Path::new(path))?) {
213                return Err("audio file changed before its loudness could be cached".into());
214            }
215        }
216        for item in &new_programmes {
217            for file in &item.files {
218                if loudness::identity(Path::new(&file.path))? != *file {
219                    return Err("audio file changed before its loudness could be cached".into());
220                }
221            }
222        }
223        let mut current = conclusions::load(&cache_path)?
224            .unwrap_or_else(|| catalog.map(Conclusions::from_catalog).unwrap_or_default());
225        current.loudness_tracks.extend(new_tracks);
226        for item in new_programmes {
227            current.loudness_programmes.retain(|cached| {
228                cached
229                    .files
230                    .iter()
231                    .map(|file| &file.path)
232                    .ne(item.files.iter().map(|file| &file.path))
233            });
234            current.loudness_programmes.push(item);
235        }
236        conclusions::save(&current, &cache_path)?;
237    }
238    Ok(plans)
239}