docs(phase-12): revise spec — Direction B, per-track datum, full-parity controls
Daniel resolved the open questions: high-res compute for all media (B); the waveform datum is per-track, not per-release (release is just the host — dissolves the multi-track-Cut question); full-parity lava controls on all detail hosts. Splits 12.B into compute+backfill / fetch+bridge; renames the scaffold slot to Ambient. NowPlaying-card controls left as open sub-question.
This commit is contained in:
@@ -1,22 +1,39 @@
|
||||
# Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec)
|
||||
|
||||
Status: **design-complete, implementation-ready** (one open product decision flagged in §8 — the
|
||||
non-Mix datum-resolution call — and two smaller calls). Author: product-designer. Date: 2026-06-17.
|
||||
**No code has been written by this doc.**
|
||||
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 backdrop into a **release-cardinal visualizer** that every Release Detail page can host
|
||||
— Cuts, Sessions, and Mixes alike.
|
||||
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* release-cardinal below the surface. The work is extraction and a
|
||||
data-source generalization, not a rebuild.
|
||||
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):
|
||||
|
||||
@@ -35,15 +52,20 @@ Cross-references (read before implementing):
|
||||
- `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 backdrop slot (§3c).
|
||||
becomes a scaffold-level concern via a new optional `Ambient` slot (§3c, §3f).
|
||||
- `DeepDrftPublic.Client/Pages/MixDetail.razor`, `SessionDetail.razor`, `CutDetail.razor` — the three
|
||||
release-detail hosts.
|
||||
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/ReleaseController.cs`, `TrackController.cs` — the waveform endpoints; the
|
||||
data-source question lives here (§5, §8).
|
||||
- `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 (content-agnostic) compute/store path and the two vaults.
|
||||
`Constants/VaultConstants.cs` — the compute/store path and the vaults; **Direction B generalizes the
|
||||
high-res compute here to every track** (§5).
|
||||
- `DeepDrftAPI/Services/UnifiedTrackService.cs` — the upload path; Direction B adds a per-track high-res
|
||||
compute at upload (§5, §8a-new).
|
||||
- `DeepDrftManager` CMS generate action (the Mix-only "generate waveform" trigger) — Direction B
|
||||
generalizes it off Mix-only to any track (§5).
|
||||
|
||||
---
|
||||
|
||||
@@ -54,69 +76,82 @@ The visualizer is named `Mix*` throughout, but its *architecture* is release-gen
|
||||
|
||||
| Layer | Reality on disk today | Mix-coupled? |
|
||||
|-------|----------------------|--------------|
|
||||
| `MixWaveformVisualizer` bridge inputs | `ReleaseEntryKey` (string) + `TrackId` (long?) + cascaded player | **No** — already release-cardinal |
|
||||
| Playback coupling | `IsActivePlayer` gates on `TrackId` matching the cascaded player's current track | **No** — works for any release's track |
|
||||
| `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` |
|
||||
| **Datum source** | the high-res `mix-waveforms` vault, keyed by `MixMetadata.WaveformEntryKey` | **Yes** — only Mixes get the high-res datum |
|
||||
| **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 the *high-res datum that only Mixes have*. Everything else is a rename.
|
||||
`Medium == Mix` and resolves per-release*, and the *high-res datum that only Mix tracks have*. Everything
|
||||
else is a rename.
|
||||
|
||||
**This is the SOLID seam the whole phase turns on.** The renderer and bridge already obey the
|
||||
right abstraction ("render a loudness datum coupled to a playing track"); they were just *named* for
|
||||
their first consumer. Generalizing is: rename to the abstraction, give the abstraction a datum source
|
||||
that exists for every release, and let three hosts mount it. No new renderer, no fork.
|
||||
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). Mixes
|
||||
track's `EntryKey` (this is the datum the player-bar `WaveformSeeker` already consumes). Mix tracks
|
||||
*additionally* get a duration-derived **high-res** datum (~333 samples/sec) in the separate
|
||||
`mix-waveforms` vault, triggered by a CMS action. So **non-Mix releases are not a data gap** — they have
|
||||
a low-res datum today. The only question is whether 512 buckets is *enough resolution* for the lava
|
||||
visualizer on a Cut/Session, or whether they should get the high-res treatment too (§5, §8 — the one real
|
||||
product decision).
|
||||
`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 as a backdrop on **all three** Release Detail pages and on the home-page NowPlaying
|
||||
card, fed by a **release-cardinal datum source** that exists for every release. The lava controls stay a
|
||||
Mix affordance unless Daniel says otherwise (§3d). The NowPlaying card drives the *same* engine off live
|
||||
playback (§6).
|
||||
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 release-cardinal identity (`MixWaveformVisualizer` →
|
||||
- **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 datum source.** A release-cardinal fetch that returns *the best available datum* for
|
||||
any release — the high-res mix datum when present, the per-track 512-bucket profile otherwise (§5).
|
||||
This is the load-bearing data change.
|
||||
- **Host the visualizer on every Release Detail page** via a new optional `Backdrop` slot on
|
||||
`ReleaseDetailScaffold` (§3c), so Cut/Session/Mix mount it without each page re-implementing the
|
||||
full-bleed wrapper.
|
||||
- **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).
|
||||
- **Decide where the lava controls live** per medium — Mix keeps the seven-knob bar; Cut/Session default
|
||||
to a **controls-less ambient** backdrop (§3d), revisitable.
|
||||
- **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*, never its art.
|
||||
changes its *input plumbing* and its *compute breadth*, never its art.
|
||||
- **No bridge redesign.** The single-owner bridge, the idempotent datum guard, the `IsActivePlayer`
|
||||
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 CMS change** unless §5 lands the "high-res for all media" option (then a generalized waveform
|
||||
trigger touches the CMS — flagged, not committed).
|
||||
- **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.
|
||||
|
||||
@@ -136,7 +171,7 @@ every later wave references the generalized names:
|
||||
| `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, still Mix-only by default — §3d) |
|
||||
| `DDIcons.LavaLamp` / `LavaLampFilled` | keep (the lava-lamp glyph is the *controls* affordance, now on every detail-page host — §3d) |
|
||||
|
||||
The `ReleaseEntryKey` / `TrackId` parameters and the fetch keep working unchanged through the rename.
|
||||
The `mix-waveforms` vault name and `MixMetadata.WaveformEntryKey` stay (they are still where the high-res
|
||||
@@ -161,143 +196,247 @@ Generalizing does **not** mean flattening every medium to the same look. The cle
|
||||
that wants the ambient backdrop but no knobs mounts the backdrop with controls suppressed.
|
||||
|
||||
This is the same "variance rides a slot, never a flag" discipline the scaffold already uses for
|
||||
`Header`/`Hero`/`TopRightAction` (Phase 9 §5.3) — extended to the backdrop.
|
||||
`Header`/`Hero`/`TopRightAction` (Phase 9 §5.3) — extended to the ambient mount.
|
||||
|
||||
### 3c. The hosting seam — a `Backdrop` slot on `ReleaseDetailScaffold` (Wave 12.C)
|
||||
### 3c. The hosting seam — an `Ambient` slot on `ReleaseDetailScaffold` (Wave 12.C)
|
||||
|
||||
> **Naming note.** This slot was first drafted as `Backdrop`. Daniel pushed back: on the Mix page the
|
||||
> visualizer is **not** a backdrop — it is the full-bleed centerpiece that *is* the page. "Backdrop" only
|
||||
> describes what the visualizer does on Cut/Session (ambient environment *behind* the hero+content). The
|
||||
> slot is named for *that* mode — the **ambient** mode — because Mix doesn't use the slot at all (it keeps
|
||||
> its own visualizer-is-the-page mount; see §3f). So: `Ambient`, not `Backdrop`.
|
||||
|
||||
Today the Mix page mounts the visualizer *outside* the scaffold (a sibling `<MixWaveformVisualizer>` then
|
||||
a `.mix-detail-foreground` wrapper, with the scaffold inside `MudContainer`). Session mounts nothing.
|
||||
Cut mounts nothing. To let every medium host the visualizer DRY-ly:
|
||||
Cut mounts nothing. To let Cut/Session host the visualizer as ambient environment DRY-ly:
|
||||
|
||||
**Add an optional `RenderFragment? Backdrop` slot to `ReleaseDetailScaffold`**, rendered as the
|
||||
**Add an optional `RenderFragment? Ambient` slot to `ReleaseDetailScaffold`**, rendered as the
|
||||
full-bleed `position: fixed; inset: 0` layer *behind* the scaffold's content (the scaffold's existing
|
||||
container becomes the foreground stacking context — promote the `mix-detail-foreground` pattern into the
|
||||
scaffold so it is the default, not a Mix bespoke). A host that supplies no `Backdrop` gets today's plain
|
||||
background (Liskov: absent slot = no backdrop, no regression).
|
||||
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).
|
||||
|
||||
- **Mix** supplies `<WaveformVisualizer ReleaseEntryKey=… TrackId=… />` to `Backdrop` and keeps its
|
||||
`TopRightAction` lava-lamp + `TopContent` knob band — same as today, just expressed through the slot.
|
||||
- **Session / Cut** *may* supply the same `<WaveformVisualizer>` to `Backdrop` with controls suppressed
|
||||
(§3d) — an ambient living backdrop behind the hero. **Whether they do is a product call (§8b).**
|
||||
- **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 backdrop 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.
|
||||
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 medium? (the controls boundary)
|
||||
### 3d. Where do the lava controls live per host? (the controls boundary — full parity, resolved)
|
||||
|
||||
The seven-knob lava bar is an **expert tuning surface** whose identity is "the lava lamp." Two clean
|
||||
positions, and a recommendation:
|
||||
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**:
|
||||
|
||||
- **Recommended default: lava controls are a Mix affordance only.** Mix keeps the lava-lamp toggle + the
|
||||
seven-knob bar. Cut/Session, *if* they mount the backdrop, mount it **controls-suppressed** — an ambient
|
||||
living gradient/lava field behind the hero with no knobs, no lava-lamp button. Rationale: the controls
|
||||
are a deliberate "I want to tune the lava" gesture that fits the Mix's full-bleed-visualizer-is-the-point
|
||||
page; on a Cut album page or a Session hero page the visualizer is *ambience*, not the subject, and a
|
||||
knob bar there competes with the content. The shared `WaveformVisualizerControlState` still supplies the
|
||||
default dial values, so Cut/Session backdrops render with Daniel's tuned defaults — they just can't be
|
||||
changed in place.
|
||||
- **Alternative (if Daniel wants parity): controls everywhere.** The `Backdrop` + a `BackdropControls`
|
||||
slot pair lets any medium opt into the knob bar. Cheap to add later precisely because the controls are
|
||||
already a separate component over shared state — this is a *composition* decision, not an engine change.
|
||||
Designing the `Backdrop` slot now leaves the door open (memory: design the seam, defer the feature).
|
||||
- **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.
|
||||
|
||||
**This is open question §8b.** Default to Mix-only controls unless Daniel says otherwise; the seam
|
||||
supports either without 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 backdrop via the scaffold's new slot,
|
||||
either (a) `SessionDetail` adopts the scaffold (a larger refactor, out of scope here — Session's
|
||||
`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 a backdrop at all — don't reopen the Session-vs-scaffold decision for this. Cut *does*
|
||||
compose the scaffold, so Cut gets the backdrop for free via the slot. This asymmetry is fine: the slot
|
||||
serves scaffold-composing media; the one non-composing page mounts the shared engine directly. The
|
||||
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.
|
||||
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 a datum for *any* release, not only Mixes.
|
||||
the rename: its datum fetch must resolve the high-res datum for the **current track**, not for a Mix
|
||||
release.
|
||||
|
||||
Today: `await ReleaseData.GetMixWaveform(ReleaseEntryKey)` → 404 for non-Mix → blank backdrop.
|
||||
Today: `await ReleaseData.GetMixWaveform(ReleaseEntryKey)` → resolves per-release, 404 for non-Mix →
|
||||
blank. This is the per-*release* fetch the model correction supersedes.
|
||||
|
||||
Generalized: `await ReleaseData.GetReleaseWaveform(ReleaseEntryKey)` → returns the **best available**
|
||||
datum for the release (§5 decides what "best available" means and where the resolution happens). The
|
||||
bridge stays otherwise identical:
|
||||
**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):
|
||||
|
||||
- Still keys the fetch on `ReleaseEntryKey`, fetch-once-per-key guard intact.
|
||||
- Still derives duration from the cascaded player (`PlayerDurationSeconds`) — **note:** the duration
|
||||
source is the *player*, which works for any release's playing track, so the time↔sample mapping
|
||||
generalizes for free.
|
||||
- **Track-keyed (recommended):** `await ReleaseData.GetTrackWaveform(trackEntryKey)` → returns the
|
||||
per-track high-res datum from the generalized waveform vault, keyed by the track's `EntryKey`. This is the
|
||||
cleanest expression of "the datum is the track's." The bridge needs the current track's `EntryKey`; it
|
||||
currently carries `TrackId` (the SQL PK) — see §5 on reconciling `TrackId` → `EntryKey`.
|
||||
- **Release-addressed, track-resolved:** `GET api/release/{releaseEntryKey}/track/{trackId}/waveform` — the
|
||||
release locates the track, the track owns the datum. Heavier route, but keeps the release as the
|
||||
addressing root the bridge already holds (`ReleaseEntryKey`). Use this if resolving `TrackId` → `EntryKey`
|
||||
client-side is awkward.
|
||||
|
||||
The bridge stays otherwise identical:
|
||||
|
||||
- **Re-fetch key changes from release to track.** The fetch-once guard must re-arm when the **track**
|
||||
changes, not (only) when the release changes — because on a multi-track Cut the release is fixed while the
|
||||
track scrolls. (Today's guard keys on `_loadedReleaseKey == ReleaseEntryKey`; under the per-track model it
|
||||
keys on the current track identity.) This is the one bridge subtlety the model correction introduces — and
|
||||
it is *exactly* the behavior the NowPlaying card already needs (§6c), so the two converge.
|
||||
- Still derives duration from the cascaded player (`PlayerDurationSeconds`) — the duration source is the
|
||||
*player*, which already reflects the current track, so the time↔sample mapping is correct per-track for
|
||||
free.
|
||||
- Still gates playback coupling on `TrackId` via `IsActivePlayer`.
|
||||
- Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle.
|
||||
|
||||
The `PlaybackPosition` composability fallback (the no-player-cascade path, used by the NowPlaying card if
|
||||
it ever runs outside the cascade — though it won't, §6) stays as the documented escape hatch.
|
||||
The `PlaybackPosition` composability fallback (the no-player-cascade path) stays as the documented escape
|
||||
hatch.
|
||||
|
||||
**The single open data question (§5, §8a):** does `GetReleaseWaveform` return the low-res 512-bucket
|
||||
per-track profile for non-Mix releases (cheap, already exists, slightly coarse for the lava), or do we
|
||||
extend the high-res compute to all media (richer, but new CMS/compute work)?
|
||||
**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 — the one real data decision
|
||||
## 5. The datum source — Direction B, per-track (committed)
|
||||
|
||||
The visualizer renders a **loudness datum + a duration**. Two datums exist in the system today:
|
||||
The visualizer renders a **loudness datum + a duration**. Daniel's model correction makes the datum
|
||||
**per-track**: every track carries its own high-res waveform datum; the release is just the host. And
|
||||
Daniel chose **Direction B**: compute that high-res datum for **all** media, not the 512-bucket fallback.
|
||||
Together these settle §5 — no remaining product call here; the work is a generalization + a backfill.
|
||||
|
||||
| Datum | Vault | Resolution | Who has it | Keyed by |
|
||||
|-------|-------|-----------|------------|----------|
|
||||
**Datums in the system today:**
|
||||
|
||||
| Datum | Vault | Resolution | Who has it today | Keyed by |
|
||||
|-------|-------|-----------|------------------|----------|
|
||||
| Per-track profile | `waveform-profiles` | **512 buckets** (fixed) | **every track** (computed at upload) | track `EntryKey` |
|
||||
| Mix high-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived, up to ~2M) | **Mixes only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) |
|
||||
| High-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived) | **Mix tracks only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) |
|
||||
|
||||
So non-Mix releases **are not a data gap** — they have the 512-bucket profile. The question is purely
|
||||
resolution. **Three directions, materially different in cost and shape:**
|
||||
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.**
|
||||
|
||||
**Direction A — "best available, no new compute" (recommended for v1).**
|
||||
`GetReleaseWaveform` returns the high-res mix datum when the release is a Mix with a stored datum;
|
||||
otherwise it falls back to the release's single-track 512-bucket profile (resolve the track via the
|
||||
release → its `EntryKey` → `waveform-profiles`). For multi-track Cuts, use the *first track's* profile (or
|
||||
a chosen representative — §8c). **Cost:** one new release-cardinal endpoint + a service method that picks
|
||||
the source; zero new compute, zero CMS work, ships immediately. **Trade-off:** a Cut/Session backdrop
|
||||
renders at 512 buckets — fine for an *ambient* backdrop (the lava doesn't need quarter-note resolution
|
||||
when it's behind a hero and not the subject), arguably coarse if a Cut ever wants the full-bleed Mix
|
||||
treatment. Since §3d makes Cut/Session *ambient* by default, 512 buckets is **enough** for the v1 look.
|
||||
### 5a. The generalization (Direction B), end to end
|
||||
|
||||
**Direction B — "high-res for all media."**
|
||||
Generalize `TriggerMixWaveformAsync` into a release-cardinal `TriggerReleaseWaveform` that computes the
|
||||
duration-derived high-res datum for *any* single-track release (Mix and Session both being single-track),
|
||||
storing into a generalized waveform vault. **Cost:** a generalized compute path + a CMS generate action
|
||||
exposed for non-Mix media + a backfill for existing releases. Larger, touches CMS + API + content.
|
||||
**Trade-off:** uniform high quality everywhere, but multi-track Cuts still need a per-track or a
|
||||
concatenated-album answer (the "what is an album's waveform" question, §8c), which Direction B doesn't by
|
||||
itself resolve. **Defer unless the ambient 512-bucket look proves too coarse on screen.**
|
||||
Direction B is **not** just a new endpoint — under the per-track model it touches five surfaces:
|
||||
|
||||
**Direction C — "low-res is fine everywhere, drop the high-res special-case."**
|
||||
Use the 512-bucket per-track profile for *everyone including Mix*, retiring the `mix-waveforms` high-res
|
||||
path. **Rejected:** the high-res mix datum exists precisely because the Mix visualizer's max-zoom window
|
||||
(one quarter note at 180 BPM) under-samples badly at 512 buckets on a long mix (`MixWaveformResolution`
|
||||
rationale). Throwing it away regresses the Mix — the exact page this engine was built for. Don't.
|
||||
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?).
|
||||
|
||||
**Recommendation: ship Direction A.** It is DRY (one endpoint, one fallback rule), ships with zero new
|
||||
compute, and is *sufficient* given §3d's ambient-backdrop framing for non-Mix media. Keep Direction B on
|
||||
the roadmap as the upgrade if/when a Cut wants the full Mix treatment. **This is open question §8a** —
|
||||
Daniel's call on whether 512-bucket ambient is acceptable for non-Mix, or he wants high-res-for-all from
|
||||
the start.
|
||||
### 5b. The per-track datum fetch endpoint
|
||||
|
||||
**Endpoint shape (Direction A).** A new unauthenticated `GET api/release/{entryKey}/waveform` that
|
||||
resolves: Mix-with-datum → mix high-res; else → first/representative track's 512-bucket profile; else →
|
||||
404 (blank backdrop, graceful). This *supersedes* the bridge's call to the Mix-gated
|
||||
`/mix/waveform` for the general case; the `/mix/waveform` route can stay (the new endpoint can delegate to
|
||||
the same mix-vault read internally) or be folded in — staff-engineer's call. `IReleaseDataService` gains
|
||||
`GetReleaseWaveform(entryKey)`; the bridge calls it.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -322,23 +461,32 @@ the **live cascaded player**:
|
||||
*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 `GetReleaseWaveform` keyed on the *current* track's release — so the home
|
||||
card shows the real waveform of whatever track the listener started, Mix or not.
|
||||
- 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* of the same
|
||||
engine, pointed at "whatever is playing right now" instead of "this page's release." No NowPlaying-specific
|
||||
renderer, no fork — the DRY win the brief demands.
|
||||
**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 Release Detail page (where the visualizer's release is fixed to the page),
|
||||
the NowPlaying card's release **changes as the track changes**. The bridge already re-fetches on
|
||||
`ReleaseEntryKey` change (the fetch-once-per-key guard re-arms when the key changes), so track-change →
|
||||
new datum is handled. Verify the guard re-fetches cleanly on key change (it keys on `_loadedReleaseKey
|
||||
== ReleaseEntryKey`, so a new key re-fetches — correct).
|
||||
- **Small surface, controls-less.** The card is a small hero panel, not a full-bleed page. Mount the
|
||||
visualizer **controls-suppressed** (no lava-lamp, no knob bar — same ambient framing as §3d for
|
||||
Cut/Session) and sized to the card, not `position: fixed`. **Flagged for staff-engineer (§8d):** the
|
||||
- **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
|
||||
@@ -364,64 +512,128 @@ The title/sub text block (`np-title`/`np-sub`) and the "Now Playing" label stay
|
||||
|
||||
## 7. Wave decomposition + dependency shape
|
||||
|
||||
Sequenced so the mechanical rename de-risks everything, the data generalization unblocks the new hosts,
|
||||
and the NowPlaying rewire (the trickiest, per §6c) comes last on a proven engine.
|
||||
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.B — Generalize the datum fetch + endpoint (the data change).** New `GET
|
||||
api/release/{entryKey}/waveform` resolving best-available datum (Direction A, §5); `IReleaseDataService.
|
||||
GetReleaseWaveform`; bridge calls it instead of `GetMixWaveform`. **Depends on 12.A** (renamed bridge).
|
||||
**Gated by §8a** (Daniel's resolution call — but Direction A needs no decision to start; B/C would).
|
||||
Acceptance: Mix still renders high-res; a non-Mix release now returns a (512-bucket) datum.
|
||||
- **12.C — `Backdrop` slot on the scaffold + mount on detail pages.** Promote the full-bleed/foreground/
|
||||
footer-clip pattern into `ReleaseDetailScaffold` as an optional `Backdrop` slot (§3c); Mix re-expresses
|
||||
its current mount through the slot; Cut mounts the controls-suppressed ambient backdrop; Session mounts
|
||||
directly (§3e) **if** §8b says non-Mix gets a backdrop. **Depends on 12.B** (a datum to render).
|
||||
Acceptance: Mix unchanged; Cut/Session (if opted in) show an ambient living backdrop at their tuned
|
||||
defaults, no knobs.
|
||||
- **12.D — NowPlayingHero rewire.** Replace the synthetic bars with a contained, controls-suppressed
|
||||
`<WaveformVisualizer>` driven by the live player (§6); add the `Fill`/container-sizing mode (§6c).
|
||||
**Depends on 12.A + 12.B** (renamed engine + a datum for whatever's playing). **Independent of 12.C**
|
||||
(different host; doesn't need the scaffold slot). Acceptance: the home card shows the *real* waveform of
|
||||
the playing track and sits at-rest when nothing plays; no synthetic bars remain.
|
||||
- **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.B → (12.C ‖ 12.D)`. 12.A is the cheap mechanical unblock; 12.B is the
|
||||
load-bearing data generalization; 12.C (detail-page hosts) and 12.D (NowPlaying host) are independent
|
||||
siblings off 12.B. The cold-start item is **12.A** — do it first, it touches everything and risks nothing.
|
||||
**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 (need Daniel before the dependent wave)
|
||||
## 8. Open product decisions
|
||||
|
||||
**§8a — Non-Mix datum resolution (gates 12.B's richness; blocks nothing if Direction A).**
|
||||
Does `GetReleaseWaveform` serve non-Mix releases the existing **512-bucket per-track profile** (Direction
|
||||
A — recommended, zero new compute, sufficient for ambient backdrops), or do we extend **high-res compute
|
||||
to all media** (Direction B — richer, new CMS/API/content work + backfill)? **Recommendation: A for v1,
|
||||
B on the roadmap.** Direction A can start immediately; only B/C need a decision before 12.B.
|
||||
**Resolved by Daniel (2026-06-17), kept visible per file convention:**
|
||||
|
||||
**§8b — Do Cut/Session get a backdrop at all, and with controls?**
|
||||
Three positions: (1) **Mix-only** — only Mix hosts the visualizer; Cut/Session stay plain (smallest, the
|
||||
generalization then serves Mix + NowPlaying only). (2) **Ambient on all media, controls Mix-only**
|
||||
(recommended) — Cut/Session get the living backdrop at tuned defaults, no knobs. (3) **Full parity** —
|
||||
every medium gets the backdrop *and* the knob bar. **Recommendation: (2).** Note that even (1) still wants
|
||||
12.A+12.B+12.D for the NowPlaying rewire — the generalization pays for itself via the home card regardless.
|
||||
- **§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.
|
||||
|
||||
**§8c — What is a multi-track Cut's waveform?**
|
||||
A Cut album has many tracks; the visualizer renders *one* datum. First track? A representative/longest
|
||||
track? A concatenated album-length datum (Direction B territory)? **Recommendation: first track by
|
||||
`TrackNumber` for v1** (cheap, deterministic), revisit if it reads wrong. Only bites if §8b chooses a Cut
|
||||
backdrop.
|
||||
**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 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -429,20 +641,31 @@ the landing page.
|
||||
|
||||
1. **Rename clean.** The engine is named for its abstraction (`WaveformVisualizer*`); the Mix detail page
|
||||
is visually and behaviorally identical to before the rename.
|
||||
2. **Release-cardinal datum.** `GET api/release/{entryKey}/waveform` returns a datum for *any* release
|
||||
that has one (high-res for Mix-with-datum, 512-bucket per-track otherwise), 404 → graceful blank.
|
||||
3. **Mix unchanged.** The Mix detail page still renders the high-res lava with the seven-knob bar, at
|
||||
parity with the Phase 10 reframe.
|
||||
4. **Non-Mix backdrop (if §8b opts in).** A Cut and/or Session detail page shows an ambient living
|
||||
waveform backdrop at the tuned default dials, controls-suppressed, no regression to the hero/content.
|
||||
5. **NowPlaying is real.** The home NowPlaying card shows the *actual* waveform of the playing track
|
||||
(scrolls/animates to the real signal, changes with track changes), and sits at-rest when nothing plays.
|
||||
No hardcoded synthetic bars remain.
|
||||
6. **One engine.** Mix detail, the (opted-in) Cut/Session backdrop, and the NowPlaying card all consume
|
||||
the *same* `WaveformVisualizer` component + renderer + state — verified by there being exactly one of
|
||||
each, no per-host fork.
|
||||
7. **Read-only everywhere.** No host (including the NowPlaying card) exposes a seek/scrub/transport via
|
||||
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.
|
||||
8. **Bridge intact.** Single-owner handle, idempotent datum guard, `IsActivePlayer` gating, and the
|
||||
`isPlaying`-gated rAF loop are unchanged across all mounts; track-change in the NowPlaying card
|
||||
re-fetches the datum cleanly.
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user