aede_core/playback/
normalization.rs1use 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
16pub const DEFAULT_TARGET_LUFS: f32 = -18.0;
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub enum Mode {
22 Off,
24 #[default]
26 Track,
27 Album,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum Source {
34 ReplayGainTrack,
36 ReplayGainAlbum,
38 OpusR128Track,
40 OpusR128Album,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Selection {
47 pub gain_db: f32,
49 pub source: Source,
51 pub source_peak: Option<f32>,
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum Error {
59 InvalidTarget,
61 RepeatedTag(&'static str),
63 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
79pub 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
94pub 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;