Files
deepdrft/product-notes/phase-12-waveform-visualizer-generalization.md
T
daniel-c-harvey cf47fee07e 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.
2026-06-17 05:33:34 -04:00

52 KiB
Raw Blame History

Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec)

Status: design-complete, implementation-ready. Daniel resolved the three §8 open questions on 2026-06-17 — committing Direction B (high-res compute for all media), correcting the datum model to per-track, not per-release, and resolving §8b to full parity on the lava controls: the visualizer rides every Release Detail host (Mix, Cut, Session) and the 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:

  1. Generalize the landed Mix waveform visualizer (the WebGL2 lava renderer + its seven-knob controls) from a Mix-only treatment into a track-cardinal visualizer that every Release Detail page can host — Cuts, Sessions, and Mixes alike — rendering the waveform of whatever track is currently playing/selected on that page.
  2. Overhaul the home-page NowPlayingHero (NowPlayingCard) so its "Now Playing" animation is the real waveform visualizer driven by live playback, replacing the 20 hardcoded CSS-animated bars (the "stochastic bullshit").

The explicit ask is DRY / SOLID: one reusable visualizer engine serving Mix detail, all Release Detail pages, and the NowPlayingHero — not three forks. This spec's central finding makes that cheap: the engine is already track-cardinal below the surface. The work is extraction, a per-track high-res compute generalization (Direction B), and three hosting modes of the one engine — not a rebuild.

The keystone model correction (Daniel, 2026-06-17). The waveform datum is per-track, not per-release. "Each track in the release must get the metadata. The waveform visualizer metadata should be tied to the track, right? The release is just the host." Every track gets its own high-res waveform datum; the release is merely the host surface, and the visualizer renders the datum of whatever track is currently playing/selected. This simplifies the design — it aligns with the bridge already keying on TrackId, and it dissolves the old "what is a multi-track Cut's waveform?" question (there is no release-level datum to choose; the visualizer just shows the current track's). Threaded through §4, §5, the endpoint shape, and the acceptance criteria below.

Cross-references (read before implementing):

  • product-notes/phase-10-mix-visualizer-lava-reframe.md — the lava renderer this generalizes. The CPU-physics wax-blob model, the OKLab three-color gradient, the seven-knob control model, the bridge contract, and the read-only contract all carry forward unchanged. This spec does not re-derive any of them; it changes where the engine lives, what feeds it, and who hosts it.
  • product-notes/mix-visualizer-webgl-renderer.md — the renderer architecture (pipeline, datum-as-texture, bridge, rAF loop). The §2 contract carries forward; §4/§7 are already superseded by the reframe.
  • DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css] — the Blazor bridge. Already keyed on ReleaseEntryKey + TrackId, not on Mix. Renamed and re-pointed by this phase (§3, §4).
  • DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs/.css], DeepDrftPublic.Client/Services/MixVisualizerControlState.cs, DeepDrftPublic.Client/Controls/MixZoomMapping.cs — the controls + state + mapping. Renamed, otherwise unchanged.
  • DeepDrftPublic/Interop/visualizer/MixVisualizer.ts — the WebGL2 renderer module. Renamed; the only logic change is how the datum's time-mapping is established when no high-res mix datum exists (§5).
  • DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor — the shared detail chrome. The visualizer becomes a scaffold-level concern via a new optional Ambient slot (§3c, §3f).
  • DeepDrftPublic.Client/Pages/MixDetail.razor, SessionDetail.razor, CutDetail.razor — the three release-detail hosts (three distinct hosting modes — §3f).
  • DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css], NowPlaying.razor — the home-page now-playing card carrying the stochastic bars (§6).
  • DeepDrftAPI/Controllers/TrackController.cs, ReleaseController.cs — the waveform endpoints; under the per-track model the datum resolves for a track (§5), with the release as addressing context.
  • DeepDrftContent/Processors/WaveformProfileService.cs, MixWaveformResolution.cs, Constants/VaultConstants.cs — the compute/store path and the vaults; Direction B generalizes the high-res compute here to every track (§5).
  • DeepDrftAPI/Services/UnifiedTrackService.cs — the upload path; Direction B adds a per-track high-res compute at upload (§5, §8a-new).
  • DeepDrftManager CMS generate action (the Mix-only "generate waveform" trigger) — Direction B generalizes it off Mix-only to any track (§5).

