Files
deepdrft/PLAN-level-meter-fill.md
T

20 KiB
Raw Blame History

PLAN — Level Meter Fill Animation

Component: DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab Status: Spec, ready for implementation Supersedes: the three-band discrete tint behavior described in PLAN-level-meter-fab.md §2/§4


Summary

Replace the single-color whole-note tint with a continuous vertical fill inside the music-note silhouette. The note fills bottom-up like a VU/liquid level meter. The fill height tracks the live audio level continuously (0100%); a fixed three-zone gradient (green → yellow → orange, painted by viewBox height) means the color at the fill line changes naturally as the level rises through the zones. Idle state is a dim, unfilled note silhouette.

The key conceptual shift: today the whole note is one of three colors and the trigger is a discrete band. After this change, the fill level is continuous and the color is a property of vertical position, not of the current band. A quiet track shows a low green pool; a hot dance master pushes the fill up into the orange cap.


1. Visual model

Coordinate system

The SVG keeps viewBox="0 0 24 24". In SVG, y increases downward: y=0 is the top of the note, y=24 is the bottom. "Fill from the bottom up" therefore means the fill rectangle's top edge (y) moves upward (decreasing y) as level rises, while its bottom edge stays pinned at y=24.

The existing note path is reused verbatim as a clip:

M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z

Its drawn extent runs roughly y≈3 (top of the stem) to y≈21 (bottom of the note head). The fill rect spans the full 024 height and is clipped to the path, so empty/full map cleanly to "no note visible" / "whole note filled" without having to know the path's exact bounding box.

Layer stack (back to front)

  1. Dim silhouette — the note path filled at rgba(255,255,255,0.25), always rendered. This is what the user sees when idle (no fill) and is the "unfilled" remainder of the note while playing. Drawn first so the fill paints over it.
  2. Clipped fill group<g clip-path="url(#lmf-clip-{id})"> containing a single <rect> painted with the zone gradient. The rect's y/height are driven by C# from _fillPercent. Because it sits inside the clip group, only the part of the rect overlapping the note path shows.

So: the fill rect is NOT the note — the note shape comes entirely from the clip path plus the always-on dim silhouette. The rect is a plain rectangle that is revealed through the note silhouette. This keeps the geometry math trivial (a rect) while the note outline stays crisp.

Fill rect geometry as a function of _fillPercent (0100)

height = 24 * (_fillPercent / 100)
y      = 24 - height          // pinned to the bottom, grows upward
x      = 0
width  = 24
  • _fillPercent = 0height = 0, rect invisible, only the dim silhouette shows (idle look).
  • _fillPercent = 50y = 12, height = 12, note filled to its vertical mid-point.
  • _fillPercent = 100y = 0, height = 24, whole note filled.

Gradient → three fixed zones

The gradient is vertical and anchored to the viewBox, not to the rect — use gradientUnits="userSpaceOnUse" with x1=0 y1=24 x2=0 y2=0 (bottom to top). This is essential: it means a given color lives at a fixed height in the note, so as the rect grows the colors stay put and the fill line crosses from green into yellow into orange. (If the gradient were objectBoundingBox-relative to the rect, the whole gradient would rescale with every level change and the zones would not be fixed — do not do that.)

Stops, expressed as % of viewBox height measured from the bottom (offset 0% = bottom = y2... note: because y1 is the bottom, offset="0%" is the green end):

Zone Height range (from bottom) Color
Green 0% 60% #2ECC71
Yellow 60% 85% #F4C430
Orange 85% 100% #FF6B35

Suggested stop list (small overlap bands give a soft blend rather than a hard line; tune to taste):

offset="0%"   stop-color="#2ECC71"   (green)
offset="55%"  stop-color="#2ECC71"
offset="62%"  stop-color="#F4C430"   (yellow)
offset="82%"  stop-color="#F4C430"
offset="88%"  stop-color="#FF6B35"   (orange)
offset="100%" stop-color="#FF6B35"

If a sharper VU-style zone transition is preferred, collapse the overlap (e.g. 60% green / 60% yellow, 85% yellow / 85% orange) for hard color edges.

Idle / dim silhouette

  • Idle (paused/stopped, _fillPercent = 0): only layer 1 renders — note at rgba(255,255,255,0.25). Recognizable as the player button, visually quiet.
  • The dim silhouette is also the resting visual when a track is playing but momentarily silent (between drops, intros). The fill simply pools low.
  • The dim opacity (0.25) should read against --mud-palette-primary (the FAB background). If contrast is weak on the light palette, bump toward 0.35; flagged as an open question.

