use alloc::format;
use rlvgl_audio_meters_core::{Ballistic, BallisticState};
use rlvgl_core::event::Event;
use rlvgl_core::renderer::Renderer;
use rlvgl_core::widget::{Color, Rect, Widget};
use super::skin::{MeterColorId, MeterType, Skin};
const DEFAULT_TEXT: Color = Color(0xdd, 0xe2, 0xe6, 0xff);
const DEFAULT_BACKGROUND: Color = Color(0x08, 0x0a, 0x0c, 0xff);
const NOMINAL_LU_HALF_WIDTH: f32 = 0.5;
const CAUTION_LU_HALF_WIDTH: f32 = 1.5;
pub struct LufsGauge {
bounds: Rect,
skin: &'static Skin,
momentary: BallisticState,
short_term: BallisticState,
integrated: BallisticState,
last_m: f32,
last_s: f32,
last_i: f32,
}
impl LufsGauge {
pub fn new(bounds: Rect, skin: &'static Skin) -> Self {
assert!(
matches!(skin.meter_type, MeterType::LufsGauge),
"LufsGauge requires a skin with meter_type = LufsGauge (got {:?} for skin `{}`)",
skin.meter_type,
skin.id
);
Self {
bounds,
skin,
momentary: BallisticState::new(Ballistic::LufsM),
short_term: BallisticState::new(Ballistic::LufsS),
integrated: BallisticState::new(Ballistic::LufsI),
last_m: rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB,
last_s: rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB,
last_i: rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB,
}
}
pub fn reset(&mut self) {
self.momentary.reset();
self.short_term.reset();
self.integrated.reset();
self.last_m = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.last_s = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.last_i = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
}
pub fn update(&mut self, dbfs: f32, dt: f32) {
self.last_m = self.momentary.update(dbfs, dt);
self.last_s = self.short_term.update(dbfs, dt);
self.last_i = self.integrated.update(dbfs, dt);
}
pub fn momentary_db(&self) -> f32 {
self.last_m
}
pub fn short_term_db(&self) -> f32 {
self.last_s
}
pub fn integrated_db(&self) -> f32 {
self.last_i
}
pub fn target_lufs(&self) -> f32 {
self.skin.scale.pivot_value
}
fn to_lu(&self, dbfs: f32) -> f32 {
let lufs = self.skin.scale.dbfs_to_scale_units(dbfs);
lufs - self.target_lufs()
}
fn integrated_color(&self) -> Color {
let lu = self.to_lu(self.last_i);
let abs = if lu < 0.0 { -lu } else { lu };
let id = if self.last_i <= self.skin.scale.range_min_db {
return self.skin.secondary.scale_text.unwrap_or(DEFAULT_TEXT);
} else if abs <= NOMINAL_LU_HALF_WIDTH {
MeterColorId::Nominal
} else if abs <= CAUTION_LU_HALF_WIDTH {
MeterColorId::Caution
} else if lu > 0.0 {
MeterColorId::Hot
} else {
MeterColorId::Safe
};
self.skin.palette.color(id)
}
}
impl Widget for LufsGauge {
fn bounds(&self) -> Rect {
self.bounds
}
fn draw(&self, renderer: &mut dyn Renderer) {
let bg = self.skin.secondary.background.unwrap_or(DEFAULT_BACKGROUND);
let text_default = self.skin.secondary.scale_text.unwrap_or(DEFAULT_TEXT);
renderer.fill_rect(self.bounds, bg);
let scale = self.skin.scale;
let units = scale.label_units;
let i_lufs = scale.dbfs_to_scale_units(self.last_i);
let s_lufs = scale.dbfs_to_scale_units(self.last_s);
let m_lufs = scale.dbfs_to_scale_units(self.last_m);
let i_lu = i_lufs - self.target_lufs();
let pad = 6;
let line1_y = self.bounds.y + self.bounds.height / 3 - 4;
let line2_y = self.bounds.y + 2 * self.bounds.height / 3 - 4;
let line3_y = self.bounds.y + self.bounds.height - pad;
let x = self.bounds.x + pad;
let i_text = format!("I {i_lufs:>6.1} {units} ({i_lu:+.1} LU)");
let s_text = format!("S {s_lufs:>6.1} {units}");
let m_text = format!("M {m_lufs:>6.1} {units}");
renderer.draw_text((x, line1_y), &i_text, self.integrated_color());
renderer.draw_text((x, line2_y), &s_text, text_default);
renderer.draw_text((x, line3_y), &m_text, text_default);
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meters::presets::LUFS_EBU_R128_GAUGE;
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
struct Recorder {
rects: usize,
texts: Vec<(String, Color)>,
}
impl Renderer for Recorder {
fn fill_rect(&mut self, _r: Rect, _c: Color) {
self.rects += 1;
}
fn draw_text(&mut self, _p: (i32, i32), text: &str, color: Color) {
self.texts.push((text.into(), color));
}
}
#[test]
#[should_panic(expected = "meter_type = LufsGauge")]
fn rejects_non_lufs_skin() {
let bargraph = &crate::meters::presets::BROADCAST_CLASSIC_BARGRAPH;
let _ = LufsGauge::new(
Rect {
x: 0,
y: 0,
width: 240,
height: 120,
},
bargraph,
);
}
#[test]
fn draws_one_bg_plus_three_text_lines() {
let g = LufsGauge::new(
Rect {
x: 0,
y: 0,
width: 240,
height: 120,
},
&LUFS_EBU_R128_GAUGE,
);
let mut r = Recorder {
rects: 0,
texts: Vec::new(),
};
g.draw(&mut r);
assert_eq!(r.rects, 1, "expected exactly 1 background fill");
assert_eq!(r.texts.len(), 3, "expected 3 text lines (I, S, M)");
assert!(
r.texts[0].0.starts_with("I "),
"first line should be Integrated, got {:?}",
r.texts[0].0
);
assert!(
r.texts[1].0.starts_with("S "),
"second line should be Short-term, got {:?}",
r.texts[1].0
);
assert!(
r.texts[2].0.starts_with("M "),
"third line should be Momentary, got {:?}",
r.texts[2].0
);
}
#[test]
fn integrated_color_is_nominal_when_at_target() {
let mut g = LufsGauge::new(
Rect {
x: 0,
y: 0,
width: 240,
height: 120,
},
&LUFS_EBU_R128_GAUGE,
);
for _ in 0..2000 {
g.update(-23.0, 1.0 / 60.0);
}
let mut r = Recorder {
rects: 0,
texts: Vec::new(),
};
g.draw(&mut r);
let i_color = r.texts[0].1;
let nominal = LUFS_EBU_R128_GAUGE.palette.color(MeterColorId::Nominal);
assert_eq!(
i_color, nominal,
"integrated text should colour as Nominal when at target"
);
assert!(
r.texts[0].0.contains("(+0.0 LU)") || r.texts[0].0.contains("(-0.0 LU)"),
"I line should show ~0.0 LU at target, got {:?}",
r.texts[0].0,
);
}
#[test]
fn integrated_color_is_hot_when_loud() {
let mut g = LufsGauge::new(
Rect {
x: 0,
y: 0,
width: 240,
height: 120,
},
&LUFS_EBU_R128_GAUGE,
);
for _ in 0..2000 {
g.update(-18.0, 1.0 / 60.0);
}
let mut r = Recorder {
rects: 0,
texts: Vec::new(),
};
g.draw(&mut r);
let i_color = r.texts[0].1;
let hot = LUFS_EBU_R128_GAUGE.palette.color(MeterColorId::Hot);
assert_eq!(
i_color, hot,
"integrated text should colour as Hot above +1.5 LU"
);
}
}