1. The central finding — the engine is already release-cardinal

Before any plan: a read of the live code shows the "Mix coupling" is mostly nominal, not structural. The visualizer is named Mix* throughout, but its architecture is release-generic:

Layer Reality on disk today Mix-coupled?
MixWaveformVisualizer bridge inputs ReleaseEntryKey (string) + TrackId (long?) + cascaded player No — already track-cardinal (keys on TrackId)
Playback coupling IsActivePlayer gates on TrackId matching the cascaded player's current track No — works for any track
Renderer (MixVisualizer.ts) datum texture + scroll/zoom geometry + wax-blob physics + OKLab gradient No — pure function of a loudness datum + duration
Controls + state seven normalized dials, scoped persistence, Changed seam No — renderer-agnostic
Datum fetch IReleaseDataService.GetMixWaveform(entryKey)GET api/release/{entryKey}/mix/waveform Yes — 404s unless Medium == Mix; resolves per release, not per track
Datum source the high-res mix-waveforms vault, keyed by MixMetadata.WaveformEntryKey Yes — only the Mix's single track gets the high-res datum
Names / comments Mix* everywhere cosmetic

So the genuinely Mix-specific surface is exactly two things: the fetch endpoint that gates on Medium == Mix and resolves per-release, and the high-res datum that only Mix tracks have. Everything else is a rename.

This is the SOLID seam the whole phase turns on. The renderer and bridge already obey the right abstraction ("render a loudness datum coupled to a playing track"); they were just named for their first consumer. Generalizing is: rename to the abstraction, give the abstraction a per-track datum source that exists for every track, and let three hosts mount it. No new renderer, no fork.

The crucial data fact (verified): every uploaded track already has a waveform profile. UnifiedTrackService.UploadAsync calls WaveformProfileService.ComputeAndStoreAsync(...) at upload time for every track, storing a 512-bucket profile in the waveform-profiles vault keyed by the track's EntryKey (this is the datum the player-bar WaveformSeeker already consumes). Mix tracks additionally get a duration-derived high-res datum (~333 samples/sec) in the separate mix-waveforms vault, triggered by a CMS action. So the high-res datum is the only gap — and under Daniel's per-track model it is a gap per track, not per release: every track should carry its own high-res datum, computed at upload and (for existing tracks) backfilled. Direction B (committed) makes the high-res compute a per-track compute for all media, keyed by track EntryKey (§5). The cheaper road — serving the existing 512-bucket profile to non-Mix and skipping new compute (old "Direction A") — was the road not taken (§5).


2. Goal and scope boundary

Goal. One reusable WaveformVisualizer (renamed from MixWaveformVisualizer) + its lava renderer + its controls, mounted in three hosting modes (§3f) — visualizer-is-the-page on Mix detail, ambient environment on Cut/Session detail, contained live element on the home-page NowPlaying card — each fed by a per-track high-res datum that exists for every track (Direction B, §5). The visualizer always renders the currently playing/selected track's datum; the release is the host, not the datum's owner. The lava controls ride every 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.

  • Rename + relocate the visualizer engine to a track-cardinal identity (MixWaveformVisualizerWaveformVisualizer, MixVisualizerControlsWaveformVisualizerControls, MixVisualizerControlStateWaveformVisualizerControlState, MixVisualizer.tsWaveformVisualizer.ts, MixZoomMappingWaveformZoomMapping). Pure renames; no behavior change. (§3a)
  • Generalize the high-res compute to every track (Direction B, §5). This is now the load-bearing data change — and it is larger than a single endpoint. It touches: (a) the content compute path (WaveformProfileService / MixWaveformResolution) — generalize the duration-derived high-res compute off Mix-only to any track; (b) the upload path (UnifiedTrackService.UploadAsync) — compute the high-res per-track datum at upload for every new track; (c) the CMS generate action — generalize the Mix-only "generate waveform" trigger to any track; (d) a backfill of the high-res datum for every existing track that lacks one (§8a-new); (e) a per-track datum fetch endpoint (§5) the bridge calls.
  • Host the visualizer in three modes — via a new optional Ambient slot on ReleaseDetailScaffold for the Cut/Session ambient mode (§3c, §3f), Mix keeping its visualizer-is-the-page full-bleed mount, and a contained mount on the NowPlaying card. The slot lets scaffold-composing pages mount without re-implementing the wrapper.
  • Rewire the NowPlayingHero to mount the visualizer driven by the live cascaded player, replacing the 20 hardcoded CSS bars (§6).
  • Mount the lava controls on every detail-page host — Mix, Cut, and Session all carry the seven-knob bar + lava-lamp toggle (full parity, §3d). The NowPlaying card mounts controls-suppressed by default (§3d, §8b-followup).

