1use crate::{MeteringError, MeteringResult};
6
7#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Color {
10 pub r: u8,
12 pub g: u8,
14 pub b: u8,
16}
17
18impl Color {
19 pub const fn new(r: u8, g: u8, b: u8) -> Self {
21 Self { r, g, b }
22 }
23
24 pub fn from_hex(hex: &str) -> MeteringResult<Self> {
26 let hex = hex.trim_start_matches('#');
27
28 if hex.len() != 6 {
29 return Err(MeteringError::InvalidConfig(
30 "Hex color must be 6 characters".to_string(),
31 ));
32 }
33
34 let r = u8::from_str_radix(&hex[0..2], 16)
35 .map_err(|_| MeteringError::InvalidConfig("Invalid hex color".to_string()))?;
36 let g = u8::from_str_radix(&hex[2..4], 16)
37 .map_err(|_| MeteringError::InvalidConfig("Invalid hex color".to_string()))?;
38 let b = u8::from_str_radix(&hex[4..6], 16)
39 .map_err(|_| MeteringError::InvalidConfig("Invalid hex color".to_string()))?;
40
41 Ok(Self { r, g, b })
42 }
43
44 pub fn lerp(&self, other: &Self, t: f64) -> Self {
51 let t = t.clamp(0.0, 1.0);
52 Self {
53 r: (f64::from(self.r) + (f64::from(other.r) - f64::from(self.r)) * t) as u8,
54 g: (f64::from(self.g) + (f64::from(other.g) - f64::from(self.g)) * t) as u8,
55 b: (f64::from(self.b) + (f64::from(other.b) - f64::from(self.b)) * t) as u8,
56 }
57 }
58}
59
60pub mod colors {
62 use super::Color;
63
64 pub const GREEN: Color = Color::new(0, 255, 0);
66 pub const YELLOW: Color = Color::new(255, 255, 0);
68 pub const RED: Color = Color::new(255, 0, 0);
70 pub const DARK_GREEN: Color = Color::new(0, 128, 0);
72 pub const ORANGE: Color = Color::new(255, 165, 0);
74 pub const BLACK: Color = Color::new(0, 0, 0);
76 pub const WHITE: Color = Color::new(255, 255, 255);
78 pub const DARK_GRAY: Color = Color::new(64, 64, 64);
80 pub const LIGHT_GRAY: Color = Color::new(192, 192, 192);
82}
83
84#[derive(Clone, Copy, Debug, PartialEq)]
86pub enum Orientation {
87 Horizontal,
89 Vertical,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq)]
95pub enum ScaleType {
96 Linear,
98 Logarithmic,
100}
101
102#[derive(Clone, Debug)]
104pub struct ColorGradient {
105 stops: Vec<(f64, Color)>,
106}
107
108impl ColorGradient {
109 pub fn new(stops: Vec<(f64, Color)>) -> Self {
115 Self { stops }
116 }
117
118 pub fn traffic_light() -> Self {
120 Self::new(vec![
121 (0.0, colors::DARK_GREEN),
122 (0.6, colors::GREEN),
123 (0.8, colors::YELLOW),
124 (0.95, colors::ORANGE),
125 (1.0, colors::RED),
126 ])
127 }
128
129 pub fn ppm() -> Self {
131 Self::new(vec![
132 (0.0, colors::DARK_GREEN),
133 (0.7, colors::GREEN),
134 (0.9, colors::YELLOW),
135 (1.0, colors::RED),
136 ])
137 }
138
139 pub fn color_at(&self, position: f64) -> Color {
145 let position = position.clamp(0.0, 1.0);
146
147 for i in 0..self.stops.len() - 1 {
149 let (pos1, color1) = self.stops[i];
150 let (pos2, color2) = self.stops[i + 1];
151
152 if position >= pos1 && position <= pos2 {
153 let range = pos2 - pos1;
154 let t = if range > 0.0 {
155 (position - pos1) / range
156 } else {
157 0.0
158 };
159 return color1.lerp(&color2, t);
160 }
161 }
162
163 self.stops.last().map_or(colors::BLACK, |(_, c)| *c)
165 }
166}
167
168#[derive(Clone, Debug)]
170pub struct BarMeterConfig {
171 pub orientation: Orientation,
173 pub width: usize,
175 pub height: usize,
177 pub min_value: f64,
179 pub max_value: f64,
181 pub scale_type: ScaleType,
183 pub gradient: ColorGradient,
185 pub show_peak_hold: bool,
187 pub show_scale: bool,
189}
190
191impl Default for BarMeterConfig {
192 fn default() -> Self {
193 Self {
194 orientation: Orientation::Vertical,
195 width: 30,
196 height: 200,
197 min_value: -60.0,
198 max_value: 0.0,
199 scale_type: ScaleType::Logarithmic,
200 gradient: ColorGradient::traffic_light(),
201 show_peak_hold: true,
202 show_scale: true,
203 }
204 }
205}
206
207#[derive(Clone, Debug)]
209pub struct BarMeterData {
210 pub level: f64,
212 pub peak_hold: f64,
214 pub is_clipping: bool,
216}
217
218impl BarMeterData {
219 pub fn from_dbfs(level_dbfs: f64, peak_hold_dbfs: f64, min_dbfs: f64, max_dbfs: f64) -> Self {
228 let normalize = |db: f64| {
229 if db.is_infinite() && db.is_sign_negative() {
230 0.0
231 } else {
232 ((db - min_dbfs) / (max_dbfs - min_dbfs)).clamp(0.0, 1.0)
233 }
234 };
235
236 Self {
237 level: normalize(level_dbfs),
238 peak_hold: normalize(peak_hold_dbfs),
239 is_clipping: level_dbfs >= max_dbfs,
240 }
241 }
242}
243
244#[derive(Clone, Debug)]
246pub struct ScaleMark {
247 pub position: f64,
249 pub label: String,
251 pub is_major: bool,
253}
254
255pub fn generate_db_scale(min_db: f64, max_db: f64) -> Vec<ScaleMark> {
257 let mut marks = Vec::new();
258 let range = max_db - min_db;
259
260 let mut db = (min_db / 10.0).ceil() * 10.0;
262 while db <= max_db {
263 let position = (db - min_db) / range;
264 marks.push(ScaleMark {
265 position,
266 label: format!("{db:.0}"),
267 is_major: true,
268 });
269 db += 10.0;
270 }
271
272 let mut db = (min_db / 5.0).ceil() * 5.0;
274 while db <= max_db {
275 let position = (db - min_db) / range;
276 if !marks.iter().any(|m| (m.position - position).abs() < 0.01) {
278 marks.push(ScaleMark {
279 position,
280 label: String::new(),
281 is_major: false,
282 });
283 }
284 db += 5.0;
285 }
286
287 marks
288}
289
290#[derive(Debug, Clone)]
292pub struct WaveformColumn {
293 pub min: f32,
295 pub max: f32,
297 pub rms: f32,
299}
300
301#[derive(Debug, Clone)]
303pub struct WaveformData {
304 pub columns: Vec<WaveformColumn>,
306}
307
308impl WaveformData {
309 pub fn generate(samples: &[f32], width: usize) -> Self {
314 if samples.is_empty() || width == 0 {
315 return Self { columns: vec![] };
316 }
317 let mut columns = Vec::with_capacity(width);
318 for col in 0..width {
319 let start = col * samples.len() / width;
320 let end = ((col + 1) * samples.len() / width)
321 .max(start + 1)
322 .min(samples.len());
323 let segment = &samples[start..end];
324 let mut min = f32::INFINITY;
325 let mut max = f32::NEG_INFINITY;
326 let mut sum_sq = 0.0f32;
327 for &s in segment {
328 if s < min {
329 min = s;
330 }
331 if s > max {
332 max = s;
333 }
334 sum_sq += s * s;
335 }
336 let rms = (sum_sq / segment.len() as f32).sqrt();
337 columns.push(WaveformColumn { min, max, rms });
338 }
339 Self { columns }
340 }
341}
342
343#[derive(Debug, Clone, Default)]
345pub struct VectorscopeBin {
346 pub count: u32,
348}
349
350#[derive(Debug, Clone)]
352pub struct VectorscopeData {
353 pub bins: Vec<VectorscopeBin>,
355 pub width: usize,
357 pub height: usize,
359}
360
361#[derive(Debug, Clone)]
363pub struct GraticulePoint {
364 pub x: f32,
366 pub y: f32,
368 pub label: &'static str,
370}
371
372impl VectorscopeData {
373 pub fn generate(cb_cr_pairs: &[(f32, f32)], width: usize, height: usize) -> Self {
378 let bins = vec![VectorscopeBin::default(); width * height];
379 let mut data = Self {
380 bins,
381 width,
382 height,
383 };
384 for &(cb, cr) in cb_cr_pairs {
385 let nx = ((cb + 0.5).clamp(0.0, 1.0) * (width - 1) as f32) as usize;
387 let ny = ((cr + 0.5).clamp(0.0, 1.0) * (height - 1) as f32) as usize;
388 let idx = ny * width + nx;
389 if idx < data.bins.len() {
390 data.bins[idx].count = data.bins[idx].count.saturating_add(1);
391 }
392 }
393 data
394 }
395
396 pub fn graticule_75pct_bar() -> Vec<GraticulePoint> {
398 vec![
400 GraticulePoint {
401 x: -0.169,
402 y: 0.500,
403 label: "Y",
404 }, GraticulePoint {
406 x: -0.338,
407 y: -0.169,
408 label: "C",
409 }, GraticulePoint {
411 x: -0.169,
412 y: -0.338,
413 label: "G",
414 }, GraticulePoint {
416 x: 0.169,
417 y: 0.169,
418 label: "M",
419 }, GraticulePoint {
421 x: 0.500,
422 y: 0.169,
423 label: "R",
424 }, GraticulePoint {
426 x: 0.338,
427 y: -0.169,
428 label: "B",
429 }, GraticulePoint {
431 x: 0.0,
432 y: 0.0,
433 label: "W",
434 }, GraticulePoint {
436 x: 0.0,
437 y: 0.0,
438 label: "K",
439 }, ]
441 }
442}
443
444#[derive(Clone, Debug)]
446pub struct CircularMeterConfig {
447 pub center_x: usize,
449 pub center_y: usize,
451 pub radius: usize,
453 pub start_angle: f64,
455 pub end_angle: f64,
457 pub gradient: ColorGradient,
459}
460
461impl Default for CircularMeterConfig {
462 fn default() -> Self {
463 Self {
464 center_x: 100,
465 center_y: 100,
466 radius: 80,
467 start_angle: 135.0, end_angle: 45.0, gradient: ColorGradient::traffic_light(),
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn test_color_creation() {
480 let color = Color::new(255, 128, 64);
481 assert_eq!(color.r, 255);
482 assert_eq!(color.g, 128);
483 assert_eq!(color.b, 64);
484 }
485
486 #[test]
487 fn test_color_from_hex() {
488 let color = Color::from_hex("#FF8040").expect("color should be valid");
489 assert_eq!(color.r, 255);
490 assert_eq!(color.g, 128);
491 assert_eq!(color.b, 64);
492 }
493
494 #[test]
495 fn test_color_lerp() {
496 let c1 = Color::new(0, 0, 0);
497 let c2 = Color::new(255, 255, 255);
498 let mid = c1.lerp(&c2, 0.5);
499
500 assert!(mid.r > 120 && mid.r < 135);
501 assert!(mid.g > 120 && mid.g < 135);
502 assert!(mid.b > 120 && mid.b < 135);
503 }
504
505 #[test]
506 fn test_gradient() {
507 let gradient = ColorGradient::traffic_light();
508
509 let color_low = gradient.color_at(0.0);
510 let color_high = gradient.color_at(1.0);
511
512 assert!(color_low.g > color_low.r);
514 assert!(color_high.r > color_high.g);
515 }
516
517 #[test]
518 fn test_bar_meter_data_from_dbfs() {
519 let data = BarMeterData::from_dbfs(-10.0, -5.0, -60.0, 0.0);
520
521 assert!(data.level > 0.8); assert!(data.peak_hold > 0.9); }
524
525 #[test]
526 fn test_bar_meter_data_clipping() {
527 let data = BarMeterData::from_dbfs(0.5, 0.5, -60.0, 0.0);
528
529 assert!(data.is_clipping);
530 }
531
532 #[test]
533 fn test_generate_db_scale() {
534 let marks = generate_db_scale(-60.0, 0.0);
535
536 assert!(!marks.is_empty());
537
538 let has_zero = marks.iter().any(|m| m.label == "0");
540 let has_minus_10 = marks.iter().any(|m| m.label == "-10");
541
542 assert!(has_zero);
543 assert!(has_minus_10);
544 }
545
546 #[test]
547 fn test_default_configs() {
548 let bar_config = BarMeterConfig::default();
549 assert_eq!(bar_config.min_value, -60.0);
550 assert_eq!(bar_config.max_value, 0.0);
551
552 let circular_config = CircularMeterConfig::default();
553 assert_eq!(circular_config.radius, 80);
554 }
555
556 #[test]
557 fn test_waveform_column_bounds_samples() {
558 let samples: Vec<f32> = (0..100).map(|i| (i as f32 / 50.0) - 1.0).collect();
559 let data = WaveformData::generate(&samples, 10);
560 assert_eq!(data.columns.len(), 10);
561 for col in &data.columns {
562 assert!(col.min <= col.max);
564 assert!(col.rms >= 0.0 && col.rms <= 1.0 + 1e-6);
565 }
566 }
567
568 #[test]
569 fn test_waveform_column_count() {
570 let samples = vec![0.0f32; 100];
571 let data = WaveformData::generate(&samples, 16);
572 assert_eq!(data.columns.len(), 16);
573 }
574
575 #[test]
576 fn test_waveform_empty_returns_empty() {
577 let data = WaveformData::generate(&[], 10);
578 assert!(data.columns.is_empty());
579 let data2 = WaveformData::generate(&[0.1, 0.2], 0);
580 assert!(data2.columns.is_empty());
581 }
582
583 #[test]
584 fn test_vectorscope_correct_quadrant() {
585 let pairs = vec![(0.25f32, 0.25f32)];
587 let data = VectorscopeData::generate(&pairs, 32, 32);
588 let hit = data
590 .bins
591 .iter()
592 .position(|b| b.count > 0)
593 .expect("expected a hit bin");
594 let hx = hit % 32;
595 let hy = hit / 32;
596 assert!(hx > 16, "x should be in right half for Cb=0.25");
597 assert!(hy > 16, "y should be in top half for Cr=0.25");
598 }
599
600 #[test]
601 fn test_vectorscope_bin_accumulation() {
602 let pairs = vec![(0.0f32, 0.0f32); 5]; let data = VectorscopeData::generate(&pairs, 16, 16);
604 let total: u32 = data.bins.iter().map(|b| b.count).sum();
605 assert_eq!(total, 5);
606 }
607
608 #[test]
609 fn test_graticule_has_8_points() {
610 let graticule = VectorscopeData::graticule_75pct_bar();
611 assert_eq!(graticule.len(), 8);
612 for p in &graticule {
614 assert!(p.x.abs() <= 0.55, "x={} out of range", p.x);
615 assert!(p.y.abs() <= 0.55, "y={} out of range", p.y);
616 }
617 }
618}