Files
deepdrft/product-notes/phase-12-waveform-visualizer-generalization.md
T
daniel-c-harvey cf47fee07e docs(phase-12): revise spec — Direction B, per-track datum, full-parity controls
Daniel resolved the open questions: high-res compute for all media (B); the
waveform datum is per-track, not per-release (release is just the host —
dissolves the multi-track-Cut question); full-parity lava controls on all
detail hosts. Splits 12.B into compute+backfill / fetch+bridge; renames the
scaffold slot to Ambient. NowPlaying-card controls left as open sub-question.
2026-06-17 05:33:34 -04:00

672 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (Design Spec)
Status: **design-complete, implementation-ready.** Daniel resolved the three §8 open questions on
2026-06-17 — committing **Direction B** (high-res compute for all media), correcting the datum model to
**per-track, not per-release**, and resolving §8b to **full parity on the lava controls**: the visualizer
rides every Release Detail host (Mix, Cut, Session) **and the seven-knob bar + lava-lamp toggle ride all
three** — not Mix-only. The three-hosting-mode *layout* framing (visualizer-is-the-page on Mix / ambient
environment on Cut/Session / contained on NowPlaying) is retained; the change is that controls are now
present on all three detail-page hosts, not suppressed on Cut/Session. The spec below is revised to those
three. One small sub-question remains open (§8b-followup): whether full-parity controls also extend onto
the small NowPlaying home card (default: **no** — the card stays controls-suppressed). Author:
product-designer. Date: 2026-06-17. **No code has been written by this doc.**
This phase has **two deliverables that share one engine**:
1. **Generalize** the landed Mix waveform visualizer (the WebGL2 lava renderer + its seven-knob controls)
from a Mix-only treatment into a **track-cardinal visualizer** that every Release Detail page can host
— Cuts, Sessions, and Mixes alike — rendering the waveform of **whatever track is currently
playing/selected** on that page.
2. **Overhaul** the home-page `NowPlayingHero` (`NowPlayingCard`) so its "Now Playing" animation is the
**real waveform visualizer** driven by live playback, replacing the 20 hardcoded CSS-animated bars
(the "stochastic bullshit").
The explicit ask is **DRY / SOLID**: one reusable visualizer engine serving Mix detail, all Release
Detail pages, and the NowPlayingHero — **not three forks.** This spec's central finding makes that
cheap: the engine is *already* track-cardinal below the surface. The work is extraction, a per-track
high-res compute generalization (Direction B), and three hosting modes of the one engine — not a rebuild.
**The keystone model correction (Daniel, 2026-06-17).** The waveform datum is **per-track, not
per-release.** *"Each track in the release must get the metadata. The waveform visualizer metadata should
be tied to the track, right? The release is just the host."* Every track gets its own high-res waveform
datum; the release is merely the *host* surface, and the visualizer renders **the datum of whatever track
is currently playing/selected**. This *simplifies* the design — it aligns with the bridge already keying on
`TrackId`, and it **dissolves** the old "what is a multi-track Cut's waveform?" question (there is no
release-level datum to choose; the visualizer just shows the current track's). Threaded through §4, §5, the
endpoint shape, and the acceptance criteria below.
Cross-references (read before implementing):
- `product-notes/phase-10-mix-visualizer-lava-reframe.md` — the lava renderer this generalizes. The
CPU-physics wax-blob model, the OKLab three-color gradient, the seven-knob control model, the bridge
contract, and the read-only contract all **carry forward unchanged**. This spec does not re-derive any
of them; it changes *where the engine lives*, *what feeds it*, and *who hosts it*.
- `product-notes/mix-visualizer-webgl-renderer.md` — the renderer architecture (pipeline, datum-as-texture,
bridge, rAF loop). The §2 contract carries forward; §4/§7 are already superseded by the reframe.
- `DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor[.cs/.css]` — the Blazor bridge. **Already
keyed on `ReleaseEntryKey` + `TrackId`, not on Mix.** Renamed and re-pointed by this phase (§3, §4).
- `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor[.cs/.css]`,
`DeepDrftPublic.Client/Services/MixVisualizerControlState.cs`,
`DeepDrftPublic.Client/Controls/MixZoomMapping.cs` — the controls + state + mapping. Renamed, otherwise
unchanged.
- `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — the WebGL2 renderer module. Renamed; the only
*logic* change is how the datum's time-mapping is established when no high-res mix datum exists (§5).
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor` — the shared detail chrome. The visualizer
becomes a scaffold-level concern via a new optional `Ambient` slot (§3c, §3f).
- `DeepDrftPublic.Client/Pages/MixDetail.razor`, `SessionDetail.razor`, `CutDetail.razor` — the three
release-detail hosts (three distinct hosting modes — §3f).
- `DeepDrftPublic.Client/Controls/NowPlayingCard.razor[.css]`, `NowPlaying.razor` — the home-page
now-playing card carrying the stochastic bars (§6).
- `DeepDrftAPI/Controllers/TrackController.cs`, `ReleaseController.cs` — the waveform endpoints; under the
per-track model the datum resolves for a **track** (§5), with the release as addressing context.
- `DeepDrftContent/Processors/WaveformProfileService.cs`, `MixWaveformResolution.cs`,
`Constants/VaultConstants.cs` — the compute/store path and the vaults; **Direction B generalizes the
high-res compute here to every track** (§5).
- `DeepDrftAPI/Services/UnifiedTrackService.cs` — the upload path; Direction B adds a per-track high-res
compute at upload (§5, §8a-new).
- `DeepDrftManager` CMS generate action (the Mix-only "generate waveform" trigger) — Direction B
generalizes it off Mix-only to any track (§5).
---
## 1. The central finding — the engine is already release-cardinal
Before any plan: a read of the live code shows the "Mix coupling" is **mostly nominal**, not structural.
The visualizer is named `Mix*` throughout, but its *architecture* is release-generic:
| Layer | Reality on disk today | Mix-coupled? |
|-------|----------------------|--------------|
| `MixWaveformVisualizer` bridge inputs | `ReleaseEntryKey` (string) + `TrackId` (long?) + cascaded player | **No** — already track-cardinal (keys on `TrackId`) |
| Playback coupling | `IsActivePlayer` gates on `TrackId` matching the cascaded player's current track | **No** — works for any track |
| Renderer (`MixVisualizer.ts`) | datum texture + scroll/zoom geometry + wax-blob physics + OKLab gradient | **No** — pure function of a loudness datum + duration |
| Controls + state | seven normalized dials, scoped persistence, `Changed` seam | **No** — renderer-agnostic |
| **Datum fetch** | `IReleaseDataService.GetMixWaveform(entryKey)``GET api/release/{entryKey}/mix/waveform` | **Yes** — 404s unless `Medium == Mix`; resolves per *release*, not per *track* |
| **Datum source** | the high-res `mix-waveforms` vault, keyed by `MixMetadata.WaveformEntryKey` | **Yes** — only the Mix's single track gets the high-res datum |
| Names / comments | `Mix*` everywhere | cosmetic |
So the genuinely Mix-specific surface is exactly **two things**: the *fetch endpoint that gates on
`Medium == Mix` and resolves per-release*, and the *high-res datum that only Mix tracks have*. Everything
else is a rename.
**This is the SOLID seam the whole phase turns on.** The renderer and bridge already obey the
right abstraction ("render a loudness datum coupled to a playing **track**"); they were just *named* for
their first consumer. Generalizing is: rename to the abstraction, give the abstraction a **per-track**
datum source that exists for every track, and let three hosts mount it. No new renderer, no fork.
**The crucial data fact (verified):** *every uploaded track already has a waveform profile.*
`UnifiedTrackService.UploadAsync` calls `WaveformProfileService.ComputeAndStoreAsync(...)` at upload time
for **every** track, storing a **512-bucket** profile in the `waveform-profiles` vault keyed by the
track's `EntryKey` (this is the datum the player-bar `WaveformSeeker` already consumes). Mix tracks
*additionally* get a duration-derived **high-res** datum (~333 samples/sec) in the separate
`mix-waveforms` vault, triggered by a CMS action. So **the high-res datum is the only gap** — and under
Daniel's per-track model it is a gap *per track*, not per release: every track should carry its own
high-res datum, computed at upload and (for existing tracks) backfilled. **Direction B (committed)** makes
the high-res compute a per-track compute for **all** media, keyed by track `EntryKey` (§5). The cheaper
road — serving the existing 512-bucket profile to non-Mix and skipping new compute (old "Direction A") —
was **the road not taken** (§5).
---
## 2. Goal and scope boundary
**Goal.** One reusable `WaveformVisualizer` (renamed from `MixWaveformVisualizer`) + its lava renderer +
its controls, mounted in **three hosting modes** (§3f) — *visualizer-is-the-page* on Mix detail, *ambient
environment* on Cut/Session detail, *contained live element* on the home-page NowPlaying card — each fed by
a **per-track high-res datum** that exists for every track (Direction B, §5). The visualizer always renders
**the currently playing/selected track's** datum; the release is the host, not the datum's owner. The lava
controls ride **every Release Detail host** — Mix, Cut, and Session all get the seven-knob bar + lava-lamp
toggle (full parity, §3d). The NowPlaying card drives the *same* engine off live playback (§6) and stays
**controls-suppressed by default** (the one open sub-question, §8b-followup).
**In scope.**
- **Rename + relocate** the visualizer engine to a track-cardinal identity (`MixWaveformVisualizer`
`WaveformVisualizer`, `MixVisualizerControls``WaveformVisualizerControls`, `MixVisualizerControlState`
`WaveformVisualizerControlState`, `MixVisualizer.ts``WaveformVisualizer.ts`, `MixZoomMapping`
`WaveformZoomMapping`). Pure renames; no behavior change. (§3a)
- **Generalize the high-res compute to every track (Direction B, §5).** This is now the load-bearing data
change — and it is **larger than a single endpoint**. It touches: (a) the **content** compute path
(`WaveformProfileService` / `MixWaveformResolution`) — generalize the duration-derived high-res compute
off Mix-only to any track; (b) the **upload** path (`UnifiedTrackService.UploadAsync`) — compute the
high-res per-track datum at upload for every new track; (c) the **CMS** generate action — generalize the
Mix-only "generate waveform" trigger to any track; (d) a **backfill** of the high-res datum for every
existing track that lacks one (§8a-new); (e) a **per-track datum fetch** endpoint (§5) the bridge calls.
- **Host the visualizer in three modes** — via a new optional `Ambient` slot on `ReleaseDetailScaffold`
for the Cut/Session ambient mode (§3c, §3f), Mix keeping its visualizer-is-the-page full-bleed mount, and
a contained mount on the NowPlaying card. The slot lets scaffold-composing pages mount without
re-implementing the wrapper.
- **Rewire the NowPlayingHero** to mount the visualizer driven by the live cascaded player, replacing the
20 hardcoded CSS bars (§6).
- **Mount the lava controls on every detail-page host** — Mix, Cut, and Session all carry the seven-knob
bar + lava-lamp toggle (full parity, §3d). The NowPlaying card mounts **controls-suppressed** by default
(§3d, §8b-followup).
**Out of scope / unchanged.**
- **No renderer rewrite.** The wax-blob physics, the OKLab gradient, the collision model, the seven dials
— all carry forward from the Phase 10 reframe exactly. This phase moves and renames the engine and
changes its *input plumbing* and its *compute breadth*, never its art.
- **No bridge redesign.** The single-owner bridge, the idempotent datum guard, the `IsActivePlayer`
gating, the `isPlaying`-gated rAF loop — all preserved. Extend the fetch, not the contract.
- **No new control model.** The seven knobs and `…ControlState` stay as-is (renamed). No new dials.
- **No new high-res *algorithm*.** Direction B generalizes the *existing* duration-derived compute to run
per-track for all media; it does not redesign how the high-res datum is computed (`MixWaveformResolution`
carries forward — the same ~333 samples/sec duration-derived model).
- **No playback-control change.** Read-only contract holds everywhere, including the NowPlaying card —
the home card visualizes, it does not become a transport.
---
## 3. Generalizing the visualizer onto every Release Detail page
### 3a. The rename (Wave 12.A — pure, mechanical, no behavior change)
Rename the engine to its abstraction. This is a mechanical sweep with zero behavior change, done first so
every later wave references the generalized names:
| Today (Mix-named) | Generalized |
|-------------------|-------------|
| `MixWaveformVisualizer.razor[.cs/.css]` | `WaveformVisualizer.razor[.cs/.css]` |
| `MixVisualizerControls.razor[.cs/.css]` | `WaveformVisualizerControls.razor[.cs/.css]` |
| `MixVisualizerControlState.cs` (+ DI registration) | `WaveformVisualizerControlState.cs` |
| `MixZoomMapping.cs` | `WaveformZoomMapping.cs` |
| `MixVisualizer.ts` (+ the `./js/visualizer/MixVisualizer.js` import path) | `WaveformVisualizer.ts` |
| `DDIcons.LavaLamp` / `LavaLampFilled` | keep (the lava-lamp glyph is the *controls* affordance, now on every detail-page host — §3d) |
The `ReleaseEntryKey` / `TrackId` parameters and the fetch keep working unchanged through the rename.
The `mix-waveforms` vault name and `MixMetadata.WaveformEntryKey` stay (they are still where the high-res
*mix* datum lives — generalizing the *fetch* doesn't require renaming the *mix-specific high-res store*;
see §5). **Acceptance:** the Mix detail page looks and behaves identically after the rename; the only diff
is identifiers.
> **DRY note.** The temptation is to skip the rename and "just reuse `MixWaveformVisualizer` on other
> pages." Resist it: a `MixWaveformVisualizer` mounted on a Cut page is a lie that every future reader has
> to decode, and it cements the wrong mental model right when we're trying to break it. The rename is
> cheap and it *is* the SOLID move — name the thing for its abstraction, not its first caller.
### 3b. What stays specialized (the abstraction boundary)
Generalizing does **not** mean flattening every medium to the same look. The clean Liskov boundary:
- **Shared (the engine):** the renderer, the bridge, the controls component, the state, the datum
contract, the playback coupling, the read-only contract. One copy, consumed by all hosts.
- **Per-host (the composition):** *whether* the visualizer is mounted, *whether* the lava controls are
exposed, and *what datum* the host points it at. These ride host composition (slots + parameters),
never a `switch (medium)` inside the engine. A medium that wants no visualizer mounts none; a medium
that wants the ambient backdrop but no knobs mounts the backdrop with controls suppressed.
This is the same "variance rides a slot, never a flag" discipline the scaffold already uses for
`Header`/`Hero`/`TopRightAction` (Phase 9 §5.3) — extended to the ambient mount.
### 3c. The hosting seam — an `Ambient` slot on `ReleaseDetailScaffold` (Wave 12.C)
> **Naming note.** This slot was first drafted as `Backdrop`. Daniel pushed back: on the Mix page the
> visualizer is **not** a backdrop — it is the full-bleed centerpiece that *is* the page. "Backdrop" only
> describes what the visualizer does on Cut/Session (ambient environment *behind* the hero+content). The
> slot is named for *that* mode — the **ambient** mode — because Mix doesn't use the slot at all (it keeps
> its own visualizer-is-the-page mount; see §3f). So: `Ambient`, not `Backdrop`.
Today the Mix page mounts the visualizer *outside* the scaffold (a sibling `<MixWaveformVisualizer>` then
a `.mix-detail-foreground` wrapper, with the scaffold inside `MudContainer`). Session mounts nothing.
Cut mounts nothing. To let Cut/Session host the visualizer as ambient environment DRY-ly:
**Add an optional `RenderFragment? Ambient` slot to `ReleaseDetailScaffold`**, rendered as the
full-bleed `position: fixed; inset: 0` layer *behind* the scaffold's content (the scaffold's existing
container becomes the foreground stacking context — promote the `mix-detail-foreground` stacking pattern
into the scaffold so it is the default, not a Mix bespoke). A host that supplies no `Ambient` gets today's
plain background (Liskov: absent slot = no ambient layer, no regression).
- **Cut** (composes the scaffold) supplies `<WaveformVisualizer ReleaseEntryKey=… TrackId=… />` to
`Ambient` **with the full seven-knob bar + lava-lamp toggle exposed** (full parity, §3d) — a living
waveform field behind the album hero + track list, rendering the *currently selected/playing track's*
datum, tunable in place. **§8b is resolved: Cut and Session get the ambient layer AND the controls
(full parity, not Mix-only).** The ambient *layout* (visualizer behind hero+content) is unchanged; the
difference from the earlier draft is that the controls are no longer suppressed here.
- **Session** does not compose the scaffold (§3e) — it mounts the ambient visualizer directly behind its
own hero overlay, the same engine, its own thin full-bleed wrapper.
- **Mix does *not* use this slot.** Mix is the *visualizer-is-the-page* mode (§3f) and keeps its existing
full-bleed mount + `TopRowCenter` knob-bar + lava-lamp toggle exactly as the Phase 10 reframe landed
them. The `Ambient` slot is for the *ambient* mode only — folding Mix into it would force the slot to
carry both "the page" and "behind the page," which is the conflation §3f exists to avoid.
**Why the scaffold, not each page.** The full-bleed wrapper, the foreground stacking context, and the
footer-clip plumbing (the dynamic-footer overflow clip from the reframe §2c) are all *chrome*, and the
scaffold is where chrome lives. Putting the ambient layer on the scaffold means the clip logic, the
stacking context, and the mount point are written once. `SessionDetail` is the lone holdout that doesn't
compose the scaffold today — see §3e.
### 3d. Where do the lava controls live per host? (the controls boundary — full parity, resolved)
The seven-knob lava bar is an **expert tuning surface** whose identity is "the lava lamp." **Daniel's §8b
call: full parity on the detail pages.** The controls ride **every Release Detail host**:
- **Mix, Cut, and Session detail pages all carry the seven-knob bar + lava-lamp toggle.** The
`WaveformVisualizerControls` mount is **no longer Mix-suppressed** — it rides every detail-page mount.
On Mix (mode A) the bar sits in `TopRowCenter` over the full-bleed visualizer as the Phase 10 reframe
landed it; on Cut and Session (mode B) the same controls bar + lava-lamp toggle ride the ambient mount,
letting a visitor tune the living field behind the release. The *layout* still differs per mode (§3f) —
visualizer-is-the-page vs. ambient-behind-content — but the **controls presence is uniform across all
three detail hosts.** Rationale (Daniel): the lava is a signature affordance of the site; giving every
release page the tuning surface makes the whole site feel alive and consistent, not just the Mix page.
The shared `WaveformVisualizerControlState` supplies the dial values and is where each mount reads/writes,
so tuning behaves identically wherever the bar appears (see the state-scoping note below).
- **The NowPlaying home card (mode C) stays controls-suppressed by default.** Full parity was resolved
against the Release *Detail* pages; the NowPlaying card is the *contained* mode-C host — a small hero
panel, not a full page — where a seven-knob tuning bar may be awkward. Default: the card mounts the
ambient/contained field with **no knobs, no lava-lamp button**, rendering at the shared tuned defaults.
**This is the one open sub-question — see §8b-followup.** The seam supports controls-on-the-card either
way (the controls are a separate component over shared state), so flipping it later is a one-line
composition change, not an engine change.
> **State-scoping note (raised by full parity, staff-engineer's call at 12.C).** With the controls now
> present on Mix *and* Cut *and* Session, decide whether `WaveformVisualizerControlState` is **one shared
> tuning** (a knob turned on a Cut page is the same lava everywhere — single source of truth, simplest, and
> consistent with the "one engine" framing) or **per-host/per-mode scoped** (each surface remembers its own
> dial positions). **Recommend one shared state** — it matches the single-engine model, it is what the
> persisted `WaveformVisualizerControlState` already is, and "the lava lamp has one set of dials" is the
> simpler mental model. Flag only; not a product blocker.
### 3e. The `SessionDetail` scaffold question
`SessionDetail` deliberately does **not** compose `ReleaseDetailScaffold` (it wraps its own
`MudContainer` + `ReleaseHeroOverlay`). If Session is to host the ambient layer via the scaffold's new
slot, either (a) `SessionDetail` adopts the scaffold (a larger refactor, out of scope here — Session's
divergence was a deliberate Phase 11 call), or (b) Session mounts `<WaveformVisualizer>` directly with
its own full-bleed wrapper (small, local, mirrors what Mix does inline today). **Recommend (b)** if
Session gets the ambient layer at all — don't reopen the Session-vs-scaffold decision for this. Cut *does*
compose the scaffold, so Cut gets the ambient layer for free via the slot. This asymmetry is fine: the
slot serves scaffold-composing pages; the one non-composing page mounts the shared engine directly. The
*engine* is still single-source either way — only the *mount* differs, which is exactly the per-host
variance §3b sanctions. **Under full parity (§3d), Session's direct mount also carries the seven-knob bar +
lava-lamp toggle** — the controls bar rides Session's own top-row composition the same way Cut's rides the
scaffold's, so Session is full-parity even though it doesn't compose the scaffold.
### 3f. Three hosting modes of the one engine (the elaboration §8b asked for)
Daniel's pushback on "backdrop": *"backdrop?? MIXES doesn't really have a backdrop?"* — and he is right.
The one engine is hosted in **three distinct modes**, and "backdrop" only fits one of them. Spelling them
out surface-by-surface, because the slot design and the naming follow from this distinction:
| Mode | Surfaces | What the visualizer *is* on screen | Controls | Mount mechanism |
|------|----------|-----------------------------------|----------|-----------------|
| **A — Visualizer-is-the-page** | **Mix detail** | The full-bleed centerpiece. The lava field **is** the page; the mix details sit *over* it as a thin overlay. You came here to watch the lava. | Seven-knob bar + lava-lamp toggle (`TopRowCenter`) | Mix's own full-bleed mount (unchanged from Phase 10 reframe) — **not** the `Ambient` slot |
| **B — Ambient environment** | **Cut detail, Session detail** | Living texture *behind and around* the hero + content. The album cover / track list / session hero is the subject; the waveform is environment that makes the page feel alive. You came here for the release; the lava is atmosphere. | **Seven-knob bar + lava-lamp toggle (full parity, §3d)** | `ReleaseDetailScaffold.Ambient` slot (Cut) / direct full-bleed mount (Session, §3e) |
| **C — Contained live element** | **NowPlaying card** (home) | A small, bounded live panel *inside* the card. Not full-bleed, not "behind" anything — a contained element that shows the real signal of whatever is playing. | Suppressed by default (§8b-followup) | Contained mount, `Fill`-mode canvas sized to the card, not the viewport (§6c) |
**Why three modes and not "backdrop everywhere":** the three differ in *what the user's eye treats as the
subject*. On Mix the visualizer **is** the subject (mode A). On Cut/Session the *release* is the subject
and the visualizer is environment (mode B — this is the only mode that is genuinely a "backdrop"). On the
home card the visualizer is a *bounded live readout* of current playback (mode C). Same engine, same datum
contract, same renderer — three compositions. This is the SOLID payoff stated precisely: the variance is
entirely in **hosting composition** (full-bleed vs. slot vs. contained; viewport-sized vs. container-sized;
controls present on all three detail hosts, suppressed-by-default only on the contained home card), never in
the engine. **Note the controls are no longer a per-mode discriminator on the detail pages** — modes A and B
both carry the full seven-knob bar (§3d, §8b resolved to full parity); only the mode-C home card suppresses
them by default (§8b-followup).
**What this resolves about the slot.** Because Mix (mode A) keeps its own mount and the NowPlaying card
(mode C) is a contained mount, the `ReleaseDetailScaffold` slot serves **mode B only** — and that is why it
is named `Ambient`, not `Backdrop` and not `Visualizer`. A `Visualizer` slot would imply Mix routes through
it too; an `Ambient` slot says exactly what it carries: the behind-the-content environment layer for
scaffold-composing detail pages. Mode A and mode C mount the engine without the slot.
**§8b status: RESOLVED — modes A + B + C all live, with FULL PARITY on the controls across all three detail
hosts (Daniel, 2026-06-17).** All three hosting modes ship (Mix is the page, Cut/Session are ambient, the
home card is contained) **and** the seven-knob lava bar + lava-lamp toggle ride every Release Detail host —
Mix, Cut, and Session — not Mix-only. The earlier "mode 1 (Mix + NowPlaying only, Cut/Session plain)"
fallback is closed; the earlier "controls Mix-only" default is overridden by Daniel's full-parity call. The
Cut/Session ambient treatment **plus its in-place tuning controls** is the distinctive-feel win the
generalization buys; the `Ambient` slot carries the field and the controls bar rides the host composition
alongside it, so each detail page is a one-mount-plus-controls-bar composition. **One sub-question remains
(§8b-followup):** whether full parity also extends the seven-knob bar onto the small NowPlaying home card
(mode C) — default **no** (the card stays controls-suppressed), flagged for Daniel.
---
## 4. The bridge, generalized (Wave 12.B)
The bridge (`WaveformVisualizer.razor.cs`, ex-`MixWaveformVisualizer`) needs **one** real change beyond
the rename: its datum fetch must resolve the high-res datum for the **current track**, not for a Mix
release.
Today: `await ReleaseData.GetMixWaveform(ReleaseEntryKey)` → resolves per-release, 404 for non-Mix →
blank. This is the per-*release* fetch the model correction supersedes.
**Generalized (per-track model).** The visualizer renders *the currently playing/selected track's*
waveform, and the bridge already knows that track — it is the `TrackId` it gates `IsActivePlayer` on. So
the fetch should be keyed to the **track**, with the release as addressing context. Two shapes for the
fetch are viable; the spec recommends the first and leaves the exact route to staff-engineer (§5):
- **Track-keyed (recommended):** `await ReleaseData.GetTrackWaveform(trackEntryKey)` → returns the
per-track high-res datum from the generalized waveform vault, keyed by the track's `EntryKey`. This is the
cleanest expression of "the datum is the track's." The bridge needs the current track's `EntryKey`; it
currently carries `TrackId` (the SQL PK) — see §5 on reconciling `TrackId``EntryKey`.
- **Release-addressed, track-resolved:** `GET api/release/{releaseEntryKey}/track/{trackId}/waveform` — the
release locates the track, the track owns the datum. Heavier route, but keeps the release as the
addressing root the bridge already holds (`ReleaseEntryKey`). Use this if resolving `TrackId``EntryKey`
client-side is awkward.
The bridge stays otherwise identical:
- **Re-fetch key changes from release to track.** The fetch-once guard must re-arm when the **track**
changes, not (only) when the release changes — because on a multi-track Cut the release is fixed while the
track scrolls. (Today's guard keys on `_loadedReleaseKey == ReleaseEntryKey`; under the per-track model it
keys on the current track identity.) This is the one bridge subtlety the model correction introduces — and
it is *exactly* the behavior the NowPlaying card already needs (§6c), so the two converge.
- Still derives duration from the cascaded player (`PlayerDurationSeconds`) — the duration source is the
*player*, which already reflects the current track, so the time↔sample mapping is correct per-track for
free.
- Still gates playback coupling on `TrackId` via `IsActivePlayer`.
- Still pushes the seven control dials, the datum, playback, and theme through the unchanged handle.
The `PlaybackPosition` composability fallback (the no-player-cascade path) stays as the documented escape
hatch.
**No open data *question* remains here** — Direction B (committed, §5) means every track has a high-res
datum, so the fetch always resolves to high-res. The open *items* the model created are operational, not
design: the backfill (§8a-new) and the upload-time compute cost (§8b-new).
---
## 5. The datum source — Direction B, per-track (committed)
The visualizer renders a **loudness datum + a duration**. Daniel's model correction makes the datum
**per-track**: every track carries its own high-res waveform datum; the release is just the host. And
Daniel chose **Direction B**: compute that high-res datum for **all** media, not the 512-bucket fallback.
Together these settle §5 — no remaining product call here; the work is a generalization + a backfill.
**Datums in the system today:**
| Datum | Vault | Resolution | Who has it today | Keyed by |
|-------|-------|-----------|------------------|----------|
| Per-track profile | `waveform-profiles` | **512 buckets** (fixed) | **every track** (computed at upload) | track `EntryKey` |
| High-res datum | `mix-waveforms` | **~333 samples/sec** (duration-derived) | **Mix tracks only** (CMS-triggered) | `MixMetadata.WaveformEntryKey` (= the mix track's `EntryKey`) |
The 512-bucket profile already exists per-track and stays (the player-bar `WaveformSeeker` consumes it —
untouched). The high-res datum is the one the lava visualizer wants, and today only Mix tracks have it.
**Direction B closes that gap per-track for every medium.**
### 5a. The generalization (Direction B), end to end
Direction B is **not** just a new endpoint — under the per-track model it touches five surfaces:
1. **Content compute path.** Generalize the duration-derived high-res compute (`WaveformProfileService` +
`MixWaveformResolution`) off its Mix-only framing so it computes a per-track high-res datum for **any**
track from that track's audio. The *algorithm* is unchanged (the same ~333 samples/sec duration-derived
model `MixWaveformResolution` already defines); only its *applicability* widens from "the mix track" to
"any track." Store keyed by the track's `EntryKey`.
2. **Store / vault.** The high-res datum becomes per-track-cardinal. Either rename/repurpose the
`mix-waveforms` vault to a medium-neutral `track-waveforms` (cleaner; consistent with the rename
discipline) or keep the vault and drop the Mix-only key assumption — staff-engineer's call, but the
*name* should stop saying "mix" once it holds every track's datum. `MixMetadata.WaveformEntryKey` is no
longer the only pointer; the datum keys directly off the track's `EntryKey` (the same key the
`waveform-profiles` profile uses).
3. **Upload path.** `UnifiedTrackService.UploadAsync` already computes the 512-bucket profile for every
track at upload; **add the per-track high-res compute alongside it** so every *new* track gets its
high-res datum at upload time, no CMS step required. (Cost flagged — §8b-new.)
4. **CMS generate action.** Generalize the Mix-only "generate waveform" trigger to **any track** — so a
CMS operator can (re)generate a track's high-res datum on demand (the backfill mechanism, and the repair
path if a compute fails at upload). This is the action that drives the backfill (§8a-new).
5. **Backfill.** Every *existing* track that predates Direction B has a 512-bucket profile but no high-res
datum. Backfill computes the per-track high-res datum for all of them. **This is a real operational
step, Daniel-gated** — see §8a-new (does it run as a one-shot migration/script, or as a CMS batch
action over the generalized generate action?).
### 5b. The per-track datum fetch endpoint
Under the per-track model the fetch resolves a datum for a **track**, addressed with the release as
context. **Recommended shape:** `GET api/track/{trackEntryKey}/waveform` (track-cardinal, on
`TrackController`) returning the per-track high-res datum, 404 → graceful blank. This is the natural home
once the datum is the track's, and it sits beside the existing per-track surfaces.
- **Reconciling with the bridge's identifiers.** The bridge today carries `ReleaseEntryKey` + `TrackId`
(the SQL PK), not the track's `EntryKey`. The fetch needs the track's `EntryKey`. Two ways to bridge:
(i) the player/track view-model the bridge already cascades exposes the current track's `EntryKey`
(preferred — the player knows the track it loaded, and `EntryKey` is how audio is keyed), so the bridge
reads `CurrentTrack.EntryKey` directly; or (ii) keep the release as the addressing root and resolve
server-side — `GET api/release/{releaseEntryKey}/track/{trackId}/waveform` — if the client can't cleanly
reach the track's `EntryKey`. **Recommend (i)**; it makes the route track-cardinal and matches the datum's
ownership. Final identifier plumbing is staff-engineer's, but the *route should be track-cardinal*.
- **`/mix/waveform` fate.** The Mix-gated `GET api/release/{entryKey}/mix/waveform` can be retired (folded
into the track-cardinal fetch — the Mix's single track resolves through the same path) or kept as a thin
delegate during transition. Staff-engineer's call; the spec's intent is **one** track-cardinal fetch.
- `IReleaseDataService` (or a track-cardinal `ITrackDataService` if cleaner) gains `GetTrackWaveform(
trackEntryKey)`; the bridge calls it.
**The road not taken (one-line, per Daniel).** A cheaper v1 — serve the existing 512-bucket per-track
profile to non-Mix surfaces and skip new compute entirely ("Direction A") — was considered and **declined**
in favor of uniform high-res. Recorded so the trade isn't relitigated: A was zero-compute but coarse for the
lava; Daniel chose quality + uniformity over the cheaper road.
---
## 6. Overhauling the NowPlayingHero (Wave 12.D)
### 6a. What's there now
`NowPlayingCard.razor` shows the now-playing title/sub plus, when `Player.IsLoaded`, a `waveform-bars`
div of **20 hardcoded `<div class="waveform-bar">`** elements, each with fixed `--h-lo/--h-hi/--dur` CSS
custom properties driving a CSS keyframe bounce. It is **purely synthetic** — no audio data, no coupling
to the actual signal; it bounces on a fixed loop whenever *anything* is loaded. This is the "stochastic
bullshit."
### 6b. The rewire — mount the real engine, driven by live playback
Replace the `waveform-bars` block with a mounted **`<WaveformVisualizer>`** scoped to the card, driven by
the **live cascaded player**:
- The NowPlaying card already cascades `IStreamingPlayerService` (it reads `Player.CurrentTrack`).
- Mount `<WaveformVisualizer ReleaseEntryKey="@Player.CurrentTrack.Release?.EntryKey"
TrackId="@Player.CurrentTrack.Id" />` — the same bridge, the same playback coupling. Because the card is
*inside* the cascade, `IsActivePlayer` is true for whatever is actually playing, and the visualizer
scrolls/animates to the *real* signal. When nothing is playing, it sits at its at-rest slice (the
bridge already handles the no-active-player state → static), which replaces the `waveform-placeholder`.
- The datum comes from §5's per-track fetch keyed on the **current track** (`CurrentTrack.EntryKey`) — so
the home card shows the real high-res waveform of whatever track the listener started, Mix or not. This
is the cleanest illustration of the per-track model: the card has no "release" of its own to speak of; it
follows the *track*, and the datum is the track's.
**This is the payoff of the generalization:** the NowPlaying card is *just another host* (mode C, §3f) of
the same engine, pointed at "whatever track is playing right now." No NowPlaying-specific renderer, no fork
— the DRY win the brief demands. And because the datum is per-track and every track now has a high-res
datum (Direction B), the home card renders at full fidelity for *any* track, not just Mixes.
### 6c. Constraints specific to the NowPlaying context
- **Live, not static.** Unlike a single-track detail page, the NowPlaying card's **track** changes as
playback advances. Under the per-track model (§4) the fetch-once guard re-arms on **track** change (not
release change), so track-change → new per-track datum is handled — and this is the *same* guard change
the per-track model already requires for a multi-track Cut (where the release is fixed but the track
scrolls). The two converge on one behavior: **re-fetch when the current track's identity changes.**
Verify the guard keys on the current track, not the release.
- **Small surface, controls-suppressed by default.** The card is a small hero panel, not a full-bleed page.
Mount the visualizer **controls-suppressed** by default (no lava-lamp, no knob bar) and sized to the card,
not `position: fixed`. **Note this is now the *exception*, not the rule:** under Daniel's §8b full-parity
call the controls ride every Release *Detail* page (Mix, Cut, Session — §3d); the NowPlaying card is the
one host that suppresses them by default, because a seven-knob tuning bar on a small home-card may be
awkward. **Whether full parity extends here too is the open sub-question, §8b-followup** — the seam
supports it either way (the controls are a separate component over shared state). **Flagged for
staff-engineer (§8d):** the
renderer's footer-clip + full-viewport assumptions (`position: fixed; inset: 0`, clip-to-footer) are
written for a full-page backdrop; mounting it in a *contained card* needs the canvas to size to its
container instead of the viewport. This is a real renderer-hosting wrinkle — the engine assumes
full-window today. Either (i) parameterize the visualizer's sizing (full-viewport vs.
fill-container), or (ii) the NowPlaying card is the forcing function to make the canvas
container-relative. **Recommend (i)** — a `Fill` mode parameter — because it also future-proofs any
contained mount (an embed, a CMS preview). This is the one genuine engineering subtlety in the
NowPlaying rewire; everything else is composition.
- **Performance.** A WebGL2 lava render on the *home page* (the highest-traffic, first-paint surface) is a
heavier ask than on a detail page the user navigated to deliberately. Keep the existing `MAX_DPR = 2`
cap and the `isPlaying`-gated rAF loop (it burns no frames when nothing plays — so an idle home page
pays nothing). If the lava is too heavy for the home card specifically, the controls-suppressed ambient
backdrop can run a *cheaper* preset (fewer blobs) via a density default — but do not fork the renderer;
use the existing density dial. **Flagged, not committed (§8d).**
### 6d. What the card keeps
The title/sub text block (`np-title`/`np-sub`) and the "Now Playing" label stay — only the synthetic
`waveform-bars` / `waveform-placeholder` block is replaced by the mounted visualizer. The pulsing
`circle-deco` rings in `NowPlaying.razor` are unrelated decor; leave them.
---
## 7. Wave decomposition + dependency shape
Sequenced so the mechanical rename de-risks everything, the per-track high-res generalization (now the
heaviest wave) unblocks the new hosts, and the NowPlaying rewire (the trickiest, per §6c) comes last on a
proven engine. **Direction B re-costs 12.B materially** — it is no longer "a new endpoint" but a
content + upload + CMS + backfill + fetch slice; the spec splits it into 12.B1 (compute generalization +
backfill) and 12.B2 (the bridge's per-track fetch) so the heavy data work and the thin client rewire can be
sequenced and tested apart.
- **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*` → `Waveform*` across the
five files + the TS module + the import path + the DI registration (§3a). **Load-bearing prerequisite**
— every later wave references the generalized names. Acceptance: Mix detail behaves identically;
diff is identifiers only.
- **12.B1 — Generalize the high-res compute to every track + backfill (the data change, Direction B).**
Generalize the duration-derived compute off Mix-only (`WaveformProfileService` / `MixWaveformResolution`),
store per-track keyed by `EntryKey` in the (renamed) `track-waveforms` vault, add the per-track high-res
compute to `UnifiedTrackService.UploadAsync`, generalize the CMS generate action to any track, and run the
**backfill** for existing tracks (§5a, §8a-new — backfill is Daniel-gated). **Independent of 12.A** (it is
server/content-side, no renamed client identifiers). Acceptance: every track — Mix, Session, and each Cut
track — has a high-res datum; new uploads get one automatically; the generate action works for any track.
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal `GET
api/track/{trackEntryKey}/waveform` (§5b); `GetTrackWaveform`; bridge resolves the *current track's*
`EntryKey` and re-fetches on track-change (§4). **Depends on 12.A (renamed bridge) + 12.B1 (a datum to
fetch).** Acceptance: the Mix detail page renders the same high-res lava via the new track-cardinal fetch;
a non-Mix track now returns a high-res datum.
- **12.C — `Ambient` slot on the scaffold + mount on detail pages (mode B, §3f).** Promote the
full-bleed/foreground/footer-clip pattern into `ReleaseDetailScaffold` as an optional `Ambient` slot
(§3c); Cut mounts the ambient layer **with the full seven-knob bar + lava-lamp toggle** (full parity,
§3d); Session mounts directly **also full-parity** (§3e). Mix is **unchanged** (mode A keeps its own
mount + controls). **Depends on 12.B2** (a datum to render). **§8b is resolved (full parity, no longer
gated)** — Cut and Session ship with both the ambient layer and the controls. This wave also makes the
state-scoping call (§3d note — recommend one shared `WaveformVisualizerControlState`). Acceptance: Mix
unchanged; Cut and Session each show an ambient living layer **with a working seven-knob bar + lava-lamp
toggle**, rendering the current track's datum, no regression to the hero/content.
- **12.D — NowPlayingHero rewire (mode C, §3f).** Replace the synthetic bars with a contained,
controls-suppressed `<WaveformVisualizer>` driven by the live player, pointed at the current track (§6);
add the `Fill`/container-sizing mode (§6c). **Depends on 12.A + 12.B2** (renamed engine + a per-track
datum for whatever's playing). **Independent of 12.C** (different host; doesn't need the scaffold slot).
Acceptance: the home card shows the *real* high-res waveform of the playing track and sits at-rest when
nothing plays; no synthetic bars remain.
**Dependency shape:** `12.A → 12.B2 → (12.C ‖ 12.D)`, with **12.B1 a parallel server-side track** that
12.B2 depends on (`12.B1 → 12.B2`) but that can start cold day one (it needs no renamed client identifiers).
12.A is the cheap mechanical unblock; **12.B1 is the new load-bearing heavy** (compute + backfill); 12.B2 is
the thin client rewire that consumes it; 12.C (detail-page ambient hosts, **full-parity controls**) and 12.D
(NowPlaying host, controls-suppressed by default) are independent siblings off 12.B2. The cold-start items
are **12.A** (touches everything, risks nothing) and **12.B1** (the data work — start it early; the backfill
gate is the only thing blocking it). With §8b resolved to full parity, 12.C no longer carries a mode-1
fallback branch — Cut and Session are in, with controls.
---
## 8. Open product decisions
**Resolved by Daniel (2026-06-17), kept visible per file convention:**
- **§8a (was: non-Mix datum resolution) → Direction B.** High-res compute for **all** media, not the
512-bucket fallback. The cheaper "Direction A" (serve the existing 512-bucket profile to non-Mix) is the
road not taken (§5). Re-cost lands in 12.B1 (compute + backfill).
- **§8c (was: multi-track Cut's waveform) → dissolved by the per-track model.** The datum is per-track; the
visualizer renders the *current* track's datum, so there is no release-level "which track represents the
album" choice to make. The old "first track by `TrackNumber`" recommendation is moot.
- **§8b (Cut/Session hosting + controls) → RESOLVED: all three modes live, FULL PARITY on controls.**
Daniel chose **option (3) — full parity on the lava controls** (over option (2) Mix-only controls): the
visualizer rides every Release Detail host (Mix, Cut, Session) **and** the seven-knob bar + lava-lamp
toggle ride all three, not just Mix. The three-hosting-mode *layout* framing (§3f — visualizer-is-the-page
on Mix, ambient on Cut/Session, contained on NowPlaying) is retained; "backdrop" stays retired as the
wrong word for Mix. The only thing the flip changed: the `WaveformVisualizerControls` mount is **no longer
Mix-suppressed** on Cut/Session. The earlier mode-1 fallback (Mix + NowPlaying only) and the "controls
Mix-only" default are both closed. One sub-question spun out — §8b-followup below.
**Newly open (created by the full-parity flip and by Direction B + the per-track model):**
**§8b-followup — Do the full-parity controls extend onto the NowPlaying home card (mode C)?**
Daniel's §8b full-parity call (option 3) was answered against the Release *Detail*-page framing — Mix, Cut,
and Session all get the seven-knob bar + lava-lamp toggle. The **NowPlaying home card** is a different host:
the contained mode-C panel, a small hero card on the highest-traffic surface, **not** a full detail page.
The spec applies full-parity controls to the three detail pages and keeps the NowPlaying card
**controls-suppressed by default** (a living waveform readout with no knobs). Open: does Daniel want the
seven-knob bar on the home card too, or does it stop at the detail pages? **A seven-knob tuning bar on a
small now-playing card may be awkward** — it competes with the card's compact title/sub layout and puts an
expert affordance on the landing surface — which is why the default is *suppressed*. But the seam supports
it either way (the controls are a separate component over shared `WaveformVisualizerControlState`), so
flipping the card to controls-on is a one-line composition change in 12.D, not an engine change.
**Recommend keeping the card controls-suppressed** (full parity = detail pages; the home card stays a clean
ambient readout) — but this is a Daniel call, surfaced not buried. Affects only 12.D's mount; everything
else in the phase is settled by the full-parity resolution.
**§8a-new — How does the high-res backfill run, and is it gated?**
Direction B means every *existing* track needs its per-track high-res datum computed once. Two shapes:
(1) a **one-shot migration/script** run against the content store (like the Daniel-gated EF migrations in
Phase 11 — authored, applied on Daniel's go-ahead); or (2) a **CMS batch action** over the generalized
generate action (an operator triggers "generate all," visible progress, re-runnable for failures). **Recommend
(2)** — it reuses the generalized generate action 12.B1 builds anyway, needs no separate one-shot tooling,
and the CMS already has the generate-status surface (Phase 8 §8.2's in-grid status column + generate
actions) to hang it on. **This is a Daniel-gated operational step either way** — like the Phase 11 release
migrations, the backfill is authored in 12.B1 but *run* on Daniel's word. Open: which shape, and when it
runs relative to shipping the per-track fetch (the fetch 404s gracefully for not-yet-backfilled tracks, so
the fetch can ship before the backfill completes — the visualizer just blanks until a track is backfilled).
**§8b-new — Per-track high-res compute at upload: perf + storage cost (flag for Daniel).**
Direction B adds a duration-derived high-res compute (~333 samples/sec) to **every** track upload, on top
of the existing 512-bucket profile. Two costs to surface:
- **Upload latency.** The high-res compute walks the full audio at upload time. For a long Mix this is
already paid (Mixes compute it today); for short Cut tracks it is small. But it is now on the *upload hot
path* for every track, where before only the cheap 512-bucket profile was. If upload latency matters,
the compute could be **deferred** (upload returns; high-res computes async/queued, the generate action
the backfill uses doubling as the async worker) rather than inline. **Recommend: inline for now**
(simplest; the 512-bucket compute is already inline and the high-res isn't dramatically heavier for
typical tracks), flag deferral as the escape hatch if upload feels slow.
- **Storage.** A per-track high-res datum (~333 samples/sec × duration) is materially larger than the
fixed 512-bucket profile, and now *every* track stores one (a multi-track Cut stores N of them). For an
album of short tracks this is modest; for a library of long Sessions it adds up. **Not a blocker** — disk
is cheap and the datum is loudness samples, not audio — but flagged so the storage growth from "every
track gets a high-res datum" is a known consequence, not a surprise. **No action recommended;** surface
only.
**§8d — NowPlaying container-sizing + home-page performance (engineering subtleties, staff-engineer-owned
but flag for Daniel).** The renderer assumes full-viewport (`position: fixed; inset: 0`, clip-to-footer);
the NowPlaying card (mode C, §3f) needs it container-relative (§6c) — recommend a `Fill` mode parameter.
And a WebGL2 lava render on the home page's first paint is heavier than on a detail page — the
`isPlaying`-gated rAF means an idle home page pays nothing, but a cheaper blob-density preset for the card
is a fallback if needed. Neither blocks; both are tuning/hosting calls surfaced so Daniel isn't surprised
by a lava lamp on the landing page.
---
## 9. Acceptance criteria (observable)
1. **Rename clean.** The engine is named for its abstraction (`WaveformVisualizer*`); the Mix detail page
is visually and behaviorally identical to before the rename.
2. **Per-track high-res datum everywhere (Direction B).** Every track — Mix, Session, and each track of a
Cut — has a high-res duration-derived datum keyed by its `EntryKey`; new uploads get one automatically;
the generalized CMS generate action produces one for any track; the backfill has populated all
pre-existing tracks.
3. **Track-cardinal fetch.** `GET api/track/{trackEntryKey}/waveform` (or the agreed track-cardinal route)
returns the current track's high-res datum, 404 → graceful blank. The bridge resolves the *current
track* and re-fetches on track change.
4. **Mix unchanged (mode A).** The Mix detail page still renders the high-res lava with the seven-knob bar +
lava-lamp toggle, visualizer-is-the-page, at parity with the Phase 10 reframe — now via the
track-cardinal fetch.
5. **Ambient + controls on Cut/Session (mode B, full parity).** A Cut and a Session detail page show an
ambient living waveform layer rendering the *currently selected/playing track's* datum, **with a working
seven-knob bar + lava-lamp toggle** (full parity — controls are present and tunable in place, not
suppressed), with no regression to the hero/content.
6. **NowPlaying is real (mode C), controls-suppressed by default.** The home NowPlaying card shows the
*actual* high-res waveform of the playing track (scrolls/animates to the real signal, changes with track
changes), sits at-rest when nothing plays, and mounts **controls-suppressed by default** (no knob bar, no
lava-lamp — pending §8b-followup). No hardcoded synthetic bars remain.
7. **One engine, three modes.** Mix (mode A), the Cut/Session ambient layer (mode B), and the NowPlaying
card (mode C) all consume the *same* `WaveformVisualizer` component + renderer + state — verified by
there being exactly one of each, no per-host fork; the differences are hosting composition only
(full-bleed vs. `Ambient` slot vs. contained; viewport- vs. container-sized; controls present on all
three detail hosts, suppressed-by-default on the contained home card).
8. **Read-only everywhere.** No host (including the NowPlaying card) exposes a seek/scrub/transport via
the visualizer; the read-only contract holds on every mount.
9. **Bridge intact, re-keyed to track.** Single-owner handle, idempotent datum guard, `IsActivePlayer`
gating, and the `isPlaying`-gated rAF loop are unchanged across all mounts; the datum guard now re-arms
on **track** change (the multi-track Cut and the NowPlaying card both rely on this — they converge).