Out of scope / unchanged.

  • No renderer rewrite. The wax-blob physics, the OKLab gradient, the collision model, the seven dials — all carry forward from the Phase 10 reframe exactly. This phase moves and renames the engine and changes its input plumbing and its compute breadth, never its art.
  • No bridge redesign. The single-owner bridge, the idempotent datum guard, the IsActivePlayer gating, the isPlaying-gated rAF loop — all preserved. Extend the fetch, not the contract.
  • No new control model. The seven knobs and …ControlState stay as-is (renamed). No new dials.
  • No new high-res algorithm. Direction B generalizes the existing duration-derived compute to run per-track for all media; it does not redesign how the high-res datum is computed (MixWaveformResolution carries forward — the same ~333 samples/sec duration-derived model).
  • No playback-control change. Read-only contract holds everywhere, including the NowPlaying card — the home card visualizes, it does not become a transport.

3. Generalizing the visualizer onto every Release Detail page

3a. The rename (Wave 12.A — pure, mechanical, no behavior change)

Rename the engine to its abstraction. This is a mechanical sweep with zero behavior change, done first so every later wave references the generalized names:

Today (Mix-named) Generalized
MixWaveformVisualizer.razor[.cs/.css] WaveformVisualizer.razor[.cs/.css]
MixVisualizerControls.razor[.cs/.css] WaveformVisualizerControls.razor[.cs/.css]
MixVisualizerControlState.cs (+ DI registration) WaveformVisualizerControlState.cs
MixZoomMapping.cs WaveformZoomMapping.cs
MixVisualizer.ts (+ the ./js/visualizer/MixVisualizer.js import path) WaveformVisualizer.ts
DDIcons.LavaLamp / LavaLampFilled keep (the lava-lamp glyph is the controls affordance, now on every detail-page host — §3d)

