Files
deepdrft/PLAN-level-meter-fab.md
T

202 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.