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.
52 KiB
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:
- 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.
- 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 onReleaseEntryKey+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 optionalAmbientslot (§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).DeepDrftManagerCMS 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 (
MixWaveformVisualizer→WaveformVisualizer,MixVisualizerControls→WaveformVisualizerControls,MixVisualizerControlState→WaveformVisualizerControlState,MixVisualizer.ts→WaveformVisualizer.ts,MixZoomMapping→WaveformZoomMapping). Pure renames; no behavior change. (§3a) - Generalize the high-res compute to every track (Direction B, §5). This is now the load-bearing data
change — and it is larger than a single endpoint. It touches: (a) the content compute path
(
WaveformProfileService/MixWaveformResolution) — generalize the duration-derived high-res compute off Mix-only to any track; (b) the upload path (UnifiedTrackService.UploadAsync) — compute the high-res per-track datum at upload for every new track; (c) the CMS generate action — generalize the Mix-only "generate waveform" trigger to any track; (d) a backfill of the high-res datum for every existing track that lacks one (§8a-new); (e) a per-track datum fetch endpoint (§5) the bridge calls. - Host the visualizer in three modes — via a new optional
Ambientslot onReleaseDetailScaffoldfor 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
IsActivePlayergating, theisPlaying-gated rAF loop — all preserved. Extend the fetch, not the contract. - No new control model. The seven knobs and
…ControlStatestay 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 (
MixWaveformResolutioncarries 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
MixWaveformVisualizeron other pages." Resist it: aMixWaveformVisualizermounted 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, notBackdrop.
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=… />toAmbientwith 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 +
TopRowCenterknob-bar + lava-lamp toggle exactly as the Phase 10 reframe landed them. TheAmbientslot 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
WaveformVisualizerControlsmount is no longer Mix-suppressed — it rides every detail-page mount. On Mix (mode A) the bar sits inTopRowCenterover 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 sharedWaveformVisualizerControlStatesupplies 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
WaveformVisualizerControlStateis 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 persistedWaveformVisualizerControlStatealready 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'sEntryKey. This is the cleanest expression of "the datum is the track's." The bridge needs the current track'sEntryKey; it currently carriesTrackId(the SQL PK) — see §5 on reconcilingTrackId→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 resolvingTrackId→EntryKeyclient-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
TrackIdviaIsActivePlayer. - 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:
- 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 modelMixWaveformResolutionalready defines); only its applicability widens from "the mix track" to "any track." Store keyed by the track'sEntryKey. - Store / vault. The high-res datum becomes per-track-cardinal. Either rename/repurpose the
mix-waveformsvault to a medium-neutraltrack-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.WaveformEntryKeyis no longer the only pointer; the datum keys directly off the track'sEntryKey(the same key thewaveform-profilesprofile uses). - Upload path.
UnifiedTrackService.UploadAsyncalready 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.) - 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).
- 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'sEntryKey. The fetch needs the track'sEntryKey. Two ways to bridge: (i) the player/track view-model the bridge already cascades exposes the current track'sEntryKey(preferred — the player knows the track it loaded, andEntryKeyis how audio is keyed), so the bridge readsCurrentTrack.EntryKeydirectly; 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'sEntryKey. 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/waveformfate. The Mix-gatedGET api/release/{entryKey}/mix/waveformcan 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-cardinalITrackDataServiceif cleaner) gainsGetTrackWaveform( 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 readsPlayer.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,IsActivePlayeris 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 thewaveform-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) — aFillmode 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 = 2cap and theisPlaying-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 byEntryKeyin the (renamed)track-waveformsvault, add the per-track high-res compute toUnifiedTrackService.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'sEntryKeyand 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 —
Ambientslot on the scaffold + mount on detail pages (mode B, §3f). Promote the full-bleed/foreground/footer-clip pattern intoReleaseDetailScaffoldas an optionalAmbientslot (§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 sharedWaveformVisualizerControlState). 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 theFill/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
WaveformVisualizerControlsmount 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)
- Rename clean. The engine is named for its abstraction (
WaveformVisualizer*); the Mix detail page is visually and behaviorally identical to before the rename. - 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. - 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. - 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.
- 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.
- 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.
- One engine, three modes. Mix (mode A), the Cut/Session ambient layer (mode B), and the NowPlaying
card (mode C) all consume the same
WaveformVisualizercomponent + renderer + state — verified by there being exactly one of each, no per-host fork; the differences are hosting composition only (full-bleed vs.Ambientslot vs. contained; viewport- vs. container-sized; controls present on all three detail hosts, suppressed-by-default on the contained home card). - 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.
- Bridge intact, re-keyed to track. Single-owner handle, idempotent datum guard,
IsActivePlayergating, and theisPlaying-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).