nsm/crates/player/src/leveler.rs
2026-07-13 09:24:20 +03:00

132 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Динамический левеллер громкости (fallback, когда в файле нет тега ReplayGain):
//! постоянно измеряет громкость последних секунд звука и плавно подстраивает усиление
//! к целевому уровню. В отличие от настоящего ReplayGain (посчитан заранее по всему
//! треку), это адаптация "на лету" — первые секунды трека выравниваются не идеально,
//! дальше подстройка сходится к цели.
const TARGET_RMS: f32 = 0.1; // примерно -20 dBFS
const MIN_GAIN: f32 = 0.1; // макс. ослабление -20 дБ (для "громких" мастерингов, близких к 0dBFS)
const MAX_GAIN: f32 = 4.0; // макс. усиление +12 дБ (не тащим почти-тишину слишком громко)
const POWER_TIME_CONSTANT_SECS: f32 = 2.0;
const GAIN_TIME_CONSTANT_SECS: f32 = 1.0;
pub struct Leveler {
ema_power: f32,
current_gain: f32,
}
impl Leveler {
pub fn new() -> Self {
Leveler {
ema_power: TARGET_RMS * TARGET_RMS,
current_gain: 1.0,
}
}
/// Обрабатывает interleaved-буфер на месте. `channels`/`sample_rate` нужны, чтобы
/// сглаживание было привязано ко времени, а не к размеру пакета декодера (иначе
/// скорость адаптации плясала бы в зависимости от формата/битрейта файла).
pub fn process(&mut self, samples: &mut [f32], channels: usize, sample_rate: f32) {
if samples.is_empty() || channels == 0 || sample_rate <= 0.0 {
return;
}
let n = samples.len() as f32;
let block_power: f32 = samples.iter().map(|s| s * s).sum::<f32>() / n;
let frames = n / channels as f32;
let block_duration = (frames / sample_rate).max(0.0001);
let power_alpha = 1.0 - (-block_duration / POWER_TIME_CONSTANT_SECS).exp();
self.ema_power = self.ema_power * (1.0 - power_alpha) + block_power * power_alpha;
let rms = self.ema_power.sqrt().max(1e-6);
let desired_gain = (TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN);
let gain_alpha = 1.0 - (-block_duration / GAIN_TIME_CONSTANT_SECS).exp();
self.current_gain = self.current_gain * (1.0 - gain_alpha) + desired_gain * gain_alpha;
for s in samples.iter_mut() {
*s *= self.current_gain;
}
}
}
impl Default for Leveler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rms(samples: &[f32]) -> f32 {
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
}
/// Прогоняет несколько секунд синусоиды заданной амплитуды через левеллер и
/// возвращает RMS последнего блока (когда сглаживание уже сошлось).
fn converged_rms(amplitude: f32) -> f32 {
let sample_rate = 44100.0;
let block_frames = 4096;
let mut leveler = Leveler::new();
let mut last_block_rms = 0.0;
// ~28 секунд звука — оба каскада сглаживания (мощность 2с + усиление 1с) успевают
// сойтись за много постоянных времени каждый (в реальном прослушивании счёт на минуты)
for block in 0..300 {
let mut samples: Vec<f32> = (0..block_frames)
.map(|i| {
let t = (block * block_frames + i) as f32 / sample_rate;
amplitude * (2.0 * std::f32::consts::PI * 440.0 * t).sin()
})
.collect();
leveler.process(&mut samples, 1, sample_rate);
last_block_rms = rms(&samples);
}
last_block_rms
}
#[test]
fn loud_and_quiet_signals_converge_to_similar_output_level() {
let loud_out = converged_rms(0.9); // громкий сигнал (близко к клиппингу)
let quiet_out = converged_rms(0.04); // тихий, но не настолько, чтобы упереться в MAX_GAIN
// оба должны приблизиться к целевому RMS, а не остаться на исходных уровнях
assert!(
(loud_out - TARGET_RMS).abs() < TARGET_RMS * 0.5,
"loud_out={loud_out}, target={TARGET_RMS}"
);
assert!(
(quiet_out - TARGET_RMS).abs() < TARGET_RMS * 0.5,
"quiet_out={quiet_out}, target={TARGET_RMS}"
);
// и главное — они должны оказаться близко друг к другу после выравнивания
assert!(
(loud_out - quiet_out).abs() < TARGET_RMS * 0.3,
"loud_out={loud_out}, quiet_out={quiet_out}"
);
}
#[test]
fn does_not_amplify_near_silence_excessively() {
let sample_rate = 44100.0;
let mut leveler = Leveler::new();
let mut samples = vec![0.0001f32; 4096]; // почти тишина
leveler.process(&mut samples, 1, sample_rate);
// усиление ограничено MAX_GAIN, а не улетает в бесконечность на почти-нулевом сигнале
assert!(samples.iter().all(|s| s.abs() <= 0.0001 * MAX_GAIN + 1e-6));
}
#[test]
fn extremely_quiet_signal_hits_max_gain_ceiling_by_design() {
// Сигнал настолько тихий, что даже максимальное разрешённое усиление (+12дБ)
// не дотягивает до целевого RMS — это осознанное ограничение (не тащим шум/тишину
// до полной громкости), а не недостижение сходимости.
let out = converged_rms(0.02);
let expected_ceiling = (0.02f32 / std::f32::consts::SQRT_2) * MAX_GAIN;
assert!(
(out - expected_ceiling).abs() < expected_ceiling * 0.1,
"expected to converge to the MAX_GAIN ceiling ~{expected_ceiling}, got {out}"
);
}
}