Files
deepdrft/product-notes/phase-12-waveform-visualizer-generalization.md
daniel-c-harvey d6df0de63a docs(phase-12): fold popover-hosted controls into spec + plan
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.
2026-06-17 06:07:49 -04:00

806 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).