docs: add LevelMeterFab product spec for minimized-dock level meter

This commit is contained in:
daniel-c-harvey
2026-06-08 06:59:03 -04:00
parent 3f02686012
commit b7b539743b
+201
View File
@@ -0,0 +1,201 @@
# PLAN — LevelMeterFab
Product spec for a new self-contained Blazor control in `DeepDrftPublic.Client`. This document is decision-grade: a developer should be able to implement it without further design questions. Once landed, the implemented points move to `COMPLETED.md` per the project convention.
---
## 1. Component identity
- **Name:** `LevelMeterFab`
- **Files:**
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor`
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs`
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css`
- **Purpose:** A floating-action button that replaces the static music-note FAB in the minimized player dock and tints its icon by live output level (green / yellow / orange) while a track plays, reverting to a static untinted icon when idle.
It is a drop-in replacement for the existing `<MudFab>` in the `_isMinimized` branch of `AudioPlayerBar.razor`. It owns its own level subscription and lifecycle; the parent bar only supplies the click handler.
---
## 2. Level-data pipeline
**Decision: reduce the existing spectrum callback array to a single dB value on the C# side. No new TypeScript surface for v1.**
### Rationale
`SpectrumAnalyzer` already broadcasts normalized 01 bucket arrays to all subscribers at ~30fps via `addCallback`, and `AudioInteropService.StartSpectrumAnimationAsync` / `StopSpectrumAnimationAsync` already expose this to C# with per-subscriber callback IDs (multiple subscribers per player are supported — `SpectrumVisualizer` and `LevelMeterFab` can both attach to the same player without conflict). Reusing this path means:
- Zero changes to `SpectrumAnalyzer.ts`, `AudioPlayer.ts`, or `index.ts`.
- Zero new interop methods on `AudioInteropService.cs`.
- Identical subscribe/animate/unsubscribe lifecycle to the already-proven `SpectrumVisualizer`, so the seam is well-trodden.
The cost: the spectrum buckets are perceptually log-mapped, filtered (high-pass / low-pass / slope), and already normalized into 01 against a fixed 80..0 dB window. Reducing them back to a dB figure is **an approximation of program loudness, not a faithful RMS of the output bus.** For a three-band FAB tint (a coarse "is it quiet / loud / hot" indicator), this is entirely sufficient — the eye reads three colors, not a calibrated meter. We are not building a mastering tool; we are tinting an icon.
### C#-side reduction
The callback delivers `double[]` buckets, each in 01 where `value = (clampedDb + 80) / 80` (from `SpectrumAnalyzer.getFrequencyData`). To get a representative level:
1. Take the **peak** bucket value across the array (peak reads more responsively than mean for a "hot signal" indicator; mean would feel sluggish and never reach the orange band on transient-heavy electronic material).
2. Convert back to dB: `db = peak * 80 - 80` (inverse of the normalization). Range is 80..0 dB.
3. Apply a short **attack-fast / release-slow** smoothing on the dB value so the color does not strobe at 30fps (see §4).
Band thresholds operate on this reconstructed dB:
| Reconstructed dB | Band |
|---|---|
| ≤ 18 dB | green |
| 18 to 6 dB | yellow |
| > 6 dB | orange |
These thresholds are the spec's contract; if the reconstructed scale reads systematically low against listening tests, the implementer may apply a single fixed offset constant (documented in code) rather than re-deriving the math — keep the three boundaries' *spacing* intact.
### Noted upgrade path (NOT in v1 scope)
If a faithful meter is ever wanted (e.g. for a future expanded-player VU meter), add `getLevelDb(playerId: number): number` to `AudioPlayer` / `index.ts` that reads `AnalyserNode.getFloatTimeDomainData` and computes true RMS over the time-domain samples, exposed via a new `AudioInteropService.GetLevelDbAsync`. Deferred deliberately: it adds TS surface and a second animation path for no v1 benefit. Captured here so the seam is known, not built. This aligns with "one source, multiple views" — a future VU meter and this FAB should consume the *same* level signal, so when that day comes, prefer adding `getLevelDb` and migrating *both* consumers rather than letting them diverge.
---
## 3. Color mapping
The FAB background is `Color.Primary`, which is **navy `#17283f` on light** and **green `#3D7A68` on dark** (per `DeepDrftPalettes`). The tint colors must read as punchy signal-meter colors against *both* grounds, so they are defined as fixed, theme-independent hues with enough luminance to pop on navy and enough saturation to separate from the green dark-mode ground.
Note: the root `CLAUDE.md` still references a retired "Charleston in the Day / Lowcountry Summer Nights" palette; the live palette is the navy/green `DeepDrftPalettes`. Ground all color decisions in `DeepDrftPalettes`, not the stale doc. (Flagged for doc-keeper — out of this spec's scope to fix.)
| Band | Color | Hex | Notes |
|---|---|---|---|
| green | vivid spring green | `#2ECC71` | Reads clearly on navy and separates from the `#3D7A68` dark ground by being brighter and more saturated. |
| yellow | warm amber-yellow | `#F4C430` | Saffron — punchy on both grounds, not the muddy pure `#FFFF00`. |
| orange | hot signal orange | `#FF6B35` | Reads as "hot / near clip" without being literal red. |
| idle | inherit FAB foreground | `currentColor` | No tint; icon uses MudFab's default `Color.Primary` contrast text (see §4). |
These are exposed as CSS custom properties in `LevelMeterFab.razor.css` (`--lmf-green`, `--lmf-yellow`, `--lmf-orange`) so they can be retuned in one place. The component sets a band class (`lmf-green` / `lmf-yellow` / `lmf-orange` / no class) on the icon element; the CSS maps each class to the corresponding variable via `color`.
A subtle `text-shadow`/glow (`0 0 6px` of the active band color at low alpha) is permitted to lift the tint off the FAB ground — optional polish, not required for acceptance.
---
## 4. Animation behavior
### Idle (no track loaded, or paused/stopped)
- **Static, full-opacity, untinted** music-note icon (default FAB foreground color). Not dimmed, not hidden — the dock must remain a clear, tappable affordance to reopen the player even when nothing is playing.
- The component is not subscribed to the level callback while idle (no animation cost when nothing plays).
### Active (track playing)
- Subscribed to the level callback; icon color reflects the current band.
- **Color transitions are eased, not snapped.** A snap at 30fps strobes harshly on transient-heavy material. Apply:
- **dB smoothing (C# side):** attack-fast, release-slow envelope on the reconstructed dB before banding — e.g. on each frame, if new dB > current, jump most of the way (attack coefficient ~0.6); if lower, ease down slowly (release coefficient ~0.15). This prevents flicker between adjacent bands on every kick drum while still feeling responsive.
- **CSS transition (render side):** `transition: color 120ms ease-out` on the icon so even a band change glides rather than cuts.
- Net effect: the icon "breathes" through green → yellow → orange and settles, rather than buzzing.
### Transitions between states
- Play starts → subscribe, begin tinting from green (lowest band) and rise.
- Pause/stop/track-end → unsubscribe, CSS-transition back to idle untinted over the same 120ms.
- Track change while playing → stays subscribed; level simply tracks the new stream (the player id is stable across track changes).
The driving state signal is `IStreamingPlayerService.IsPlaying`, observed via the `StateChanged` multicast event — identical to how `SpectrumVisualizer` decides when to animate. **Reuse that exact pattern** (subscribe in `OnParametersSetAsync` with a reference guard, resubscribe-safe, start/stop the level animation off `IsPlaying`).
---
## 5. Component interface
```csharp
public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
[Parameter] public Size Size { get; set; } = Size.Large;
[Parameter] public Color Color { get; set; } = Color.Primary;
}
```
- **Player access: cascade, not inject** — matches `SpectrumVisualizer` and `AudioPlayerBar`. The player id is read from `PlayerService as AudioPlayerService``.PlayerId`, exactly as `SpectrumVisualizer` does (`if (PlayerService is AudioPlayerService baseService) _playerId = baseService.PlayerId;`).
- `OnClick` — forwarded to the rendered `<MudFab OnClick>`. The parent passes its existing `ToggleMinimized` handler. The component does **not** know about minimize state; it only relays the click.
- `Size` / `Color` — passed through to `<MudFab>` so the FAB keeps its current `Size.Large` / `Color.Primary` look without the parent hardcoding inside the control. Defaults match today's markup, so the swap is visually identical at idle.
- Owns its own `_instanceId = Guid.NewGuid().ToString()` for the spectrum callback id, its own `_isAnimating` guard, and unsubscribes in `DisposeAsync`. Copy the subscribe/unsubscribe/`StateChanged` lifecycle from `SpectrumVisualizer` verbatim in shape.
---
## 6. Integration point
In `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor`, replace the `_isMinimized` branch:
```razor
@* before *@
@if (_isMinimized)
{
<div class="minimized-dock">
<MudFab Color="Color.Primary"
StartIcon="@Icons.Material.Filled.MusicNote"
Size="Size.Large"
OnClick="@ToggleMinimized"/>
</div>
}
@* after *@
@if (_isMinimized)
{
<div class="minimized-dock">
<LevelMeterFab Size="Size.Large"
Color="Color.Primary"
OnClick="@ToggleMinimized" />
</div>
}
```
- `LevelMeterFab` reads `PlayerService` from the same cascade `AudioPlayerBar` already lives under (`AudioPlayerProvider`), so no extra wiring is needed in the parent.
- The music-note icon is owned **inside** `LevelMeterFab` (it renders its own `<MudFab StartIcon="@Icons.Material.Filled.MusicNote">`), so the parent no longer specifies the glyph.
- No `.razor.cs` change to `AudioPlayerBar` is required — `ToggleMinimized` already exists and is passed as the callback.
---
## 7. TypeScript changes
**None for v1.** The existing `startSpectrumAnimation` / `stopSpectrumAnimation` surface on `index.ts` and the multi-subscriber `SpectrumAnalyzer.addCallback` already support a second concurrent subscriber on the same player. Confirmed: `SpectrumAnalyzer.callbacks` is a `Map<string, ...>` keyed by callback id and broadcasts to all entries.
Deferred (documented in §2, not implemented):
```ts
// AudioPlayer.ts — only if a faithful meter is later needed
getLevelDb(): number // true RMS over getFloatTimeDomainData, returns dBFS
// index.ts
getLevelDb: (playerId: string): number => audioPlayers.get(playerId)?.getLevelDb() ?? -Infinity
```
---
## 8. C# interop changes
**None.** `LevelMeterFab` calls the existing:
- `AudioInterop.StartSpectrumAnimationAsync(playerId, instanceId, OnLevelData)`
- `AudioInterop.StopSpectrumAnimationAsync(playerId, instanceId)`
`OnLevelData(double[] buckets)` is the component-local callback that performs the §2 reduction (peak → dB → smoothing → band) and calls `StateHasChanged` when the band changes.
Deferred (only if §7's `getLevelDb` is ever built): `AudioInteropService.GetLevelDbAsync(string playerId)` wrapping `DeepDrftAudio.getLevelDb`. Not in v1.
---
## 9. Acceptance criteria
1. When the player is minimized and **no track is loaded**, the dock shows a static, full-opacity, untinted music-note FAB identical in size/position to today's. Clicking it expands the player.
2. When the player is minimized and a track is **playing**, the music-note icon is tinted, and the tint changes between green / yellow / orange as the track's level moves through the bands.
3. Band mapping holds: sustained quiet passages read green (≤ 18 dB reconstructed), moderate read yellow (18 to 6 dB), loud/hot passages read orange (> 6 dB).
4. Color changes are smooth (no per-frame strobing): adjacent-band flicker on percussive transients is visibly damped, and band changes glide over ~120ms rather than snapping.
5. **Pausing** the track transitions the icon back to the idle untinted state within ~120ms; **resuming** re-engages tinting.
6. Track-end and stop both return the icon to idle untinted.
7. Changing tracks while playing keeps the meter live against the new stream with no re-subscription glitch.
8. Clicking the FAB in any state expands the player (the `OnClick``ToggleMinimized` path is unchanged).
9. No new TypeScript or `AudioInteropService` methods are introduced; the component reuses `StartSpectrumAnimationAsync` / `StopSpectrumAnimationAsync`.
10. `LevelMeterFab` and `SpectrumVisualizer` can be mounted against the same player simultaneously without one starving the other's callbacks (verify by expanding the bar — both the spectrum bars and, on a return to minimized, the FAB tint, animate correctly).
11. On dispose / navigation teardown, the component unsubscribes its spectrum callback (no leaked `DotNetObjectReference`, no orphaned animation frame).
12. The tint colors are legible against both the light (navy) and dark (green) `Color.Primary` FAB grounds.
---
## Open questions
None blocking. One judgment call left to the implementer: the exact attack/release smoothing coefficients and any fixed dB offset (§2) are tuning values — pick by ear against a few representative tracks; the three band *boundaries* and their spacing are the fixed contract.