Files
gramps 0f62e55474 feat(audio): the segment meter control (unit 3 of the dock)
`Controls/SegmentMeter` draws a 24-segment ladder in ONE OnRender pass — no per-segment elements
updating on the UI thread, which is the difference between a meter and a UI thread tax. Level and
peak are display fractions (what was MEASURED), never gain settings; vertical for the bay ladders,
horizontal for the master strip.

Bands come from `Helpers/MeterScale` (nominal/caution/peak, DESIGN.md thresholds) and live as
dependencies of the control, so the painting logic is pure and testable: the band a segment paints
is the level it measured. Brushes resolve from the theme tokens via SetResourceReference, so the
ladder cannot drift from DESIGN.md.

Peak-hold is the reason the header exists: a bar that follows the moment hides clipping, and
clipping is the one audio fault that cannot be fixed after the stream.

Guard: MeterScaleTests — band edges (inclusive at −12 and −3, as the standard states the ranges),
the −60…0 display scale, and peak-hold rising instantly then decaying at 0.35/s.
2026-10-30 12:36:05 -07:00

106 lines
5.6 KiB
C#

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ytLive.Helpers;
namespace ytLive.Controls;
/// <summary>A segmented level ladder for the audio dock.
///
/// <para>Drawn in ONE <see cref="OnRender"/> pass. Twenty-four <c>Rectangle</c>s per meter would
/// mean hundreds of elements updating on the UI thread; a single drawing call does not. The
/// consumer updates <see cref="Level"/> at ~20 Hz — music-level smoothness is not needed and the
/// paint cost should never compete with the compositor.</para>
///
/// <para>Level is a 0…1 display fraction (see <see cref="MeterScale"/>), never a gain setting: the
/// ladder shows what was measured.</para></summary>
public sealed class SegmentMeter : FrameworkElement
{
public static readonly DependencyProperty LevelProperty = DependencyProperty.Register(
nameof(Level), typeof(double), typeof(SegmentMeter),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
/// <summary>Peak-hold mark, 0…1. Falls back slowly so a clip stays visible long enough to see.</summary>
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);
}
}