aede_core/playback/
gain_plan_ready.rs

1//! Playback chooses known gains without decoding the selection in advance.
2
3use 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
20/// A completed source measurement, saved outside the audio decoding loop.
21pub struct LoudnessUpdate {
22    track: Option<(ProgrammeFile, CachedTrack)>,
23    programme: Option<CachedProgramme>,
24    legacy_seed: Option<Arc<Conclusions>>,
25}
26
27impl LoudnessUpdate {
28    /// Merge into the latest store under its writer lock. A source changed
29    /// after decoding is rejected instead of publishing a stale measurement.
30    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(&current, &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
77/// A lazy normalization session shared by CLI and native PCM playback.
78///
79/// Only the current track (or current album's tags/identities) is inspected.
80/// The CLI can measure missing loudness from source PCM already being decoded;
81/// native PCM playback only reuses existing gains and does not enable capture.
82/// An album decision is frozen for the session, never changed midway through it.
83pub 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    /// Load available measurements without reading audio files or their tags.
99    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    /// Reuse known gains without measuring or publishing new source loudness.
137    ///
138    /// Native PCM transport selects this policy because it does not observe
139    /// source samples or publish complete-track measurements. Missing track
140    /// gains retain the existing fallback policy; missing album gain retains
141    /// one unchanged level. No meter or capture is prepared for unknown values.
142    /// Calling this after preparation discards any pending optional capture.
143    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    /// Choose an already available gain for the current track or album.
151    /// Missing source measurements are captured later through `observe_source`.
152    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                // Restart learning only from the album's beginning; its gain
182                // decision remains frozen even after a complete measurement.
183                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            // Incomplete album metadata must not turn album mode into a series
273            // of per-track adjustments. Learn one programme for a later play.
274            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    /// Whether an active meter is capturing source PCM for a later decision.
291    pub fn is_measuring(&self) -> bool {
292        self.capture
293            .as_ref()
294            .is_some_and(|capture| capture.meter.is_some())
295    }
296
297    /// Observe decoded source PCM before downmix, rate conversion and effects.
298    /// Unsupported layouts retain an unavailable result only after a complete
299    /// decode. Other meter failures abandon the optional capture.
300    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    /// Publish only fully decoded tracks/programmes with unchanged sources.
320    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;