diff --git a/COMPLETED.md b/COMPLETED.md index 934fb89..f652f39 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -6,6 +6,16 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM --- +## LevelMeterFab — Reactive audio-level-tint FAB + +**Status:** Fully landed on 2026-06-08 (feature complete, component + integration, merged to dev). + +- **What:** A new `LevelMeterFab` Blazor component that replaces the static music-note FAB in the minimized player dock (`AudioPlayerBar.razor`). Reactively tints the icon based on live audio output level: green (−∞ to −18 dB), yellow (−18 to −6 dB), orange (above −6 dB). When idle (no track, paused, stopped), reverts to static untinted state. +- **Why it matters:** The minimized dock is always visible in the UI; adding a live level indicator gives real-time visual feedback on the audio stream's loudness, and the three-band color coding immediately communicates whether the output is quiet, normal, or hot. +- **Implementation:** No TypeScript or `AudioInteropService` changes — reuses the existing spectrum callback infrastructure (`StartSpectrumAnimationAsync` / `StopSpectrumAnimationAsync`). The component subscribes to live spectrum buckets at ~30fps, reduces the peak bucket to a reconstructed dB value via the inverse of the spectrum normalization formula, applies attack-fast/release-slow smoothing, and updates the icon color class. CSS transitions on the color (120ms ease-out) smooth the band changes. Follows the identical state-subscription pattern as `SpectrumVisualizer` — observes `IPlayerService.StateChanged` to toggle animation on play and off on pause/stop/track-end. + +--- + ## Phase 2.5 — "Stream Now" — random-track instant play **Status:** Fully landed on 2026-06-07 (feature complete, endpoints + service methods + menu wiring, merged to dev). diff --git a/DeepDrftPublic.Client/CLAUDE.md b/DeepDrftPublic.Client/CLAUDE.md index 3bf4729..a86ade4 100644 --- a/DeepDrftPublic.Client/CLAUDE.md +++ b/DeepDrftPublic.Client/CLAUDE.md @@ -20,6 +20,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream - `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). - `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via ``. - `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state. + - `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Reactively tints the music-note icon based on live audio level (green/yellow/orange bands), reusing spectrum-callback infrastructure. Idle when paused/stopped. - `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback. - `Helpers/`: Utilities and mapper functions. - `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple. diff --git a/PLAN-level-meter-fab.md b/PLAN-level-meter-fab.md deleted file mode 100644 index aabb8d1..0000000 --- a/PLAN-level-meter-fab.md +++ /dev/null @@ -1,201 +0,0 @@ -# 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.