1use std::error::Error;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use aede_dsp::{
8 PcmFormat,
9 loudness::{LoudnessError, LoudnessProgramme},
10};
11
12use super::{GainPlan, album_groups, is_requested_scope, measured_plan, metadata_plan};
13use crate::conclusions::{self, Conclusions};
14use crate::model::Catalog;
15use crate::playback::loudness::{self, CachedProgramme, CachedTrack, ProgrammeFile};
16use crate::playback::normalization::{self, Mode as NormalizationMode};
17use crate::store_lock::StoreLock;
18use crate::tags;
19
20pub struct LoudnessUpdate {
22 track: Option<(ProgrammeFile, CachedTrack)>,
23 programme: Option<CachedProgramme>,
24 legacy_seed: Option<Arc<Conclusions>>,
25}
26
27impl LoudnessUpdate {
28 pub fn save(&self, directory: &Path) -> Result<(), Box<dyn Error>> {
31 let _lock = StoreLock::acquire(directory)?;
32 let files = self
33 .track
34 .as_ref()
35 .map(|(file, _)| std::slice::from_ref(file))
36 .or_else(|| self.programme.as_ref().map(|item| item.files.as_slice()))
37 .unwrap_or_default();
38 for file in files {
39 if loudness::identity(Path::new(&file.path))? != *file {
40 return Err("audio file changed before its loudness could be cached".into());
41 }
42 }
43 let path = conclusions::conclusions_path(directory);
44 let mut current = conclusions::load(&path)?
45 .unwrap_or_else(|| self.legacy_seed.as_deref().cloned().unwrap_or_default());
46 if let Some((file, track)) = &self.track {
47 current
48 .loudness_tracks
49 .insert(file.path.clone(), track.clone());
50 }
51 if let Some(programme) = &self.programme {
52 replace_programme(&mut current, programme.clone());
53 }
54 conclusions::save(¤t, &path)?;
55 Ok(())
56 }
57}
58
59fn replace_programme(cache: &mut Conclusions, item: CachedProgramme) {
60 cache.loudness_programmes.retain(|cached| {
61 cached
62 .files
63 .iter()
64 .map(|file| &file.path)
65 .ne(item.files.iter().map(|file| &file.path))
66 });
67 cache.loudness_programmes.push(item);
68}
69
70struct Capture {
71 indices: Vec<usize>,
72 files: Vec<ProgrammeFile>,
73 next: usize,
74 meter: Option<LoudnessProgramme>,
75}
76
77pub struct ReadyNormalization<'a> {
84 paths: &'a [PathBuf],
85 catalog: Option<&'a Catalog>,
86 mode: NormalizationMode,
87 cache: Conclusions,
88 legacy_seed: Option<Arc<Conclusions>>,
89 groups: Vec<Vec<usize>>,
90 prepared: Vec<bool>,
91 learnable_album_starts: Vec<bool>,
92 plans: Vec<Option<GainPlan>>,
93 capture: Option<Capture>,
94 capture_enabled: bool,
95}
96
97impl<'a> ReadyNormalization<'a> {
98 pub fn new(
100 paths: &'a [PathBuf],
101 is_album: bool,
102 catalog: Option<&'a Catalog>,
103 directory: &Path,
104 mode: NormalizationMode,
105 ) -> Result<Self, Box<dyn Error>> {
106 let loaded = if mode == NormalizationMode::Off {
107 Some(Conclusions::default())
108 } else {
109 conclusions::load(&conclusions::conclusions_path(directory))?
110 };
111 let legacy_seed = if loaded.is_none() {
112 catalog.map(|catalog| Arc::new(Conclusions::from_catalog(catalog)))
113 } else {
114 None
115 };
116 let cache = loaded.unwrap_or_else(|| legacy_seed.as_deref().cloned().unwrap_or_default());
117 Ok(Self {
118 paths,
119 catalog,
120 mode,
121 cache,
122 legacy_seed,
123 groups: if mode == NormalizationMode::Album {
124 album_groups(paths, is_album, catalog)
125 } else {
126 Vec::new()
127 },
128 prepared: vec![false; paths.len()],
129 learnable_album_starts: vec![false; paths.len()],
130 plans: vec![None; paths.len()],
131 capture: None,
132 capture_enabled: true,
133 })
134 }
135
136 pub fn without_capture(mut self) -> Self {
144 self.capture_enabled = false;
145 self.capture = None;
146 self.learnable_album_starts.fill(false);
147 self
148 }
149
150 pub fn prepare(&mut self, index: usize) -> Result<Option<GainPlan>, Box<dyn Error>> {
153 let path = self
154 .paths
155 .get(index)
156 .ok_or("playback track index is out of range")?;
157 if self
158 .capture
159 .as_ref()
160 .is_some_and(|capture| capture.indices.get(capture.next) != Some(&index))
161 {
162 self.capture = None;
163 }
164 if self.mode == NormalizationMode::Off {
165 return Ok(None);
166 }
167 if self.mode == NormalizationMode::Album {
168 if !self.prepared[index] {
169 self.prepare_album(index)?;
170 } else if self.capture.is_none() && self.learnable_album_starts[index] {
171 let indices = self
172 .groups
173 .iter()
174 .find(|group| group.first() == Some(&index))
175 .ok_or("playback album group is missing")?
176 .clone();
177 let files = indices
178 .iter()
179 .map(|&index| loudness::identity(&self.paths[index]))
180 .collect::<Result<Vec<_>, _>>()?;
181 self.capture = Some(Capture {
184 indices,
185 files,
186 next: 0,
187 meter: Some(LoudnessProgramme::new()),
188 });
189 }
190 return Ok(self.plans[index]);
191 }
192 let file = loudness::identity(path)?;
193 let raw = tags::read(path)?;
194 let metadata =
195 normalization::select_raw(&raw, self.mode, normalization::DEFAULT_TARGET_LUFS)?;
196 if let Some(gain) = metadata.filter(|gain| is_requested_scope(gain.source, self.mode)) {
197 self.capture = None;
198 return Ok(Some(metadata_plan(gain)));
199 }
200 let imported = loudness::from_flaccompagnon(&self.cache.analyses, &file).or_else(|| {
201 self.catalog
202 .and_then(|catalog| loudness::from_flaccompagnon(&catalog.analyses, &file))
203 });
204 if let Some(measurement) = imported {
205 self.capture = None;
206 return Ok(Some(measured_plan(measurement, false, true)));
207 }
208 if let Some(cached) = self
209 .cache
210 .loudness_tracks
211 .get(&file.path)
212 .filter(|cached| cached.matches(&file))
213 {
214 self.capture = None;
215 return Ok(cached
216 .measurement
217 .map(|value| measured_plan(value, false, false))
218 .or_else(|| metadata.map(metadata_plan)));
219 }
220 if self.capture_enabled {
221 self.capture = Some(Capture {
222 indices: vec![index],
223 files: vec![file],
224 next: 0,
225 meter: Some(LoudnessProgramme::new()),
226 });
227 }
228 Ok(metadata.map(metadata_plan))
229 }
230
231 fn prepare_album(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
232 let indices = self
233 .groups
234 .iter()
235 .find(|group| group.contains(&index))
236 .ok_or("playback album group is missing")?
237 .clone();
238 let files = indices
239 .iter()
240 .map(|&index| loudness::identity(&self.paths[index]))
241 .collect::<Result<Vec<_>, _>>()?;
242 let metadata = indices
243 .iter()
244 .map(|&index| {
245 let raw = tags::read(&self.paths[index])?;
246 normalization::select_raw(&raw, self.mode, normalization::DEFAULT_TARGET_LUFS)
247 .map_err(|error| Box::new(error) as Box<dyn Error>)
248 })
249 .collect::<Result<Vec<_>, Box<dyn Error>>>()?;
250 if metadata
251 .iter()
252 .all(|gain| gain.is_some_and(|gain| is_requested_scope(gain.source, self.mode)))
253 {
254 for (&index, gain) in indices.iter().zip(metadata) {
255 self.plans[index] = gain.map(metadata_plan);
256 }
257 } else if let Some(cached) = self
258 .cache
259 .loudness_programmes
260 .iter()
261 .find(|cached| cached.files == files)
262 {
263 let plan = cached
264 .measurement
265 .map(|value| measured_plan(value, true, false));
266 for &index in &indices {
267 self.plans[index] = plan;
268 }
269 } else if self.capture_enabled
270 && let Some(&first) = indices.first()
271 {
272 self.learnable_album_starts[first] = true;
275 if first == index {
276 self.capture = Some(Capture {
277 indices: indices.clone(),
278 files,
279 next: 0,
280 meter: Some(LoudnessProgramme::new()),
281 });
282 }
283 }
284 for index in indices {
285 self.prepared[index] = true;
286 }
287 Ok(())
288 }
289
290 pub fn is_measuring(&self) -> bool {
292 self.capture
293 .as_ref()
294 .is_some_and(|capture| capture.meter.is_some())
295 }
296
297 pub fn observe_source(
301 &mut self,
302 format: PcmFormat,
303 samples: &[f32],
304 ) -> Result<(), Box<dyn Error>> {
305 if let Some(capture) = &mut self.capture
306 && let Some(meter) = &mut capture.meter
307 && let Err(error) = meter.push(format, samples)
308 {
309 if matches!(error, LoudnessError::UnsupportedLayout) {
310 capture.meter = None;
311 } else {
312 self.capture = None;
313 }
314 return Err(error.into());
315 }
316 Ok(())
317 }
318
319 pub fn finish_track(
321 &mut self,
322 index: usize,
323 complete: bool,
324 ) -> Result<Option<LoudnessUpdate>, Box<dyn Error>> {
325 let Some(mut capture) = self.capture.take() else {
326 return Ok(None);
327 };
328 if !complete || capture.indices.get(capture.next) != Some(&index) {
329 return Ok(None);
330 }
331 let file = &capture.files[capture.next];
332 if loudness::identity(Path::new(&file.path))? != *file {
333 return Err("audio file changed while its loudness was measured".into());
334 }
335 capture.next += 1;
336 if capture.next < capture.indices.len() {
337 self.capture = Some(capture);
338 return Ok(None);
339 }
340 let measurement = capture
341 .meter
342 .as_ref()
343 .map(LoudnessProgramme::measurement)
344 .transpose()?
345 .flatten();
346 let mut update = LoudnessUpdate {
347 track: None,
348 programme: None,
349 legacy_seed: self.legacy_seed.clone(),
350 };
351 if self.mode == NormalizationMode::Track {
352 let file = capture.files.remove(0);
353 let cached = CachedTrack {
354 size: file.size,
355 mtime: file.mtime,
356 mtime_subseconds: file.mtime_subseconds,
357 measurement,
358 };
359 self.cache
360 .loudness_tracks
361 .insert(file.path.clone(), cached.clone());
362 update.track = Some((file, cached));
363 } else {
364 if let Some(&first) = capture.indices.first() {
365 self.learnable_album_starts[first] = false;
366 }
367 let cached = CachedProgramme {
368 files: capture.files,
369 measurement,
370 };
371 replace_programme(&mut self.cache, cached.clone());
372 update.programme = Some(cached);
373 }
374 Ok(Some(update))
375 }
376}
377
378#[cfg(test)]
379#[path = "gain_plan_ready_tests.rs"]
380mod tests;