# WaveformSeeker — loudness-waveform seekbar to replace the MudSlider
Status: approved. Decisions resolved 2026-06-05. Author: product-designer. Date: 2026-06-05.
**Plan only — no code edits made by this doc.**
---
## 1. Summary
Replace the `MudSlider`-based scrub bar in `PlayerSeekZone.razor` with a new
`` component that renders the track's **loudness profile** as a
high-density vertical bar chart and serves as the seek surface (click / drag to seek).
The point is to make the seekbar *informative*: instead of a featureless line, the
listener sees the track's energy shape — the quiet intro, the drop, the breakdown, the
outro — and can scrub against that shape. This is the established "waveform scrubber"
idiom from SoundCloud, Overcast, and most DAW transport bars. We are borrowing it
deliberately; the novel part for us is only that the profile is **preprocessed server-side
and shipped as a small quantized array**, so the visual paints the instant a track loads rather
than waiting for the audio to decode.
The loudness measure is **not hardcoded to RMS**. The first implementation computes RMS, but
the compute path is built around a swappable `ILoudnessAlgorithm` abstraction (§5a) so a
different perceptual loudness profile (e.g. LUFS) can be substituted later without touching the
component, the wire format, or the storage. The component and the data are named for the
*concept* (waveform / loudness profile), not the algorithm.
Two visualizations currently coexist in the seek zone. They are being separated by
*kind*:
- **Real-time spectrum** (FFT frequency bars, `SpectrumVisualizer.razor`) — a *live* readout
of "what is sounding right now." This moves **up, above the volume slider**.
- **Static loudness-over-time** (the new `WaveformSeeker`) — a *whole-track* readout of "how loud
is each moment." This takes over the seek area.
This is a clean conceptual split: live-frequency lives with the output level (volume),
whole-track-amplitude lives with the transport position (seek). The current arrangement
(real-time spectrum behind the seek slider) conflates the two.
### Naming (decided)
"Spectrum" properly means frequency content; what this component shows is **amplitude over
time**, not spectrum. The component is named honestly: **`WaveformSeeker`** (decided), which
reads correctly against the live `SpectrumVisualizer` (frequency) without implying FFT data is
in the payload. The *data* is named for the concept, not the algorithm: **`WaveformProfile`** /
`WaveformProfileDto` / `waveformBuckets` / a `profile` field — so substituting the loudness
algorithm (RMS → LUFS, §5a) never forces a rename of the type that carries it.
---
## 2. Current state (what we're changing)
The seek zone today (`PlayerSeekZone.razor`):
```razor
@* live FFT bars, sits on top *@
@* the scrub bar *@
@* time text *@
```
Relevant mechanics already in place that the new component must preserve:
- **Seek gesture plumbing** lives in `PlayerSeekZone.razor.cs`: `OnSeekStart` /
`OnSeekChange` / `OnSeekEnd` callbacks bubble to `AudioPlayerBar.razor.cs`, which sets
`_isSeeking`, tracks `_seekPosition`, and calls `PlayerService.Seek(position)` on release.
`DisplayTime` shows the drag position while seeking, real `CurrentTime` otherwise.
- **`CanSeek`** = `IsLoaded && Duration.HasValue && Duration > 0`. Seek is allowed during
streaming, including beyond the buffer (the offset-refetch path in
`StreamingAudioPlayerService` / `AudioPlayer.ts.seekBeyondBuffer`). The new component does
**not** touch that path — it only produces a target time and hands it to the same
`Seek(double)` call.
- **`SpectrumVisualizer`** is driven entirely by `AudioInteropService.StartSpectrumAnimationAsync`,
which subscribes a callback to the TS `SpectrumAnalyzer` (live FFT, ~30fps). It already
self-manages animation lifecycle off `PlayerService.StateChanged`. Moving it is a pure
layout move — no logic change.
- **Player layout** (`AudioPlayerBar.razor.css`) is pure-CSS responsive: at ≥600px the row is
`[transport] [seek grows] [volume]`; at <600px it's `[transport][volume]` then full-width
seek below. Wherever the spectrum lands, it must respect this.
---
## 3. UI layout changes
### 3a. What moves
| Element | Today | After |
|---|---|---|
| Live FFT spectrum (`SpectrumVisualizer`) | Inside `PlayerSeekZone`, above the slider | Inside the **volume cluster**, above the volume slider |
| Scrub bar (`MudSlider`) | `PlayerSeekZone` | Replaced by `WaveformSeeker` (loudness bars + playhead) |
| Timestamp (`TimestampLabel`) | Below the slider in `PlayerSeekZone` | Stays with the seeker (below or overlaid on the bars) |
| Volume slider (`VolumeControls`) | Right cluster | Unchanged position; now has the live spectrum stacked above it |
### 3b. Resulting zones
- **Transport zone** — unchanged (play/pause/stop + load spinner).
- **Volume zone** — becomes a small vertical stack: live FFT spectrum on top, volume
slider below. This is a natural pairing ("here's the live output, here's how loud").
`VolumeControls.razor` gets the `` stacked above its existing
`MudStack`. The wrapper is renamed `VolumeZone` (**decided**) for symmetry with the other
two zones.
- **Seek zone** — becomes the `WaveformSeeker`: a wide loudness bar chart that grows to fill the
available width (it inherits the `flex-grow:1` the seek zone has today), with the
timestamp beneath.
### 3c. Layout risk
The live spectrum is currently a wide element. Stacking it above the *volume* slider
constrains it to the narrow right cluster — at ≥600px the volume cluster is only as wide as
the slider (the CSS halves and flex-start-pins it per commit `78c6803`). A 32-bucket FFT bar
chart squeezed into ~120px will look cramped.
**Decided: 24 buckets in the volume cluster, parameterized.** The live spectrum renders **24
buckets** in the narrow volume slot, set via the existing `BucketCount` parameter on
`SpectrumVisualizer` so the count can be tuned without a code change to the component. 24 reads
denser than 16 while still fitting the ~120px cluster comfortably.
---
## 4. WaveformSeeker component design
### 4a. Data → geometry
The component receives a normalized loudness profile: `double[] profile`, each value in `[0,1]`,
representing the loudness measure of a contiguous time slice. Profile length is **N buckets**
covering the whole track regardless of duration (fixed bucket count, variable bucket
*duration*). Each bucket renders as one vertical bar; bar height = `profile[i]` scaled to the
component height (with a small floor, ~2%, so silence is still visible as a hairline — mirrors
`SpectrumVisualizer.GetBarHeight`).
**Bar count.** Two regimes:
- **Preprocessed resolution (N):** how many buckets the backend computes and stores. **N is
configurable** (e.g. via `WaveformProfileOptions` bound from DI/config), **default 512**. A
high source resolution lets the front end downsample to whatever fits the rendered width
without re-fetching. Storage is tiny regardless of N (see §5).
- **Rendered resolution:** how many bars actually draw, = pixels-available / (bar + gap). **The
front end derives its rendered bar count from the available width, regardless of N** — it does
not assume the stored N is the bar count. At a typical ~600px seek zone with 2px bars + 1px
gaps that's ~200 bars. The component **downsamples N → rendered count** by max-or-mean over
each rendered bucket's source range. Use **max** (peak) for the visual — peak-per-bucket gives
the punchy DAW look; mean flattens transients.
**Decided: N configurable, default 512; rendered count derived from width; downsample by peak.**
512 is a clean power-of-two, downsamples evenly to 256/128/64, and is ~512B on the wire as
quantized bytes (§5b). The wire format is the quantized `byte[]` base64 either way; N being
configurable does not change the format.
### 4b. Playhead / progress indication
The current position is shown two ways simultaneously (both cheap, both standard):
1. **Played/unplayed split** — bars left of the playhead render in the played colour (moss
green `--deepdrft-green-accent`, matching the house waveform identity called out in
`track-card-theming.md`), bars right render muted. The split point = `CurrentTime / Duration`.
2. **Playhead line** — a 1–2px vertical rule at the split, for precision.
While dragging, the split/line follow the pointer (`DisplayTime`), not playback — same
`_isSeeking` discipline as today.
### 4c. Interaction model
Pointer-based, reusing the existing callback contract so `AudioPlayerBar.razor.cs` is barely
touched:
- **Hover** → a faint preview line at the cursor + a tooltip/label showing the time under the
cursor (`hoverTime = (cursorX / width) * Duration`). Preview only; no seek. (New affordance;
the MudSlider had none. Borrowed from SoundCloud/YouTube scrubbers.)
- **Click** → seek to `clickX / width * Duration`. Fires `OnSeekStart` then immediately
`OnSeekEnd(clickTime)`.
- **Drag** → `pointerdown` starts seeking (`OnSeekStart`), `pointermove` updates the preview
position and fires `OnSeekChange(t)` (so `DisplayTime` and the played/unplayed split track
the drag live), `pointerup` commits (`OnSeekEnd(t)` → `PlayerService.Seek(t)`).
`pointerleave` while dragging commits at the last position (matches current
`HandlePointerLeave` behaviour) — or, better, use **pointer capture** (`setPointerCapture`)
so a drag that leaves the element keeps tracking until release. Recommend pointer capture;
it's the more forgiving gesture and avoids the "lost the drag" feel.
Position math needs the element's pixel width and the pointer's offset. Two implementations:
- **Pure Blazor:** use `@onpointermove`/`@onpointerdown` with `PointerEventArgs.OffsetX` and a
cached bounding width (one JS `getBoundingClientRect` call on resize). Simple, no per-frame
interop.
- **Thin JS helper:** a tiny interop that does hit-testing and returns a normalized `[0,1]`
fraction. Only worth it if `OffsetX` proves unreliable across the responsive reflows.
**Recommend pure-Blazor pointer events first**, with `OffsetX`/cached width; fall back to a JS
helper only if hit-testing is flaky. Keeps the new surface out of the TS bundle (see §7).
### 4d. Rendering approach
- **DOM bars** (one `
` per rendered bar, CSS `--bar-height`) — exactly how
`SpectrumVisualizer` works today, so it's consistent and themeable via existing
`deepdrft-` tokens. At ~200 bars this is fine; Blazor diffing over 200 static divs that only
change a CSS var on seek is cheap.
- **Canvas** — one `