Skip to main content

oximedia_metering/
render.rs

1//! Meter rendering and visualization.
2//!
3//! Provides data structures and utilities for rendering meters visually.
4
5use crate::{MeteringError, MeteringResult};
6
7/// Color in RGB format.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Color {
10    /// Red component (0-255).
11    pub r: u8,
12    /// Green component (0-255).
13    pub g: u8,
14    /// Blue component (0-255).
15    pub b: u8,
16}
17
18impl Color {
19    /// Create a new color.
20    pub const fn new(r: u8, g: u8, b: u8) -> Self {
21        Self { r, g, b }
22    }
23
24    /// Create a color from hex string (e.g., "#FF0000" for red).
25    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    /// Interpolate between two colors.
45    ///
46    /// # Arguments
47    ///
48    /// * `other` - The other color
49    /// * `t` - Interpolation factor (0.0 to 1.0)
50    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
60/// Common meter colors.
61pub mod colors {
62    use super::Color;
63
64    /// Green (safe zone).
65    pub const GREEN: Color = Color::new(0, 255, 0);
66    /// Yellow (warning zone).
67    pub const YELLOW: Color = Color::new(255, 255, 0);
68    /// Red (danger zone).
69    pub const RED: Color = Color::new(255, 0, 0);
70    /// Dark green (lower range).
71    pub const DARK_GREEN: Color = Color::new(0, 128, 0);
72    /// Orange (intermediate warning).
73    pub const ORANGE: Color = Color::new(255, 165, 0);
74    /// Black (background).
75    pub const BLACK: Color = Color::new(0, 0, 0);
76    /// White (foreground/text).
77    pub const WHITE: Color = Color::new(255, 255, 255);
78    /// Dark gray (scale markings).
79    pub const DARK_GRAY: Color = Color::new(64, 64, 64);
80    /// Light gray (grid).
81    pub const LIGHT_GRAY: Color = Color::new(192, 192, 192);
82}
83
84/// Meter orientation.
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub enum Orientation {
87    /// Horizontal meter (left to right).
88    Horizontal,
89    /// Vertical meter (bottom to top).
90    Vertical,
91}
92
93/// Meter scale type.
94#[derive(Clone, Copy, Debug, PartialEq)]
95pub enum ScaleType {
96    /// Linear scale.
97    Linear,
98    /// Logarithmic scale (dB).
99    Logarithmic,
100}
101
102/// Color gradient for meter display.
103#[derive(Clone, Debug)]
104pub struct ColorGradient {
105    stops: Vec<(f64, Color)>,
106}
107
108impl ColorGradient {
109    /// Create a new color gradient.
110    ///
111    /// # Arguments
112    ///
113    /// * `stops` - List of (position, color) tuples where position is 0.0 to 1.0
114    pub fn new(stops: Vec<(f64, Color)>) -> Self {
115        Self { stops }
116    }
117
118    /// Create a standard traffic light gradient (green -> yellow -> red).
119    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    /// Create a standard PPM gradient.
130    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    /// Get the color at a specific position.
140    ///
141    /// # Arguments
142    ///
143    /// * `position` - Position in gradient (0.0 to 1.0)
144    pub fn color_at(&self, position: f64) -> Color {
145        let position = position.clamp(0.0, 1.0);
146
147        // Find the two stops to interpolate between
148        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        // Return last color if position is beyond all stops
164        self.stops.last().map_or(colors::BLACK, |(_, c)| *c)
165    }
166}
167
168/// Bar meter renderer configuration.
169#[derive(Clone, Debug)]
170pub struct BarMeterConfig {
171    /// Meter orientation.
172    pub orientation: Orientation,
173    /// Meter width in pixels.
174    pub width: usize,
175    /// Meter height in pixels.
176    pub height: usize,
177    /// Minimum value (e.g., -60.0 dBFS).
178    pub min_value: f64,
179    /// Maximum value (e.g., 0.0 dBFS).
180    pub max_value: f64,
181    /// Scale type.
182    pub scale_type: ScaleType,
183    /// Color gradient.
184    pub gradient: ColorGradient,
185    /// Show peak hold indicator.
186    pub show_peak_hold: bool,
187    /// Show scale markings.
188    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/// Bar meter render data.
208#[derive(Clone, Debug)]
209pub struct BarMeterData {
210    /// Current level (0.0 to 1.0 normalized).
211    pub level: f64,
212    /// Peak hold level (0.0 to 1.0 normalized).
213    pub peak_hold: f64,
214    /// Whether the meter is clipping.
215    pub is_clipping: bool,
216}
217
218impl BarMeterData {
219    /// Create bar meter data from dBFS values.
220    ///
221    /// # Arguments
222    ///
223    /// * `level_dbfs` - Current level in dBFS
224    /// * `peak_hold_dbfs` - Peak hold level in dBFS
225    /// * `min_dbfs` - Minimum dBFS for normalization
226    /// * `max_dbfs` - Maximum dBFS for normalization
227    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/// Scale marking on a meter.
245#[derive(Clone, Debug)]
246pub struct ScaleMark {
247    /// Position (0.0 to 1.0).
248    pub position: f64,
249    /// Label text.
250    pub label: String,
251    /// Whether this is a major marking.
252    pub is_major: bool,
253}
254
255/// Generate scale markings for a dBFS meter.
256pub 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    // Major markings every 10 dB
261    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    // Minor markings every 5 dB
273    let mut db = (min_db / 5.0).ceil() * 5.0;
274    while db <= max_db {
275        let position = (db - min_db) / range;
276        // Skip if this is already a major marking
277        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/// One column of a waveform display (oscilloscope-style per-pixel envelope).
291#[derive(Debug, Clone)]
292pub struct WaveformColumn {
293    /// Minimum sample value in this column's time window.
294    pub min: f32,
295    /// Maximum sample value in this column's time window.
296    pub max: f32,
297    /// Root-mean-square level in this column's time window.
298    pub rms: f32,
299}
300
301/// Per-pixel waveform envelope data suitable for oscilloscope display.
302#[derive(Debug, Clone)]
303pub struct WaveformData {
304    /// Ordered list of per-column envelope data, one entry per display pixel column.
305    pub columns: Vec<WaveformColumn>,
306}
307
308impl WaveformData {
309    /// Generate waveform display data from interleaved audio samples.
310    ///
311    /// `samples` — mono or interleaved audio (use channel 0 for simplicity)
312    /// `width`   — number of display columns (pixels wide)
313    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/// One bin in a vectorscope display grid.
344#[derive(Debug, Clone, Default)]
345pub struct VectorscopeBin {
346    /// Accumulated hit count for this (x, y) position.
347    pub count: u32,
348}
349
350/// Vectorscope data for chroma/phase display.
351#[derive(Debug, Clone)]
352pub struct VectorscopeData {
353    /// 2D grid of bins, row-major: bins[y * width + x].
354    pub bins: Vec<VectorscopeBin>,
355    /// Horizontal dimension of the bin grid in pixels.
356    pub width: usize,
357    /// Vertical dimension of the bin grid in pixels.
358    pub height: usize,
359}
360
361/// Polar reference point for the 75% color bar graticule.
362#[derive(Debug, Clone)]
363pub struct GraticulePoint {
364    /// Normalized X coordinate in [-1, 1].
365    pub x: f32,
366    /// Normalized Y coordinate in [-1, 1].
367    pub y: f32,
368    /// Short human-readable label for this color bar reference (e.g. "Y", "C", "G").
369    pub label: &'static str,
370}
371
372impl VectorscopeData {
373    /// Generate vectorscope data from (Cb, Cr) chroma pairs in [-0.5, 0.5].
374    ///
375    /// Cb maps to X axis, Cr maps to Y axis.
376    /// Values outside [-0.5, 0.5] are clamped to the grid boundary.
377    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            // Map [-0.5, 0.5] → [0, width/height)
386            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    /// Returns the 8 standard 75% color-bar reference points in (Cb, Cr) space.
397    pub fn graticule_75pct_bar() -> Vec<GraticulePoint> {
398        // Standard 75% color-bar CbCr values (BT.601/BT.709 approximate)
399        vec![
400            GraticulePoint {
401                x: -0.169,
402                y: 0.500,
403                label: "Y",
404            }, // Yellow
405            GraticulePoint {
406                x: -0.338,
407                y: -0.169,
408                label: "C",
409            }, // Cyan
410            GraticulePoint {
411                x: -0.169,
412                y: -0.338,
413                label: "G",
414            }, // Green
415            GraticulePoint {
416                x: 0.169,
417                y: 0.169,
418                label: "M",
419            }, // Magenta
420            GraticulePoint {
421                x: 0.500,
422                y: 0.169,
423                label: "R",
424            }, // Red
425            GraticulePoint {
426                x: 0.338,
427                y: -0.169,
428                label: "B",
429            }, // Blue
430            GraticulePoint {
431                x: 0.0,
432                y: 0.0,
433                label: "W",
434            }, // White (origin)
435            GraticulePoint {
436                x: 0.0,
437                y: 0.0,
438                label: "K",
439            }, // Black (origin)
440        ]
441    }
442}
443
444/// Circular meter configuration for radial displays.
445#[derive(Clone, Debug)]
446pub struct CircularMeterConfig {
447    /// Center X coordinate.
448    pub center_x: usize,
449    /// Center Y coordinate.
450    pub center_y: usize,
451    /// Radius in pixels.
452    pub radius: usize,
453    /// Start angle in degrees (0 = right, 90 = top).
454    pub start_angle: f64,
455    /// End angle in degrees.
456    pub end_angle: f64,
457    /// Color gradient.
458    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, // Lower left
468            end_angle: 45.0,    // Lower right
469            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        // Low should be greenish, high should be reddish
513        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); // -10 dB is high on -60 to 0 scale
522        assert!(data.peak_hold > 0.9); // -5 dB is very high
523    }
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        // Should have markings at 0, -10, -20, etc.
539        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            // Every sample in the segment must lie within [min, max]
563            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        // (Cb=0.25, Cr=0.25) → right-top quadrant (x > width/2, y > height/2)
586        let pairs = vec![(0.25f32, 0.25f32)];
587        let data = VectorscopeData::generate(&pairs, 32, 32);
588        // Find the bin with count > 0
589        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]; // 5 identical pairs → bin count = 5
603        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        // All points should be in [-0.5, 0.5]
613        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}