docs(phase-12): revise spec — Direction B, per-track datum, full-parity controls

Daniel resolved the open questions: high-res compute for all media (B); the
waveform datum is per-track, not per-release (release is just the host —
dissolves the multi-track-Cut question); full-parity lava controls on all
detail hosts. Splits 12.B into compute+backfill / fetch+bridge; renames the
scaffold slot to Ambient. NowPlaying-card controls left as open sub-question.
This commit is contained in:
daniel-c-harvey
2026-06-17 05:33:34 -04:00
parent a0b3255028
commit cf47fee07e
2 changed files with 527 additions and 273 deletions
+99 -68
View File
@@ -202,22 +202,7 @@ Full design, renderer architecture, the four effects, acceptance criteria, and p
### Phase 10 — Reframe (Lava): Waves R1R4 ### Phase 10 — Reframe (Lava): Waves R1R4
A **major reframe of the Mix visualizer's effects, controls, and color model**, folded **under this same Phase 10** as a reframe wave-set (**Waves R1R4** — labelled to stay unambiguous against the landed Waves 14 above). It builds on the landed Phase 10 renderer infrastructure (pipeline, datum texture, playhead interp, bridge, widened body, lava-lamp trigger) but **replaces what it paints**. Daniel tested the Phase 10 effects end-to-end and rejected the visual result: the lava read as "giant disconnected circles," the colors drifted to cyan (an HSL saturation-boost artifact), and the waveform and lava read as two unrelated things sharing a canvas. The diagnosis (staff-engineer research pass) is that the rejected look is **structural to the effect approach, not a tuning miss**. **Landed:** 2026-06-17 on dev. See `COMPLETED.md` for the full completion record.
**This supersedes the original Phase 10 (Waves 14) effects/controls/color design**`product-notes/mix-visualizer-webgl-renderer.md` §4 (effects) and §7 (popover-controls) are marked superseded with a pointer to the reframe spec. The renderer *infrastructure* carries forward unchanged.
**The three reframes:**
- **Lava → CPU-physics wax blobs.** Keep the single-pass WebGL2 fragment renderer; add a small CPU-side per-frame physics step modeling ~1632 Lagrangian "wax blobs" (position/velocity/temperature/radius) uploaded as uniforms and blended with `smin` SDF metaballs. The waveform and lava share **the same plane WITH real 2D elastic collision** (blob↔waveform-boundary + blob↔blob) — the waveform pushes the fluid out of its way (read-only authority preserved; the fluid never deforms the waveform). **Refined by Daniel's Wave R2 eval:** the fluid must read **flat** (an evenly-lit unified body, NOT blobs with bright pointed centers / cone-like radial gradients) and **melt into one fluid** (low viscosity / strong coalescence — "behave more like a fluid," not stiff globs in contact; the slow wax-like *motion* damping and the freely-coalescing *surface* are independent). **Energy-coupled dynamics:** higher heat/energy → **smaller bubbles + more turbulence** (many small lively turbulent bubbles at high heat; fewer, larger, calmer wax at low heat) — the lamp should feel dynamic and fun. At heat 0 the wax rests on the floor and only collision moves it (collision always on, independent of heat); at heat max many small turbulent bubbles rise/morph per second. **Tuning anchor: Daniel's sweet spot is ~20% gravity / ~100% heat** — calibrate defaults/ranges so that combination lands lively. **Rejected: a full ping-pong FBO Navier-Stokes fluid sim** — a lava lamp is high-viscosity/low-turbulence, the opposite regime; large rewrite for unwanted realism. Deliberate later upgrade only.
- **Color → three-color OKLab gradient with three motions.** One source of truth (`DeepDrftPalettes`), no hardcoded hexes. Always A→B linear from the center line outward. Three combined motions: (1) anchors A/B **rotate among three theme colors X/Y/Z** at the rotation-speed control's rate — **OKLab interpolation, never through the rainbow** (the cyan fix is structural, not a tuning dial); (2) per-bar sinusoidal variation **baked at segment entry and fixed as the segment scrolls** — realized by **keying the sinusoid to mix-time** so it travels with the segment by construction (decided 2026-06-16; the explicit ring-buffer alternative is rejected for maintainability); (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). The static noise/frost texture is **removed** (Daniel: makes the screen look dirty).
- **Controls → seven knobs in an inline collapse/expand knob-bar.** Replaces the four: (1) waveform scroll speed [replaces resolution/zoom as a standalone control], (2) gradient rotation speed, (3) lava gravity, (4) lava heat, (5) blob density/size, (6) collision strength (soft mush→hard up-and-out throw), (7) **waveform width** [added in the Wave R2 eval — narrows the waveform band so the lava fluid has more room to move on loud/busy songs; scales the waveform's amplitude→width mapping and its collision boundary]. **NOT a popover or drawer — an in-flow controls container BETWEEN the back link (left) and the lava-lamp toggle (right)** on the detail top row (redesigned 2026-06-16, superseding the first realization). The first implementation read "inline collapse/expand" as an `position:absolute` bar floating under the lamp (`.mix-visualizer-controls-anchor`) — it clipped to a vertical sliver and read as a detached popover; **Daniel rejected it.** The redesign: `ReleaseDetailScaffold` gains a new optional **`TopRowCenter`** slot so the top row is `back | center-controls | lamp` (Track/Cut/Session supply neither → back-link-alone, reusability preserved); the seven `RadialKnob`s live in that center slot and **expand/collapse in the layout flow** (CSS width/opacity transition, container grows horizontally between back and lamp — **no `position:absolute`, no float, no overlay, no clipping, never overlaps the masthead/hero**). On narrow widths the container wraps in-flow to a second line / drops to the scaffold's `TopContent` band — never edge-clips. The lava-lamp icon button toggles a bound bool — no MudPopover/MudDrawer. Styled to **match the NowPlaying hero aesthetic** (the session-detail hero overlay — translucent dark glass, overlay-label typography, `Color.Secondary`). Also: overflow-clip the visualizer to the **dynamic footer height** (the player bar changes height minimized/expanded) so visuals stop cleanly above it; the clip line is also the lava rest line.
**Plus: redraw the lava-lamp glyph.** The current `DDIcons.LavaLamp` is rejected (Daniel: "form is shit, colors are shit"). Redraw to the classic 1970s silhouette — a wide truncated-cone metal **base**, a bulbous→roundedly-pointed teardrop **glass body**, a small cone **cap** ("offset cones") — with **navy fluid + moss blobs** (the theme's blue+green, faithful to the reference and on-theme) and a neutral/metallic base+cap. Authored in `DeepDrftShared.Client/Common/DDIcons.cs` as inner SVG markup (no `<svg>` wrapper; 24×24 viewBox); body silhouette `currentColor`, the two accent fills are commented literals traced to their `DeepDrftPalettes` source (SVG cannot resolve `var()`).
Heat→intensity and collision soft↔hard transfer functions are **staff-engineer tuning tasks** (endpoints fixed in the spec — heat 0 = wax rests on floor / heat max = many small turbulent rising bubbles; collision soft = gentle mush / hard = high-elasticity up-and-out throw, wide range, smooth/no jitter — formulas not). Full design, the wax-blob model, the collision model, the three-motion color model, the inline knob-bar, the icon redraw, observable acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`.
**Open / undecided (spec §10):** (a) **pause behavior** — whether the lava keeps convecting while audio is *paused* (lamp "always on") vs. the current freeze-on-pause (rAF gated on `isPlaying`) is an **undecided Daniel call**; default to freeze-on-pause until decided, switching is a localized gating change, not a model change. (b) **Future enhancement (deferred, NOT in R1R4 scope):** a per-control **"auto-modulate" checkbox** that slowly oscillates each knob's value via a low-frequency oscillator so the visualizer drifts on its own — Daniel flagged it as a cool future idea; the knob-bar layout leaves room but nothing builds it now.
**Sequenced as four reframe waves.** `Wave R1 → Wave R2 → (Wave R3 ‖ Wave R4)`. Wave R1 (de-noise + dynamic footer clip + icon redraw) is a cheap unblock for a clean substrate. Wave R2 (wax-blob physics + 2D collision) is the load-bearing prerequisite — prove the lava before the color and the UI. Wave R3 (OKLab three-color gradient, the three motions) and Wave R4 (seven controls — including the new waveform-width knob — + NowPlaying-styled inline knob-bar + widened state to seven properties + extended bridge handle) both depend on Wave R2 but are independent of each other. **Both prior open Daniel calls are now decided:** controls-UI is an inline collapse/expand knob-bar (not popover/drawer); per-segment color is mix-time-keyed.
--- ---
@@ -256,74 +241,120 @@ Sequenced as **eight waves**; the critical path is `11.A → 11.B → 11.C → 1
## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire ## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire
Take the landed Mix waveform visualizer (the WebGL2 lava renderer + its seven-knob controls, Phase 10 Take the landed Mix waveform visualizer (the WebGL2 lava renderer + its eight-knob controls, Phase 10
reframe) and **make it the one release-cardinal visualizer** — serving Mix detail, all Release Detail reframe) and **make it the one track-cardinal visualizer** — serving Mix detail, all Release Detail
pages, *and* the home-page NowPlaying card — instead of a Mix-only backdrop forked three ways. **Two pages, *and* the home-page NowPlaying card — rendering the waveform of **whatever track is currently
deliverables, one engine, DRY/SOLID the explicit ask.** Full design, the extraction analysis, the datum playing/selected**, instead of a Mix-only treatment forked three ways. **Two deliverables, one engine in
decision, wave decomposition, and open questions: `product-notes/phase-12-waveform-visualizer-generalization.md`. three hosting modes, DRY/SOLID the explicit ask.** Full design, the extraction analysis, the per-track
model, Direction B compute, wave decomposition, and open questions:
`product-notes/phase-12-waveform-visualizer-generalization.md`.
**Central finding (verified read, 2026-06-17): the engine is already release-cardinal below the surface.** **Keystone model correction (Daniel, 2026-06-17): the datum is PER-TRACK, not per-release.** *"Each track
in the release must get the metadata… the release is just the host."* Every track carries its own high-res
waveform datum; the visualizer renders the *currently playing/selected* track's datum, and the release is
merely the host surface. 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 (no release-level
datum to choose). Threaded through the datum source, the endpoint shape, the bridge, and acceptance.
**Central finding (verified read, 2026-06-17): the engine is already track-cardinal below the surface.**
`MixWaveformVisualizer`'s bridge keys on `ReleaseEntryKey` + `TrackId` (not Mix); the renderer is a pure `MixWaveformVisualizer`'s bridge keys on `ReleaseEntryKey` + `TrackId` (not Mix); the renderer is a pure
function of a loudness datum + duration; the controls/state are renderer-agnostic. The *only* genuinely function of a loudness datum + duration; the controls/state are renderer-agnostic. The *only* genuinely
Mix-coupled surface is (1) the datum **fetch** (`GET api/release/{entryKey}/mix/waveform` 404s unless Mix-coupled surface is (1) the datum **fetch** (per-release, `GET api/release/{entryKey}/mix/waveform` 404s
`Medium == Mix`) and (2) the high-res datum **source** (the `mix-waveforms` vault, Mix-only). Everything unless `Medium == Mix`) and (2) the high-res datum **source** (the `mix-waveforms` vault, Mix-track-only).
else is just *named* `Mix*`. So "generalize from Mix to all releases" is a **rename + a data-source Everything else is just *named* `Mix*`. So "generalize from Mix to all tracks" is a **rename + a per-track
generalization, not a rebuild** — the renderer, bridge, controls, read-only contract all carry forward high-res compute generalization, not a rebuild** — the renderer, bridge, controls, read-only contract all
from the Phase 10 reframe unchanged. carry forward from the Phase 10 reframe unchanged.
**Crucial data fact (verified): non-Mix releases are not a data gap.** *Every* uploaded track already gets **Datum decision (Daniel, 2026-06-17): Direction B — high-res for ALL media.** Today every uploaded track
a **512-bucket** waveform profile (`UnifiedTrackService.UploadAsync``waveform-profiles` vault, the gets a **512-bucket** profile (`UnifiedTrackService.UploadAsync``waveform-profiles` vault, consumed by
datum the player-bar `WaveformSeeker` consumes). Mixes *additionally* get a duration-derived **high-res** the player-bar `WaveformSeeker`); only **Mix tracks** *additionally* get the duration-derived **high-res**
datum (~333 samples/sec, `mix-waveforms` vault, CMS-triggered). So the only question for non-Mix is datum (~333 samples/sec, `mix-waveforms` vault, CMS-triggered). Direction B **generalizes the high-res
*resolution*, not existence — and §8a frames that as the one real product call (recommend: serve the compute to every track**: the content compute path goes medium-neutral, the upload path computes a per-track
existing 512-bucket profile for ambient non-Mix backdrops in v1; high-res-for-all is a roadmap upgrade). high-res datum for every new track, the CMS generate action generalizes off Mix-only, and a **backfill**
populates existing tracks. The cheaper road (serve the existing 512-bucket profile to non-Mix, zero new
compute — old "Direction A") is **declined** in favor of uniform high-res. So 12.B is no longer "a new
endpoint" — it is a content + upload + CMS + backfill + fetch slice (split into 12.B1 / 12.B2 below).
**Deliverable 2 — NowPlayingHero overhaul.** `NowPlayingCard.razor` today animates **20 hardcoded **Three hosting modes of the one engine (Daniel corrected "backdrop").** *"backdrop?? MIXES doesn't really
have a backdrop?"* — right: on Mix the visualizer is the full-bleed **centerpiece that IS the page**, not
something behind content. The one engine is hosted three ways (spec §3f): **mode A — visualizer-is-the-page**
(Mix detail, full-bleed, seven-knob bar); **mode B — ambient environment** (Cut/Session detail, living
texture *behind* the hero+content — this is the only mode that is genuinely a "backdrop"); **mode C —
contained live element** (NowPlaying card, a bounded live readout, `Fill`-sized to the card). Same engine,
same datum contract — variance is entirely in hosting composition. **Controls (Daniel, full parity, §8b):**
the seven-knob bar + lava-lamp toggle ride **every Release Detail host** — Mix, Cut, **and** Session (modes
A and B both carry the controls, not Mix-only); only the contained NowPlaying card (mode C) suppresses them
by default (one open sub-question — §8b-followup).
**Deliverable 2 — NowPlayingHero overhaul (mode C).** `NowPlayingCard.razor` today animates **20 hardcoded
CSS-bounce bars** with no audio coupling (the "stochastic" visualizer). Replace them with the *same* CSS-bounce bars** with no audio coupling (the "stochastic" visualizer). Replace them with the *same*
`WaveformVisualizer`, mounted inside the existing player cascade and pointed at "whatever is playing right `WaveformVisualizer`, mounted inside the existing player cascade and pointed at the **current track** — so
now" — so the home card shows the **real** waveform of the live track, Mix or not. The payoff of the the home card shows the **real** high-res waveform of the live track, Mix or not. The payoff of the
generalization: the NowPlaying card is *just another host* of the one engine, not a fork. The one genuine generalization: the NowPlaying card is *just another host* of the one engine. The one genuine engineering
engineering wrinkle is that the renderer assumes full-viewport (`position: fixed; inset: 0`, wrinkle is that the renderer assumes full-viewport (`position: fixed; inset: 0`, clip-to-footer) and the
clip-to-footer) and the card needs it container-relative — recommend a `Fill` mode parameter (spec §6c). card needs it container-relative — recommend a `Fill` mode parameter (spec §6c).
**Design discipline.** Rename the engine to its abstraction (`MixWaveformVisualizer``WaveformVisualizer`, **Design discipline.** Rename the engine to its abstraction (`MixWaveformVisualizer``WaveformVisualizer`,
etc.) — a `Mix`-named component on a Cut page is a lie that cements the wrong model. Variance rides etc.) — a `Mix`-named component on a Cut page is a lie that cements the wrong model. Variance rides
**composition** (a new optional `Backdrop` slot on `ReleaseDetailScaffold`; per-host control suppression), **composition** (a new optional `Ambient` slot on `ReleaseDetailScaffold` for mode B; Mix keeps its own
never a `switch (medium)` in the engine (memory *One source, multiple views*; scaffold's "variance rides a mode-A mount; the card is a mode-C contained mount; per-host control suppression), never a `switch (medium)`
slot, never a flag" idiom, Phase 9 §5.3). The lava controls stay a **Mix affordance by default**; in the engine (memory *One source, multiple views*; scaffold's "variance rides a slot, never a flag" idiom,
Cut/Session (if they get a backdrop) mount it controls-suppressed as ambient — the seam supports controls- Phase 9 §5.3). The slot is named `Ambient` not `Backdrop` precisely because Mix doesn't use it. The lava
everywhere later with no engine change (memory *Design for adaptability up front*). controls ride **every Release Detail host** (Mix, Cut, Session — full parity, Daniel's §8b call); only the
contained NowPlaying card mounts controls-suppressed by default. The seam still supports flipping the card
to controls-on later with no engine change (memory *Design for adaptability up front*).
Sequenced as **four waves**: `12.A → 12.B → (12.C ‖ 12.D)`. Sequenced as **five waves**: `12.A → 12.B2 → (12.C ‖ 12.D)`, with **12.B1 a parallel server-side track**
(`12.B1 → 12.B2`) that can start cold day one.
- **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*``Waveform*` across the - **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*``Waveform*` across the
five C#/Razor files + the TS module + its import path + the DI registration. **Load-bearing five C#/Razor files + the TS module + its import path + the DI registration. **Load-bearing
prerequisite** — every later wave references the generalized names. Acceptance: Mix detail identical; prerequisite** — every later wave references the generalized names. Acceptance: Mix detail identical;
diff is identifiers only. diff is identifiers only.
- **12.B — Generalize the datum fetch + endpoint.** New unauthenticated `GET - **12.B1 — Generalize the high-res compute to every track + backfill (Direction B, the data change).**
api/release/{entryKey}/waveform` resolving the **best-available** datum (high-res for Mix-with-datum; Generalize the duration-derived compute off Mix-only (`WaveformProfileService` / `MixWaveformResolution`),
512-bucket per-track profile otherwise); `IReleaseDataService.GetReleaseWaveform`; bridge calls it store per-track keyed by `EntryKey` in a (renamed) `track-waveforms` vault, add per-track high-res compute
instead of `GetMixWaveform`. **Depends on 12.A.** Direction A (recommended) needs no decision to start; to `UnifiedTrackService.UploadAsync`, generalize the CMS generate action to any track, and run the
the high-res-for-all alternative (Direction B) does — §8a. **Daniel-gated backfill** for existing tracks (§8a-new). **Independent of 12.A** (server/content-side).
- **12.C — `Backdrop` slot on `ReleaseDetailScaffold` + mount on detail pages.** Promote the full-bleed / The new load-bearing heavy. Acceptance: every track has a high-res datum; new uploads get one; the
foreground-stacking / dynamic-footer-clip pattern into the scaffold as an optional `Backdrop` slot; Mix generate action works for any track.
re-expresses its current mount through it; Cut mounts the controls-suppressed ambient backdrop; Session - **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal `GET
mounts directly (it doesn't compose the scaffold — spec §3e) **if** §8b opts non-Mix in. **Depends on api/track/{trackEntryKey}/waveform` (spec §5b); `GetTrackWaveform`; bridge resolves the *current track's*
12.B.** `EntryKey` and re-fetches on **track** change (not release change). **Depends on 12.A + 12.B1.**
- **12.D — NowPlayingHero rewire.** Replace the synthetic bars with a contained, controls-suppressed Acceptance: Mix renders the same high-res lava via the track-cardinal fetch; a non-Mix track returns
`<WaveformVisualizer>` driven by the live cascaded player; add the `Fill`/container-sizing mode (spec high-res.
§6c). **Depends on 12.A + 12.B; independent of 12.C** (different host). Acceptance: home card shows the - **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B, full parity).**
real playing-track waveform, at-rest when nothing plays; no synthetic bars remain. Promote the full-bleed / foreground-stacking / dynamic-footer-clip pattern into the scaffold as an optional
`Ambient` slot; Cut mounts the ambient layer **with the full seven-knob bar + lava-lamp toggle** (full
parity); Session mounts directly **also full-parity** (it doesn't compose the scaffold — spec §3e). Mix is
**unchanged** (mode A keeps its own mount + controls). Also makes the state-scoping call (recommend one
shared `WaveformVisualizerControlState`). **Depends on 12.B2.** **§8b resolved (full parity) — no longer
gated**; Cut and Session ship with both the ambient layer and the controls.
- **12.D — NowPlayingHero rewire (mode C).** Replace the synthetic bars with a contained,
controls-suppressed `<WaveformVisualizer>` driven by the live cascaded player, pointed at the current
track; add the `Fill`/container-sizing mode (spec §6c). **Depends on 12.A + 12.B2; independent of 12.C**
(different host). Acceptance: home card shows the real playing-track high-res waveform, at-rest when
nothing plays; no synthetic bars remain.
**Open product decisions (spec §8, need Daniel before the dependent wave):** (a) **non-Mix datum **Resolved by Daniel (2026-06-17), kept visible per file convention:** datum resolution → **Direction B**
resolution** — existing 512-bucket per-track profile (Direction A, recommended, zero new compute) vs. (high-res all media; 512-bucket-fallback "Direction A" declined); multi-track-Cut datum → **dissolved by
high-res-for-all (Direction B, new CMS/API/content + backfill); gates 12.B's *richness*, blocks nothing if the per-track model** (renders the current track's datum, no album-representative choice); Cut/Session
A. (b) **Do Cut/Session get a backdrop, and with controls?** — recommend ambient-on-all-media / hosting + controls → **full parity (option 3)**: all three hosting modes ship **and** the seven-knob bar +
controls-Mix-only; note even Mix-only still wants 12.A/B/D for the NowPlaying win. (c) **What is a lava-lamp toggle ride every Release Detail host (Mix, Cut, Session), not Mix-only — the three-mode *layout*
multi-track Cut's waveform?** — recommend first track by `TrackNumber` for v1; only bites if (b) gives Cut framing is retained, the change is that controls are no longer Mix-suppressed on Cut/Session (the old "mode
a backdrop. (d) **NowPlaying container-sizing + home-page perf** — staff-engineer-owned (`Fill` mode; 1 Mix-only" and "controls Mix-only" alternatives are both closed). **Newly open (created by the full-parity
`isPlaying`-gated rAF means an idle home page pays nothing), flagged so a lava lamp on the landing page is flip + Direction B + per-track):** (a) **§8b-followup — do full-parity controls extend onto the NowPlaying
no surprise. home card (mode C)?** Full parity was answered against the *detail* pages; the small contained home card
stays **controls-suppressed by default** (a seven-knob bar on a compact now-playing card may be awkward) —
recommend keeping it suppressed, but flag for Daniel; one-line composition flip in 12.D either way.
(b) **§8a-new — backfill shape + gate**: one-shot migration/script vs. a CMS
batch action over the generalized generate action (recommend the CMS action; Daniel-gated to *run* either
way; the fetch 404s gracefully for not-yet-backfilled tracks so it can ship before the backfill completes).
(c) **§8b-new — per-track high-res compute cost** (flag only): upload latency (recommend inline; deferral is
the escape hatch) + storage growth (every track now stores a high-res datum, a multi-track Cut stores N —
modest, surfaced not blocking). (d) **§8d — NowPlaying container-sizing + home-page perf** —
staff-engineer-owned (`Fill` mode; `isPlaying`-gated rAF means an idle home page pays nothing), flagged so
a lava lamp on the landing page is no surprise.
--- ---
@@ -1,22 +1,39 @@
# Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec) # Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec)
Status: **design-complete, implementation-ready** (one open product decision flagged in §8 — the Status: **design-complete, implementation-ready.** Daniel resolved the three §8 open questions on
non-Mix datum-resolution call — and two smaller calls). Author: product-designer. Date: 2026-06-17. 2026-06-17 — committing **Direction B** (high-res compute for all media), correcting the datum model to
**No code has been written by this doc.** **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 seven-knob bar + lava-lamp toggle 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 now
present on all three detail-page hosts, not suppressed on Cut/Session. The spec below is revised to those
three. One small sub-question remains open (§8b-followup): whether full-parity controls also extend onto
the small NowPlaying home card (default: **no** — the card stays controls-suppressed). Author:
product-designer. Date: 2026-06-17. **No code has been written by this doc.**
This phase has **two deliverables that share one engine**: This phase has **two deliverables that share one engine**:
1. **Generalize** the landed Mix waveform visualizer (the WebGL2 lava renderer + its seven-knob controls) 1. **Generalize** the landed Mix waveform visualizer (the WebGL2 lava renderer + its seven-knob controls)
from a Mix-only backdrop into a **release-cardinal visualizer** that every Release Detail page can host from a Mix-only treatment into a **track-cardinal visualizer** that every Release Detail page can host
— Cuts, Sessions, and Mixes alike. — 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 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 **real waveform visualizer** driven by live playback, replacing the 20 hardcoded CSS-animated bars
(the "stochastic bullshit"). (the "stochastic bullshit").
The explicit ask is **DRY / SOLID**: one reusable visualizer engine serving Mix detail, all Release 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 Detail pages, and the NowPlayingHero — **not three forks.** This spec's central finding makes that
cheap: the engine is *already* release-cardinal below the surface. The work is extraction and a cheap: the engine is *already* track-cardinal below the surface. The work is extraction, a per-track
data-source generalization, not a rebuild. 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): Cross-references (read before implementing):
@@ -35,15 +52,20 @@ Cross-references (read before implementing):
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the WebGL2 renderer module. Renamed; the only - `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). *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 - `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor` — the shared detail chrome. The visualizer
becomes a scaffold-level concern via a new optional backdrop slot (§3c). becomes a scaffold-level concern via a new optional `Ambient` slot (§3c, §3f).
- `DeepDrftPublic.Client/Pages/MixDetail.razor`, `SessionDetail.razor`, `CutDetail.razor` — the three - `DeepDrftPublic.Client/Pages/MixDetail.razor`, `SessionDetail.razor`, `CutDetail.razor` — the three
release-detail hosts. release-detail hosts (three distinct hosting modes — §3f).
- `DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css]`, `NowPlaying.razor` — the home-page - `DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css]`, `NowPlaying.razor` — the home-page
now-playing card carrying the stochastic bars (§6). now-playing card carrying the stochastic bars (§6).
- `DeepDrftAPI/Controllers/ReleaseController.cs`, `TrackController.cs` — the waveform endpoints; the - `DeepDrftAPI/Controllers/TrackController.cs`, `ReleaseController.cs` — the waveform endpoints; under the
data-source question lives here (§5, §8). per-track model the datum resolves for a **track** (§5), with the release as addressing context.
- `DeepDrftContent/Processors/WaveformProfileService.cs`, `MixWaveformResolution.cs`, - `DeepDrftContent/Processors/WaveformProfileService.cs`, `MixWaveformResolution.cs`,
`Constants/VaultConstants.cs` — the (content-agnostic) compute/store path and the two vaults. `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).
--- ---
@@ -54,69 +76,82 @@ The visualizer is named `Mix*` throughout, but its *architecture* is release-gen
| Layer | Reality on disk today | Mix-coupled? | | Layer | Reality on disk today | Mix-coupled? |
|-------|----------------------|--------------| |-------|----------------------|--------------|
| `MixWaveformVisualizer` bridge inputs | `ReleaseEntryKey` (string) + `TrackId` (long?) + cascaded player | **No** — already release-cardinal | | `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 release's track | | 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 | | 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 | | 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` | | **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 Mixes get the high-res datum | | **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 | | Names / comments | `Mix*` everywhere | cosmetic |
So the genuinely Mix-specific surface is exactly **two things**: the *fetch endpoint that gates on So the genuinely Mix-specific surface is exactly **two things**: the *fetch endpoint that gates on
`Medium == Mix`*, and the *high-res datum that only Mixes have*. Everything else is a rename. `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 **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 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 datum source their first consumer. Generalizing is: rename to the abstraction, give the abstraction a **per-track**
that exists for every release, and let three hosts mount it. No new renderer, no fork. 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.* **The crucial data fact (verified):** *every uploaded track already has a waveform profile.*
`UnifiedTrackService.UploadAsync` calls `WaveformProfileService.ComputeAndStoreAsync(...)` at upload time `UnifiedTrackService.UploadAsync` calls `WaveformProfileService.ComputeAndStoreAsync(...)` at upload time
for **every** track, storing a **512-bucket** profile in the `waveform-profiles` vault keyed by the 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). Mixes 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 *additionally* get a duration-derived **high-res** datum (~333 samples/sec) in the separate
`mix-waveforms` vault, triggered by a CMS action. So **non-Mix releases are not a data gap**they have `mix-waveforms` vault, triggered by a CMS action. So **the high-res datum is the only gap**and under
a low-res datum today. The only question is whether 512 buckets is *enough resolution* for the lava Daniel's per-track model it is a gap *per track*, not per release: every track should carry its own
visualizer on a Cut/Session, or whether they should get the high-res treatment too (§5, §8 — the one real high-res datum, computed at upload and (for existing tracks) backfilled. **Direction B (committed)** makes
product decision). 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 ## 2. Goal and scope boundary
**Goal.** One reusable `WaveformVisualizer` (renamed from `MixWaveformVisualizer`) + its lava renderer + **Goal.** One reusable `WaveformVisualizer` (renamed from `MixWaveformVisualizer`) + its lava renderer +
its controls, mounted as a backdrop on **all three** Release Detail pages and on the home-page NowPlaying its controls, mounted in **three hosting modes** (§3f) — *visualizer-is-the-page* on Mix detail, *ambient
card, fed by a **release-cardinal datum source** that exists for every release. The lava controls stay a environment* on Cut/Session detail, *contained live element* on the home-page NowPlaying card — each fed by
Mix affordance unless Daniel says otherwise (§3d). The NowPlaying card drives the *same* engine off live a **per-track high-res datum** that exists for every track (Direction B, §5). The visualizer always renders
playback (§6). **the currently playing/selected track's** datum; the release is the host, not the datum's owner. The lava
controls ride **every Release Detail host** — Mix, Cut, and Session all get the seven-knob bar + lava-lamp
toggle (full parity, §3d). The NowPlaying card drives the *same* engine off live playback (§6) and stays
**controls-suppressed by default** (the one open sub-question, §8b-followup).
**In scope.** **In scope.**
- **Rename + relocate** the visualizer engine to a release-cardinal identity (`MixWaveformVisualizer` - **Rename + relocate** the visualizer engine to a track-cardinal identity (`MixWaveformVisualizer`
`WaveformVisualizer`, `MixVisualizerControls``WaveformVisualizerControls`, `MixVisualizerControlState` `WaveformVisualizer`, `MixVisualizerControls``WaveformVisualizerControls`, `MixVisualizerControlState`
`WaveformVisualizerControlState`, `MixVisualizer.ts``WaveformVisualizer.ts`, `MixZoomMapping` `WaveformVisualizerControlState`, `MixVisualizer.ts``WaveformVisualizer.ts`, `MixZoomMapping`
`WaveformZoomMapping`). Pure renames; no behavior change. (§3a) `WaveformZoomMapping`). Pure renames; no behavior change. (§3a)
- **Generalize the datum source.** A release-cardinal fetch that returns *the best available datum* for - **Generalize the high-res compute to every track (Direction B, §5).** This is now the load-bearing data
any release — the high-res mix datum when present, the per-track 512-bucket profile otherwise (§5). change — and it is **larger than a single endpoint**. It touches: (a) the **content** compute path
This is the load-bearing data change. (`WaveformProfileService` / `MixWaveformResolution`) — generalize the duration-derived high-res compute
- **Host the visualizer on every Release Detail page** via a new optional `Backdrop` slot on off Mix-only to any track; (b) the **upload** path (`UnifiedTrackService.UploadAsync`) — compute the
`ReleaseDetailScaffold` (§3c), so Cut/Session/Mix mount it without each page re-implementing the high-res per-track datum at upload for every new track; (c) the **CMS** generate action — generalize the
full-bleed wrapper. 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 - **Rewire the NowPlayingHero** to mount the visualizer driven by the live cascaded player, replacing the
20 hardcoded CSS bars (§6). 20 hardcoded CSS bars (§6).
- **Decide where the lava controls live** per medium — Mix keeps the seven-knob bar; Cut/Session default - **Mount the lava controls on every detail-page host** — Mix, Cut, and Session all carry the seven-knob
to a **controls-less ambient** backdrop (§3d), revisitable. bar + lava-lamp toggle (full parity, §3d). The NowPlaying card mounts **controls-suppressed** by default
(§3d, §8b-followup).
**Out of scope / unchanged.** **Out of scope / unchanged.**
- **No renderer rewrite.** The wax-blob physics, the OKLab gradient, the collision model, the seven dials - **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 — all carry forward from the Phase 10 reframe exactly. This phase moves and renames the engine and
changes its *input plumbing*, never its art. changes its *input plumbing* and its *compute breadth*, never its art.
- **No bridge redesign.** The single-owner bridge, the idempotent datum guard, the `IsActivePlayer` - **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. gating, the `isPlaying`-gated rAF loop — all preserved. Extend the fetch, not the contract.
- **No new control model.** The seven knobs and `…ControlState` stay as-is (renamed). No new dials. - **No new control model.** The seven knobs and `…ControlState` stay as-is (renamed). No new dials.
- **No CMS change** unless §5 lands the "high-res for all media" option (then a generalized waveform - **No new high-res *algorithm*.** Direction B generalizes the *existing* duration-derived compute to run
trigger touches the CMS — flagged, not committed). 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 — - **No playback-control change.** Read-only contract holds everywhere, including the NowPlaying card —
the home card visualizes, it does not become a transport. the home card visualizes, it does not become a transport.
@@ -136,7 +171,7 @@ every later wave references the generalized names:
| `MixVisualizerControlState.cs` (+ DI registration) | `WaveformVisualizerControlState.cs` | | `MixVisualizerControlState.cs` (+ DI registration) | `WaveformVisualizerControlState.cs` |
| `MixZoomMapping.cs` | `WaveformZoomMapping.cs` | | `MixZoomMapping.cs` | `WaveformZoomMapping.cs` |
| `MixVisualizer.ts` (+ the `./js/visualizer/MixVisualizer.js` import path) | `WaveformVisualizer.ts` | | `MixVisualizer.ts` (+ the `./js/visualizer/MixVisualizer.js` import path) | `WaveformVisualizer.ts` |
| `DDIcons.LavaLamp` / `LavaLampFilled` | keep (the lava-lamp glyph is the *controls* affordance, still Mix-only by default — §3d) | | `DDIcons.LavaLamp` / `LavaLampFilled` | keep (the lava-lamp glyph is the *controls* affordance, now on every detail-page host — §3d) |
The `ReleaseEntryKey` / `TrackId` parameters and the fetch keep working unchanged through the rename. 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 The `mix-waveforms` vault name and `MixMetadata.WaveformEntryKey` stay (they are still where the high-res
@@ -161,143 +196,247 @@ Generalizing does **not** mean flattening every medium to the same look. The cle
that wants the ambient backdrop but no knobs mounts the backdrop with controls suppressed. that wants the ambient backdrop but no knobs mounts the backdrop with controls suppressed.
This is the same "variance rides a slot, never a flag" discipline the scaffold already uses for 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 backdrop. `Header`/`Hero`/`TopRightAction` (Phase 9 §5.3) — extended to the ambient mount.
### 3c. The hosting seam — a `Backdrop` slot on `ReleaseDetailScaffold` (Wave 12.C) ### 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 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. a `.mix-detail-foreground` wrapper, with the scaffold inside `MudContainer`). Session mounts nothing.
Cut mounts nothing. To let every medium host the visualizer DRY-ly: Cut mounts nothing. To let Cut/Session host the visualizer as ambient environment DRY-ly:
**Add an optional `RenderFragment? Backdrop` slot to `ReleaseDetailScaffold`**, rendered as the **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 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` pattern into the container becomes the foreground stacking context — promote the `mix-detail-foreground` stacking pattern
scaffold so it is the default, not a Mix bespoke). A host that supplies no `Backdrop` gets today's plain into the scaffold so it is the default, not a Mix bespoke). A host that supplies no `Ambient` gets today's
background (Liskov: absent slot = no backdrop, no regression). plain background (Liskov: absent slot = no ambient layer, no regression).
- **Mix** supplies `<WaveformVisualizer ReleaseEntryKey=… TrackId=… />` to `Backdrop` and keeps its - **Cut** (composes the scaffold) supplies `<WaveformVisualizer ReleaseEntryKey=… TrackId=… />` to
`TopRightAction` lava-lamp + `TopContent` knob band — same as today, just expressed through the slot. `Ambient` **with the full seven-knob bar + lava-lamp toggle exposed** (full parity, §3d) — a living
- **Session / Cut** *may* supply the same `<WaveformVisualizer>` to `Backdrop` with controls suppressed waveform field behind the album hero + track list, rendering the *currently selected/playing track's*
(§3d) — an ambient living backdrop behind the hero. **Whether they do is a product call (§8b).** datum, tunable in place. **§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 no longer suppressed here.
- **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 + `TopRowCenter` knob-bar + lava-lamp toggle exactly as the Phase 10 reframe landed
them. 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 **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 footer-clip plumbing (the dynamic-footer overflow clip from the reframe §2c) are all *chrome*, and the
scaffold is where chrome lives. Putting the backdrop on the scaffold means the clip logic, the stacking scaffold is where chrome lives. Putting the ambient layer on the scaffold means the clip logic, the
context, and the mount point are written once. `SessionDetail` is the lone holdout that doesn't compose stacking context, and the mount point are written once. `SessionDetail` is the lone holdout that doesn't
the scaffold today — see §3e. compose the scaffold today — see §3e.
### 3d. Where do the lava controls live per medium? (the controls boundary) ### 3d. Where do the lava controls live per host? (the controls boundary — full parity, resolved)
The seven-knob lava bar is an **expert tuning surface** whose identity is "the lava lamp." Two clean The seven-knob lava bar is an **expert tuning surface** whose identity is "the lava lamp." **Daniel's §8b
positions, and a recommendation: call: full parity on the detail pages.** The controls ride **every Release Detail host**:
- **Recommended default: lava controls are a Mix affordance only.** Mix keeps the lava-lamp toggle + the - **Mix, Cut, and Session detail pages all carry the seven-knob bar + lava-lamp toggle.** The
seven-knob bar. Cut/Session, *if* they mount the backdrop, mount it **controls-suppressed**an ambient `WaveformVisualizerControls` mount is **no longer Mix-suppressed**it rides every detail-page mount.
living gradient/lava field behind the hero with no knobs, no lava-lamp button. Rationale: the controls On Mix (mode A) the bar sits in `TopRowCenter` over the full-bleed visualizer as the Phase 10 reframe
are a deliberate "I want to tune the lava" gesture that fits the Mix's full-bleed-visualizer-is-the-point landed it; on Cut and Session (mode B) the same controls bar + lava-lamp toggle ride the ambient mount,
page; on a Cut album page or a Session hero page the visualizer is *ambience*, not the subject, and a letting a visitor tune the living field behind the release. The *layout* still differs per mode (§3f) —
knob bar there competes with the content. The shared `WaveformVisualizerControlState` still supplies the visualizer-is-the-page vs. ambient-behind-content — but the **controls presence is uniform across all
default dial values, so Cut/Session backdrops render with Daniel's tuned defaults — they just can't be three detail hosts.** Rationale (Daniel): the lava is a signature affordance of the site; giving every
changed in place. release page the tuning surface makes the whole site feel alive and consistent, not just the Mix page.
- **Alternative (if Daniel wants parity): controls everywhere.** The `Backdrop` + a `BackdropControls` The shared `WaveformVisualizerControlState` supplies the dial values and is where each mount reads/writes,
slot pair lets any medium opt into the knob bar. Cheap to add later precisely because the controls are so tuning behaves identically wherever the bar appears (see the state-scoping note below).
already a separate component over shared state — this is a *composition* decision, not an engine change. - **The NowPlaying home card (mode C) stays controls-suppressed by default.** Full parity was resolved
Designing the `Backdrop` slot now leaves the door open (memory: design the seam, defer the feature). against the Release *Detail* pages; the NowPlaying card is the *contained* mode-C host — a small hero
panel, not a full page — where a seven-knob tuning bar may be awkward. Default: the card mounts the
ambient/contained field with **no knobs, no lava-lamp button**, rendering at the shared tuned defaults.
**This is the one open sub-question — see §8b-followup.** The seam supports controls-on-the-card either
way (the controls are a separate component over shared state), so flipping it later is a one-line
composition change, not an engine change.
**This is open question §8b.** Default to Mix-only controls unless Daniel says otherwise; the seam > **State-scoping note (raised by full parity, staff-engineer's call at 12.C).** With the controls now
supports either without an engine change. > present on Mix *and* Cut *and* Session, decide whether `WaveformVisualizerControlState` is **one shared
> tuning** (a knob turned on a Cut page is the same lava everywhere — single source of truth, simplest, and
> consistent with the "one engine" framing) or **per-host/per-mode scoped** (each surface remembers its own
> dial positions). **Recommend one shared state** — it matches the single-engine model, it is what the
> persisted `WaveformVisualizerControlState` already is, and "the lava lamp has one set of dials" is the
> simpler mental model. Flag only; not a product blocker.
### 3e. The `SessionDetail` scaffold question ### 3e. The `SessionDetail` scaffold question
`SessionDetail` deliberately does **not** compose `ReleaseDetailScaffold` (it wraps its own `SessionDetail` deliberately does **not** compose `ReleaseDetailScaffold` (it wraps its own
`MudContainer` + `ReleaseHeroOverlay`). If Session is to host the backdrop via the scaffold's new slot, `MudContainer` + `ReleaseHeroOverlay`). If Session is to host the ambient layer via the scaffold's new
either (a) `SessionDetail` adopts the scaffold (a larger refactor, out of scope here — Session's 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 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 its own full-bleed wrapper (small, local, mirrors what Mix does inline today). **Recommend (b)** if
Session gets a backdrop at all — don't reopen the Session-vs-scaffold decision for this. Cut *does* 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 backdrop for free via the slot. This asymmetry is fine: the slot compose the scaffold, so Cut gets the ambient layer for free via the slot. This asymmetry is fine: the
serves scaffold-composing media; the one non-composing page mounts the shared engine directly. 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 *engine* is still single-source either way — only the *mount* differs, which is exactly the per-host
variance §3b sanctions. variance §3b sanctions. **Under full parity (§3d), Session's direct mount also carries the seven-knob bar +
lava-lamp toggle** — the controls bar rides Session's own top-row composition the same way Cut's rides the
scaffold's, so 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. | Seven-knob bar + lava-lamp toggle (`TopRowCenter`) | 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. | **Seven-knob bar + lava-lamp toggle (full parity, §3d)** | `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. | Suppressed by default (§8b-followup) | 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;
controls present on all three detail hosts, suppressed-by-default only on the contained home card), never in
the engine. **Note the controls are no longer a per-mode discriminator on the detail pages** — modes A and B
both carry the full seven-knob bar (§3d, §8b resolved to full parity); only the mode-C home card suppresses
them by default (§8b-followup).
**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.
**§8b status: RESOLVED — modes A + B + C all live, with FULL PARITY on the controls across all three detail
hosts (Daniel, 2026-06-17).** All three hosting modes ship (Mix is the page, Cut/Session are ambient, the
home card is contained) **and** the seven-knob lava bar + lava-lamp toggle ride every Release Detail host —
Mix, Cut, and Session — not Mix-only. The earlier "mode 1 (Mix + NowPlaying only, Cut/Session plain)"
fallback is closed; the earlier "controls Mix-only" default is overridden by Daniel's full-parity call. The
Cut/Session ambient treatment **plus its in-place tuning controls** is the distinctive-feel win the
generalization buys; the `Ambient` slot carries the field and the controls bar rides the host composition
alongside it, so each detail page is a one-mount-plus-controls-bar composition. **One sub-question remains
(§8b-followup):** whether full parity also extends the seven-knob bar onto the small NowPlaying home card
(mode C) — default **no** (the card stays controls-suppressed), flagged for Daniel.
--- ---
## 4. The bridge, generalized (Wave 12.B) ## 4. The bridge, generalized (Wave 12.B)
The bridge (`WaveformVisualizer.razor.cs`, ex-`MixWaveformVisualizer`) needs **one** real change beyond The bridge (`WaveformVisualizer.razor.cs`, ex-`MixWaveformVisualizer`) needs **one** real change beyond
the rename: its datum fetch must resolve a datum for *any* release, not only Mixes. 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)` → 404 for non-Mix → blank backdrop. Today: `await ReleaseData.GetMixWaveform(ReleaseEntryKey)` resolves per-release, 404 for non-Mix →
blank. This is the per-*release* fetch the model correction supersedes.
Generalized: `await ReleaseData.GetReleaseWaveform(ReleaseEntryKey)` → returns the **best available** **Generalized (per-track model).** The visualizer renders *the currently playing/selected track's*
datum for the release (§5 decides what "best available" means and where the resolution happens). The waveform, and the bridge already knows that track — it is the `TrackId` it gates `IsActivePlayer` on. So
bridge stays otherwise identical: 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):
- Still keys the fetch on `ReleaseEntryKey`, fetch-once-per-key guard intact. - **Track-keyed (recommended):** `await ReleaseData.GetTrackWaveform(trackEntryKey)` → returns the
- Still derives duration from the cascaded player (`PlayerDurationSeconds`) — **note:** the duration per-track high-res datum from the generalized waveform vault, keyed by the track's `EntryKey`. This is the
source is the *player*, which works for any release's playing track, so the time↔sample mapping cleanest expression of "the datum is the track's." The bridge needs the current track's `EntryKey`; it
generalizes for free. 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 gates playback coupling on `TrackId` via `IsActivePlayer`.
- Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle. - Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle.
The `PlaybackPosition` composability fallback (the no-player-cascade path, used by the NowPlaying card if The `PlaybackPosition` composability fallback (the no-player-cascade path) stays as the documented escape
it ever runs outside the cascade — though it won't, §6) stays as the documented escape hatch. hatch.
**The single open data question (§5, §8a):** does `GetReleaseWaveform` return the low-res 512-bucket **No open data *question* remains here** — Direction B (committed, §5) means every track has a high-res
per-track profile for non-Mix releases (cheap, already exists, slightly coarse for the lava), or do we datum, so the fetch always resolves to high-res. The open *items* the model created are operational, not
extend the high-res compute to all media (richer, but new CMS/compute work)? design: the backfill (§8a-new) and the upload-time compute cost (§8b-new).
--- ---
## 5. The datum source — the one real data decision ## 5. The datum source — Direction B, per-track (committed)
The visualizer renders a **loudness datum + a duration**. Two datums exist in the system today: 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.
| Datum | Vault | Resolution | Who has it | Keyed by | **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` | | Per-track profile | `waveform-profiles` | **512 buckets** (fixed) | **every track** (computed at upload) | track `EntryKey` |
| Mix high-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived, up to ~2M) | **Mixes only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) | | High-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived) | **Mix tracks only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) |
So non-Mix releases **are not a data gap** — they have the 512-bucket profile. The question is purely The 512-bucket profile already exists per-track and stays (the player-bar `WaveformSeeker` consumes it —
resolution. **Three directions, materially different in cost and shape:** 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.**
**Direction A — "best available, no new compute" (recommended for v1).** ### 5a. The generalization (Direction B), end to end
`GetReleaseWaveform` returns the high-res mix datum when the release is a Mix with a stored datum;
otherwise it falls back to the release's single-track 512-bucket profile (resolve the track via the
release → its `EntryKey``waveform-profiles`). For multi-track Cuts, use the *first track's* profile (or
a chosen representative — §8c). **Cost:** one new release-cardinal endpoint + a service method that picks
the source; zero new compute, zero CMS work, ships immediately. **Trade-off:** a Cut/Session backdrop
renders at 512 buckets — fine for an *ambient* backdrop (the lava doesn't need quarter-note resolution
when it's behind a hero and not the subject), arguably coarse if a Cut ever wants the full-bleed Mix
treatment. Since §3d makes Cut/Session *ambient* by default, 512 buckets is **enough** for the v1 look.
**Direction B — "high-res for all media."** Direction B is **not** just a new endpoint — under the per-track model it touches five surfaces:
Generalize `TriggerMixWaveformAsync` into a release-cardinal `TriggerReleaseWaveform` that computes the
duration-derived high-res datum for *any* single-track release (Mix and Session both being single-track),
storing into a generalized waveform vault. **Cost:** a generalized compute path + a CMS generate action
exposed for non-Mix media + a backfill for existing releases. Larger, touches CMS + API + content.
**Trade-off:** uniform high quality everywhere, but multi-track Cuts still need a per-track or a
concatenated-album answer (the "what is an album's waveform" question, §8c), which Direction B doesn't by
itself resolve. **Defer unless the ambient 512-bucket look proves too coarse on screen.**
**Direction C — "low-res is fine everywhere, drop the high-res special-case."** 1. **Content compute path.** Generalize the duration-derived high-res compute (`WaveformProfileService` +
Use the 512-bucket per-track profile for *everyone including Mix*, retiring the `mix-waveforms` high-res `MixWaveformResolution`) off its Mix-only framing so it computes a per-track high-res datum for **any**
path. **Rejected:** the high-res mix datum exists precisely because the Mix visualizer's max-zoom window track from that track's audio. The *algorithm* is unchanged (the same ~333 samples/sec duration-derived
(one quarter note at 180 BPM) under-samples badly at 512 buckets on a long mix (`MixWaveformResolution` model `MixWaveformResolution` already defines); only its *applicability* widens from "the mix track" to
rationale). Throwing it away regresses the Mix — the exact page this engine was built for. Don't. "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?).
**Recommendation: ship Direction A.** It is DRY (one endpoint, one fallback rule), ships with zero new ### 5b. The per-track datum fetch endpoint
compute, and is *sufficient* given §3d's ambient-backdrop framing for non-Mix media. Keep Direction B on
the roadmap as the upgrade if/when a Cut wants the full Mix treatment. **This is open question §8a**
Daniel's call on whether 512-bucket ambient is acceptable for non-Mix, or he wants high-res-for-all from
the start.
**Endpoint shape (Direction A).** A new unauthenticated `GET api/release/{entryKey}/waveform` that Under the per-track model the fetch resolves a datum for a **track**, addressed with the release as
resolves: Mix-with-datum → mix high-res; else → first/representative track's 512-bucket profile; else → context. **Recommended shape:** `GET api/track/{trackEntryKey}/waveform` (track-cardinal, on
404 (blank backdrop, graceful). This *supersedes* the bridge's call to the Mix-gated `TrackController`) returning the per-track high-res datum, 404 → graceful blank. This is the natural home
`/mix/waveform` for the general case; the `/mix/waveform` route can stay (the new endpoint can delegate to once the datum is the track's, and it sits beside the existing per-track surfaces.
the same mix-vault read internally) or be folded in — staff-engineer's call. `IReleaseDataService` gains
`GetReleaseWaveform(entryKey)`; the bridge calls it. - **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.
--- ---
@@ -322,23 +461,32 @@ the **live cascaded player**:
*inside* the cascade, `IsActivePlayer` is true for whatever is actually playing, and the visualizer *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 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`. bridge already handles the no-active-player state → static), which replaces the `waveform-placeholder`.
- The datum comes from §5's `GetReleaseWaveform` keyed on the *current* track's release — so the home - The datum comes from §5's per-track fetch keyed on the **current track** (`CurrentTrack.EntryKey`) — so
card shows the real waveform of whatever track the listener started, Mix or not. 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* of the same **This is the payoff of the generalization:** the NowPlaying card is *just another host* (mode C, §3f) of
engine, pointed at "whatever is playing right now" instead of "this page's release." No NowPlaying-specific the same engine, pointed at "whatever track is playing right now." No NowPlaying-specific renderer, no fork
renderer, no fork — the DRY win the brief demands. — 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 ### 6c. Constraints specific to the NowPlaying context
- **Live, not static.** Unlike a Release Detail page (where the visualizer's release is fixed to the page), - **Live, not static.** Unlike a single-track detail page, the NowPlaying card's **track** changes as
the NowPlaying card's release **changes as the track changes**. The bridge already re-fetches on playback advances. Under the per-track model (§4) the fetch-once guard re-arms on **track** change (not
`ReleaseEntryKey` change (the fetch-once-per-key guard re-arms when the key changes), so track-change release change), so track-change → new per-track datum is handled — and this is the *same* guard change
new datum is handled. Verify the guard re-fetches cleanly on key change (it keys on `_loadedReleaseKey the per-track model already requires for a multi-track Cut (where the release is fixed but the track
== ReleaseEntryKey`, so a new key re-fetches — correct). scrolls). The two converge on one behavior: **re-fetch when the current track's identity changes.**
- **Small surface, controls-less.** The card is a small hero panel, not a full-bleed page. Mount the Verify the guard keys on the current track, not the release.
visualizer **controls-suppressed** (no lava-lamp, no knob bar — same ambient framing as §3d for - **Small surface, controls-suppressed by default.** The card is a small hero panel, not a full-bleed page.
Cut/Session) and sized to the card, not `position: fixed`. **Flagged for staff-engineer (§8d):** the Mount the visualizer **controls-suppressed** by default (no lava-lamp, no knob bar) and sized to the card,
not `position: fixed`. **Note this is now the *exception*, not the rule:** under Daniel's §8b full-parity
call the controls ride every Release *Detail* page (Mix, Cut, Session — §3d); the NowPlaying card is the
one host that suppresses them by default, because a seven-knob tuning bar on a small home-card may be
awkward. **Whether full parity extends here too is the open sub-question, §8b-followup** — the seam
supports it either way (the controls are a separate component over shared state). **Flagged for
staff-engineer (§8d):** the
renderer's footer-clip + full-viewport assumptions (`position: fixed; inset: 0`, clip-to-footer) are 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 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 container instead of the viewport. This is a real renderer-hosting wrinkle — the engine assumes
@@ -364,64 +512,128 @@ The title/sub text block (`np-title`/`np-sub`) and the "Now Playing" label stay
## 7. Wave decomposition + dependency shape ## 7. Wave decomposition + dependency shape
Sequenced so the mechanical rename de-risks everything, the data generalization unblocks the new hosts, Sequenced so the mechanical rename de-risks everything, the per-track high-res generalization (now the
and the NowPlaying rewire (the trickiest, per §6c) comes last on a proven engine. 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.
- **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*` → `Waveform*` across the - **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** 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; — every later wave references the generalized names. Acceptance: Mix detail behaves identically;
diff is identifiers only. diff is identifiers only.
- **12.B — Generalize the datum fetch + endpoint (the data change).** New `GET - **12.B1 — Generalize the high-res compute to every track + backfill (the data change, Direction B).**
api/release/{entryKey}/waveform` resolving best-available datum (Direction A, §5); `IReleaseDataService. Generalize the duration-derived compute off Mix-only (`WaveformProfileService` / `MixWaveformResolution`),
GetReleaseWaveform`; bridge calls it instead of `GetMixWaveform`. **Depends on 12.A** (renamed bridge). store per-track keyed by `EntryKey` in the (renamed) `track-waveforms` vault, add the per-track high-res
**Gated by §8a** (Daniel's resolution call — but Direction A needs no decision to start; B/C would). compute to `UnifiedTrackService.UploadAsync`, generalize the CMS generate action to any track, and run the
Acceptance: Mix still renders high-res; a non-Mix release now returns a (512-bucket) datum. **backfill** for existing tracks (§5a, §8a-new — backfill is Daniel-gated). **Independent of 12.A** (it is
- **12.C — `Backdrop` slot on the scaffold + mount on detail pages.** Promote the full-bleed/foreground/ server/content-side, no renamed client identifiers). Acceptance: every track — Mix, Session, and each Cut
footer-clip pattern into `ReleaseDetailScaffold` as an optional `Backdrop` slot (§3c); Mix re-expresses track — has a high-res datum; new uploads get one automatically; the generate action works for any track.
its current mount through the slot; Cut mounts the controls-suppressed ambient backdrop; Session mounts - **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal `GET
directly (§3e) **if** §8b says non-Mix gets a backdrop. **Depends on 12.B** (a datum to render). api/track/{trackEntryKey}/waveform` (§5b); `GetTrackWaveform`; bridge resolves the *current track's*
Acceptance: Mix unchanged; Cut/Session (if opted in) show an ambient living backdrop at their tuned `EntryKey` and re-fetches on track-change (§4). **Depends on 12.A (renamed bridge) + 12.B1 (a datum to
defaults, no knobs. fetch).** Acceptance: the Mix detail page renders the same high-res lava via the new track-cardinal fetch;
- **12.D — NowPlayingHero rewire.** Replace the synthetic bars with a contained, controls-suppressed a non-Mix track now returns a high-res datum.
`<WaveformVisualizer>` driven by the live player (§6); add the `Fill`/container-sizing mode (§6c). - **12.C — `Ambient` slot on the scaffold + mount on detail pages (mode B, §3f).** Promote the
**Depends on 12.A + 12.B** (renamed engine + a datum for whatever's playing). **Independent of 12.C** full-bleed/foreground/footer-clip pattern into `ReleaseDetailScaffold` as an optional `Ambient` slot
(different host; doesn't need the scaffold slot). Acceptance: the home card shows the *real* waveform of (§3c); Cut mounts the ambient layer **with the full seven-knob bar + lava-lamp toggle** (full parity,
the playing track and sits at-rest when nothing plays; no synthetic bars remain. §3d); Session mounts directly **also full-parity** (§3e). Mix is **unchanged** (mode A keeps its own
mount + controls). **Depends on 12.B2** (a datum to render). **§8b is resolved (full parity, no longer
gated)** — Cut and Session ship with both the ambient layer and the controls. This wave also makes the
state-scoping call (§3d note — recommend one shared `WaveformVisualizerControlState`). Acceptance: Mix
unchanged; Cut and Session each show an ambient living layer **with a working seven-knob bar + lava-lamp
toggle**, 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,
controls-suppressed `<WaveformVisualizer>` driven by the live player, pointed at the current track (§6);
add the `Fill`/container-sizing mode (§6c). **Depends on 12.A + 12.B2** (renamed engine + a per-track
datum for whatever's playing). **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 and sits at-rest when
nothing plays; no synthetic bars remain.
**Dependency shape:** `12.A → 12.B → (12.C ‖ 12.D)`. 12.A is the cheap mechanical unblock; 12.B is the **Dependency shape:** `12.A → 12.B2 → (12.C ‖ 12.D)`, with **12.B1 a parallel server-side track** that
load-bearing data generalization; 12.C (detail-page hosts) and 12.D (NowPlaying host) are independent 12.B2 depends on (`12.B1 → 12.B2`) but that can start cold day one (it needs no renamed client identifiers).
siblings off 12.B. The cold-start item is **12.A** — do it first, it touches everything and risks nothing. 12.A is the cheap mechanical unblock; **12.B1 is the new load-bearing heavy** (compute + backfill); 12.B2 is
the thin client rewire that consumes it; 12.C (detail-page ambient hosts, **full-parity controls**) and 12.D
(NowPlaying host, controls-suppressed by default) are independent siblings off 12.B2. The cold-start items
are **12.A** (touches everything, risks nothing) and **12.B1** (the data work — start it early; the backfill
gate is the only thing blocking it). With §8b resolved to full parity, 12.C no longer carries a mode-1
fallback branch — Cut and Session are in, with controls.
--- ---
## 8. Open product decisions (need Daniel before the dependent wave) ## 8. Open product decisions
**§8a — Non-Mix datum resolution (gates 12.B's richness; blocks nothing if Direction A).** **Resolved by Daniel (2026-06-17), kept visible per file convention:**
Does `GetReleaseWaveform` serve non-Mix releases the existing **512-bucket per-track profile** (Direction
A — recommended, zero new compute, sufficient for ambient backdrops), or do we extend **high-res compute
to all media** (Direction B — richer, new CMS/API/content work + backfill)? **Recommendation: A for v1,
B on the roadmap.** Direction A can start immediately; only B/C need a decision before 12.B.
**§8b — Do Cut/Session get a backdrop at all, and with controls?** - **§8a (was: non-Mix datum resolution) → Direction B.** High-res compute for **all** media, not the
Three positions: (1) **Mix-only** — only Mix hosts the visualizer; Cut/Session stay plain (smallest, the 512-bucket fallback. The cheaper "Direction A" (serve the existing 512-bucket profile to non-Mix) is the
generalization then serves Mix + NowPlaying only). (2) **Ambient on all media, controls Mix-only** road not taken (§5). Re-cost lands in 12.B1 (compute + backfill).
(recommended) — Cut/Session get the living backdrop at tuned defaults, no knobs. (3) **Full parity** — - **§8c (was: multi-track Cut's waveform) → dissolved by the per-track model.** The datum is per-track; the
every medium gets the backdrop *and* the knob bar. **Recommendation: (2).** Note that even (1) still wants visualizer renders the *current* track's datum, so there is no release-level "which track represents the
12.A+12.B+12.D for the NowPlaying rewire — the generalization pays for itself via the home card regardless. 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.**
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 seven-knob bar + lava-lamp
toggle ride all three, not just Mix. 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. The only thing the flip changed: the `WaveformVisualizerControls` mount is **no longer
Mix-suppressed** on Cut/Session. The earlier mode-1 fallback (Mix + NowPlaying only) and the "controls
Mix-only" default are both closed. One sub-question spun out — §8b-followup below.
**§8c — What is a multi-track Cut's waveform?** **Newly open (created by the full-parity flip and by Direction B + the per-track model):**
A Cut album has many tracks; the visualizer renders *one* datum. First track? A representative/longest
track? A concatenated album-length datum (Direction B territory)? **Recommendation: first track by **§8b-followup — Do the full-parity controls extend onto the NowPlaying home card (mode C)?**
`TrackNumber` for v1** (cheap, deterministic), revisit if it reads wrong. Only bites if §8b chooses a Cut Daniel's §8b full-parity call (option 3) was answered against the Release *Detail*-page framing — Mix, Cut,
backdrop. and Session all get the seven-knob bar + lava-lamp toggle. The **NowPlaying home card** is a different host:
the contained mode-C panel, a small hero card on the highest-traffic surface, **not** a full detail page.
The spec applies full-parity controls to the three detail pages and keeps the NowPlaying card
**controls-suppressed by default** (a living waveform readout with no knobs). Open: does Daniel want the
seven-knob bar on the home card too, or does it stop at the detail pages? **A seven-knob tuning bar on a
small now-playing card may be awkward** — it competes with the card's compact title/sub layout and puts an
expert affordance on the landing surface — which is why the default is *suppressed*. But the seam supports
it either way (the controls are a separate component over shared `WaveformVisualizerControlState`), so
flipping the card to controls-on is a one-line composition change in 12.D, not an engine change.
**Recommend keeping the card controls-suppressed** (full parity = detail pages; the home card stays a clean
ambient readout) — but this is a Daniel call, surfaced not buried. Affects only 12.D's mount; everything
else in the phase is settled by the full-parity resolution.
**§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 **§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); but flag for Daniel).** The renderer assumes full-viewport (`position: fixed; inset: 0`, clip-to-footer);
the NowPlaying card needs it container-relative (§6c) — recommend a `Fill` mode parameter. And a WebGL2 the NowPlaying card (mode C, §3f) needs it container-relative (§6c) — recommend a `Fill` mode parameter.
lava render on the home page's first paint is heavier than on a detail page — the `isPlaying`-gated rAF And a WebGL2 lava render on the home page's first paint is heavier than on a detail page — the
means an idle home page pays nothing, but a cheaper blob-density preset for the card is a fallback if `isPlaying`-gated rAF means an idle home page pays nothing, but a cheaper blob-density preset for the card
needed. Neither blocks; both are tuning/hosting calls surfaced so Daniel isn't surprised by a lava lamp on is a fallback if needed. Neither blocks; both are tuning/hosting calls surfaced so Daniel isn't surprised
the landing page. by a lava lamp on the landing page.
--- ---
@@ -429,20 +641,31 @@ the landing page.
1. **Rename clean.** The engine is named for its abstraction (`WaveformVisualizer*`); the Mix detail page 1. **Rename clean.** The engine is named for its abstraction (`WaveformVisualizer*`); the Mix detail page
is visually and behaviorally identical to before the rename. is visually and behaviorally identical to before the rename.
2. **Release-cardinal datum.** `GET api/release/{entryKey}/waveform` returns a datum for *any* release 2. **Per-track high-res datum everywhere (Direction B).** Every track — Mix, Session, and each track of a
that has one (high-res for Mix-with-datum, 512-bucket per-track otherwise), 404 → graceful blank. Cut — has a high-res duration-derived datum keyed by its `EntryKey`; new uploads get one automatically;
3. **Mix unchanged.** The Mix detail page still renders the high-res lava with the seven-knob bar, at the generalized CMS generate action produces one for any track; the backfill has populated all
parity with the Phase 10 reframe. pre-existing tracks.
4. **Non-Mix backdrop (if §8b opts in).** A Cut and/or Session detail page shows an ambient living 3. **Track-cardinal fetch.** `GET api/track/{trackEntryKey}/waveform` (or the agreed track-cardinal route)
waveform backdrop at the tuned default dials, controls-suppressed, no regression to the hero/content. returns the current track's high-res datum, 404 → graceful blank. The bridge resolves the *current
5. **NowPlaying is real.** The home NowPlaying card shows the *actual* waveform of the playing track track* and re-fetches on track change.
(scrolls/animates to the real signal, changes with track changes), and sits at-rest when nothing plays. 4. **Mix unchanged (mode A).** The Mix detail page still renders the high-res lava with the seven-knob bar +
No hardcoded synthetic bars remain. lava-lamp toggle, visualizer-is-the-page, at parity with the Phase 10 reframe — now via the
6. **One engine.** Mix detail, the (opted-in) Cut/Session backdrop, and the NowPlaying card all consume track-cardinal fetch.
the *same* `WaveformVisualizer` component + renderer + state — verified by there being exactly one of 5. **Ambient + controls on Cut/Session (mode B, full parity).** A Cut and a Session detail page show an
each, no per-host fork. ambient living waveform layer rendering the *currently selected/playing track's* datum, **with a working
7. **Read-only everywhere.** No host (including the NowPlaying card) exposes a seek/scrub/transport via seven-knob bar + lava-lamp toggle** (full parity — controls are present and tunable in place, not
suppressed), with no regression to the hero/content.
6. **NowPlaying is real (mode C), controls-suppressed by default.** 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 mounts **controls-suppressed by default** (no knob bar, no
lava-lamp — pending §8b-followup). No hardcoded synthetic bars remain.
7. **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 present on all
three detail hosts, suppressed-by-default on the contained home card).
8. **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 visualizer; the read-only contract holds on every mount.
8. **Bridge intact.** Single-owner handle, idempotent datum guard, `IsActivePlayer` gating, and the 9. **Bridge intact, re-keyed to track.** Single-owner handle, idempotent datum guard, `IsActivePlayer`
`isPlaying`-gated rAF loop are unchanged across all mounts; track-change in the NowPlaying card gating, and the `isPlaying`-gated rAF loop are unchanged across all mounts; the datum guard now re-arms
re-fetches the datum cleanly. on **track** change (the multi-track Cut and the NowPlaying card both rely on this — they converge).