diff --git a/Controls/SegmentMeter.cs b/Controls/SegmentMeter.cs new file mode 100644 index 0000000..46e56bd --- /dev/null +++ b/Controls/SegmentMeter.cs @@ -0,0 +1,105 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ytLive.Helpers; + +namespace ytLive.Controls; + +/// A segmented level ladder for the audio dock. +/// +/// Drawn in ONE pass. Twenty-four Rectangles per meter would +/// mean hundreds of elements updating on the UI thread; a single drawing call does not. The +/// consumer updates at ~20 Hz — music-level smoothness is not needed and the +/// paint cost should never compete with the compositor. +/// +/// Level is a 0…1 display fraction (see ), never a gain setting: the +/// ladder shows what was measured. +public sealed class SegmentMeter : FrameworkElement +{ + public static readonly DependencyProperty LevelProperty = DependencyProperty.Register( + nameof(Level), typeof(double), typeof(SegmentMeter), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + /// Peak-hold mark, 0…1. Falls back slowly so a clip stays visible long enough to see. + public static readonly DependencyProperty PeakProperty = DependencyProperty.Register( + nameof(Peak), typeof(double), typeof(SegmentMeter), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty SegmentsProperty = DependencyProperty.Register( + nameof(Segments), typeof(int), typeof(SegmentMeter), + new FrameworkPropertyMetadata(24, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register( + nameof(Orientation), typeof(Orientation), typeof(SegmentMeter), + new FrameworkPropertyMetadata(Orientation.Vertical, FrameworkPropertyMetadataOptions.AffectsRender)); + + public double Level { get => (double)GetValue(LevelProperty); set => SetValue(LevelProperty, value); } + public double Peak { get => (double)GetValue(PeakProperty); set => SetValue(PeakProperty, value); } + public int Segments { get => (int)GetValue(SegmentsProperty); set => SetValue(SegmentsProperty, value); } + public Orientation Orientation { get => (Orientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); } + + public SegmentMeter() + { + // Token-driven, not literal: the ladder cannot drift from DESIGN.md. + SetResourceReference(NominalBrushProperty, "Brush.Meter.Nominal"); + SetResourceReference(CautionBrushProperty, "Brush.Meter.Caution"); + SetResourceReference(PeakBrushProperty, "Brush.Meter.Peak"); + SetResourceReference(DimBrushProperty, "Brush.SurfaceContainerHighest"); + } + + private static readonly DependencyProperty NominalBrushProperty = DependencyProperty.Register( + "NominalBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Cyan, FrameworkPropertyMetadataOptions.AffectsRender)); + private static readonly DependencyProperty CautionBrushProperty = DependencyProperty.Register( + "CautionBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + private static readonly DependencyProperty PeakBrushProperty = DependencyProperty.Register( + "PeakBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Red, FrameworkPropertyMetadataOptions.AffectsRender)); + private static readonly DependencyProperty DimBrushProperty = DependencyProperty.Register( + "DimBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.DimGray, FrameworkPropertyMetadataOptions.AffectsRender)); + + private Brush NominalBrush => (Brush)GetValue(NominalBrushProperty); + private Brush CautionBrush => (Brush)GetValue(CautionBrushProperty); + private Brush PeakBrush => (Brush)GetValue(PeakBrushProperty); + private Brush DimBrush => (Brush)GetValue(DimBrushProperty); + + private const double Gap = 2; + + protected override void OnRender(DrawingContext dc) + { + var count = Math.Max(1, Segments); + var vertical = Orientation == Orientation.Vertical; + var extent = vertical ? ActualHeight : ActualWidth; + var thickness = vertical ? ActualWidth : ActualHeight; + if (extent <= 0 || thickness <= 0) return; + + var segmentSize = (extent - Gap * (count - 1)) / count; + if (segmentSize <= 0) return; + + var lit = Math.Clamp(Level, 0, 1) * count; + + for (var i = 0; i < count; i++) + { + // Segment 0 sits at the bottom (vertical) or the left (horizontal). + var fraction = (i + 1) / (double)count; + var brush = fraction >= MeterScale.ToFraction(MeterScale.PeakDb) ? PeakBrush + : fraction >= MeterScale.ToFraction(MeterScale.CautionDb) ? CautionBrush + : NominalBrush; + + if (i >= lit) brush = DimBrush; + + var offset = i * (segmentSize + Gap); + var rect = vertical + ? new Rect(0, extent - offset - segmentSize, thickness, segmentSize) + : new Rect(offset, 0, segmentSize, thickness); + dc.DrawRectangle(brush, null, rect); + } + + // Peak-hold: a 2px marker at the held level, drawn above the ladder. + var peak = Math.Clamp(Peak, 0, 1); + if (peak <= 0) return; + var line = vertical + ? new Rect(0, extent - peak * extent - 1, thickness, 2) + : new Rect(peak * extent - 1, 0, 2, thickness); + dc.DrawRectangle(PeakBrush, null, line); + } +} diff --git a/Helpers/MeterScale.cs b/Helpers/MeterScale.cs new file mode 100644 index 0000000..7a2991f --- /dev/null +++ b/Helpers/MeterScale.cs @@ -0,0 +1,45 @@ +using System; + +namespace ytLive.Helpers; + +/// Which colour band a level falls in (DESIGN.md meter thresholds). +public enum MeterBand +{ + Nominal, + Caution, + Peak, +} + +/// The mathematics of the dock's segment meter, kept out of the control so it can be +/// tested: a segment ladder is only honest if the band it paints is the level it measured. +/// +/// Bands follow DESIGN.md: nominal to −12 dB, caution to −3 dB, peak above. The display +/// fraction spreads −60…0 dBFS across 0…1 — the same scale AudioLevelMeter.ToDisplay uses, +/// so the ladder and the old bars cannot disagree. +public static class MeterScale +{ + public const float FloorDb = -60f; + public const float CautionDb = -12f; + public const float PeakDb = -3f; + + /// dBFS → 0…1 across the whole ladder. + public static float ToFraction(float dbfs) + => Math.Clamp(1f + Math.Clamp(dbfs, FloorDb, 0f) / 60f, 0f, 1f); + + /// The band boundaries are INCLUSIVE at the bottom edge (a meter reading exactly + /// −12.0 dB is cautious, exactly −3.0 is peaking), matching the ranges as DESIGN.md states + /// them: "−12 … −3" and "−3 … 0+". + public static MeterBand BandFor(float dbfs) + => dbfs >= PeakDb ? MeterBand.Peak + : dbfs >= CautionDb ? MeterBand.Caution + : MeterBand.Nominal; + + /// Peak hold: jumps up instantly to a new maximum and falls back at + /// — a moment-to-moment bar hides clipping, which is the one + /// audio fault that cannot be fixed afterwards. + public static float NextPeak(float held, float incoming, float seconds, float decayPerSecond = 0.35f) + { + if (incoming >= held) return incoming; + return Math.Max(0f, held - decayPerSecond * Math.Max(0f, seconds)); + } +} diff --git a/ytLive.Tests/MeterScaleTests.cs b/ytLive.Tests/MeterScaleTests.cs new file mode 100644 index 0000000..e852d25 --- /dev/null +++ b/ytLive.Tests/MeterScaleTests.cs @@ -0,0 +1,35 @@ +using Xunit; +using ytLive.Helpers; + +namespace ytLive.Tests; + +/// A segment ladder is only useful if the band it paints is the level it measured. The +/// thresholds are DESIGN.md's (nominal / caution / peak), and peak-hold is what makes clipping +/// visible — a bar that follows the moment hides the one audio fault that cannot be fixed later. +/// +public class MeterScaleTests +{ + [Fact] + public void Bands_peak_hold_and_the_display_scale_follow_the_standard() + { + // Bands at the documented thresholds. + Assert.Equal(MeterBand.Nominal, MeterScale.BandFor(-30f)); + Assert.Equal(MeterBand.Nominal, MeterScale.BandFor(-12.1f)); + Assert.Equal(MeterBand.Caution, MeterScale.BandFor(-12f)); + Assert.Equal(MeterBand.Caution, MeterScale.BandFor(-6f)); + Assert.Equal(MeterBand.Peak, MeterScale.BandFor(-3f)); + Assert.Equal(MeterBand.Peak, MeterScale.BandFor(0f)); + + // Display fraction: −60 dBFS is empty, 0 dBFS is full, −30 is halfway, and nothing escapes. + Assert.Equal(0f, MeterScale.ToFraction(-60f)); + Assert.Equal(1f, MeterScale.ToFraction(0f)); + Assert.Equal(0.5f, MeterScale.ToFraction(-30f), 4); + Assert.Equal(1f, MeterScale.ToFraction(+12f)); + Assert.Equal(0f, MeterScale.ToFraction(-120f)); + + // Peak hold rises instantly, then decays at the stated rate. + Assert.Equal(0.8f, MeterScale.NextPeak(0.2f, 0.8f, 0.05f)); + Assert.Equal(0.7f, MeterScale.NextPeak(0.8f, 0.1f, 0.1f / 0.35f), 3); // 0.1 fall at 0.35/s + Assert.Equal(0f, MeterScale.NextPeak(0.1f, 0f, 10f)); // never negative + } +}