2. dB → fill % calibration for dance music

Source signal

OnLevelData already reconstructs an instantaneous dB from the spectrum peak:

instantDb = peak * 80.0 - 80.0     // range: -80 dB (silence) .. 0 dB (full scale)

This stays. What changes is the mapping from smoothed dB to a continuous fill percent instead of a band switch.

Why recalibrate

The old band ceilings (green ≤ 18, yellow ≤ 6) were tuned for a tri-state tint where "mostly green" was the resting expectation. For a continuous fill we want the meter to live in its middle during a typical loud dance master and to use the orange cap on peaks rather than sitting pegged. Commercial dance music masters hot — roughly 8 to 3 dBFS true peak, with dense sustained energy. If the ceiling were 0 dB and the floor 80, a 4 dB master would sit at ~95% fill and never move; if the floor were too high it would clip to 100% constantly. The window below is chosen so the bulk of a track sweeps the green/yellow zones and drops/hot sections reach into orange.

Calibration window

Parameter Value Reasoning
Floor (fill = 0%) 30 dB Below this is intro/breakdown/near-silence; meter rests low.
Ceiling (fill =100%) 0 dB Full scale; reserved for true peaks/clipping headroom.
Green/Yellow boundary 12 dB → 60% fill A loud sustained section sits here; "running hot but fine."
Yellow/Orange boundary 4.5 dB → 85% fill Drops and peak loudness push into the orange cap.

The mapping

Linear map of smoothed dB onto 0100 across the [floor, ceiling] window:

fillPercent = clamp( (smoothedDb - Floor) / (Ceiling - Floor) * 100, 0, 100 )
            = clamp( (smoothedDb + 30) / 30 * 100, 0, 100 )

Check against the gradient stops:

  • smoothedDb = -12(12+30)/30 = 0.6060% fill, exactly the green/yellow stop.
  • smoothedDb = -4.5(4.5+30)/30 = 0.8585% fill, exactly the yellow/orange stop.
  • smoothedDb = -30 → 0% (floor). smoothedDb = 0 → 100% (ceiling).

Because the dB→fill map and the gradient zone heights are both linear and pinned to the same two boundary percentages, the color at the fill line corresponds to the intended zone at every level — the implementer does not need to keep two calibrations in sync beyond these two boundary constants.

A perceptual (non-linear) curve is possible if the linear feel is too "bottom-heavy," but linear is the simple, defensible default. See Open Questions.


3. C# side changes (LevelMeterFab.razor.cs)

Fields / constants

Remove _bandClass, the _svgStyle band switch, BandFor, GreenCeilingDb, YellowCeilingDb. Replace with a continuous fill model.

// Calibration window (see §2). These four constants are the spec contract.
private const double FloorDb   = -30.0;   // fill = 0%
private const double CeilingDb =   0.0;   // fill = 100%
// Zone boundaries are encoded in the SVG gradient stops (§4); the linear map
// places -12 dB at 60% and -4.5 dB at 85% automatically.

private const double SilenceFloorDb = -80.0;  // unchanged: analyzer normalization window

// Smoothing — operates on continuous fill percent, not a band.
private const double AttackCoefficient  = 0.5;   // fast rise (see note below)
private const double ReleaseCoefficient = 0.12;  // slow decay so the column doesn't strobe

private double _smoothedDb = SilenceFloorDb;
private double _fillPercent;   // 0..100, the single piece of render state

Smoothing — operate on the continuous value

Keep the attack-fast / release-slow envelope, but it must drive the continuous fill rather than gate a band change. Two valid placements; smooth the dB, then map (recommended — keeps one envelope and the linear map is monotonic so smoothing order is equivalent, but smoothing in dB keeps the perceptual meaning of the coefficients):

private Task OnLevelData(double[] buckets)
{
    if (buckets.Length == 0) return Task.CompletedTask;

    var peak = 0.0;
    foreach (var b in buckets) if (b > peak) peak = b;

    var instantDb = peak * 80.0 - 80.0;   // unchanged reconstruction

    var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
    _smoothedDb += (instantDb - _smoothedDb) * coeff;

    var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);

    // Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
    if (Math.Abs(next - _fillPercent) >= 0.5)
    {
        _fillPercent = next;
        InvokeAsync(StateHasChanged);
    }

    return Task.CompletedTask;
}

