aede_core/playback/
gain_plan.rs1use 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#[derive(Clone, Copy)]
20pub struct GainPlan {
21 pub gain_db: f32,
23 pub source_peak: Option<f32>,
25 pub label: &'static str,
27 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
104pub 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(¤t, &cache_path)?;
237 }
238 Ok(plans)
239}