aede_core/playback/
normalization.rs

1//! Selection of a playback gain from local audio metadata.
2//!
3//! This module chooses a gain; `aede-dsp` applies it to decoded samples.
4//! The decoder must have applied the mandatory Opus ID
5//! header output gain before a selected R128 tag gain is applied.
6
7use std::collections::BTreeMap;
8use std::fmt;
9
10use crate::model::AudioFile;
11use crate::tags::RawTags;
12
13const REPLAYGAIN_REFERENCE_LUFS: f32 = -18.0;
14const OPUS_R128_REFERENCE_LUFS: f32 = -23.0;
15
16/// Default playback target shared by metadata gain schemes.
17pub const DEFAULT_TARGET_LUFS: f32 = -18.0;
18
19/// Which loudness metadata to use for playback.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub enum Mode {
22    /// Leave the decoded signal's level alone.
23    Off,
24    /// Prefer the gain measured for an individual track.
25    #[default]
26    Track,
27    /// Prefer the gain measured for a whole album.
28    Album,
29}
30
31/// Origin and scope of the selected gain.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum Source {
34    /// Gain measured for one track, from a ReplayGain tag.
35    ReplayGainTrack,
36    /// Gain measured for an album, from a ReplayGain tag.
37    ReplayGainAlbum,
38    /// Gain measured for one Opus track, from an R128 tag.
39    OpusR128Track,
40    /// Gain measured for an Opus album, from an R128 tag.
41    OpusR128Album,
42}
43
44/// The gain sent to the DSP and any corresponding peak measurement.
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Selection {
47    /// Gain to apply after decoding, in dB, adjusted to `target_lufs`.
48    pub gain_db: f32,
49    /// Which tag supplied the gain.
50    pub source: Source,
51    /// Linear peak supplied by ReplayGain, before applying `gain_db`.
52    /// Opus R128 has no corresponding peak tag.
53    pub source_peak: Option<f32>,
54}
55
56/// Invalid metadata is reported rather than replaced by another tag.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum Error {
59    /// The requested loudness target is not finite or is outside the usable range.
60    InvalidTarget,
61    /// Several values claim the same gain or peak.
62    RepeatedTag(&'static str),
63    /// A selected gain or peak is malformed.
64    InvalidTag(&'static str),
65}
66
67impl fmt::Display for Error {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::InvalidTarget => f.write_str("loudness target must be between -100 and 0 LUFS"),
71            Self::RepeatedTag(key) => write!(f, "audio file has conflicting {key} values"),
72            Self::InvalidTag(key) => write!(f, "audio file has an invalid {key} value"),
73        }
74    }
75}
76
77impl std::error::Error for Error {}
78
79/// Choose a gain for a file without altering its tags or audio.
80///
81/// `target_lufs` is explicit so a future player can choose its listening
82/// reference. ReplayGain uses a nominal -18 LUFS reference; Opus R128 uses
83/// -23 LUFS. On Opus, R128 wins over ReplayGain when both are present. Within
84/// a scheme, the other scope is used only when the requested scope is absent.
85/// A malformed selected tag is an error, not a reason to fall back silently.
86///
87/// This returns `None` when normalization is off or no supported tag exists.
88/// A returned gain can produce samples above full scale; the output adapter
89/// must handle them when it converts floating-point PCM for the audio device.
90pub fn select(file: &AudioFile, mode: Mode, target_lufs: f32) -> Result<Option<Selection>, Error> {
91    select_values(&file.properties.codec, &file.tags, mode, target_lufs)
92}
93
94/// Select gain from freshly read tags, including a file outside the catalog.
95pub fn select_raw(raw: &RawTags, mode: Mode, target_lufs: f32) -> Result<Option<Selection>, Error> {
96    select_values(&raw.properties.codec, &raw.fields, mode, target_lufs)
97}
98
99fn select_values(
100    codec: &str,
101    tags: &BTreeMap<String, Vec<String>>,
102    mode: Mode,
103    target_lufs: f32,
104) -> Result<Option<Selection>, Error> {
105    if mode == Mode::Off {
106        return Ok(None);
107    }
108    if !target_lufs.is_finite() || !(-100.0..=0.0).contains(&target_lufs) {
109        return Err(Error::InvalidTarget);
110    }
111
112    let scopes = match mode {
113        Mode::Track => [true, false],
114        Mode::Album => [false, true],
115        Mode::Off => return Ok(None),
116    };
117    if codec == "opus" {
118        for track in scopes {
119            let key = if track {
120                "r128_track_gain"
121            } else {
122                "r128_album_gain"
123            };
124            if let Some(raw) = one_value(tags, key)? {
125                let gain_db = parse_r128(raw).ok_or(Error::InvalidTag(key))?;
126                return Ok(Some(Selection {
127                    gain_db: gain_db + target_lufs - OPUS_R128_REFERENCE_LUFS,
128                    source: if track {
129                        Source::OpusR128Track
130                    } else {
131                        Source::OpusR128Album
132                    },
133                    source_peak: None,
134                }));
135            }
136        }
137    }
138
139    for track in scopes {
140        let key = if track {
141            "replaygain_track_gain"
142        } else {
143            "replaygain_album_gain"
144        };
145        if let Some(raw) = one_value(tags, key)? {
146            let gain_db = parse_db(raw).ok_or(Error::InvalidTag(key))?;
147            let peak_key = if track {
148                "replaygain_track_peak"
149            } else {
150                "replaygain_album_peak"
151            };
152            let source_peak = one_value(tags, peak_key)?
153                .map(|peak| parse_peak(peak).ok_or(Error::InvalidTag(peak_key)))
154                .transpose()?;
155            return Ok(Some(Selection {
156                gain_db: gain_db + target_lufs - REPLAYGAIN_REFERENCE_LUFS,
157                source: if track {
158                    Source::ReplayGainTrack
159                } else {
160                    Source::ReplayGainAlbum
161                },
162                source_peak,
163            }));
164        }
165    }
166    Ok(None)
167}
168
169fn one_value<'a>(
170    tags: &'a BTreeMap<String, Vec<String>>,
171    key: &'static str,
172) -> Result<Option<&'a str>, Error> {
173    let Some(values) = tags.get(key) else {
174        return Ok(None);
175    };
176    match values.as_slice() {
177        [] => Ok(None),
178        [value] => Ok(Some(value)),
179        _ => Err(Error::RepeatedTag(key)),
180    }
181}
182
183fn parse_db(raw: &str) -> Option<f32> {
184    let value = raw.trim();
185    let number = if value.to_ascii_lowercase().ends_with("db") {
186        value.get(..value.len() - 2)?.trim_end()
187    } else {
188        value
189    };
190    let gain = number.parse::<f32>().ok()?;
191    (gain.is_finite() && (-128.0..=128.0).contains(&gain)).then_some(gain)
192}
193
194fn parse_r128(raw: &str) -> Option<f32> {
195    if raw.is_empty() || raw.len() > 6 {
196        return None;
197    }
198    let digits = raw.strip_prefix(['+', '-']).unwrap_or(raw);
199    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
200        return None;
201    }
202    let fixed = raw.parse::<i16>().ok()?;
203    Some(f32::from(fixed) / 256.0)
204}
205
206fn parse_peak(raw: &str) -> Option<f32> {
207    let peak = raw.parse::<f32>().ok()?;
208    (peak.is_finite() && peak >= 0.0).then_some(peak)
209}
210
211#[cfg(test)]
212#[path = "normalization_tests.rs"]
213mod tests;