Coefficient tuning rationale: the old values (attack 0.6 / release 0.15) were tuned so a band switch didn't strobe. A continuous column is more visually sensitive to jitter because every frame is potentially a new height. Slightly gentler values (attack ~0.5, release ~0.12) give a meter that snaps up on transients but settles smoothly. These are by-ear values — the implementer should tune live against a real dance track. The 0.5% re-render threshold is the anti-churn guard (replaces the old "band != _bandClass" gate).

Stop / idle behavior

StopAnimation resets to idle. The continuous equivalent of "revert to untinted":

// In StopAnimation, replacing the _bandClass reset:
if (_fillPercent != 0)
{
    _fillPercent = 0;
    await InvokeAsync(StateHasChanged);
}
_smoothedDb = SilenceFloorDb;

This drops the column to empty → only the dim silhouette remains. (A CSS-eased decay is no longer available on y/height; see §5. If a graceful drain on stop is desired rather than a snap, the implementer can run a short C# ramp-down — Open Question.)

Computed render output

Expose the rect geometry as computed properties (string-formatted with invariant culture to avoid comma decimal separators on non-US locales):

using System.Globalization;

private double FillHeight => 24.0 * (_fillPercent / 100.0);
private string FillY      => (24.0 - FillHeight).ToString("0.###", CultureInfo.InvariantCulture);
private string FillH      => FillHeight.ToString("0.###", CultureInfo.InvariantCulture);

_instanceId already exists (Guid.NewGuid().ToString()) and is reused for the clip/gradient IDs (§4). Strip hyphens or prefix it so it is a valid SVG/CSS id token, e.g. private string IdSuffix => _instanceId.Replace("-", "");.


4. SVG structure (LevelMeterFab.razor)

Complete replacement for the <svg> body. @(IdSuffix) makes the clipPath and linearGradient IDs unique per instance so two FABs on screen don't cross-clip (Blazor can render more than one if the dock and another surface both mount it).

@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar

<button class="lmf-fab-btn" type="button" @onclick="OnClick">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
        <defs>
            <!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
                 y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
            <linearGradient id="lmf-grad-@(IdSuffix)"
                            gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
                <stop offset="0%"   stop-color="#2ECC71" />
                <stop offset="55%"  stop-color="#2ECC71" />
                <stop offset="62%"  stop-color="#F4C430" />
                <stop offset="82%"  stop-color="#F4C430" />
                <stop offset="88%"  stop-color="#FF6B35" />
                <stop offset="100%" stop-color="#FF6B35" />
            </linearGradient>

            <!-- The note silhouette, used to clip the fill rect. -->
            <clipPath id="lmf-clip-@(IdSuffix)">
                <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
            </clipPath>
        </defs>

        <!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
        <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
              fill="rgba(255,255,255,0.25)" />

        <!-- Clipped fill: a full-width rect revealed through the note shape. -->
        <g clip-path="url(#lmf-clip-@(IdSuffix))">
            <rect class="lmf-fill-rect"
                  x="0" width="24"
                  y="@FillY" height="@FillH"
                  fill="url(#lmf-grad-@(IdSuffix))" />
        </g>
    </svg>
</button>

Notes:

  • The style="@_svgStyle" attribute on the <svg> is gone; color now lives in the gradient.
  • fill="currentColor" is gone; the dim silhouette uses an explicit rgba so it does not inherit the (now removed) color cascade.
  • The drop-shadow glow that previously lived in _svgStyle is dropped by default. If a glow is wanted on hot signal, it can return as a CSS filter on .lmf-fill-rect gated by a class — Open Question.

5. CSS changes (LevelMeterFab.razor.css)

The fill animation is driven by C#-computed SVG attribute changes + Blazor re-render, not CSS classes. Important constraint:

  • CSS transition does work on SVG presentation properties exposed as CSS (fill, fill-opacity, opacity, stroke).
  • CSS transition does NOT work on the SVG geometry attributes y and height in any reliable cross-browser way. (They are presentation attributes but not animatable via CSS transitions in most engines.) Therefore the column's vertical motion must come from the 30fps C# value updates, which already produce smooth motion given the smoothing envelope.

Concrete edits:

  1. Remove the band-tint transition rule — it targets a color cascade that no longer exists:
    /* DELETE: */
    .lmf-icon {
        width: 24px;
        height: 24px;
        transition: color 120ms ease-out, filter 120ms ease-out;
    }
    
  2. Replace with a plain sizing rule (no transition needed):
    .lmf-icon {
        width: 24px;
        height: 24px;
    }
    
  3. Optionally add a short fade on the fill rect's opacity (legal CSS transition target) so the column appears/disappears softly on play/stop without affecting the height motion:
    .lmf-fill-rect {
        transition: fill-opacity 120ms ease-out;
    }
    
    (Only meaningful if the implementer chooses to toggle fill-opacity on stop rather than snapping _fillPercent to 0. Otherwise omit.)
  4. The .lmf-fab-btn rules (size, shadow, hover, focus) are unchanged.

6. Acceptance criteria

  • When paused or stopped, the FAB shows a single dim note silhouette (≈25% white), no fill, recognizable as the player button.
  • On play, a colored column rises from the bottom of the note and tracks the audio level continuously (not a 3-state snap).
  • The fill height responds to level in real time: quiet passages pool low (green), sustained loud sections sit mid-to-high (yellow), drops/peaks push into the orange cap.
  • The color at any fill height matches its zone: bottom 60% green, 6085% yellow, top 15% orange — and these stay fixed as the column rises and falls.
  • On a typical commercial dance master (8 to 3 dBFS peak), the meter visibly "breathes" through the green/yellow zones and reaches orange on drops — it is neither pegged at 100% nor dead near the bottom.
  • The column motion is smooth, not strobing, at 30fps; transients rise quickly and decay gently.
  • On stop, the column returns to empty (snap or short drain) leaving the dim silhouette.
  • Two LevelMeterFab instances rendered simultaneously do not interfere (unique clip/gradient IDs); neither clips the other.
  • No regression to the existing subscribe/animate lifecycle (StartAnimation/StopAnimation, StateChanged side-channel, dispose).
  • Renders correctly under both light and dark MudBlazor palettes; dim silhouette is visible against --mud-palette-primary in both.

7. Open questions / implementation notes

  1. Gradient stop hardness. Soft overlap (current §4 stops) vs. hard zone edges (collapse overlaps to a single offset). VU-meter convention is hard edges; liquid-fill convention is soft. Decide by eye against a real track. Recommendation: start soft, harden if it reads muddy.
  2. Linear vs. perceptual dB→fill curve. Linear is the default and matches the calibration table exactly. If the meter feels bottom-heavy (sits low for too much of the track), a mild gamma (fill = pow(linearFill, 0.8)) lifts the mid-range. Defer until tested live.
  3. Drain-on-stop vs. snap. Snapping _fillPercent = 0 is simplest. A 200300ms C# ramp-down reads more like a real meter releasing. Geometry can't be CSS-transitioned (§5), so a graceful drain needs a short C# timer loop. Recommendation: ship the snap, add drain only if it feels abrupt.
  4. Hot-signal glow. The old design had a drop-shadow glow per band. Reintroducing a glow only in the orange zone (e.g. a CSS filter class toggled when _fillPercent >= 85) could make clipping feel urgent. Out of scope for v1; note for later.
  5. Re-render threshold. The 0.5% change gate (§3) is a starting value. If motion looks steppy, lower it; if CPU is a concern on low-end devices, raise it. The spectrum callback already runs at the analyzer's cadence, so this only filters redundant renders.
  6. <animate> SMIL elements — explicitly not recommended. SMIL could animate the rect, but the level data already arrives as a C#-side stream at 30fps; routing it through declarative SMIL would mean re-issuing animation targets every frame, which is strictly worse than just setting y/height. Keep the animation in C#.
  7. ID token validity. _instanceId is a GUID with hyphens — valid in SVG id and url(#...), but strip hyphens (IdSuffix) defensively for any future CSS-selector use. Confirm the chosen form is consistent between the id="..." and the url(#...) reference.
  8. Invariant formatting. FillY/FillH must format with CultureInfo.InvariantCulture — a comma decimal separator (e.g. German locale) would produce invalid SVG. Called out in §3; verify in implementation.

Migration note for PLAN-level-meter-fab.md

The original FAB plan documents the three-band discrete tint as the contract (§2/§4 referenced in the current code comments). Once this fill model lands, those sections are superseded. doc-keeper should reconcile or archive the discrete-band description so the two plans don't both claim to be authoritative on the same component.