# 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 `` 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 `` 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 — `` — 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 `` 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 ``) 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 `
`** 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 **``** scoped to the card, driven by the **live cascaded player**: - The NowPlaying card already cascades `IStreamingPlayerService` (it reads `Player.CurrentTrack`). - Mount `` — 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 `` 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).