docs: spec level-meter fill animation (continuous VU-style note fill)
This commit is contained in:
@@ -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** — `<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` (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 `<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, 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. **`<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.
|
||||
Reference in New Issue
Block a user