docs: add LevelMeterFab product spec for minimized-dock level meter
This commit is contained in:
@@ -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 0–1 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 0–1 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 0–1 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.
|
||||
Reference in New Issue
Block a user