docs: spec level-meter fill animation (continuous VU-style note fill)

This commit is contained in:
daniel-c-harvey
2026-06-08 08:40:03 -04:00
parent 16f4f894f9
commit 5b50879476
+332
View File
@@ -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 (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 = 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 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.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 `<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).
```razor
@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:
```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, 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.