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

14 KiB
Raw Blame History

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

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:

@* 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):

// 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 OnClickToggleMinimized 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.