From 5b508794763d641f0dded8040a17465d4555a806 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 8 Jun 2026 08:40:03 -0400 Subject: [PATCH] docs: spec level-meter fill animation (continuous VU-style note fill) --- PLAN-level-meter-fill.md | 332 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 PLAN-level-meter-fill.md diff --git a/PLAN-level-meter-fill.md b/PLAN-level-meter-fill.md new file mode 100644 index 0000000..bef1a8f --- /dev/null +++ b/PLAN-level-meter-fill.md @@ -0,0 +1,332 @@ +# 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 (0–100%); 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 `0–24` 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** — `` containing a single `` 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` (0–100) + +``` +height = 24 * (_fillPercent / 100) +y = 24 - height // pinned to the bottom, grows upward +x = 0 +width = 24 +``` + +- `_fillPercent = 0` → `height = 0`, rect invisible, only the dim silhouette shows (idle look). +- `_fillPercent = 50` → `y = 12, height = 12`, note filled to its vertical mid-point. +- `_fillPercent = 100` → `y = 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 0–100 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.60` → **60% fill**, exactly the green/yellow stop. ✅ +- `smoothedDb = -4.5` → `(−4.5+30)/30 = 0.85` → **85% 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. + +```csharp +// 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): + +```csharp +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": + +```csharp +// 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): + +```csharp +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 `` 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). + +```razor +@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar + + +``` + +Notes: +- The `style="@_svgStyle"` attribute on the `` 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: + ```css + /* 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): + ```css + .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: + ```css + .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, 60–85% 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 200–300ms 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. **`` 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.