From b7b539743b219e235c38bbd9baead6de61c25477 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 8 Jun 2026 06:59:03 -0400 Subject: [PATCH] docs: add LevelMeterFab product spec for minimized-dock level meter --- PLAN-level-meter-fab.md | 201 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 PLAN-level-meter-fab.md diff --git a/PLAN-level-meter-fab.md b/PLAN-level-meter-fab.md new file mode 100644 index 0000000..aabb8d1 --- /dev/null +++ b/PLAN-level-meter-fab.md @@ -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 `` 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 ``. 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 `` 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) +{ +
+ +
+} + +@* after *@ +@if (_isMinimized) +{ +
+ +
+} +``` + +- `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 ``), 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` 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.