The ReleaseEntryKey / TrackId parameters and the fetch keep working unchanged through the rename. The mix-waveforms vault name and MixMetadata.WaveformEntryKey stay (they are still where the high-res mix datum lives — generalizing the fetch doesn't require renaming the mix-specific high-res store; see §5). Acceptance: the Mix detail page looks and behaves identically after the rename; the only diff is identifiers.

DRY note. The temptation is to skip the rename and "just reuse MixWaveformVisualizer on other pages." Resist it: a MixWaveformVisualizer mounted on a Cut page is a lie that every future reader has to decode, and it cements the wrong mental model right when we're trying to break it. The rename is cheap and it is the SOLID move — name the thing for its abstraction, not its first caller.

3b. What stays specialized (the abstraction boundary)

Generalizing does not mean flattening every medium to the same look. The clean Liskov boundary:

  • Shared (the engine): the renderer, the bridge, the controls component, the state, the datum contract, the playback coupling, the read-only contract. One copy, consumed by all hosts.
  • Per-host (the composition): whether the visualizer is mounted, whether the lava controls are exposed, and what datum the host points it at. These ride host composition (slots + parameters), never a switch (medium) inside the engine. A medium that wants no visualizer mounts none; a medium 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 Header/Hero/TopRightAction (Phase 9 §5.3) — extended to the ambient mount.

3c. The hosting seam — an Ambient slot on ReleaseDetailScaffold (Wave 12.C)

Naming note. This slot was first drafted as Backdrop. Daniel pushed back: on the Mix page the visualizer is not a backdrop — it is the full-bleed centerpiece that is the page. "Backdrop" only describes what the visualizer does on Cut/Session (ambient environment behind the hero+content). The slot is named for that mode — the ambient mode — because Mix doesn't use the slot at all (it keeps its own visualizer-is-the-page mount; see §3f). So: Ambient, not Backdrop.

Today the Mix page mounts the visualizer outside the scaffold (a sibling <MixWaveformVisualizer> then a .mix-detail-foreground wrapper, with the scaffold inside MudContainer). Session mounts nothing. Cut mounts nothing. To let Cut/Session host the visualizer as ambient environment DRY-ly:

Add an optional RenderFragment? Ambient slot to ReleaseDetailScaffold, rendered as the full-bleed position: fixed; inset: 0 layer behind the scaffold's content (the scaffold's existing container becomes the foreground stacking context — promote the mix-detail-foreground stacking pattern into the scaffold so it is the default, not a Mix bespoke). A host that supplies no Ambient gets today's plain background (Liskov: absent slot = no ambient layer, no regression).

  • Cut (composes the scaffold) supplies <WaveformVisualizer ReleaseEntryKey=… TrackId=… /> to Ambient with the full seven-knob bar + lava-lamp toggle exposed (full parity, §3d) — a living waveform field behind the album hero + track list, rendering the currently selected/playing track's 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 footer-clip plumbing (the dynamic-footer overflow clip from the reframe §2c) are all chrome, and the scaffold is where chrome lives. Putting the ambient layer on the scaffold means the clip logic, the stacking context, and the mount point are written once. SessionDetail is the lone holdout that doesn't compose the scaffold today — see §3e.

3d. Where do the lava controls live per host? (the controls boundary — full parity, resolved)

The seven-knob lava bar is an expert tuning surface whose identity is "the lava lamp." Daniel's §8b call: full parity on the detail pages. The controls ride every Release Detail host:

  • Mix, Cut, and Session detail pages all carry the seven-knob bar + lava-lamp toggle. The WaveformVisualizerControls mount is no longer Mix-suppressed — it rides every detail-page mount. On Mix (mode A) the bar sits in TopRowCenter over the full-bleed visualizer as the Phase 10 reframe landed it; on Cut and Session (mode B) the same controls bar + lava-lamp toggle ride the ambient mount, letting a visitor tune the living field behind the release. The layout still differs per mode (§3f) — visualizer-is-the-page vs. ambient-behind-content — but the controls presence is uniform across all three detail hosts. Rationale (Daniel): the lava is a signature affordance of the site; giving every release page the tuning surface makes the whole site feel alive and consistent, not just the Mix page. The shared WaveformVisualizerControlState supplies the dial values and is where each mount reads/writes, so tuning behaves identically wherever the bar appears (see the state-scoping note below).
  • The NowPlaying home card (mode C) stays controls-suppressed by default. Full parity was resolved 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.

State-scoping note (raised by full parity, staff-engineer's call at 12.C). With the controls now 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

SessionDetail deliberately does not compose ReleaseDetailScaffold (it wraps its own MudContainer + ReleaseHeroOverlay). If Session is to host the ambient layer via the scaffold's new slot, either (a) SessionDetail adopts the scaffold (a larger refactor, out of scope here — Session's divergence was a deliberate Phase 11 call), or (b) Session mounts <WaveformVisualizer> directly with its own full-bleed wrapper (small, local, mirrors what Mix does inline today). Recommend (b) if Session gets the ambient layer at all — don't reopen the Session-vs-scaffold decision for this. Cut does compose the scaffold, so Cut gets the ambient layer for free via the slot. This asymmetry is fine: the slot serves scaffold-composing pages; the one non-composing page mounts the shared engine directly. The engine is still single-source either way — only the mount differs, which is exactly the per-host variance §3b sanctions. Under full parity (§3d), 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)

