d6df0de63a
Controls move from an inline per-page knob bar to a single popover-hosted panel triggered by the lava-lamp icon, placed identically on every host (Mix, Cut, Session, NowPlaying card). Dissolves the NowPlaying-controls question — full parity via the popover. Adds the popover panel wave, panel styling from theme tokens, and a popover-anchor open item.
806 lines
64 KiB
Markdown
806 lines
64 KiB
Markdown
# Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec)
|
||
|
||
Status: **design-complete, implementation-ready.** Daniel resolved the three §8 open questions on
|
||
2026-06-17 — committing **Direction B** (high-res compute for all media), correcting the datum model to
|
||
**per-track, not per-release**, and resolving §8b to **full parity on the lava controls**: the visualizer
|
||
rides every Release Detail host (Mix, Cut, Session) **and the controls ride all three** — not Mix-only. The
|
||
three-hosting-mode *layout* framing (visualizer-is-the-page on Mix / ambient environment on Cut/Session /
|
||
contained on NowPlaying) is retained; the change is that controls are present on all three detail-page hosts,
|
||
not suppressed on Cut/Session.
|
||
|
||
**Controls-hosting revision (Daniel, 2026-06-17, supersedes the inline knob-bar model).** *"We have enough
|
||
[controls] now that I want to design a panel to be hosted in a popover for the visualizer controls. The
|
||
lava-lamp toggle should be wired to this popover, so anywhere we can put one Icon we can put the control
|
||
surface."* The eight knobs no longer ride an inline *bar* per page. They move into a **single
|
||
popover-hosted control panel** triggered by the **lava-lamp icon**: click the icon → the panel pops over.
|
||
This is **more DRY than the per-page inline bar** and it **dissolves §8b-followup** — with a popover, every
|
||
host (Mix, Cut, Session, *and* the NowPlaying home card) places the *same* lava-lamp icon and gets the
|
||
*identical* panel; full parity is achieved *through the popover*, not through a bar re-laid-out on each
|
||
page. §8b-followup is now **answered**: the NowPlaying card gets the icon → popover like everywhere else.
|
||
The panel adopts the **NowPlaying Hero look** (dark-navy ground, green-accent knobs, light icons, muted-navy
|
||
filler — §3g). The §3/§6 hosting sections and the wave decomposition (§7, controls work consolidates into a
|
||
distinct 12.E concern) are revised to this model. One new open question the popover creates — its
|
||
positioning/anchor per host — is surfaced at §8e. Author: product-designer. Date: 2026-06-17. **No code has
|
||
been written by this doc.**
|
||
|
||
This phase has **two deliverables that share one engine**:
|
||
|
||
1. **Generalize** the landed Mix waveform visualizer (the WebGL2 lava renderer + its eight-knob controls)
|
||
from a Mix-only treatment into a **track-cardinal visualizer** that every Release Detail page can host
|
||
— Cuts, Sessions, and Mixes alike — rendering the waveform of **whatever track is currently
|
||
playing/selected** on that page.
|
||
2. **Overhaul** the home-page `NowPlayingHero` (`NowPlayingCard`) so its "Now Playing" animation is the
|
||
**real waveform visualizer** driven by live playback, replacing the 20 hardcoded CSS-animated bars
|
||
(the "stochastic bullshit").
|
||
|
||
The explicit ask is **DRY / SOLID**: one reusable visualizer engine serving Mix detail, all Release
|
||
Detail pages, and the NowPlayingHero — **not three forks.** This spec's central finding makes that
|
||
cheap: the engine is *already* track-cardinal below the surface. The work is extraction, a per-track
|
||
high-res compute generalization (Direction B), and three hosting modes of the one engine — not a rebuild.
|
||
|
||
**The keystone model correction (Daniel, 2026-06-17).** The waveform datum is **per-track, not
|
||
per-release.** *"Each track in the release must get the metadata. The waveform visualizer metadata should
|
||
be tied to the track, right? The release is just the host."* Every track gets its own high-res waveform
|
||
datum; the release is merely the *host* surface, and the visualizer renders **the datum of whatever track
|
||
is currently playing/selected**. This *simplifies* the design — it aligns with the bridge already keying on
|
||
`TrackId`, and it **dissolves** the old "what is a multi-track Cut's waveform?" question (there is no
|
||
release-level datum to choose; the visualizer just shows the current track's). Threaded through §4, §5, the
|
||
endpoint shape, and the acceptance criteria below.
|
||
|
||
Cross-references (read before implementing):
|
||
|
||
- `product-notes/phase-10-mix-visualizer-lava-reframe.md` — the lava renderer this generalizes. The
|
||
CPU-physics wax-blob model, the OKLab three-color gradient, the eight-knob control model, the bridge
|
||
contract, and the read-only contract all **carry forward unchanged**. This spec does not re-derive any
|
||
of them; it changes *where the engine lives*, *what feeds it*, and *who hosts it*.
|
||
- `product-notes/mix-visualizer-webgl-renderer.md` — the renderer architecture (pipeline, datum-as-texture,
|
||
bridge, rAF loop). The §2 contract carries forward; §4/§7 are already superseded by the reframe.
|
||
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the Blazor bridge. **Already
|
||
keyed on `ReleaseEntryKey` + `TrackId`, not on Mix.** Renamed and re-pointed by this phase (§3, §4).
|
||
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs/.css]`,
|
||
`DeepDrftPublic.Client/Services/MixVisualizerControlState.cs`,
|
||
`DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — the controls + state + mapping. Renamed; the controls
|
||
component becomes the **panel content** hosted inside a new popover wrapper (§3d-revised, §3g) rather than
|
||
an inline `TopRowCenter` bar. State + mapping otherwise unchanged.
|
||
- **New (this revision):** a popover host component — working name `WaveformVisualizerControlPopover` — that
|
||
pairs the lava-lamp trigger icon with the `WaveformVisualizerControls` panel as its overlay content. This
|
||
is the single affordance every host places (§3d-revised). MudBlazor `MudPopover` (already in the dependency
|
||
set) is the natural substrate. Styled to the NowPlaying Hero look (§3g) — no inline hex; tokens from
|
||
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css`.
|
||
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the WebGL2 renderer module. Renamed; the only
|
||
*logic* change is how the datum's time-mapping is established when no high-res mix datum exists (§5).
|
||
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor` — the shared detail chrome. The visualizer
|
||
becomes a scaffold-level concern via a new optional `Ambient` slot (§3c, §3f).
|
||
- `DeepDrftPublic.Client/Pages/MixDetail.razor`, `SessionDetail.razor`, `CutDetail.razor` — the three
|
||
release-detail hosts (three distinct hosting modes — §3f).
|
||
- `DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css]`, `NowPlaying.razor` — the home-page
|
||
now-playing card carrying the stochastic bars (§6).
|
||
- `DeepDrftAPI/Controllers/TrackController.cs`, `ReleaseController.cs` — the waveform endpoints; under the
|
||
per-track model the datum resolves for a **track** (§5), with the release as addressing context.
|
||
- `DeepDrftContent/Processors/WaveformProfileService.cs`, `MixWaveformResolution.cs`,
|
||
`Constants/VaultConstants.cs` — the compute/store path and the vaults; **Direction B generalizes the
|
||
high-res compute here to every track** (§5).
|
||
- `DeepDrftAPI/Services/UnifiedTrackService.cs` — the upload path; Direction B adds a per-track high-res
|
||
compute at upload (§5, §8a-new).
|
||
- `DeepDrftManager` CMS generate action (the Mix-only "generate waveform" trigger) — Direction B
|
||
generalizes it off Mix-only to any track (§5).
|
||
|
||
---
|
||
|
||
## 1. The central finding — the engine is already release-cardinal
|
||
|
||
Before any plan: a read of the live code shows the "Mix coupling" is **mostly nominal**, not structural.
|
||
The visualizer is named `Mix*` throughout, but its *architecture* is release-generic:
|
||
|
||
| Layer | Reality on disk today | Mix-coupled? |
|
||
|-------|----------------------|--------------|
|
||
| `MixWaveformVisualizer` bridge inputs | `ReleaseEntryKey` (string) + `TrackId` (long?) + cascaded player | **No** — already track-cardinal (keys on `TrackId`) |
|
||
| Playback coupling | `IsActivePlayer` gates on `TrackId` matching the cascaded player's current track | **No** — works for any track |
|
||
| Renderer (`MixVisualizer.ts`) | datum texture + scroll/zoom geometry + wax-blob physics + OKLab gradient | **No** — pure function of a loudness datum + duration |
|
||
| Controls + state | seven normalized dials, scoped persistence, `Changed` seam | **No** — renderer-agnostic |
|
||
| **Datum fetch** | `IReleaseDataService.GetMixWaveform(entryKey)` → `GET api/release/{entryKey}/mix/waveform` | **Yes** — 404s unless `Medium == Mix`; resolves per *release*, not per *track* |
|
||
| **Datum source** | the high-res `mix-waveforms` vault, keyed by `MixMetadata.WaveformEntryKey` | **Yes** — only the Mix's single track gets the high-res datum |
|
||
| Names / comments | `Mix*` everywhere | cosmetic |
|
||
|
||
So the genuinely Mix-specific surface is exactly **two things**: the *fetch endpoint that gates on
|
||
`Medium == Mix` and resolves per-release*, and the *high-res datum that only Mix tracks have*. Everything
|
||
else is a rename.
|
||
|
||
**This is the SOLID seam the whole phase turns on.** The renderer and bridge already obey the
|
||
right abstraction ("render a loudness datum coupled to a playing **track**"); they were just *named* for
|
||
their first consumer. Generalizing is: rename to the abstraction, give the abstraction a **per-track**
|
||
datum source that exists for every track, and let three hosts mount it. No new renderer, no fork.
|
||
|
||
**The crucial data fact (verified):** *every uploaded track already has a waveform profile.*
|
||
`UnifiedTrackService.UploadAsync` calls `WaveformProfileService.ComputeAndStoreAsync(...)` at upload time
|
||
for **every** track, storing a **512-bucket** profile in the `waveform-profiles` vault keyed by the
|
||
track's `EntryKey` (this is the datum the player-bar `WaveformSeeker` already consumes). Mix tracks
|
||
*additionally* get a duration-derived **high-res** datum (~333 samples/sec) in the separate
|
||
`mix-waveforms` vault, triggered by a CMS action. So **the high-res datum is the only gap** — and under
|
||
Daniel's per-track model it is a gap *per track*, not per release: every track should carry its own
|
||
high-res datum, computed at upload and (for existing tracks) backfilled. **Direction B (committed)** makes
|
||
the high-res compute a per-track compute for **all** media, keyed by track `EntryKey` (§5). The cheaper
|
||
road — serving the existing 512-bucket profile to non-Mix and skipping new compute (old "Direction A") —
|
||
was **the road not taken** (§5).
|
||
|
||
---
|
||
|
||
## 2. Goal and scope boundary
|
||
|
||
**Goal.** One reusable `WaveformVisualizer` (renamed from `MixWaveformVisualizer`) + its lava renderer +
|
||
its controls, mounted in **three hosting modes** (§3f) — *visualizer-is-the-page* on Mix detail, *ambient
|
||
environment* on Cut/Session detail, *contained live element* on the home-page NowPlaying card — each fed by
|
||
a **per-track high-res datum** that exists for every track (Direction B, §5). The visualizer always renders
|
||
**the currently playing/selected track's** datum; the release is the host, not the datum's owner. The lava
|
||
controls ride **every host** — Mix, Cut, Session, *and* the NowPlaying card — via a **single
|
||
popover-hosted control panel** triggered by the lava-lamp icon (§3d-revised). Every host places one icon and
|
||
gets the identical panel; full parity (including the NowPlaying card) is achieved through the popover, so
|
||
§8b-followup is closed. The NowPlaying card drives the *same* engine off live playback (§6).
|
||
|
||
**In scope.**
|
||
|
||
- **Rename + relocate** the visualizer engine to a track-cardinal identity (`MixWaveformVisualizer` →
|
||
`WaveformVisualizer`, `MixVisualizerControls` → `WaveformVisualizerControls`, `MixVisualizerControlState`
|
||
→ `WaveformVisualizerControlState`, `MixVisualizer.ts` → `WaveformVisualizer.ts`, `MixZoomMapping` →
|
||
`WaveformZoomMapping`). Pure renames; no behavior change. (§3a)
|
||
- **Generalize the high-res compute to every track (Direction B, §5).** This is now the load-bearing data
|
||
change — and it is **larger than a single endpoint**. It touches: (a) the **content** compute path
|
||
(`WaveformProfileService` / `MixWaveformResolution`) — generalize the duration-derived high-res compute
|
||
off Mix-only to any track; (b) the **upload** path (`UnifiedTrackService.UploadAsync`) — compute the
|
||
high-res per-track datum at upload for every new track; (c) the **CMS** generate action — generalize the
|
||
Mix-only "generate waveform" trigger to any track; (d) a **backfill** of the high-res datum for every
|
||
existing track that lacks one (§8a-new); (e) a **per-track datum fetch** endpoint (§5) the bridge calls.
|
||
- **Host the visualizer in three modes** — via a new optional `Ambient` slot on `ReleaseDetailScaffold`
|
||
for the Cut/Session ambient mode (§3c, §3f), Mix keeping its visualizer-is-the-page full-bleed mount, and
|
||
a contained mount on the NowPlaying card. The slot lets scaffold-composing pages mount without
|
||
re-implementing the wrapper.
|
||
- **Rewire the NowPlayingHero** to mount the visualizer driven by the live cascaded player, replacing the
|
||
20 hardcoded CSS bars (§6).
|
||
- **Build the popover-hosted control panel** (§3d-revised, §3g). The renamed `WaveformVisualizerControls`
|
||
becomes the **panel content**; a new `WaveformVisualizerControlPopover` host pairs the lava-lamp trigger
|
||
icon with that panel as overlay content. One panel, one popover host, placed by an icon anywhere — the
|
||
SOLID seam (§3d-revised). Style the panel to the **NowPlaying Hero look** (§3g) from real theme tokens.
|
||
- **Place the lava-lamp icon → popover on every host** — Mix, Cut, Session, *and* the NowPlaying card. Every
|
||
host's controls affordance is now identical: one icon, one popover, one panel. Full parity on all four
|
||
surfaces (§3d-revised) — §8b-followup is dissolved by the popover.
|
||
|
||
**Out of scope / unchanged.**
|
||
|
||
- **No renderer rewrite.** The wax-blob physics, the OKLab gradient, the collision model, the seven dials
|
||
— all carry forward from the Phase 10 reframe exactly. This phase moves and renames the engine and
|
||
changes its *input plumbing* and its *compute breadth*, never its art.
|
||
- **No bridge redesign.** The single-owner bridge, the idempotent datum guard, the `IsActivePlayer`
|
||
gating, the `isPlaying`-gated rAF loop — all preserved. Extend the fetch, not the contract.
|
||
- **No new control model.** The eight knobs and `…ControlState` stay as-is (renamed). No new dials. The
|
||
popover changes *where the controls are hosted* (a popover panel instead of an inline bar), not *what they
|
||
are* — same knobs, same state, same `Changed` seam.
|
||
- **No new high-res *algorithm*.** Direction B generalizes the *existing* duration-derived compute to run
|
||
per-track for all media; it does not redesign how the high-res datum is computed (`MixWaveformResolution`
|
||
carries forward — the same ~333 samples/sec duration-derived model).
|
||
- **No playback-control change.** Read-only contract holds everywhere, including the NowPlaying card —
|
||
the home card visualizes, it does not become a transport.
|
||
|
||
---
|
||
|
||
## 3. Generalizing the visualizer onto every Release Detail page
|
||
|
||
### 3a. The rename (Wave 12.A — pure, mechanical, no behavior change)
|
||
|
||
Rename the engine to its abstraction. This is a mechanical sweep with zero behavior change, done first so
|
||
every later wave references the generalized names:
|
||
|
||
| Today (Mix-named) | Generalized |
|
||
|-------------------|-------------|
|
||
| `MixWaveformVisualizer.razor[.cs/.css]` | `WaveformVisualizer.razor[.cs/.css]` |
|
||
| `MixVisualizerControls.razor[.cs/.css]` | `WaveformVisualizerControls.razor[.cs/.css]` |
|
||
| `MixVisualizerControlState.cs` (+ DI registration) | `WaveformVisualizerControlState.cs` |
|
||
| `MixZoomMapping.cs` | `WaveformZoomMapping.cs` |
|
||
| `MixVisualizer.ts` (+ the `./js/visualizer/MixVisualizer.js` import path) | `WaveformVisualizer.ts` |
|
||
| `DDIcons.LavaLamp` / `LavaLampFilled` | keep (the lava-lamp glyph is now the **popover trigger** — the single controls affordance, placed on every host — §3d-revised) |
|
||
| *(new)* | `WaveformVisualizerControlPopover.razor[.cs/.css]` — popover host pairing the lava-lamp trigger icon with the `WaveformVisualizerControls` panel (§3d-revised, §3g). Not a rename; new in 12.E. |
|
||
|
||
The `ReleaseEntryKey` / `TrackId` parameters and the fetch keep working unchanged through the rename.
|
||
The `mix-waveforms` vault name and `MixMetadata.WaveformEntryKey` stay (they are still where the high-res
|
||
*mix* datum lives — generalizing the *fetch* doesn't require renaming the *mix-specific high-res store*;
|
||
see §5). **Acceptance:** the Mix detail page looks and behaves identically after the rename; the only diff
|
||
is identifiers.
|
||
|
||
> **DRY note.** The temptation is to skip the rename and "just reuse `MixWaveformVisualizer` on other
|
||
> pages." Resist it: a `MixWaveformVisualizer` mounted on a Cut page is a lie that every future reader has
|
||
> to decode, and it cements the wrong mental model right when we're trying to break it. The rename is
|
||
> cheap and it *is* the SOLID move — name the thing for its abstraction, not its first caller.
|
||
|
||
### 3b. What stays specialized (the abstraction boundary)
|
||
|
||
Generalizing does **not** mean flattening every medium to the same look. The clean Liskov boundary:
|
||
|
||
- **Shared (the engine):** the renderer, the bridge, the controls component, the state, the datum
|
||
contract, the playback coupling, the read-only contract. One copy, consumed by all hosts.
|
||
- **Per-host (the composition):** *whether* the visualizer is mounted, *where the lava-lamp icon sits* (the
|
||
controls affordance is now a single popover-triggering icon — §3d-revised), and *what datum* the host
|
||
points it at. These ride host composition (slots + parameters), never a `switch (medium)` inside the
|
||
engine. A medium that wants no visualizer mounts none; a host that wants the controls places the lava-lamp
|
||
icon and the identical popover panel follows.
|
||
|
||
This is the same "variance rides a slot, never a flag" discipline the scaffold already uses for
|
||
`Header`/`Hero`/`TopRightAction` (Phase 9 §5.3) — extended to the ambient mount.
|
||
|
||
### 3c. The hosting seam — an `Ambient` slot on `ReleaseDetailScaffold` (Wave 12.C)
|
||
|
||
> **Naming note.** This slot was first drafted as `Backdrop`. Daniel pushed back: on the Mix page the
|
||
> visualizer is **not** a backdrop — it is the full-bleed centerpiece that *is* the page. "Backdrop" only
|
||
> describes what the visualizer does on Cut/Session (ambient environment *behind* the hero+content). The
|
||
> slot is named for *that* mode — the **ambient** mode — because Mix doesn't use the slot at all (it keeps
|
||
> its own visualizer-is-the-page mount; see §3f). So: `Ambient`, not `Backdrop`.
|
||
|
||
Today the Mix page mounts the visualizer *outside* the scaffold (a sibling `<MixWaveformVisualizer>` then
|
||
a `.mix-detail-foreground` wrapper, with the scaffold inside `MudContainer`). Session mounts nothing.
|
||
Cut mounts nothing. To let Cut/Session host the visualizer as ambient environment DRY-ly:
|
||
|
||
**Add an optional `RenderFragment? Ambient` slot to `ReleaseDetailScaffold`**, rendered as the
|
||
full-bleed `position: fixed; inset: 0` layer *behind* the scaffold's content (the scaffold's existing
|
||
container becomes the foreground stacking context — promote the `mix-detail-foreground` stacking pattern
|
||
into the scaffold so it is the default, not a Mix bespoke). A host that supplies no `Ambient` gets today's
|
||
plain background (Liskov: absent slot = no ambient layer, no regression).
|
||
|
||
- **Cut** (composes the scaffold) supplies `<WaveformVisualizer ReleaseEntryKey=… TrackId=… />` to
|
||
`Ambient` and places the **lava-lamp icon → popover panel** (full parity, §3d-revised) — a living
|
||
waveform field behind the album hero + track list, rendering the *currently selected/playing track's*
|
||
datum, tunable in place via the popover. **§8b is resolved: Cut and Session get the ambient layer AND the
|
||
controls (full parity, not Mix-only).** The ambient *layout* (visualizer behind hero+content) is
|
||
unchanged; the difference from the earlier draft is that the controls are present, and (this revision)
|
||
they live in a popover behind the lava-lamp icon rather than an inline bar.
|
||
- **Session** does not compose the scaffold (§3e) — it mounts the ambient visualizer directly behind its
|
||
own hero overlay, the same engine, its own thin full-bleed wrapper.
|
||
- **Mix does *not* use this slot.** Mix is the *visualizer-is-the-page* mode (§3f) and keeps its existing
|
||
full-bleed mount as the Phase 10 reframe landed it. **The one controls change for Mix in this revision:**
|
||
its `TopRowCenter` inline knob-bar is replaced by the lava-lamp icon → popover panel (§3d-revised), the
|
||
same affordance every other host now uses. The `TopRightAction` lava-lamp glyph Mix already mounts becomes
|
||
the popover trigger directly — a small, contained change to Mix, not a redesign. The `Ambient` slot is for
|
||
the *ambient* mode only — folding Mix into it would force the slot to carry both "the page" and "behind
|
||
the page," which is the conflation §3f exists to avoid.
|
||
|
||
**Why the scaffold, not each page.** The full-bleed wrapper, the foreground stacking context, and the
|
||
footer-clip plumbing (the dynamic-footer overflow clip from the reframe §2c) are all *chrome*, and the
|
||
scaffold is where chrome lives. Putting the ambient layer on the scaffold means the clip logic, the
|
||
stacking context, and the mount point are written once. `SessionDetail` is the lone holdout that doesn't
|
||
compose the scaffold today — see §3e.
|
||
|
||
### 3d. Where do the lava controls live? (the controls boundary — popover-hosted, full parity, resolved)
|
||
|
||
**Revised model (Daniel, 2026-06-17): a single popover-hosted control panel, triggered by the lava-lamp
|
||
icon.** *"We have enough [controls] now that I want to design a panel to be hosted in a popover for the
|
||
visualizer controls. The lava-lamp toggle should be wired to this popover, so anywhere we can put one Icon
|
||
we can put the control surface."* This supersedes the inline knob-*bar* model (where each detail page laid
|
||
out its own eight-knob bar). The controls are now **one panel behind one icon**, placed identically on
|
||
every host.
|
||
|
||
The mechanics:
|
||
|
||
- **The lava-lamp icon is the single affordance.** Click the icon → the control panel pops over. There is
|
||
no separate inline bar and no separate toggle; the icon *is* the toggle and the popover *is* the panel.
|
||
Closed state is just the icon (compact, unobtrusive). Open state floats the panel over the surface.
|
||
- **One panel, one popover host, placed by an icon anywhere.** The renamed `WaveformVisualizerControls`
|
||
becomes the **panel content** (the eight RadialKnobs, unchanged). A new `WaveformVisualizerControlPopover`
|
||
host pairs the lava-lamp trigger icon with that panel as its overlay content (MudBlazor `MudPopover` the
|
||
natural substrate). Every host renders exactly this one component; the panel is byte-for-byte identical
|
||
everywhere. **This is the SOLID seam, named precisely:** *one panel component, one popover host, placed by
|
||
an icon anywhere.*
|
||
- **Full parity across all four surfaces, the popover way.** Mix, Cut, Session, **and** the NowPlaying card
|
||
each place the lava-lamp icon and get the identical popover panel. There is no per-host control divergence
|
||
left to decide — the variance that *used* to live in "which page shows the bar" is dissolved because every
|
||
host shows the same icon. The only per-host question that remains is *where the icon sits and where the
|
||
popover anchors* (§8e), which is positioning, not presence.
|
||
- **Why this is more DRY than the inline bar.** The earlier model asked each detail page to host the
|
||
controls bar in its own layout (Mix in `TopRowCenter`, Cut/Session in the ambient composition, the
|
||
NowPlaying card suppressed because a bar didn't fit). That is three-to-four *different* control
|
||
compositions over one shared state. The popover collapses them to **one** composition — `<icon → popover →
|
||
panel>` — reused verbatim. The awkwardness that justified suppressing controls on the small NowPlaying card
|
||
evaporates: a popover doesn't compete with the card's compact layout the way an inline bar would, because
|
||
it's closed by default and floats when opened. **That is what dissolves §8b-followup** (below).
|
||
|
||
**§8b-followup is dissolved, not deferred.** The old open sub-question — "do the full-parity controls extend
|
||
onto the small NowPlaying card?" — existed *because* an inline eight-knob bar was awkward on a compact card.
|
||
With the popover, the card places the same lava-lamp icon as every other host and the panel floats on
|
||
demand; there is no compact-layout conflict to weigh. **Resolution: the NowPlaying card gets the icon →
|
||
popover like everywhere else — full parity on all four surfaces.** (See §8b-followup, now marked resolved.)
|
||
|
||
> **State-scoping note (staff-engineer's call at 12.E).** With one shared panel surfaced on every host,
|
||
> `WaveformVisualizerControlState` should be **one shared tuning** — a knob turned in the popover on a Cut
|
||
> page is the same lava everywhere. This is even more natural under the popover than under the inline-bar
|
||
> model: there is literally one panel, so "one set of dials" is the only coherent reading. It matches the
|
||
> single-engine framing and is what the persisted `WaveformVisualizerControlState` already is. Flag only;
|
||
> not a product blocker.
|
||
|
||
### 3e. The `SessionDetail` scaffold question
|
||
|
||
`SessionDetail` deliberately does **not** compose `ReleaseDetailScaffold` (it wraps its own
|
||
`MudContainer` + `ReleaseHeroOverlay`). If Session is to host the ambient layer via the scaffold's new
|
||
slot, either (a) `SessionDetail` adopts the scaffold (a larger refactor, out of scope here — Session's
|
||
divergence was a deliberate Phase 11 call), or (b) Session mounts `<WaveformVisualizer>` directly with
|
||
its own full-bleed wrapper (small, local, mirrors what Mix does inline today). **Recommend (b)** if
|
||
Session gets the ambient layer at all — don't reopen the Session-vs-scaffold decision for this. Cut *does*
|
||
compose the scaffold, so Cut gets the ambient layer for free via the slot. This asymmetry is fine: the
|
||
slot serves scaffold-composing pages; the one non-composing page mounts the shared engine directly. The
|
||
*engine* is still single-source either way — only the *mount* differs, which is exactly the per-host
|
||
variance §3b sanctions. **Under full parity (§3d-revised), Session also places the lava-lamp icon → popover
|
||
panel** — the same single affordance every host uses. Because the controls are now one popover behind one
|
||
icon (not an inline bar threaded into a page's top row), Session's lack of scaffold composition is a
|
||
non-issue for controls: it just places the icon wherever its own chrome puts it. Session is full-parity even
|
||
though it doesn't compose the scaffold.
|
||
|
||
### 3f. Three hosting modes of the one engine (the elaboration §8b asked for)
|
||
|
||
Daniel's pushback on "backdrop": *"backdrop?? MIXES doesn't really have a backdrop?"* — and he is right.
|
||
The one engine is hosted in **three distinct modes**, and "backdrop" only fits one of them. Spelling them
|
||
out surface-by-surface, because the slot design and the naming follow from this distinction:
|
||
|
||
| Mode | Surfaces | What the visualizer *is* on screen | Controls | Mount mechanism |
|
||
|------|----------|-----------------------------------|----------|-----------------|
|
||
| **A — Visualizer-is-the-page** | **Mix detail** | The full-bleed centerpiece. The lava field **is** the page; the mix details sit *over* it as a thin overlay. You came here to watch the lava. | Lava-lamp icon → popover panel (§3d-revised) | Mix's own full-bleed mount (unchanged from Phase 10 reframe) — **not** the `Ambient` slot |
|
||
| **B — Ambient environment** | **Cut detail, Session detail** | Living texture *behind and around* the hero + content. The album cover / track list / session hero is the subject; the waveform is environment that makes the page feel alive. You came here for the release; the lava is atmosphere. | Lava-lamp icon → popover panel (full parity, §3d-revised) | `ReleaseDetailScaffold.Ambient` slot (Cut) / direct full-bleed mount (Session, §3e) |
|
||
| **C — Contained live element** | **NowPlaying card** (home) | A small, bounded live panel *inside* the card. Not full-bleed, not "behind" anything — a contained element that shows the real signal of whatever is playing. | Lava-lamp icon → popover panel (full parity — the popover dissolves the old suppression, §3d-revised) | Contained mount, `Fill`-mode canvas sized to the card, not the viewport (§6c) |
|
||
|
||
**Why three modes and not "backdrop everywhere":** the three differ in *what the user's eye treats as the
|
||
subject*. On Mix the visualizer **is** the subject (mode A). On Cut/Session the *release* is the subject
|
||
and the visualizer is environment (mode B — this is the only mode that is genuinely a "backdrop"). On the
|
||
home card the visualizer is a *bounded live readout* of current playback (mode C). Same engine, same datum
|
||
contract, same renderer — three compositions. This is the SOLID payoff stated precisely: the variance is
|
||
entirely in **hosting composition** (full-bleed vs. slot vs. contained; viewport-sized vs. container-sized),
|
||
never in the engine. **The controls are no longer a per-mode discriminator at all** — under the
|
||
popover-hosted model (§3d-revised) all four surfaces place the *same* lava-lamp icon → popover panel. The
|
||
only per-mode controls nuance left is *where the icon anchors* (§8e), which is positioning, not presence.
|
||
|
||
**What this resolves about the slot.** Because Mix (mode A) keeps its own mount and the NowPlaying card
|
||
(mode C) is a contained mount, the `ReleaseDetailScaffold` slot serves **mode B only** — and that is why it
|
||
is named `Ambient`, not `Backdrop` and not `Visualizer`. A `Visualizer` slot would imply Mix routes through
|
||
it too; an `Ambient` slot says exactly what it carries: the behind-the-content environment layer for
|
||
scaffold-composing detail pages. Mode A and mode C mount the engine without the slot. The **controls popover
|
||
is orthogonal to the slot** — it's an icon any host places, independent of how the *visualizer layer* is
|
||
mounted (slot vs. full-bleed vs. contained).
|
||
|
||
**§8b status: RESOLVED — modes A + B + C all live, with FULL PARITY on the controls across all four hosts
|
||
via the popover (Daniel, 2026-06-17).** All three hosting *modes* ship (Mix is the page, Cut/Session are
|
||
ambient, the home card is contained) **and** the lava-lamp controls ride every host — Mix, Cut, Session, and
|
||
the NowPlaying card — via the single popover-hosted panel (§3d-revised). The earlier "mode 1 (Mix +
|
||
NowPlaying only, Cut/Session plain)" fallback is closed; the earlier "controls Mix-only" default is
|
||
overridden; and the "is the NowPlaying card controls-suppressed?" sub-question is **dissolved by the
|
||
popover** (§8b-followup, now resolved). Each host is a one-visualizer-mount-plus-one-lava-lamp-icon
|
||
composition. The remaining open item is the popover's anchor/positioning per host (§8e) — a layout detail,
|
||
not a presence decision.
|
||
|
||
### 3g. Panel styling — the NowPlaying Hero look (theme tokens, no hardcoded hex)
|
||
|
||
Daniel's styling direction for the popover panel: **"same look and feel as the NowPlaying Hero."** Concretely:
|
||
|
||
- **Dark-navy primary background** — the same navy ground the NowPlaying Hero card sits on.
|
||
- **Green-accent knobs** with **light-colored icons**.
|
||
- **Muted-navy filler / circular border fill** around the knobs.
|
||
|
||
These map directly onto the existing token layer
|
||
(`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` — the single source of truth; the Phase 10
|
||
reframe's "one source, no hardcoded hexes" discipline holds). The mapping staff-engineer should pull from:
|
||
|
||
| Panel element | Daniel's intent | Token (source of truth) | Value (for reference only — *use the token, not the hex*) |
|
||
|---------------|-----------------|--------------------------|-----------------------------------------------------------|
|
||
| Panel background | dark-navy primary | `--deepdrft-navy` (ground) / `--deepdrft-navy-mid` (elevated panel) | `#112338` / `#17283f` |
|
||
| Knob accent / active arc | green accent | `--deepdrft-green-accent` | `#3D7A68` |
|
||
| Knob icons / labels | light-colored | `--deepdrft-white` | `#FAFAF8` |
|
||
| Knob filler / circular border fill | muted-navy | `--deepdrft-navy-mid` (fill) / `--deepdrft-muted` (border-muted) | `#17283f` / `#8A9BB0` |
|
||
| Panel border / divider | subtle | `--deepdrft-border` (or the dark-theme `rgba(250,250,248,0.10)` divider) | — |
|
||
|
||
The NowPlaying Hero card itself (`NowPlayingCard.razor.css`) renders on a translucent off-white wash
|
||
(`rgba(250,250,248,0.06)` over the page's navy ground, `backdrop-filter: blur(8px)`) with the green accent
|
||
on its label/dot (`--deepdrft-green-accent`) and off-white title (`--deepdrft-white`). The panel should read
|
||
as a sibling of that card — the same navy-ground/green-accent/off-white vocabulary, the same restrained
|
||
translucency-over-navy feel — so a popover opened anywhere looks like it belongs to the same family as the
|
||
home card. **Staff-engineer pulls the tokens above; no inline hexes.** If a token is missing for a needed
|
||
shade (e.g. a knob's inactive track fill), prefer a `color-mix()` over the nearest token to a new hardcoded
|
||
value, and flag the gap rather than minting an untokened hex.
|
||
|
||
> **Note on the MudBlazor palette.** The MudBlazor theme (`DeepDrftShared.Client/Common/DeepDrftPalettes.cs`,
|
||
> applied via `<MudThemeProvider Theme="DeepDrftPalettes.Default">`) carries the *same* navy/green/off-white
|
||
> vocabulary in its `PaletteDark` (`Primary = #3D7A68` green-accent, `Background = #0D1B2A` navy,
|
||
> `Surface = #162437` navy-mid, `TextPrimary = #FAFAF8` off-white). For MudBlazor-component-level theming
|
||
> inside the popover, prefer `Color="Color.Primary"`/`Color.Surface` against that palette over CSS overrides
|
||
> where the component supports it; reach for the CSS `--deepdrft-*` tokens for the bespoke knob/panel chrome
|
||
> the RadialKnob owns. Either way the colors trace back to one source — the tokens file and the palette are
|
||
> the same vocabulary — so there is no third place to hardcode.
|
||
|
||
---
|
||
|
||
## 4. The bridge, generalized (Wave 12.B)
|
||
|
||
The bridge (`WaveformVisualizer.razor.cs`, ex-`MixWaveformVisualizer`) needs **one** real change beyond
|
||
the rename: its datum fetch must resolve the high-res datum for the **current track**, not for a Mix
|
||
release.
|
||
|
||
Today: `await ReleaseData.GetMixWaveform(ReleaseEntryKey)` → resolves per-release, 404 for non-Mix →
|
||
blank. This is the per-*release* fetch the model correction supersedes.
|
||
|
||
**Generalized (per-track model).** The visualizer renders *the currently playing/selected track's*
|
||
waveform, and the bridge already knows that track — it is the `TrackId` it gates `IsActivePlayer` on. So
|
||
the fetch should be keyed to the **track**, with the release as addressing context. Two shapes for the
|
||
fetch are viable; the spec recommends the first and leaves the exact route to staff-engineer (§5):
|
||
|
||
- **Track-keyed (recommended):** `await ReleaseData.GetTrackWaveform(trackEntryKey)` → returns the
|
||
per-track high-res datum from the generalized waveform vault, keyed by the track's `EntryKey`. This is the
|
||
cleanest expression of "the datum is the track's." The bridge needs the current track's `EntryKey`; it
|
||
currently carries `TrackId` (the SQL PK) — see §5 on reconciling `TrackId` → `EntryKey`.
|
||
- **Release-addressed, track-resolved:** `GET api/release/{releaseEntryKey}/track/{trackId}/waveform` — the
|
||
release locates the track, the track owns the datum. Heavier route, but keeps the release as the
|
||
addressing root the bridge already holds (`ReleaseEntryKey`). Use this if resolving `TrackId` → `EntryKey`
|
||
client-side is awkward.
|
||
|
||
The bridge stays otherwise identical:
|
||
|
||
- **Re-fetch key changes from release to track.** The fetch-once guard must re-arm when the **track**
|
||
changes, not (only) when the release changes — because on a multi-track Cut the release is fixed while the
|
||
track scrolls. (Today's guard keys on `_loadedReleaseKey == ReleaseEntryKey`; under the per-track model it
|
||
keys on the current track identity.) This is the one bridge subtlety the model correction introduces — and
|
||
it is *exactly* the behavior the NowPlaying card already needs (§6c), so the two converge.
|
||
- Still derives duration from the cascaded player (`PlayerDurationSeconds`) — the duration source is the
|
||
*player*, which already reflects the current track, so the time↔sample mapping is correct per-track for
|
||
free.
|
||
- Still gates playback coupling on `TrackId` via `IsActivePlayer`.
|
||
- Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle.
|
||
|
||
The `PlaybackPosition` composability fallback (the no-player-cascade path) stays as the documented escape
|
||
hatch.
|
||
|
||
**No open data *question* remains here** — Direction B (committed, §5) means every track has a high-res
|
||
datum, so the fetch always resolves to high-res. The open *items* the model created are operational, not
|
||
design: the backfill (§8a-new) and the upload-time compute cost (§8b-new).
|
||
|
||
---
|
||
|
||
## 5. The datum source — Direction B, per-track (committed)
|
||
|
||
The visualizer renders a **loudness datum + a duration**. Daniel's model correction makes the datum
|
||
**per-track**: every track carries its own high-res waveform datum; the release is just the host. And
|
||
Daniel chose **Direction B**: compute that high-res datum for **all** media, not the 512-bucket fallback.
|
||
Together these settle §5 — no remaining product call here; the work is a generalization + a backfill.
|
||
|
||
**Datums in the system today:**
|
||
|
||
| Datum | Vault | Resolution | Who has it today | Keyed by |
|
||
|-------|-------|-----------|------------------|----------|
|
||
| Per-track profile | `waveform-profiles` | **512 buckets** (fixed) | **every track** (computed at upload) | track `EntryKey` |
|
||
| High-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived) | **Mix tracks only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) |
|
||
|
||
The 512-bucket profile already exists per-track and stays (the player-bar `WaveformSeeker` consumes it —
|
||
untouched). The high-res datum is the one the lava visualizer wants, and today only Mix tracks have it.
|
||
**Direction B closes that gap per-track for every medium.**
|
||
|
||
### 5a. The generalization (Direction B), end to end
|
||
|
||
Direction B is **not** just a new endpoint — under the per-track model it touches five surfaces:
|
||
|
||
1. **Content compute path.** Generalize the duration-derived high-res compute (`WaveformProfileService` +
|
||
`MixWaveformResolution`) off its Mix-only framing so it computes a per-track high-res datum for **any**
|
||
track from that track's audio. The *algorithm* is unchanged (the same ~333 samples/sec duration-derived
|
||
model `MixWaveformResolution` already defines); only its *applicability* widens from "the mix track" to
|
||
"any track." Store keyed by the track's `EntryKey`.
|
||
2. **Store / vault.** The high-res datum becomes per-track-cardinal. Either rename/repurpose the
|
||
`mix-waveforms` vault to a medium-neutral `track-waveforms` (cleaner; consistent with the rename
|
||
discipline) or keep the vault and drop the Mix-only key assumption — staff-engineer's call, but the
|
||
*name* should stop saying "mix" once it holds every track's datum. `MixMetadata.WaveformEntryKey` is no
|
||
longer the only pointer; the datum keys directly off the track's `EntryKey` (the same key the
|
||
`waveform-profiles` profile uses).
|
||
3. **Upload path.** `UnifiedTrackService.UploadAsync` already computes the 512-bucket profile for every
|
||
track at upload; **add the per-track high-res compute alongside it** so every *new* track gets its
|
||
high-res datum at upload time, no CMS step required. (Cost flagged — §8b-new.)
|
||
4. **CMS generate action.** Generalize the Mix-only "generate waveform" trigger to **any track** — so a
|
||
CMS operator can (re)generate a track's high-res datum on demand (the backfill mechanism, and the repair
|
||
path if a compute fails at upload). This is the action that drives the backfill (§8a-new).
|
||
5. **Backfill.** Every *existing* track that predates Direction B has a 512-bucket profile but no high-res
|
||
datum. Backfill computes the per-track high-res datum for all of them. **This is a real operational
|
||
step, Daniel-gated** — see §8a-new (does it run as a one-shot migration/script, or as a CMS batch
|
||
action over the generalized generate action?).
|
||
|
||
### 5b. The per-track datum fetch endpoint
|
||
|
||
Under the per-track model the fetch resolves a datum for a **track**, addressed with the release as
|
||
context. **Recommended shape:** `GET api/track/{trackEntryKey}/waveform` (track-cardinal, on
|
||
`TrackController`) returning the per-track high-res datum, 404 → graceful blank. This is the natural home
|
||
once the datum is the track's, and it sits beside the existing per-track surfaces.
|
||
|
||
- **Reconciling with the bridge's identifiers.** The bridge today carries `ReleaseEntryKey` + `TrackId`
|
||
(the SQL PK), not the track's `EntryKey`. The fetch needs the track's `EntryKey`. Two ways to bridge:
|
||
(i) the player/track view-model the bridge already cascades exposes the current track's `EntryKey`
|
||
(preferred — the player knows the track it loaded, and `EntryKey` is how audio is keyed), so the bridge
|
||
reads `CurrentTrack.EntryKey` directly; or (ii) keep the release as the addressing root and resolve
|
||
server-side — `GET api/release/{releaseEntryKey}/track/{trackId}/waveform` — if the client can't cleanly
|
||
reach the track's `EntryKey`. **Recommend (i)**; it makes the route track-cardinal and matches the datum's
|
||
ownership. Final identifier plumbing is staff-engineer's, but the *route should be track-cardinal*.
|
||
- **`/mix/waveform` fate.** The Mix-gated `GET api/release/{entryKey}/mix/waveform` can be retired (folded
|
||
into the track-cardinal fetch — the Mix's single track resolves through the same path) or kept as a thin
|
||
delegate during transition. Staff-engineer's call; the spec's intent is **one** track-cardinal fetch.
|
||
- `IReleaseDataService` (or a track-cardinal `ITrackDataService` if cleaner) gains `GetTrackWaveform(
|
||
trackEntryKey)`; the bridge calls it.
|
||
|
||
**The road not taken (one-line, per Daniel).** A cheaper v1 — serve the existing 512-bucket per-track
|
||
profile to non-Mix surfaces and skip new compute entirely ("Direction A") — was considered and **declined**
|
||
in favor of uniform high-res. Recorded so the trade isn't relitigated: A was zero-compute but coarse for the
|
||
lava; Daniel chose quality + uniformity over the cheaper road.
|
||
|
||
---
|
||
|
||
## 6. Overhauling the NowPlayingHero (Wave 12.D)
|
||
|
||
### 6a. What's there now
|
||
|
||
`NowPlayingCard.razor` shows the now-playing title/sub plus, when `Player.IsLoaded`, a `waveform-bars`
|
||
div of **20 hardcoded `<div class="waveform-bar">`** elements, each with fixed `--h-lo/--h-hi/--dur` CSS
|
||
custom properties driving a CSS keyframe bounce. It is **purely synthetic** — no audio data, no coupling
|
||
to the actual signal; it bounces on a fixed loop whenever *anything* is loaded. This is the "stochastic
|
||
bullshit."
|
||
|
||
### 6b. The rewire — mount the real engine, driven by live playback
|
||
|
||
Replace the `waveform-bars` block with a mounted **`<WaveformVisualizer>`** scoped to the card, driven by
|
||
the **live cascaded player**:
|
||
|
||
- The NowPlaying card already cascades `IStreamingPlayerService` (it reads `Player.CurrentTrack`).
|
||
- Mount `<WaveformVisualizer ReleaseEntryKey="@Player.CurrentTrack.Release?.EntryKey"
|
||
TrackId="@Player.CurrentTrack.Id" />` — the same bridge, the same playback coupling. Because the card is
|
||
*inside* the cascade, `IsActivePlayer` is true for whatever is actually playing, and the visualizer
|
||
scrolls/animates to the *real* signal. When nothing is playing, it sits at its at-rest slice (the
|
||
bridge already handles the no-active-player state → static), which replaces the `waveform-placeholder`.
|
||
- The datum comes from §5's per-track fetch keyed on the **current track** (`CurrentTrack.EntryKey`) — so
|
||
the home card shows the real high-res waveform of whatever track the listener started, Mix or not. This
|
||
is the cleanest illustration of the per-track model: the card has no "release" of its own to speak of; it
|
||
follows the *track*, and the datum is the track's.
|
||
|
||
**This is the payoff of the generalization:** the NowPlaying card is *just another host* (mode C, §3f) of
|
||
the same engine, pointed at "whatever track is playing right now." No NowPlaying-specific renderer, no fork
|
||
— the DRY win the brief demands. And because the datum is per-track and every track now has a high-res
|
||
datum (Direction B), the home card renders at full fidelity for *any* track, not just Mixes.
|
||
|
||
### 6c. Constraints specific to the NowPlaying context
|
||
|
||
- **Live, not static.** Unlike a single-track detail page, the NowPlaying card's **track** changes as
|
||
playback advances. Under the per-track model (§4) the fetch-once guard re-arms on **track** change (not
|
||
release change), so track-change → new per-track datum is handled — and this is the *same* guard change
|
||
the per-track model already requires for a multi-track Cut (where the release is fixed but the track
|
||
scrolls). The two converge on one behavior: **re-fetch when the current track's identity changes.**
|
||
Verify the guard keys on the current track, not the release.
|
||
- **Small surface, popover controls (full parity via the icon).** The card is a small hero panel, not a
|
||
full-bleed page — but under the popover-hosted model (§3d-revised) that is no longer a reason to suppress
|
||
controls. The card places the **same lava-lamp icon → popover panel** as every other host; the panel
|
||
floats over the page on demand and doesn't compete with the card's compact title/sub layout the way an
|
||
inline eight-knob bar would have. **This is what dissolved §8b-followup** — the popover removed the
|
||
awkwardness that justified suppression. The card's lava-lamp icon sits in/near the card chrome (its anchor
|
||
is one of the §8e positioning calls — a small card is the tightest anchor case). The visualizer canvas is
|
||
still sized to the card, not `position: fixed` (§6c container-sizing below). **Flagged for staff-engineer
|
||
(§8d):** the renderer's footer-clip + full-viewport assumptions (`position: fixed; inset: 0`,
|
||
clip-to-footer) are
|
||
written for a full-page backdrop; mounting it in a *contained card* needs the canvas to size to its
|
||
container instead of the viewport. This is a real renderer-hosting wrinkle — the engine assumes
|
||
full-window today. Either (i) parameterize the visualizer's sizing (full-viewport vs.
|
||
fill-container), or (ii) the NowPlaying card is the forcing function to make the canvas
|
||
container-relative. **Recommend (i)** — a `Fill` mode parameter — because it also future-proofs any
|
||
contained mount (an embed, a CMS preview). This is the one genuine engineering subtlety in the
|
||
NowPlaying rewire; everything else is composition.
|
||
- **Performance.** A WebGL2 lava render on the *home page* (the highest-traffic, first-paint surface) is a
|
||
heavier ask than on a detail page the user navigated to deliberately. Keep the existing `MAX_DPR = 2`
|
||
cap and the `isPlaying`-gated rAF loop (it burns no frames when nothing plays — so an idle home page
|
||
pays nothing). If the lava is too heavy for the home card specifically, the card's mount
|
||
can run a *cheaper* preset (fewer blobs) via a density default — but do not fork the renderer;
|
||
use the existing density dial (now adjustable from the card's own popover too). **Flagged, not committed
|
||
(§8d).**
|
||
|
||
### 6d. What the card keeps
|
||
|
||
The title/sub text block (`np-title`/`np-sub`) and the "Now Playing" label stay — only the synthetic
|
||
`waveform-bars` / `waveform-placeholder` block is replaced by the mounted visualizer. The pulsing
|
||
`circle-deco` rings in `NowPlaying.razor` are unrelated decor; leave them.
|
||
|
||
---
|
||
|
||
## 7. Wave decomposition + dependency shape
|
||
|
||
Sequenced so the mechanical rename de-risks everything, the per-track high-res generalization (now the
|
||
heaviest wave) unblocks the new hosts, and the NowPlaying rewire (the trickiest, per §6c) comes last on a
|
||
proven engine. **Direction B re-costs 12.B materially** — it is no longer "a new endpoint" but a
|
||
content + upload + CMS + backfill + fetch slice; the spec splits it into 12.B1 (compute generalization +
|
||
backfill) and 12.B2 (the bridge's per-track fetch) so the heavy data work and the thin client rewire can be
|
||
sequenced and tested apart.
|
||
|
||
**The controls work now consolidates into its own wave, 12.E** (the popover-panel revision). Under the
|
||
inline-bar model the controls were threaded into each detail-page mount, so 12.C carried "ambient layer +
|
||
controls bar" together. The popover model **pulls the controls out of the per-host mounts into one shared
|
||
component** built once (12.E) and *placed* by each host (an icon, §3d-revised). This is cleaner: 12.C and
|
||
12.D each reduce to "mount the visualizer layer + place the lava-lamp icon," and the panel/popover/styling
|
||
all live in one wave. 12.E depends only on the rename (12.A) — it touches the controls component + a new
|
||
popover host + the panel styling, none of which need the per-track fetch — so it can run in parallel with
|
||
the data work (12.B1/12.B2).
|
||
|
||
- **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*` → `Waveform*` across the
|
||
five files + the TS module + the import path + the DI registration (§3a). **Load-bearing prerequisite**
|
||
— every later wave references the generalized names. Acceptance: Mix detail behaves identically;
|
||
diff is identifiers only.
|
||
- **12.B1 — Generalize the high-res compute to every track + backfill (the data change, Direction B).**
|
||
Generalize the duration-derived compute off Mix-only (`WaveformProfileService` / `MixWaveformResolution`),
|
||
store per-track keyed by `EntryKey` in the (renamed) `track-waveforms` vault, add the per-track high-res
|
||
compute to `UnifiedTrackService.UploadAsync`, generalize the CMS generate action to any track, and run the
|
||
**backfill** for existing tracks (§5a, §8a-new — backfill is Daniel-gated). **Independent of 12.A** (it is
|
||
server/content-side, no renamed client identifiers). Acceptance: every track — Mix, Session, and each Cut
|
||
track — has a high-res datum; new uploads get one automatically; the generate action works for any track.
|
||
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal `GET
|
||
api/track/{trackEntryKey}/waveform` (§5b); `GetTrackWaveform`; bridge resolves the *current track's*
|
||
`EntryKey` and re-fetches on track-change (§4). **Depends on 12.A (renamed bridge) + 12.B1 (a datum to
|
||
fetch).** Acceptance: the Mix detail page renders the same high-res lava via the new track-cardinal fetch;
|
||
a non-Mix track now returns a high-res datum.
|
||
- **12.E — Popover-hosted control panel (the controls revision).** Turn the renamed
|
||
`WaveformVisualizerControls` into the **panel content** and build the `WaveformVisualizerControlPopover`
|
||
host pairing the lava-lamp trigger icon with that panel as overlay content (`MudPopover`, §3d-revised).
|
||
Style the panel to the **NowPlaying Hero look** from theme tokens (§3g — no hardcoded hex). Make the
|
||
state-scoping call (one shared `WaveformVisualizerControlState`, §3d-revised note). **Depends on 12.A**
|
||
(renamed controls component) only — no per-track datum needed, so it runs **parallel to 12.B**. Acceptance:
|
||
the lava-lamp icon opens a popover panel with all eight knobs, styled to the Hero look; turning a knob
|
||
drives the visualizer (where one is mounted) via the unchanged `Changed` seam; the panel is one component
|
||
reused everywhere. **This wave is the unit every host then places (§3d-revised).**
|
||
- **12.C — `Ambient` slot on the scaffold + mount on detail pages (mode B, §3f).** Promote the
|
||
full-bleed/foreground/footer-clip pattern into `ReleaseDetailScaffold` as an optional `Ambient` slot
|
||
(§3c); Cut mounts the ambient layer **and places the lava-lamp icon → popover** (full parity, §3d-revised);
|
||
Session mounts directly **also full-parity** (§3e). Mix is **unchanged** as a visualizer layer (mode A
|
||
keeps its own full-bleed mount); its only controls change is swapping the inline `TopRowCenter` bar for the
|
||
lava-lamp icon → popover (folded into 12.E's affordance). **Depends on 12.B2** (a datum to render) **and
|
||
12.E** (the popover to place). **§8b is resolved (full parity, no longer gated)** — Cut and Session ship
|
||
with both the ambient layer and the popover controls. Acceptance: Mix unchanged as a layer; Cut and Session
|
||
each show an ambient living layer **with a working lava-lamp icon → popover panel**, rendering the current
|
||
track's datum, no regression to the hero/content.
|
||
- **12.D — NowPlayingHero rewire (mode C, §3f).** Replace the synthetic bars with a contained
|
||
`<WaveformVisualizer>` driven by the live player, pointed at the current track (§6); add the
|
||
`Fill`/container-sizing mode (§6c); **place the lava-lamp icon → popover on the card** (full parity, §6c —
|
||
the popover dissolves the old suppression). **Depends on 12.A + 12.B2 + 12.E** (renamed engine + a
|
||
per-track datum + the popover to place). **Independent of 12.C** (different host; doesn't need the scaffold
|
||
slot). Acceptance: the home card shows the *real* high-res waveform of the playing track, sits at-rest when
|
||
nothing plays, and carries the lava-lamp icon → popover like every other host; no synthetic bars remain.
|
||
|
||
**Dependency shape:** `12.A → 12.B2 → (12.C ‖ 12.D)`, with **12.B1 a parallel server-side track** that
|
||
12.B2 depends on (`12.B1 → 12.B2`) but that can start cold day one (it needs no renamed client identifiers),
|
||
and **12.E (the popover controls) a third parallel track** depending only on 12.A — both 12.C and 12.D now
|
||
also depend on 12.E to place the icon. So: `12.A → {12.B1 → 12.B2, 12.E}`, then `(12.B2 ∧ 12.E) → (12.C ‖
|
||
12.D)`. 12.A is the cheap mechanical unblock; **12.B1 is the load-bearing heavy** (compute + backfill); 12.B2
|
||
is the thin client rewire that consumes it; **12.E is the controls consolidation** (one panel + popover +
|
||
styling, built once); 12.C (detail-page ambient hosts) and 12.D (NowPlaying host) are independent siblings
|
||
that each mount a visualizer layer and place the popover. The cold-start items are **12.A** (touches
|
||
everything, risks nothing), **12.B1** (the data work — start it early; the backfill gate is the only thing
|
||
blocking it), and **12.E** (the controls panel — independent of all the data work). With §8b resolved to
|
||
full parity *and* the popover dissolving §8b-followup, 12.C and 12.D carry no controls-suppression branch —
|
||
every host places the same icon.
|
||
|
||
---
|
||
|
||
## 8. Open product decisions
|
||
|
||
**Resolved by Daniel (2026-06-17), kept visible per file convention:**
|
||
|
||
- **§8a (was: non-Mix datum resolution) → Direction B.** High-res compute for **all** media, not the
|
||
512-bucket fallback. The cheaper "Direction A" (serve the existing 512-bucket profile to non-Mix) is the
|
||
road not taken (§5). Re-cost lands in 12.B1 (compute + backfill).
|
||
- **§8c (was: multi-track Cut's waveform) → dissolved by the per-track model.** The datum is per-track; the
|
||
visualizer renders the *current* track's datum, so there is no release-level "which track represents the
|
||
album" choice to make. The old "first track by `TrackNumber`" recommendation is moot.
|
||
- **§8b (Cut/Session hosting + controls) → RESOLVED: all three modes live, FULL PARITY on controls, now
|
||
POPOVER-HOSTED.** Daniel chose **option (3) — full parity on the lava controls** (over option (2) Mix-only
|
||
controls): the visualizer rides every Release Detail host (Mix, Cut, Session) **and** the controls ride all
|
||
three. The three-hosting-mode *layout* framing (§3f — visualizer-is-the-page on Mix, ambient on
|
||
Cut/Session, contained on NowPlaying) is retained; "backdrop" stays retired as the wrong word for Mix.
|
||
**Controls-hosting revision (2026-06-17):** the controls are no longer an inline knob *bar* per page — they
|
||
are a **single popover-hosted panel** triggered by the lava-lamp icon, placed identically on every host
|
||
(§3d-revised). The earlier mode-1 fallback (Mix + NowPlaying only) and the "controls Mix-only" default are
|
||
both closed. The sub-question that spun out (§8b-followup) is now **resolved by the popover** (below).
|
||
|
||
- **§8b-followup (do full-parity controls extend onto the NowPlaying card?) → RESOLVED by the popover.**
|
||
This sub-question existed *because* an inline eight-knob bar was awkward on a small, compact, high-traffic
|
||
card — so the earlier draft kept the card controls-suppressed by default and flagged the question for
|
||
Daniel. **The popover-hosting revision dissolves it:** the card places the same lava-lamp icon as every
|
||
other host, and the panel floats on demand rather than occupying the card's layout. There is no
|
||
compact-layout conflict left to weigh. **Resolution: the NowPlaying card gets the icon → popover like
|
||
everywhere else — full parity on all four surfaces** (Mix, Cut, Session, NowPlaying card). The only
|
||
remaining controls nuance is *where the icon anchors per host* — a positioning detail, now §8e.
|
||
|
||
**Newly open (created by the popover revision and by Direction B + the per-track model):**
|
||
|
||
**§8e — Popover anchor/positioning per host (created by the popover revision).**
|
||
The popover dissolves the *presence* question but creates a *placement* one: where does the lava-lamp
|
||
trigger icon sit, and where does the panel anchor, on each host? The hosts differ enough that one anchor
|
||
rule may not fit all:
|
||
- **Mix (mode A, full-bleed)** — the icon can take Mix's existing `TopRightAction` slot (it already mounts
|
||
the lava-lamp glyph there); the panel anchors off that corner over the full-bleed field. Cleanest case.
|
||
- **Cut/Session (mode B, ambient)** — the icon rides the page chrome (Cut via the scaffold, Session via its
|
||
own composition); the panel floats over hero+content. Needs to clear the hero overlay and not collide with
|
||
the share/play affordances already in the top row.
|
||
- **NowPlaying card (mode C, small contained)** — the tightest case. A small card has little room for an
|
||
icon, and a popover panel sized to the Hero look may be larger than the card itself — so the panel likely
|
||
anchors to the icon but **overflows the card bounds** (which is fine — popovers float above), yet its open
|
||
position must not cover the title/sub it's meant to accompany. Worth a deliberate anchor choice (e.g.
|
||
open upward/leftward away from the card body).
|
||
**Recommend:** a single `WaveformVisualizerControlPopover` with a per-host *anchor origin* parameter
|
||
(MudPopover already exposes `AnchorOrigin`/`TransformOrigin`), defaulted sensibly and overridden per host —
|
||
not a forked popover. This keeps one component while letting each host place it well. **Staff-engineer-owned
|
||
layout call; flagged for Daniel only because the card case (mode C) may look cramped and is worth a glance
|
||
in review.** Does not block the phase; the default anchor is shippable and tunable.
|
||
|
||
**§8a-new — How does the high-res backfill run, and is it gated?**
|
||
Direction B means every *existing* track needs its per-track high-res datum computed once. Two shapes:
|
||
(1) a **one-shot migration/script** run against the content store (like the Daniel-gated EF migrations in
|
||
Phase 11 — authored, applied on Daniel's go-ahead); or (2) a **CMS batch action** over the generalized
|
||
generate action (an operator triggers "generate all," visible progress, re-runnable for failures). **Recommend
|
||
(2)** — it reuses the generalized generate action 12.B1 builds anyway, needs no separate one-shot tooling,
|
||
and the CMS already has the generate-status surface (Phase 8 §8.2's in-grid status column + generate
|
||
actions) to hang it on. **This is a Daniel-gated operational step either way** — like the Phase 11 release
|
||
migrations, the backfill is authored in 12.B1 but *run* on Daniel's word. Open: which shape, and when it
|
||
runs relative to shipping the per-track fetch (the fetch 404s gracefully for not-yet-backfilled tracks, so
|
||
the fetch can ship before the backfill completes — the visualizer just blanks until a track is backfilled).
|
||
|
||
**§8b-new — Per-track high-res compute at upload: perf + storage cost (flag for Daniel).**
|
||
Direction B adds a duration-derived high-res compute (~333 samples/sec) to **every** track upload, on top
|
||
of the existing 512-bucket profile. Two costs to surface:
|
||
- **Upload latency.** The high-res compute walks the full audio at upload time. For a long Mix this is
|
||
already paid (Mixes compute it today); for short Cut tracks it is small. But it is now on the *upload hot
|
||
path* for every track, where before only the cheap 512-bucket profile was. If upload latency matters,
|
||
the compute could be **deferred** (upload returns; high-res computes async/queued, the generate action
|
||
the backfill uses doubling as the async worker) rather than inline. **Recommend: inline for now**
|
||
(simplest; the 512-bucket compute is already inline and the high-res isn't dramatically heavier for
|
||
typical tracks), flag deferral as the escape hatch if upload feels slow.
|
||
- **Storage.** A per-track high-res datum (~333 samples/sec × duration) is materially larger than the
|
||
fixed 512-bucket profile, and now *every* track stores one (a multi-track Cut stores N of them). For an
|
||
album of short tracks this is modest; for a library of long Sessions it adds up. **Not a blocker** — disk
|
||
is cheap and the datum is loudness samples, not audio — but flagged so the storage growth from "every
|
||
track gets a high-res datum" is a known consequence, not a surprise. **No action recommended;** surface
|
||
only.
|
||
|
||
**§8d — NowPlaying container-sizing + home-page performance (engineering subtleties, staff-engineer-owned
|
||
but flag for Daniel).** The renderer assumes full-viewport (`position: fixed; inset: 0`, clip-to-footer);
|
||
the NowPlaying card (mode C, §3f) needs it container-relative (§6c) — recommend a `Fill` mode parameter.
|
||
And a WebGL2 lava render on the home page's first paint is heavier than on a detail page — the
|
||
`isPlaying`-gated rAF means an idle home page pays nothing, but a cheaper blob-density preset for the card
|
||
is a fallback if needed. Neither blocks; both are tuning/hosting calls surfaced so Daniel isn't surprised
|
||
by a lava lamp on the landing page.
|
||
|
||
---
|
||
|
||
## 9. Acceptance criteria (observable)
|
||
|
||
1. **Rename clean.** The engine is named for its abstraction (`WaveformVisualizer*`); the Mix detail page
|
||
is visually and behaviorally identical to before the rename.
|
||
2. **Per-track high-res datum everywhere (Direction B).** Every track — Mix, Session, and each track of a
|
||
Cut — has a high-res duration-derived datum keyed by its `EntryKey`; new uploads get one automatically;
|
||
the generalized CMS generate action produces one for any track; the backfill has populated all
|
||
pre-existing tracks.
|
||
3. **Track-cardinal fetch.** `GET api/track/{trackEntryKey}/waveform` (or the agreed track-cardinal route)
|
||
returns the current track's high-res datum, 404 → graceful blank. The bridge resolves the *current
|
||
track* and re-fetches on track change.
|
||
4. **Mix unchanged as a layer (mode A); controls via popover.** The Mix detail page still renders the
|
||
high-res lava visualizer-is-the-page at parity with the Phase 10 reframe — now via the track-cardinal
|
||
fetch. Its controls are reached through the **lava-lamp icon → popover panel** (replacing the inline
|
||
`TopRowCenter` bar), the same affordance every other host uses.
|
||
5. **Ambient + popover controls on Cut/Session (mode B, full parity).** A Cut and a Session detail page show
|
||
an ambient living waveform layer rendering the *currently selected/playing track's* datum, **with a
|
||
working lava-lamp icon → popover control panel** (full parity — controls present and tunable in place via
|
||
the popover), with no regression to the hero/content.
|
||
6. **NowPlaying is real (mode C), with popover controls (full parity).** The home NowPlaying card shows the
|
||
*actual* high-res waveform of the playing track (scrolls/animates to the real signal, changes with track
|
||
changes), sits at-rest when nothing plays, and **carries the lava-lamp icon → popover panel like every
|
||
other host** (§8b-followup resolved by the popover). No hardcoded synthetic bars remain.
|
||
7. **One popover-hosted control panel, placed everywhere.** There is exactly one
|
||
`WaveformVisualizerControls` panel and one `WaveformVisualizerControlPopover` host; Mix, Cut, Session, and
|
||
the NowPlaying card all place the *same* lava-lamp icon → popover and get the *identical* panel — verified
|
||
by no per-host control fork. The panel is styled to the NowPlaying Hero look (§3g) from theme tokens, no
|
||
hardcoded hex.
|
||
8. **One engine, three modes.** Mix (mode A), the Cut/Session ambient layer (mode B), and the NowPlaying
|
||
card (mode C) all consume the *same* `WaveformVisualizer` component + renderer + state — verified by
|
||
there being exactly one of each, no per-host fork; the differences are hosting composition only
|
||
(full-bleed vs. `Ambient` slot vs. contained; viewport- vs. container-sized). Controls are uniform across
|
||
all four hosts (the single popover panel, criterion 7).
|
||
9. **Read-only everywhere.** No host (including the NowPlaying card) exposes a seek/scrub/transport via
|
||
the visualizer; the read-only contract holds on every mount. The popover panel exposes only the eight
|
||
tuning dials — no transport.
|
||
10. **Bridge intact, re-keyed to track.** Single-owner handle, idempotent datum guard, `IsActivePlayer`
|
||
gating, and the `isPlaying`-gated rAF loop are unchanged across all mounts; the datum guard now re-arms
|
||
on **track** change (the multi-track Cut and the NowPlaying card both rely on this — they converge).
|