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.
This commit is contained in:
2026-10-30 12:36:05 -07:00
parent 575caa7bc4
commit 0f62e55474
3 changed files with 185 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
using System;
namespace ytLive.Helpers;
/// <summary>Which colour band a level falls in (DESIGN.md meter thresholds).</summary>
public enum MeterBand
{
Nominal,
Caution,
Peak,
}
/// <summary>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.
///
/// <para>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 <c>AudioLevelMeter.ToDisplay</c> uses,
/// so the ladder and the old bars cannot disagree.</para></summary>
public static class MeterScale
{
public const float FloorDb = -60f;
public const float CautionDb = -12f;
public const float PeakDb = -3f;
/// <summary>dBFS → 0…1 across the whole ladder.</summary>
public static float ToFraction(float dbfs)
=> Math.Clamp(1f + Math.Clamp(dbfs, FloorDb, 0f) / 60f, 0f, 1f);
/// <summary>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+".</summary>
public static MeterBand BandFor(float dbfs)
=> dbfs >= PeakDb ? MeterBand.Peak
: dbfs >= CautionDb ? MeterBand.Caution
: MeterBand.Nominal;
/// <summary>Peak hold: jumps up instantly to a new maximum and falls back at
/// <paramref name="decayPerSecond"/> — a moment-to-moment bar hides clipping, which is the one
/// audio fault that cannot be fixed afterwards.</summary>
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));
}
}