The bridge (WaveformVisualizer.razor.cs, ex-MixWaveformVisualizer) needs one real change beyond the rename: its datum fetch must resolve the high-res datum for the current track, not for a Mix release.

Today: await ReleaseData.GetMixWaveform(ReleaseEntryKey) → resolves per-release, 404 for non-Mix → blank. This is the per-release fetch the model correction supersedes.

Generalized (per-track model). The visualizer renders the currently playing/selected track's waveform, and the bridge already knows that track — it is the TrackId it gates IsActivePlayer on. So the fetch should be keyed to the track, with the release as addressing context. Two shapes for the fetch are viable; the spec recommends the first and leaves the exact route to staff-engineer (§5):

  • Track-keyed (recommended): await ReleaseData.GetTrackWaveform(trackEntryKey) → returns the per-track high-res datum from the generalized waveform vault, keyed by the track's EntryKey. This is the cleanest expression of "the datum is the track's." The bridge needs the current track's EntryKey; it currently carries TrackId (the SQL PK) — see §5 on reconciling TrackIdEntryKey.
  • 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 TrackIdEntryKey client-side is awkward.

The bridge stays otherwise identical:

  • Re-fetch key changes from release to track. The fetch-once guard must re-arm when the track changes, not (only) when the release changes — because on a multi-track Cut the release is fixed while the track scrolls. (Today's guard keys on _loadedReleaseKey == ReleaseEntryKey; under the per-track model it keys on the current track identity.) This is the one bridge subtlety the model correction introduces — and it is exactly the behavior the NowPlaying card already needs (§6c), so the two converge.
  • Still derives duration from the cascaded player (PlayerDurationSeconds) — the duration source is the player, which already reflects the current track, so the time↔sample mapping is correct per-track for free.
  • Still gates playback coupling on TrackId via IsActivePlayer.
  • Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle.

The PlaybackPosition composability fallback (the no-player-cascade path) stays as the documented escape hatch.

No open data question remains here — Direction B (committed, §5) means every track has a high-res datum, so the fetch always resolves to high-res. The open items the model created are operational, not design: the backfill (§8a-new) and the upload-time compute cost (§8b-new).


5. The datum source — Direction B, per-track (committed)

The visualizer renders a loudness datum + a duration. Daniel's model correction makes the datum per-track: every track carries its own high-res waveform datum; the release is just the host. And Daniel chose Direction B: compute that high-res datum for all media, not the 512-bucket fallback. Together these settle §5 — no remaining product call here; the work is a generalization + a backfill.

Datums in the system today:

Datum Vault Resolution Who has it today Keyed by
Per-track profile waveform-profiles 512 buckets (fixed) every track (computed at upload) track EntryKey
High-res datum mix-waveforms ~333 samples/sec (duration-derived) Mix tracks only (CMS-triggered) MixMetadata.WaveformEntryKey (= the mix track's EntryKey)

The 512-bucket profile already exists per-track and stays (the player-bar WaveformSeeker consumes it — untouched). The high-res datum is the one the lava visualizer wants, and today only Mix tracks have it. Direction B closes that gap per-track for every medium.

5a. The generalization (Direction B), end to end

Direction B is not just a new endpoint — under the per-track model it touches five surfaces:

  1. Content compute path. Generalize the duration-derived high-res compute (WaveformProfileService + MixWaveformResolution) off its Mix-only framing so it computes a per-track high-res datum for any track from that track's audio. The algorithm is unchanged (the same ~333 samples/sec duration-derived model MixWaveformResolution already defines); only its applicability widens from "the mix track" to "any track." Store keyed by the track's EntryKey.
  2. Store / vault. The high-res datum becomes per-track-cardinal. Either rename/repurpose the mix-waveforms vault to a medium-neutral track-waveforms (cleaner; consistent with the rename discipline) or keep the vault and drop the Mix-only key assumption — staff-engineer's call, but the name should stop saying "mix" once it holds every track's datum. MixMetadata.WaveformEntryKey is no longer the only pointer; the datum keys directly off the track's EntryKey (the same key the waveform-profiles profile uses).
  3. Upload path. UnifiedTrackService.UploadAsync already computes the 512-bucket profile for every track at upload; add the per-track high-res compute alongside it so every new track gets its high-res datum at upload time, no CMS step required. (Cost flagged — §8b-new.)
  4. CMS generate action. Generalize the Mix-only "generate waveform" trigger to any track — so a CMS operator can (re)generate a track's high-res datum on demand (the backfill mechanism, and the repair path if a compute fails at upload). This is the action that drives the backfill (§8a-new).
  5. Backfill. Every existing track that predates Direction B has a 512-bucket profile but no high-res datum. Backfill computes the per-track high-res datum for all of them. This is a real operational step, Daniel-gated — see §8a-new (does it run as a one-shot migration/script, or as a CMS batch action over the generalized generate action?).

5b. The per-track datum fetch endpoint

Under the per-track model the fetch resolves a datum for a track, addressed with the release as context. Recommended shape: GET api/track/{trackEntryKey}/waveform (track-cardinal, on TrackController) returning the per-track high-res datum, 404 → graceful blank. This is the natural home once the datum is the track's, and it sits beside the existing per-track surfaces.

  • Reconciling with the bridge's identifiers. The bridge today carries ReleaseEntryKey + TrackId (the SQL PK), not the track's EntryKey. The fetch needs the track's EntryKey. Two ways to bridge: (i) the player/track view-model the bridge already cascades exposes the current track's EntryKey (preferred — the player knows the track it loaded, and EntryKey is how audio is keyed), so the bridge reads CurrentTrack.EntryKey directly; or (ii) keep the release as the addressing root and resolve server-side — GET api/release/{releaseEntryKey}/track/{trackId}/waveform — if the client can't cleanly reach the track's EntryKey. Recommend (i); it makes the route track-cardinal and matches the datum's ownership. Final identifier plumbing is staff-engineer's, but the route should be track-cardinal.
  • /mix/waveform fate. The Mix-gated GET api/release/{entryKey}/mix/waveform can be retired (folded into the track-cardinal fetch — the Mix's single track resolves through the same path) or kept as a thin delegate during transition. Staff-engineer's call; the spec's intent is one track-cardinal fetch.
  • IReleaseDataService (or a track-cardinal ITrackDataService if cleaner) gains GetTrackWaveform( trackEntryKey); the bridge calls it.

The road not taken (one-line, per Daniel). A cheaper v1 — serve the existing 512-bucket per-track profile to non-Mix surfaces and skip new compute entirely ("Direction A") — was considered and declined in favor of uniform high-res. Recorded so the trade isn't relitigated: A was zero-compute but coarse for the lava; Daniel chose quality + uniformity over the cheaper road.


6. Overhauling the NowPlayingHero (Wave 12.D)

6a. What's there now

NowPlayingCard.razor shows the now-playing title/sub plus, when Player.IsLoaded, a waveform-bars div of 20 hardcoded <div class="waveform-bar"> elements, each with fixed --h-lo/--h-hi/--dur CSS custom properties driving a CSS keyframe bounce. It is purely synthetic — no audio data, no coupling to the actual signal; it bounces on a fixed loop whenever anything is loaded. This is the "stochastic bullshit."

6b. The rewire — mount the real engine, driven by live playback

Replace the waveform-bars block with a mounted <WaveformVisualizer> scoped to the card, driven by the live cascaded player:

  • The NowPlaying card already cascades IStreamingPlayerService (it reads Player.CurrentTrack).
  • Mount <WaveformVisualizer ReleaseEntryKey="@Player.CurrentTrack.Release?.EntryKey" TrackId="@Player.CurrentTrack.Id" /> — the same bridge, the same playback coupling. Because the card is inside the cascade, IsActivePlayer is true for whatever is actually playing, and the visualizer scrolls/animates to the real signal. When nothing is playing, it sits at its at-rest slice (the bridge already handles the no-active-player state → static), which replaces the waveform-placeholder.
  • The datum comes from §5's per-track fetch keyed on the current track (CurrentTrack.EntryKey) — so the home card shows the real high-res waveform of whatever track the listener started, Mix or not. This is the cleanest illustration of the per-track model: the card has no "release" of its own to speak of; it follows the track, and the datum is the track's.

This is the payoff of the generalization: the NowPlaying card is just another host (mode C, §3f) of the same engine, pointed at "whatever track is playing right now." No NowPlaying-specific renderer, no fork — the DRY win the brief demands. And because the datum is per-track and every track now has a high-res datum (Direction B), the home card renders at full fidelity for any track, not just Mixes.

6c. Constraints specific to the NowPlaying context

  • Live, not static. Unlike a single-track detail page, the NowPlaying card's track changes as playback advances. Under the per-track model (§4) the fetch-once guard re-arms on track change (not release change), so track-change → new per-track datum is handled — and this is the same guard change the per-track model already requires for a multi-track Cut (where the release is fixed but the track scrolls). The two converge on one behavior: re-fetch when the current track's identity changes. Verify the guard keys on the current track, not the release.
  • Small surface, controls-suppressed by default. The card is a small hero panel, not a full-bleed page. 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 written for a full-page backdrop; mounting it in a contained card needs the canvas to size to its container instead of the viewport. This is a real renderer-hosting wrinkle — the engine assumes full-window today. Either (i) parameterize the visualizer's sizing (full-viewport vs. fill-container), or (ii) the NowPlaying card is the forcing function to make the canvas container-relative. Recommend (i) — a Fill mode parameter — because it also future-proofs any contained mount (an embed, a CMS preview). This is the one genuine engineering subtlety in the NowPlaying rewire; everything else is composition.
  • Performance. A WebGL2 lava render on the home page (the highest-traffic, first-paint surface) is a heavier ask than on a detail page the user navigated to deliberately. Keep the existing MAX_DPR = 2 cap and the isPlaying-gated rAF loop (it burns no frames when nothing plays — so an idle home page pays nothing). If the lava is too heavy for the home card specifically, the controls-suppressed ambient backdrop can run a cheaper preset (fewer blobs) via a density default — but do not fork the renderer; use the existing density dial. Flagged, not committed (§8d).

6d. What the card keeps

The title/sub text block (np-title/np-sub) and the "Now Playing" label stay — only the synthetic waveform-bars / waveform-placeholder block is replaced by the mounted visualizer. The pulsing circle-deco rings in NowPlaying.razor are unrelated decor; leave them.


7. Wave decomposition + dependency shape

Sequenced so the mechanical rename de-risks everything, the per-track high-res generalization (now the heaviest wave) unblocks the new hosts, and the NowPlaying rewire (the trickiest, per §6c) comes last on a proven engine. Direction B re-costs 12.B materially — it is no longer "a new endpoint" but a content + upload + CMS + backfill + fetch slice; the spec splits it into 12.B1 (compute generalization + backfill) and 12.B2 (the bridge's per-track fetch) so the heavy data work and the thin client rewire can be sequenced and tested apart.

  • 12.A — Rename to the abstraction (mechanical, no behavior change). Mix*Waveform* across the five files + the TS module + the import path + the DI registration (§3a). Load-bearing prerequisite — every later wave references the generalized names. Acceptance: Mix detail behaves identically; diff is identifiers only.
  • 12.B1 — Generalize the high-res compute to every track + backfill (the data change, Direction B). Generalize the duration-derived compute off Mix-only (WaveformProfileService / MixWaveformResolution), store per-track keyed by EntryKey in the (renamed) track-waveforms vault, add the per-track high-res compute to UnifiedTrackService.UploadAsync, generalize the CMS generate action to any track, and run the backfill for existing tracks (§5a, §8a-new — backfill is Daniel-gated). Independent of 12.A (it is server/content-side, no renamed client identifiers). Acceptance: every track — Mix, Session, and each Cut track — has a high-res datum; new uploads get one automatically; the generate action works for any track.
  • 12.B2 — Per-track datum fetch + bridge rewire. New track-cardinal GET api/track/{trackEntryKey}/waveform (§5b); GetTrackWaveform; bridge resolves the current track's EntryKey and re-fetches on track-change (§4). Depends on 12.A (renamed bridge) + 12.B1 (a datum to fetch). Acceptance: the Mix detail page renders the same high-res lava via the new track-cardinal fetch; a non-Mix track now returns a high-res datum.
  • 12.C — Ambient slot on the scaffold + mount on detail pages (mode B, §3f). Promote the full-bleed/foreground/footer-clip pattern into ReleaseDetailScaffold as an optional Ambient slot (§3c); Cut mounts the ambient layer with the full seven-knob bar + lava-lamp toggle (full parity, §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.B2 → (12.C ‖ 12.D), with 12.B1 a parallel server-side track that 12.B2 depends on (12.B1 → 12.B2) but that can start cold day one (it needs no renamed client identifiers). 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

Resolved by Daniel (2026-06-17), kept visible per file convention:

  • §8a (was: non-Mix datum resolution) → Direction B. High-res compute for all media, not the 512-bucket fallback. The cheaper "Direction A" (serve the existing 512-bucket profile to non-Mix) is the road not taken (§5). Re-cost lands in 12.B1 (compute + backfill).
  • §8c (was: multi-track Cut's waveform) → dissolved by the per-track model. The datum is per-track; the visualizer renders the current track's datum, so there is no release-level "which track represents the album" choice to make. The old "first track by TrackNumber" recommendation is moot.
  • §8b (Cut/Session hosting + controls) → RESOLVED: all three modes live, FULL PARITY on controls. 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.

Newly open (created by the full-parity flip and by Direction B + the per-track model):

§8b-followup — Do the full-parity controls extend onto the NowPlaying home card (mode C)? Daniel's §8b full-parity call (option 3) was answered against the Release Detail-page framing — Mix, Cut, 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 but flag for Daniel). The renderer assumes full-viewport (position: fixed; inset: 0, clip-to-footer); the NowPlaying card (mode C, §3f) needs it container-relative (§6c) — recommend a Fill mode parameter. And a WebGL2 lava render on the home page's first paint is heavier than on a detail page — the isPlaying-gated rAF means an idle home page pays nothing, but a cheaper blob-density preset for the card is a fallback if needed. Neither blocks; both are tuning/hosting calls surfaced so Daniel isn't surprised by a lava lamp on the landing page.


9. Acceptance criteria (observable)

  1. Rename clean. The engine is named for its abstraction (WaveformVisualizer*); the Mix detail page is visually and behaviorally identical to before the rename.
  2. Per-track high-res datum everywhere (Direction B). Every track — Mix, Session, and each track of a Cut — has a high-res duration-derived datum keyed by its EntryKey; new uploads get one automatically; the generalized CMS generate action produces one for any track; the backfill has populated all pre-existing tracks.
  3. Track-cardinal fetch. GET api/track/{trackEntryKey}/waveform (or the agreed track-cardinal route) returns the current track's high-res datum, 404 → graceful blank. The bridge resolves the current track and re-fetches on track change.
  4. Mix unchanged (mode A). The Mix detail page still renders the high-res lava with the seven-knob bar + lava-lamp toggle, visualizer-is-the-page, at parity with the Phase 10 reframe — now via the track-cardinal fetch.
  5. Ambient + controls on Cut/Session (mode B, full parity). A Cut and a Session detail page show an ambient living waveform layer rendering the currently selected/playing track's datum, with a working 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.
  9. Bridge intact, re-keyed to track. Single-owner handle, idempotent datum guard, IsActivePlayer gating, and the isPlaying-gated rAF loop are unchanged across all mounts; the datum guard now re-arms on track change (the multi-track Cut and the NowPlaying card both rely on this — they converge).