# Audio + Video — separated Visual & Audio controls — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Split the bundled 7-way **Content** selector into two orthogonal controls — **Visual** (on/off) × **Audio** (off / soundtrack / white-noise) — give the experience real sound (per-altitude soundtracks that follow the Altitude dial + a synthesized white-noise bed), and reframe the still-unbuilt networked-control-surface spec to match.

**Architecture:** Built into the **current single-page simulator** (FastAPI + `simulator/static/index.html`), not the unbuilt SSE/`/remote` control surface — per the standing *simulator-first* directive, the networked surface is "v1 hardware" and deferred. A pure Python resolver (`player/audio.py`) maps `(visual, audio source, current altitude)` → `render.video.shown` + `render.audio.{source,url}`; `/api/alteration` resolves it **server-side** from the request's `altitude_index`; the browser gains an A/B `<audio>` crossfade layer. Per-scale soundtracks and the white-noise bed are produced by a deterministic ffmpeg pass (the existing `tools/pipeline` style) and wired through a new `Scale.audio` manifest field. This build *is* the "Content step" the audio spec §11 anticipates folding into the control surface later.

**Tech Stack:** Python 3 · FastAPI · pytest (unit + FastAPI `TestClient` contract) · pytest-playwright (E2E) · ffmpeg via `imageio_ffmpeg.get_ffmpeg_exe()` (no system ffmpeg) · vanilla JS client.

## Global Constraints

- **No system ffmpeg.** Resolve the binary with `tools.pipeline.run.resolve_ffmpeg()` (PATH `ffmpeg`, else `imageio_ffmpeg.get_ffmpeg_exe()`). Never assume a system `ffmpeg`/`ffprobe`.
- **Media is gitignored.** Audio assets live under `simulator/sample_media/audio/<scale>/<file>` (and `audio/noise/<file>`), served at `/media/audio/...`. Commit *generators and the manifest*, never media bytes.
- **Pure arg builders, no I/O.** Every ffmpeg command is a `list[str]` from a pure function (mirrors `tools/pipeline/ffmpeg_ops.py`), unit-tested without running ffmpeg. Only the thin runner shells out.
- **Audio source enum (v1):** `off` · `soundtrack` · `white_noise`. `music` is a **reserved, deferred** position — do **not** add it (no assets). **Visual enum:** `on` · `off`.
- **Soundtrack follows the single Altitude dial.** No separate audio altitude. White-noise is altitude-independent.
- **Audio = live** (no transition/`settled` ack); short ~0.6 s renderer-side gain crossfade. **Visual = transitional** video fade (reuses the existing `#black` fade). Altitude unchanged (transitional).
- **Asset params (locked):** output **mp3** `-q:a 4`; loudness-normalize to **I = −18 LUFS**, TP = −1.5 dBTP, LRA = 11 across all five soundtracks + the noise bed; white-noise color **pink** (`anoisesrc=color=pink`), ~60 s; audio seamless loop mirrors the proven video `crossfade_loop_args` recipe.
- **Back-compat:** `Scale.audio` defaults to `""` (pre-audio manifests still load). Adding fields must not break existing `test_player_ring.py` / `test_clips.py`.
- **Testing tiers (mandatory, spec §9):** pure unit (resolver), API/contract (`/api/alteration`, `/api/ring`), Playwright E2E (renderer audio element). Ship through the §9 pipeline (localhost+E2E → PPE → prod, prod human-gated).

---

## File Structure

**New files:**
- `tools/pipeline/audio_ops.py` — pure ffmpeg arg builders for audio: seamless loop, loudnorm, white-noise synth.
- `tools/pipeline/audio_run.py` — thin runner: produce one normalized soundtrack loop; generate the white-noise bed.
- `simulator/build_audio_media.py` — reproducible production pass over the 5 sourced tracks + the pink-noise bed (the audio analogue of `build_pool_manifest.py --media`).
- `player/audio.py` — pure resolver: `VISUAL_POSITIONS`, `AUDIO_SOURCES`, `resolve_visual()`, `resolve_audio_url()`, `AudioResolution`.
- `tests/test_pipeline_audio_ops.py` — unit tests for the arg builders.
- `tests/test_player_audio.py` — unit tests for the resolver.
- `tests/test_e2e_audio.py` — Playwright E2E (opt-in / skip-if-no-browser).

**Modified files:**
- `player/ring.py:51-71` — add `audio: str = ""` to `Scale`.
- `simulator/clips.py:76-130` — read `audio` in `_scale_from_dict`; emit it in `ring_to_dict`.
- `simulator/build_pool_manifest.py:39-60,354-366` — add `SCALE_AUDIO` + emit per-scale `audio`.
- `player/controls.py:14-67` — reframe the serial `Controls` contract: `content` → `visual` + `audio`.
- `simulator/app.py:29-30,97-105,166-186` — `ControlsModel` (visual+audio+altitude_index), `/api/alteration` server-side audio resolution, noise-url constant, import swap.
- `simulator/static/index.html:24-35` — replace the Content `<select>` with a Visual toggle + Audio selector.
- `simulator/static/app.js` — `controls()`, `update()` (render.video/render.audio), audio A/B layer + crossfade, autoplay gesture.
- `docs/superpowers/specs/2026-06-26-networked-control-surface-design.md` — reframe §3/§4/§5/§6.2 (content → visual+audio).
- `docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md` — record the §12 resolutions.
- `ROADMAP.md` — note the audio capability.

**Retired:**
- `player/content.py` + `tests/test_player_content.py` — the 7-way table is replaced by `player/audio.py`.

---

## Task 1: Audio ffmpeg arg builders (pure)

**Files:**
- Create: `tools/pipeline/audio_ops.py`
- Test: `tests/test_pipeline_audio_ops.py`

**Interfaces:**
- Produces:
  - `audio_loop_args(src, dst, *, duration: float, overlap: float, ff="ffmpeg") -> list[str]`
  - `loudnorm_args(src, dst, *, i=-18.0, tp=-1.5, lra=11.0, ff="ffmpeg") -> list[str]`
  - `white_noise_args(dst, *, duration=60.0, color="pink", ff="ffmpeg") -> list[str]`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_pipeline_audio_ops.py
"""Unit tests for the pure audio ffmpeg arg builders (no ffmpeg run)."""
import pytest
from tools.pipeline.audio_ops import audio_loop_args, loudnorm_args, white_noise_args


def test_loop_args_mirror_the_video_crossfade_recipe():
    args = audio_loop_args("in.mp3", "out.mp3", duration=30.0, overlap=2.0, ff="FF")
    assert args[0] == "FF"
    joined = " ".join(args)
    # tail crossfades over head, then concats the middle — the audio analogue of
    # crossfade_loop_args. Output length = duration - overlap.
    assert "acrossfade=d=2.0" in joined
    assert "atrim=0:2.0" in joined          # head
    assert "atrim=28.0:30.0" in joined      # tail (d-overlap : d)
    assert "concat=n=2:v=0:a=1" in joined
    assert "-map" in args and "[out]" in args
    assert "-q:a" in args and "4" in args


def test_loop_args_reject_overlap_past_half():
    with pytest.raises(ValueError):
        audio_loop_args("in.mp3", "out.mp3", duration=10.0, overlap=5.0)


def test_loudnorm_args_carry_the_locked_targets():
    args = loudnorm_args("in.mp3", "out.mp3", ff="FF")
    joined = " ".join(args)
    assert "loudnorm=I=-18.0:TP=-1.5:LRA=11.0" in joined
    assert args[0] == "FF" and args[-1] == "out.mp3"


def test_white_noise_args_are_pink_and_clean():
    args = white_noise_args("noise.mp3", duration=60.0, color="pink", ff="FF")
    joined = " ".join(args)
    assert "anoisesrc=color=pink" in joined
    assert "-t" in args and "60.0" in args
    assert args[-1] == "noise.mp3"


def test_white_noise_rejects_unknown_color():
    with pytest.raises(ValueError):
        white_noise_args("noise.mp3", color="ultraviolet")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_pipeline_audio_ops.py -v`
Expected: FAIL — `ModuleNotFoundError: tools.pipeline.audio_ops`.

- [ ] **Step 3: Write the implementation**

```python
# tools/pipeline/audio_ops.py
"""Pure ffmpeg argument builders (no I/O) for the audio production pass.

The audio analogue of tools/pipeline/ffmpeg_ops.py: each function returns a
list[str] ready for ffmpeg, so command construction is unit-testable without
running ffmpeg. tools/pipeline/audio_run.py executes them. The ffmpeg binary
name is injected (default "ffmpeg")."""

from __future__ import annotations

_NOISE_COLORS = frozenset({"white", "pink", "brown", "blue", "violet"})


def audio_loop_args(src, dst, *, duration: float, overlap: float,
                    ff: str = "ffmpeg") -> list[str]:
    """Make `src` loop seamlessly by crossfading its tail over its head — the
    audio mirror of ffmpeg_ops.crossfade_loop_args. Output length = duration -
    overlap. Requires overlap < duration/2 so a non-empty middle remains."""
    if overlap <= 0 or overlap >= duration / 2:
        raise ValueError(f"overlap {overlap} must be in (0, duration/2={duration / 2})")
    d, o = duration, overlap
    fc = (
        f"[0:a]atrim=0:{o},asetpts=N/SR/TB[head];"
        f"[0:a]atrim={o}:{d - o},asetpts=N/SR/TB[mid];"
        f"[0:a]atrim={d - o}:{d},asetpts=N/SR/TB[tail];"
        f"[tail][head]acrossfade=d={o}:c1=tri:c2=tri[xf];"
        f"[xf][mid]concat=n=2:v=0:a=1[out]"
    )
    return [
        ff, "-y", "-i", str(src), "-filter_complex", fc,
        "-map", "[out]", "-c:a", "libmp3lame", "-q:a", "4", str(dst),
    ]


def loudnorm_args(src, dst, *, i: float = -18.0, tp: float = -1.5,
                  lra: float = 11.0, ff: str = "ffmpeg") -> list[str]:
    """EBU R128 loudness normalize to the locked installation target so all five
    soundtracks (and the noise bed) sit at one comfortable level."""
    return [
        ff, "-y", "-i", str(src),
        "-af", f"loudnorm=I={i}:TP={tp}:LRA={lra}",
        "-c:a", "libmp3lame", "-q:a", "4", str(dst),
    ]


def white_noise_args(dst, *, duration: float = 60.0, color: str = "pink",
                     ff: str = "ffmpeg") -> list[str]:
    """Synthesize a calm colored-noise bed (default pink) of `duration` seconds —
    deterministic, zero licensing. Loops cleanly (steady-state noise has no seam)."""
    if color not in _NOISE_COLORS:
        raise ValueError(f"unknown noise color {color!r}; expected one of {sorted(_NOISE_COLORS)}")
    return [
        ff, "-y", "-f", "lavfi",
        "-i", f"anoisesrc=color={color}:amplitude=0.5:duration={duration}",
        "-t", str(duration), "-c:a", "libmp3lame", "-q:a", "4", str(dst),
    ]
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_pipeline_audio_ops.py -v`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/pipeline/audio_ops.py tests/test_pipeline_audio_ops.py
git commit -m "feat(audio): pure ffmpeg arg builders for loop/loudnorm/white-noise"
```

---

## Task 2: Audio production runner + asset build

**Files:**
- Create: `tools/pipeline/audio_run.py`
- Create: `simulator/build_audio_media.py`
- Test: `tests/test_pipeline_audio_ops.py` (add an opt-in integration test)

**Interfaces:**
- Consumes: `tools.pipeline.audio_ops` (Task 1), `tools.pipeline.run.resolve_ffmpeg`, `tools.pipeline.run.probe_duration` patterns.
- Produces:
  - `audio_run.process_soundtrack(src, dst, *, overlap=2.0, ff=None) -> Path`
  - `audio_run.generate_white_noise(dst, *, duration=60.0, color="pink", ff=None) -> Path`
  - `simulator/build_audio_media.py` with `SOURCES: dict[str, tuple[str, str]]` (scale → (raw filename, output filename)) and `main()`.

- [ ] **Step 1: Write the failing opt-in integration test**

Append to `tests/test_pipeline_audio_ops.py`:

```python
import shutil
from pathlib import Path

_HAVE_FFMPEG = shutil.which("ffmpeg") is not None or _imageio_ffmpeg_present()


def _imageio_ffmpeg_present() -> bool:
    try:
        import imageio_ffmpeg  # noqa: F401
        return True
    except Exception:
        return False


@pytest.mark.skipif(not _HAVE_FFMPEG, reason="no ffmpeg available")
def test_generate_white_noise_writes_a_playable_loop(tmp_path):
    from tools.pipeline.audio_run import generate_white_noise
    out = generate_white_noise(tmp_path / "pink.mp3", duration=2.0)
    assert out.exists() and out.stat().st_size > 0
```

(Move the `_imageio_ffmpeg_present` definition above its use; the snippet shows intent.)

- [ ] **Step 2: Run to verify it fails**

Run: `pytest tests/test_pipeline_audio_ops.py::test_generate_white_noise_writes_a_playable_loop -v`
Expected: FAIL — `ModuleNotFoundError: tools.pipeline.audio_run`.

- [ ] **Step 3: Write the runner**

```python
# tools/pipeline/audio_run.py
"""Thin runner: probe duration, then run the audio production pass (seamless
loop → loudness normalize) and synthesize the white-noise bed. The pure arg
builders (tools.pipeline.audio_ops) are unit-tested; this glue is covered by the
opt-in integration test."""

from __future__ import annotations

import subprocess
from pathlib import Path

from .audio_ops import audio_loop_args, loudnorm_args, white_noise_args
from .run import resolve_ffmpeg


def _probe_audio_duration(src, ff: str) -> float:
    """Audio length in seconds via ffmpeg (no system ffprobe): decode to null and
    read the reported time. Falls back by re-encoding measurement if needed."""
    import re
    proc = subprocess.run(
        [ff, "-i", str(src), "-f", "null", "-"],
        capture_output=True, text=True,
    )
    # ffmpeg prints "time=HH:MM:SS.ss" on the last progress line of stderr.
    times = re.findall(r"time=(\d+):(\d+):(\d+\.\d+)", proc.stderr)
    if not times:
        raise ValueError(f"could not probe audio duration of {src}")
    h, m, s = times[-1]
    return int(h) * 3600 + int(m) * 60 + float(s)


def _run(args: list[str]) -> None:
    subprocess.run(args, check=True, capture_output=True)


def process_soundtrack(src, dst, *, overlap: float = 2.0, ff: str | None = None) -> Path:
    """Produce a seamless, loudness-normalized loop from a sourced ambience clip.
    Clips shorter than 2*overlap are normalized only (too short to crossfade-loop)."""
    ff = ff or resolve_ffmpeg()
    src, dst = Path(src), Path(dst)
    dst.parent.mkdir(parents=True, exist_ok=True)
    duration = _probe_audio_duration(src, ff)
    tmp = dst.with_suffix(".loop.tmp.mp3")
    if duration > 2 * overlap + 0.5:
        _run(audio_loop_args(src, tmp, duration=duration, overlap=overlap, ff=ff))
        _run(loudnorm_args(tmp, dst, ff=ff))
        tmp.unlink(missing_ok=True)
    else:
        _run(loudnorm_args(src, dst, ff=ff))
    return dst


def generate_white_noise(dst, *, duration: float = 60.0, color: str = "pink",
                         ff: str | None = None) -> Path:
    """Synthesize + normalize the global white-noise bed."""
    ff = ff or resolve_ffmpeg()
    dst = Path(dst)
    dst.parent.mkdir(parents=True, exist_ok=True)
    tmp = dst.with_suffix(".raw.tmp.mp3")
    _run(white_noise_args(tmp, duration=duration, color=color, ff=ff))
    _run(loudnorm_args(tmp, dst, ff=ff))
    tmp.unlink(missing_ok=True)
    return dst
```

- [ ] **Step 4: Write the asset-build script**

```python
# simulator/build_audio_media.py
"""Production pass for the 5 per-altitude soundtracks + the white-noise bed
(audio spec §5.3). The audio analogue of build_pool_manifest.py --media: read the
sourced ambience clips (re-downloadable per docs/audio-candidate-pool.md), make
each a seamless loudness-normalized loop, and synthesize the pink-noise bed.

Media is gitignored; this script is the reproducible record. Outputs land under
simulator/sample_media/audio/<scale>/<name>.mp3 and audio/noise/pink.mp3, served
at /media/audio/...  Run:  python simulator/build_audio_media.py
"""

from __future__ import annotations

from pathlib import Path

from tools.pipeline.audio_run import generate_white_noise, process_soundtrack

AUDIO = Path(__file__).parent / "sample_media" / "audio"

# scale -> (sourced raw file, production output file), both under audio/<scale>/.
# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks).
SOURCES: dict[str, tuple[str, str]] = {
    "cosmos": ("pillars.mp3", "pillars.loop.mp3"),
    "orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"),
    "coast": ("waves.mp3", "waves.loop.mp3"),
    "reef": ("soundscape.mp3", "soundscape.loop.mp3"),
    "abyss": ("whale.wav", "whale.loop.mp3"),
}


def main() -> None:
    for scale, (raw, out) in SOURCES.items():
        src = AUDIO / scale / raw
        if not src.exists():
            print(f"  SKIP {scale}: source missing ({src}) — re-source per audio-candidate-pool.md")
            continue
        dst = process_soundtrack(src, AUDIO / scale / out)
        print(f"  produced {dst.relative_to(AUDIO.parent)}")
    noise = generate_white_noise(AUDIO / "noise" / "pink.mp3")
    print(f"  produced {noise.relative_to(AUDIO.parent)}")


if __name__ == "__main__":
    main()
```

- [ ] **Step 5: Run the unit/integration tests**

Run: `pytest tests/test_pipeline_audio_ops.py -v`
Expected: PASS (white-noise integration test runs if ffmpeg is present, else skips).

- [ ] **Step 6: Produce the real assets**

Run: `python simulator/build_audio_media.py`
Expected: prints `produced audio/<scale>/<name>.loop.mp3` for each present source + `produced audio/noise/pink.mp3`. (Sources absent → re-source per `docs/audio-candidate-pool.md` "Re-download / re-bake" first.)

Verify the produced files exist and are non-empty:

Run: `ls -la simulator/sample_media/audio/*/*.loop.mp3 simulator/sample_media/audio/noise/pink.mp3`
Expected: six files, all > 0 bytes.

- [ ] **Step 7: Commit (generators only — media is gitignored)**

```bash
git add tools/pipeline/audio_run.py simulator/build_audio_media.py tests/test_pipeline_audio_ops.py
git commit -m "feat(audio): production-pass runner + build script for soundtracks + pink-noise bed"
```

---

## Task 3: `Scale.audio` field + manifest wiring

**Files:**
- Modify: `player/ring.py:51-71`
- Modify: `simulator/clips.py:76-130`
- Modify: `simulator/build_pool_manifest.py:39-60,354-366`
- Test: `tests/test_player_ring.py`, `tests/test_simulator_api.py`

**Interfaces:**
- Produces: `Scale.audio: str` (relative path under `/media/audio/`, e.g. `"cosmos/pillars.loop.mp3"`, or `""`); `ring_to_dict` scale entries gain `"audio"`.

- [ ] **Step 1: Write the failing tests**

Add to `tests/test_player_ring.py`:

```python
def test_scale_carries_an_optional_audio_path():
    from player.ring import Scale
    s = Scale(id="coast", clip_id="coast_birdrock", audio="coast/waves.loop.mp3")
    assert s.audio == "coast/waves.loop.mp3"
    # back-compat: audio defaults to empty
    assert Scale(id="x", clip_id="x").audio == ""
```

Add to `tests/test_simulator_api.py` (near `test_ring_exposes_each_scales_pool`):

```python
def test_ring_exposes_each_scales_audio(client):
    body = client.get("/api/ring").json()
    by_id = {s["id"]: s for s in body["scales"]}
    # every scale advertises an audio path the client resolves the soundtrack from
    assert by_id["coast"]["audio"].endswith(".mp3")
    assert "audio" in by_id["cosmos"]
```

- [ ] **Step 2: Run to verify failure**

Run: `pytest tests/test_player_ring.py::test_scale_carries_an_optional_audio_path tests/test_simulator_api.py::test_ring_exposes_each_scales_audio -v`
Expected: FAIL — `TypeError: __init__() got an unexpected keyword 'audio'` and `KeyError: 'audio'`.

- [ ] **Step 3: Add the field to `Scale`** (`player/ring.py`)

In the `Scale` dataclass (currently lines 51-71), add the field after `pool`:

```python
    id: str
    clip_id: str
    pool: tuple[str, ...] = ()
    audio: str = ""              # per-scale soundtrack, relative to /media/audio/ (audio spec §5.1)
```

- [ ] **Step 4: Read + emit `audio`** (`simulator/clips.py`)

In `_scale_from_dict` (lines 76-85), pass it through:

```python
    pool = tuple(s.get("pool", []))
    clip_id = s.get("clip_id") or (pool[0] if pool else "")
    return Scale(id=s["id"], clip_id=clip_id, pool=pool, audio=s.get("audio", ""))
```

In `ring_to_dict` (lines 108-130), add `audio` to each scale dict:

```python
            {
                "id": s.id,
                "clip_id": s.clip_id,
                "title": titles.get(s.clip_id, s.id),
                "audio": s.audio,
                "pool": [
                    {"clip_id": cid, "title": titles.get(cid, cid)}
                    for cid in s.members
                ],
            }
```

- [ ] **Step 5: Emit per-scale `audio` from the manifest builder** (`simulator/build_pool_manifest.py`)

After `RING_ORDER` (line 49) add the production-output paths from Task 2's `SOURCES`:

```python
# Per-scale soundtrack (audio spec §5.1) — the production-pass output of
# simulator/build_audio_media.py, served at /media/audio/<path>.
SCALE_AUDIO: dict[str, str] = {
    "cosmos": "cosmos/pillars.loop.mp3",
    "orbit": "orbit/spaceamb.loop.mp3",
    "coast": "coast/waves.loop.mp3",
    "reef": "reef/soundscape.loop.mp3",
    "abyss": "abyss/whale.loop.mp3",
}
```

In `build_manifest()` (line 360), attach it to each scale entry:

```python
    scales = [
        {"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s], "audio": SCALE_AUDIO[s]}
        for s in RING_ORDER
    ]
```

- [ ] **Step 6: Regenerate the manifest**

Run: `python simulator/build_pool_manifest.py`
Expected: `wrote .../manifest.json — 19 clips, 5 ring scales`. Confirm each ring scale now has `"audio"`:

Run: `python -c "import json; r=json.load(open('simulator/sample_media/manifest.json'))['ring']; print([s.get('audio') for s in r['scales']])"`
Expected: the five `.loop.mp3` paths.

- [ ] **Step 7: Run the tests**

Run: `pytest tests/test_player_ring.py tests/test_clips.py tests/test_simulator_api.py -v`
Expected: PASS (including the two new tests; the regenerated manifest carries `audio`).

- [ ] **Step 8: Commit**

```bash
git add player/ring.py simulator/clips.py simulator/build_pool_manifest.py simulator/sample_media/manifest.json tests/test_player_ring.py tests/test_simulator_api.py
git commit -m "feat(audio): Scale.audio field + per-scale soundtrack wiring in the manifest"
```

---

## Task 4: Visual×Audio resolver (`player/audio.py`), retire `content.py`

**Files:**
- Create: `player/audio.py`
- Test: `tests/test_player_audio.py`
- Delete: `player/content.py`, `tests/test_player_content.py`

**Interfaces:**
- Produces:
  - `VISUAL_POSITIONS = frozenset({"on", "off"})`
  - `AUDIO_SOURCES = frozenset({"off", "soundtrack", "white_noise"})`
  - `resolve_visual(position: str) -> bool` (on → True)
  - `resolve_audio_url(source: str, *, scale_audio: str, noise_url: str) -> str | None`
  - `AudioResolution` dataclass `{source: str, url: str | None, altitude_coupled: bool}`
  - `resolve_audio(source, *, scale_audio, noise_url) -> AudioResolution`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_player_audio.py
"""Unit tests for the pure Visual×Audio resolver (audio spec §4/§6)."""
import pytest
from player.audio import (
    AUDIO_SOURCES, VISUAL_POSITIONS, AudioResolution,
    resolve_audio, resolve_audio_url, resolve_visual,
)

NOISE = "/media/audio/noise/pink.mp3"


def test_visual_on_off():
    assert resolve_visual("on") is True
    assert resolve_visual("off") is False


def test_visual_rejects_unknown():
    with pytest.raises(ValueError):
        resolve_visual("flicker")


def test_soundtrack_resolves_to_the_current_scales_asset():
    url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
    assert url == "/media/audio/coast/waves.loop.mp3"


def test_white_noise_resolves_to_the_global_bed_independent_of_altitude():
    url = resolve_audio_url("white_noise", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
    assert url == NOISE


def test_off_resolves_to_silence():
    assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3", noise_url=NOISE) is None


def test_soundtrack_with_no_scale_asset_is_silent():
    # a scale with no soundtrack authored yet → no url (renderer plays nothing)
    assert resolve_audio_url("soundtrack", scale_audio="", noise_url=NOISE) is None


def test_resolve_audio_marks_only_soundtrack_altitude_coupled():
    r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3", noise_url=NOISE)
    assert isinstance(r, AudioResolution)
    assert r.source == "soundtrack" and r.altitude_coupled is True
    assert r.url == "/media/audio/reef/soundscape.loop.mp3"
    assert resolve_audio("white_noise", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
    assert resolve_audio("off", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False


def test_resolve_audio_url_rejects_unknown_source():
    with pytest.raises(ValueError):
        resolve_audio_url("podcast", scale_audio="", noise_url=NOISE)


def test_music_is_not_a_v1_source():
    assert "music" not in AUDIO_SOURCES
```

- [ ] **Step 2: Run to verify failure**

Run: `pytest tests/test_player_audio.py -v`
Expected: FAIL — `ModuleNotFoundError: player.audio`.

- [ ] **Step 3: Write the resolver**

```python
# player/audio.py
"""Resolve the orthogonal Visual × Audio controls into render outputs (audio spec
§4/§6). The single source of truth for: whether the projector shows video, and
which audio url plays for a given (audio source, current altitude).

Replaces player/content.py's 7-way bundled table. White-noise is a global,
altitude-independent bed; soundtrack follows the single Altitude dial; off is
silence. `music` is a reserved, deferred source — not in v1."""

from __future__ import annotations

from dataclasses import dataclass

VISUAL_POSITIONS = frozenset({"on", "off"})
# v1 audio sources. "music" is reserved/deferred (no assets) and intentionally absent.
AUDIO_SOURCES = frozenset({"off", "soundtrack", "white_noise"})


@dataclass(frozen=True)
class AudioResolution:
    source: str
    url: str | None
    altitude_coupled: bool


def resolve_visual(position: str) -> bool:
    """Visual on/off → whether the renderer shows video (vs fade-to-black)."""
    if position not in VISUAL_POSITIONS:
        raise ValueError(
            f"unknown visual position {position!r}; expected one of {sorted(VISUAL_POSITIONS)}"
        )
    return position == "on"


def resolve_audio_url(source: str, *, scale_audio: str, noise_url: str) -> str | None:
    """The audio url for (source, current altitude): soundtrack → the current
    scale's asset (or None if none authored); white_noise → the global bed; off →
    None. `scale_audio` is the ring scale's `audio` path (relative to /media/audio/)."""
    if source not in AUDIO_SOURCES:
        raise ValueError(
            f"unknown audio source {source!r}; expected one of {sorted(AUDIO_SOURCES)}"
        )
    if source == "off":
        return None
    if source == "white_noise":
        return noise_url
    # soundtrack — couple to the current altitude's asset
    return f"/media/audio/{scale_audio}" if scale_audio else None


def resolve_audio(source: str, *, scale_audio: str, noise_url: str) -> AudioResolution:
    """The full render.audio view: source, resolved url, and whether it follows the
    Altitude dial (true only for soundtrack)."""
    url = resolve_audio_url(source, scale_audio=scale_audio, noise_url=noise_url)
    return AudioResolution(source=source, url=url, altitude_coupled=(source == "soundtrack"))
```

- [ ] **Step 4: Run the tests**

Run: `pytest tests/test_player_audio.py -v`
Expected: PASS (10 tests).

- [ ] **Step 5: Retire the old content module**

```bash
git rm player/content.py tests/test_player_content.py
```

(Task 5 and Task 6 remove the remaining imports of `player.content` / `CONTENT_POSITIONS`. Do not run the full suite until those land.)

- [ ] **Step 6: Commit**

```bash
git add player/audio.py tests/test_player_audio.py
git commit -m "feat(audio): pure Visual×Audio resolver; retire the 7-way content table"
```

---

## Task 5: Reframe the serial `Controls` contract

**Files:**
- Modify: `player/controls.py:14-67`
- Test: `tests/test_player_controls.py`

**Interfaces:**
- Consumes: `player.audio.VISUAL_POSITIONS`, `player.audio.AUDIO_SOURCES` (Task 4).
- Produces: `Controls` dataclass with `visual: str` + `audio: str` (replacing `content`); `validate_controls`/`parse_controls` enforce the two enums.

- [ ] **Step 1: Update the failing tests**

In `tests/test_player_controls.py`, replace the `content`-based cases. The valid payload and the invalid-enum cases become:

```python
def _valid() -> dict:
    return {"visual": "on", "audio": "soundtrack",
            "left": 0, "right": 0, "dark": 0, "light": 0,
            "volume": 2, "brightness": 2}


def test_parses_a_valid_visual_audio_payload():
    from player.controls import parse_controls
    c = parse_controls(_valid())
    assert c.visual == "on" and c.audio == "soundtrack"


def test_rejects_bad_visual():
    from player.controls import parse_controls, ControlsError
    import pytest
    with pytest.raises(ControlsError):
        parse_controls({**_valid(), "visual": "flicker"})


def test_rejects_bad_audio_source():
    from player.controls import parse_controls, ControlsError
    import pytest
    with pytest.raises(ControlsError):
        parse_controls({**_valid(), "audio": "music"})   # reserved/deferred, not v1
```

(Update any other `content`-keyed assertions in the file to the `visual`/`audio` shape.)

- [ ] **Step 2: Run to verify failure**

Run: `pytest tests/test_player_controls.py -v`
Expected: FAIL — `Controls.__init__` has no `visual`/`audio`.

- [ ] **Step 3: Reframe the contract** (`player/controls.py`)

Replace the `CONTENT_POSITIONS` import/definition and the `content` field:

```python
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS

KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
KNOB_MIN = 0
KNOB_MAX = 4


class ControlsError(ValueError):
    """Raised when a Controls payload is structurally invalid."""


@dataclass(frozen=True)
class Controls:
    visual: str
    audio: str
    left: int
    right: int
    dark: int
    light: int
    volume: int
    brightness: int


def validate_controls(c: Controls) -> None:
    """Raise ControlsError if the payload is structurally invalid."""
    if c.visual not in VISUAL_POSITIONS:
        raise ControlsError(
            f"invalid visual position {c.visual!r}; expected one of {sorted(VISUAL_POSITIONS)}"
        )
    if c.audio not in AUDIO_SOURCES:
        raise ControlsError(
            f"invalid audio source {c.audio!r}; expected one of {sorted(AUDIO_SOURCES)}"
        )
    for name in KNOB_FIELDS:
        value = getattr(c, name)
        if isinstance(value, bool) or not isinstance(value, int):
            raise ControlsError(f"knob {name} must be an int, got {value!r}")
        if not (KNOB_MIN <= value <= KNOB_MAX):
            raise ControlsError(f"knob {name}={value} out of range {KNOB_MIN}..{KNOB_MAX}")
```

`parse_controls` is unchanged (it derives `known` from `fields(Controls)`). Update the module docstring's "7-way content dial" line to "Visual toggle + Audio source selector (audio spec §3)".

- [ ] **Step 4: Run the tests**

Run: `pytest tests/test_player_controls.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add player/controls.py tests/test_player_controls.py
git commit -m "feat(audio): reframe serial Controls contract content -> visual + audio"
```

---

## Task 6: `/api/alteration` contract — visual + audio + server-side url

**Files:**
- Modify: `simulator/app.py:29-30,97-105,166-186`
- Test: `tests/test_simulator_api.py`

**Interfaces:**
- Consumes: `player.audio.resolve_visual`, `player.audio.resolve_audio`, `player.audio.VISUAL_POSITIONS`, `player.audio.AUDIO_SOURCES`; `player.ring.scale_at`.
- Produces: `/api/alteration` response `{plan, render: {video: {shown: bool}, audio: {source, url, altitude_coupled}}}`. Request: `{controls: {visual, audio, left, right, dark, light, volume, brightness}, altitude_index: int = 0, calibration?}`.

- [ ] **Step 1: Update the failing contract tests**

In `tests/test_simulator_api.py`, replace `test_alteration_honors_off_as_black` / `test_alteration_rejects_bad_content` and add audio cases. Use the existing client fixture:

```python
def _controls(**over) -> dict:
    base = {"visual": "on", "audio": "off",
            "left": 0, "right": 0, "dark": 0, "light": 0,
            "volume": 2, "brightness": 2}
    base.update(over)
    return base


def test_alteration_returns_render_video_and_audio(client):
    body = client.post("/api/alteration", json={"controls": _controls()}).json()
    assert body["render"]["video"]["shown"] is True
    assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}


def test_alteration_visual_off_hides_video(client):
    body = client.post("/api/alteration", json={"controls": _controls(visual="off")}).json()
    assert body["render"]["video"]["shown"] is False


def test_alteration_soundtrack_resolves_url_for_the_given_altitude(client):
    # altitude_index 0 = cosmos (ring order); soundtrack couples to its asset
    body = client.post("/api/alteration", json={
        "controls": _controls(audio="soundtrack"), "altitude_index": 0,
    }).json()
    a = body["render"]["audio"]
    assert a["source"] == "soundtrack" and a["altitude_coupled"] is True
    assert a["url"].startswith("/media/audio/cosmos/")


def test_alteration_white_noise_is_altitude_independent(client):
    for idx in (0, 2, 4):
        body = client.post("/api/alteration", json={
            "controls": _controls(audio="white_noise"), "altitude_index": idx,
        }).json()
        assert body["render"]["audio"]["url"] == "/media/audio/noise/pink.mp3"
        assert body["render"]["audio"]["altitude_coupled"] is False


def test_alteration_rejects_bad_audio(client):
    r = client.post("/api/alteration", json={"controls": _controls(audio="music")})
    assert r.status_code == 422


def test_alteration_rejects_bad_visual(client):
    r = client.post("/api/alteration", json={"controls": _controls(visual="dim")})
    assert r.status_code == 422
```

- [ ] **Step 2: Run to verify failure**

Run: `pytest tests/test_simulator_api.py -k "alteration" -v`
Expected: FAIL — old model rejects `visual`/`audio`; no `render` key.

- [ ] **Step 3: Update imports + model + endpoint** (`simulator/app.py`)

Replace the content import (lines 29-30):

```python
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
from player.ring import (
    DEFAULT_FAST_SPIN_THRESHOLD,
    advance_ring,
    pick_clip_id,
    scale_at,
)
```

Add a noise-url constant near the path constants (after line 41):

```python
# The global white-noise bed (audio spec §5.2): synthesized, altitude-independent.
NOISE_URL = "/media/audio/noise/pink.mp3"
```

Replace `ControlsModel` (lines 97-105):

```python
class ControlsModel(BaseModel):
    visual: str
    audio: str
    left: int = Field(ge=0, le=4)
    right: int = Field(ge=0, le=4)
    dark: int = Field(ge=0, le=4)
    light: int = Field(ge=0, le=4)
    volume: int = Field(ge=0, le=4)
    brightness: int = Field(ge=0, le=4)
```

Add `altitude_index` to `AlterationRequest` (lines 113-115):

```python
class AlterationRequest(BaseModel):
    controls: ControlsModel
    altitude_index: int = 0
    calibration: Optional[CalibrationModel] = None
```

Rewrite `api_alteration` (lines 166-186):

```python
    @app.post("/api/alteration")
    def api_alteration(req: AlterationRequest):
        c = req.controls
        if c.visual not in VISUAL_POSITIONS:
            raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
        if c.audio not in AUDIO_SOURCES:
            raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!r}")
        coord = Coordinate(c.left, c.right, c.dark, c.light)
        cal = (
            Calibration(
                mood_gain=req.calibration.mood_gain,
                overlay_gain=req.calibration.overlay_gain,
                dream_gain=req.calibration.dream_gain,
            )
            if req.calibration
            else DEFAULT_CALIBRATION
        )
        plan = plan_alteration(coord, cal)
        # Resolve the soundtrack url against the CURRENT altitude (server-side, audio
        # spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
        scale_audio = ""
        if app.state.ring is not None:
            scale_audio = scale_at(app.state.ring, req.altitude_index).audio
        audio = resolve_audio(c.audio, scale_audio=scale_audio, noise_url=NOISE_URL)
        return {
            "plan": render_plan_to_dict(plan),
            "render": {
                "video": {"shown": resolve_visual(c.visual)},
                "audio": {
                    "source": audio.source,
                    "url": audio.url,
                    "altitude_coupled": audio.altitude_coupled,
                },
            },
        }
```

- [ ] **Step 4: Run the contract tests**

Run: `pytest tests/test_simulator_api.py -v`
Expected: PASS (the new alteration cases + unchanged ring/media/author cases).

- [ ] **Step 5: Run the full Python suite**

Run: `pytest -q`
Expected: PASS — confirms no stray `player.content` / `CONTENT_POSITIONS` references remain anywhere.

- [ ] **Step 6: Commit**

```bash
git add simulator/app.py tests/test_simulator_api.py
git commit -m "feat(audio): /api/alteration carries visual + server-resolved render.audio"
```

---

## Task 7: Client — split the Content selector into Visual + Audio

**Files:**
- Modify: `simulator/static/index.html:24-35`
- Modify: `simulator/static/app.js` (`controls()` ~494-505, `update()` ~508-530, listeners ~906-908)

**Interfaces:**
- Consumes: `/api/alteration` response `render.video.shown` + `render.audio` (Task 6).
- Produces: DOM ids `visual` (checkbox), `audio` (select); `controls()` returns `{visual, audio, ...}`; `update()` posts `altitude_index: ringIndex` and reads `data.render`. Exposes `applyAudio(data.render.audio)` (defined in Task 8) — call site added here, no-op until Task 8 lands.

- [ ] **Step 1: Replace the Content fieldset** (`simulator/static/index.html`, lines 24-35)

```html
      <fieldset>
        <legend>Visual</legend>
        <label class="toggle"><input type="checkbox" id="visual" checked /> show video</label>
      </fieldset>

      <fieldset>
        <legend>Audio</legend>
        <select id="audio">
          <option value="off">off (silence)</option>
          <option value="soundtrack">soundtrack (follows altitude)</option>
          <option value="white_noise">white noise</option>
        </select>
      </fieldset>
```

Add the audio A/B elements inside `.screen` (after the `<video>` at line 14) — Task 8 drives them; declaring them here keeps the markup in one place:

```html
        <audio id="audA" loop preload="auto"></audio>
        <audio id="audB" loop preload="auto"></audio>
```

- [ ] **Step 2: Update `controls()`** (`simulator/static/app.js`, lines 494-505)

```javascript
function controls() {
  const mood = +$("mood").value;
  return {
    visual: $("visual").checked ? "on" : "off",
    audio: $("audio").value,
    left: +$("left").value, right: +$("right").value,
    dark: mood < 0 ? -mood : 0, light: mood > 0 ? mood : 0,
    volume: 2, brightness: 2,
  };
}
```

- [ ] **Step 3: Update `update()`** to send altitude + read `render` (lines 508-530)

```javascript
async function update() {
  if (busy) return;
  const resp = await fetch("/api/alteration", {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
  });
  if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
  const data = await resp.json();
  readout.textContent = JSON.stringify(data, null, 2);
  applyAudio(data.render.audio);                       // Task 8 — audio crossfade layer
  if (!data.render.video.shown) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
  black.classList.add("hidden");
  try {
    ensureClipMedia();
    applyVideoLook(data.plan.grade.tone, data.plan.dream.intensity);
    renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
    renderAffect(data.plan.affect.strength, data.plan.affect.intensity, data.plan.dream.strength);
    const b = document.getElementById("err-banner");
    if (b) b.remove();
  } catch (err) {
    showError("render failed: " + (err && err.message ? err.message : err));
    throw err;
  }
}
```

- [ ] **Step 4: Update the listener loop** (lines 906-908)

```javascript
for (const id of ["visual", "audio", "left", "right", "mood"]) {
  $(id).addEventListener("input", debounced);
}
```

- [ ] **Step 5: Add a temporary no-op `applyAudio`** so the page runs before Task 8

Near the top of `app.js` (after the state declarations ~line 40):

```javascript
// Replaced by the real A/B crossfade layer in the audio section below.
function applyAudio(_audio) { /* no-op until the audio layer is wired (Task 8) */ }
```

- [ ] **Step 6: Smoke-test the page boots**

Run: `python -m uvicorn simulator.app:app --port 8011 &` then `curl -s localhost:8011/ | grep -c 'id="audio"'`
Expected: `1` (the new Audio selector is served). Stop the server.

- [ ] **Step 7: Commit**

```bash
git add simulator/static/index.html simulator/static/app.js
git commit -m "feat(audio): split Content selector into Visual toggle + Audio source"
```

---

## Task 8: Client — audio A/B crossfade layer + autoplay gesture

**Files:**
- Modify: `simulator/static/app.js` (replace the Task-7 no-op `applyAudio`; add the crossfade helper + the start-gesture overlay)
- Modify: `simulator/static/style.css` (the click-to-start overlay)

**Interfaces:**
- Consumes: `render.audio = {source, url, altitude_coupled}` from `update()`.
- Produces: real `applyAudio(audio)` — crossfades the active `<audio>` to `audio.url` (or fades to silence for `null`); `audioReady` gating + a one-time start gesture.

- [ ] **Step 1: Replace the no-op `applyAudio` with the crossfade layer**

Remove the Task-7 placeholder and add this block (e.g. after `update()`):

```javascript
// --- Audio layer: two <audio> elements, gain-crossfaded (audio spec §8) ---
const audA = $("audA"), audB = $("audB");
let audActive = audA, audIdle = audB;   // which element is currently audible
let audUrl = null;                      // the url currently playing (null = silence)
let audioReady = false;                 // unlocked after the first user gesture (autoplay policy)
const XFADE_MS = 600;                   // ~0.6s gain crossfade — "live", no busy ack (§7)

function fadeVolume(el, to, ms) {
  const from = el.volume, t0 = performance.now();
  return new Promise((resolve) => {
    function tick(now) {
      const k = Math.min(1, (now - t0) / ms);
      el.volume = from + (to - from) * k;
      if (k < 1) requestAnimationFrame(tick);
      else resolve();
    }
    requestAnimationFrame(tick);
  });
}

// Reconcile the audio layer to render.audio: crossfade the active element to the
// new url (or fade to silence for off). White-noise and soundtrack are the same
// mechanism — only the url differs; an unchanged url is a no-op so altitude
// re-rolls on the same scale don't restart the bed.
async function applyAudio(audio) {
  const url = audio ? audio.url : null;
  if (url === audUrl) return;
  audUrl = url;
  if (!audioReady) return;              // deferred until the start gesture (§8)
  if (!url) {                           // fade current out to silence
    await fadeVolume(audActive, 0, XFADE_MS);
    audActive.pause();
    return;
  }
  audIdle.src = mediaUrl(url);          // reuse the blob/version-aware resolver
  audIdle.volume = 0;
  await audIdle.play().catch(() => {});
  await Promise.all([fadeVolume(audIdle, 1, XFADE_MS), fadeVolume(audActive, 0, XFADE_MS)]);
  audActive.pause();
  [audActive, audIdle] = [audIdle, audActive];   // swap roles
}
```

Note: `mediaUrl()` already version/blob-resolves a path; audio urls are absolute `/media/...`, so add a guard in `mediaUrl` to pass through absolute urls unchanged:

```javascript
function mediaUrl(file) {
  if (file.startsWith("/media/")) return file;   // already a resolved absolute url (audio)
  return mediaBlobs[file] || mediaNetUrl(file);
}
```

- [ ] **Step 2: Add the one-time start gesture (autoplay policy)**

```javascript
// Browsers block autoplay until a user gesture. The renderer is launched
// full-screen by the operator; a one-time tap unlocks audio, then it follows
// state. (audio spec §8 — documented launch step.)
function showStartGesture() {
  const ov = document.createElement("div");
  ov.id = "start-gesture";
  ov.textContent = "▶ tap to start sound";
  ov.addEventListener("click", async () => {
    audioReady = true;
    // prime both elements within the gesture so later play() calls are allowed
    for (const el of [audA, audB]) { try { await el.play(); el.pause(); } catch (_) {} }
    ov.remove();
    update();                            // re-apply current state, now with audio
  }, { once: true });
  document.body.appendChild(ov);
}
window.addEventListener("DOMContentLoaded", showStartGesture);
```

- [ ] **Step 3: Style the overlay** (`simulator/static/style.css`)

```css
#start-gesture {
  position: fixed; inset: 0; z-index: 10000;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0,0,0,0.82); color: #fff;
  font: 600 28px/1.2 system-ui, sans-serif; letter-spacing: 0.02em;
  cursor: pointer; user-select: none;
}
```

- [ ] **Step 4: Manual verification (where a browser is available)**

Run: `python -m uvicorn simulator.app:app --port 8011`
In a browser at `http://localhost:8011/`: tap to start → set **Audio = white noise** (hear the bed); switch **Audio = soundtrack** (hear the current altitude's ambience, crossfaded); turn **Altitude** down a scale (soundtrack crossfades to the new scale); toggle **Visual = off** (video fades to black, audio keeps playing). *(Per memory, the dev box may lack a browser — then this is verified at the PPE stage; the E2E test in Task 10 asserts the same via element `src`.)*

- [ ] **Step 5: Commit**

```bash
git add simulator/static/app.js simulator/static/style.css
git commit -m "feat(audio): A/B gain-crossfade audio layer + one-time start gesture"
```

---

## Task 9: Reframe the control-surface spec + roadmap + decision record

**Files:**
- Modify: `docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`
- Modify: `docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md`
- Modify: `ROADMAP.md`

This is a clean pre-implementation edit (the control surface is unbuilt) — no code depends on it; do it as documentation so the eventual hardware build inherits the split.

- [ ] **Step 1: Reframe §3 (control inventory)** in the control-surface spec — replace the single **Content** row of the §3 table with two rows:

```
| **Visual** | toggle (on/off) | video shown vs black | 2-pos switch |
| **Audio** | off / soundtrack / white noise (·music deferred) | the audio source | 3-pos rotary selector |
```

Update the surrounding prose ("the five controls" → "the six controls") and the §3 intro line that lists the inventory.

- [ ] **Step 2: Reframe §4 (live vs transitional)** — change "Transitional controls — Altitude, Content/mode" to "Altitude, Visual"; add Audio to the **live** controls ("Left, Right, Mood, Audio"); update the `transitions: { altitude, content }` line to `transitions: { altitude, visual }`.

- [ ] **Step 3: Reframe §5 (server contract)**:
  - §5.1 snapshot JSON: `controls.content` → `controls.visual` + `controls.audio`; `transitions.content` → `transitions.visual`; `render.content` → `render.video` + `render.audio` (matching the audio spec §6.1 shape).
  - §5.2 `POST /api/control` examples: a `{set:{content}}` example becomes `{set:{visual}}` and `{set:{audio}}`.
  - §5.3 SSE: the `mode` event note repurposed to the **Visual** fade; add that `ring` also carries the new `audio.url`.

- [ ] **Step 4: Reframe §6.2 (`/remote` controls)** — the controller's control list "Content selector" → "Visual toggle + Audio selector".

- [ ] **Step 5: Record the §12 resolutions** in the audio spec — under §12, mark each flagged item resolved: audio knob = **live** (~0.6 s crossfade); white-noise = **pink**; autoplay = **one-time tap-to-start overlay**. Add a one-line `**Status:** proposed → in implementation (plan 2026-06-26)` note near the header.

- [ ] **Step 6: Note the capability in `ROADMAP.md`** — add a line under the relevant sub-project that the simulator now has per-altitude soundtracks + a white-noise bed via separated Visual/Audio controls (the "Content step" of the future control surface).

- [ ] **Step 7: Commit**

```bash
git add docs/superpowers/specs/2026-06-26-networked-control-surface-design.md docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md ROADMAP.md
git commit -m "docs(audio): reframe control-surface spec content->visual+audio; record decisions"
```

---

## Task 10: Playwright E2E (opt-in / skip-if-no-browser)

**Files:**
- Create: `tests/test_e2e_audio.py`
- Modify: `requirements-dev.txt` (or the project's dev-deps file) — add `pytest-playwright`.

**Interfaces:**
- Consumes: the running app (`simulator.app:app`) + a real browser via `pytest-playwright`'s `page` fixture.

The spec mandates a Playwright tier for the UI surface (§9). The dev box may lack a browser (memory, session 0017), so the suite **skips cleanly** when Playwright/Chromium is unavailable, keeping unit+contract green; it runs at the PPE stage where a browser is present.

- [ ] **Step 1: Add the dev dependency**

Add `pytest-playwright` to the dev requirements. Then (where a browser is wanted): `python -m playwright install chromium`.

- [ ] **Step 2: Write the E2E test**

```python
# tests/test_e2e_audio.py
"""Playwright E2E for the audio layer (audio spec §9). Skips cleanly when
Playwright or its browser binary is absent (the headless dev box) — the unit +
contract tiers cover the logic; this asserts the rendered <audio> element."""
import socket
import threading
import time
from contextlib import closing

import pytest

playwright = pytest.importorskip("playwright.sync_api")


def _free_port() -> int:
    with closing(socket.socket()) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


@pytest.fixture(scope="module")
def base_url():
    import uvicorn
    from simulator.app import app

    port = _free_port()
    config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
    server = uvicorn.Server(config)
    thread = threading.Thread(target=server.run, daemon=True)
    thread.start()
    for _ in range(50):
        if server.started:
            break
        time.sleep(0.1)
    yield f"http://127.0.0.1:{port}"
    server.should_exit = True
    thread.join(timeout=5)


@pytest.fixture
def page(base_url):
    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        try:
            browser = p.chromium.launch()
        except Exception as exc:                       # no browser binary installed
            pytest.skip(f"chromium not available: {exc}")
        pg = browser.new_page()
        pg.goto(base_url)
        pg.click("#start-gesture")                     # unlock autoplay
        yield pg
        browser.close()


def test_white_noise_loads_the_noise_asset(page):
    page.select_option("#audio", "white_noise")
    page.wait_for_function(
        "document.getElementById('audA').src.includes('/media/audio/noise/pink.mp3')"
        " || document.getElementById('audB').src.includes('/media/audio/noise/pink.mp3')"
    )


def test_visual_off_keeps_audio_and_fades_video(page):
    page.select_option("#audio", "white_noise")
    page.uncheck("#visual")
    page.wait_for_selector("#black:not(.hidden)")       # video faded to black
    playing = page.evaluate(
        "[document.getElementById('audA'), document.getElementById('audB')]"
        ".some(a => !a.paused && a.src)"
    )
    assert playing is True                               # audio survives the video fade
```

- [ ] **Step 3: Run it**

Run: `pytest tests/test_e2e_audio.py -v`
Expected: PASS where Chromium is installed; **SKIPPED** (not failed) where it is absent.

- [ ] **Step 4: Commit**

```bash
git add tests/test_e2e_audio.py requirements-dev.txt
git commit -m "test(audio): Playwright E2E for the renderer audio element (skip-if-no-browser)"
```

---

## Final: full suite + ship through the pipeline

- [ ] **Run the full Python suite** — `pytest -q`. Expected: all green (E2E skips where no browser).
- [ ] **Localhost smoke** — boot `simulator.app`, exercise Visual/Audio/Altitude by eye where a browser exists.
- [ ] **§9 pipeline (executing-plans / finalize):** localhost+E2E → **PPE** (provision if absent) + E2E → prod (human-gated release tag). The audio media is gitignored; the deploy must run `python simulator/build_audio_media.py` (and re-source per `docs/audio-candidate-pool.md` if the raw clips are absent) so the served `/media/audio/...` assets exist on the box.

---

## Self-Review (against the spec)

- **§2 orthogonalize Content → Visual×Audio** → Tasks 4–7 (resolver, model, client split). ✓
- **§3 controls table / 6 controls** → Task 7 (client), Task 9 (spec reframe). ✓
- **§4 audio model (soundtrack follows Altitude; white-noise global; off silent; music deferred)** → Task 4 resolver + Task 6 server resolution; `music` excluded by Global Constraints. ✓
- **§5.1 per-scale soundtrack + `Scale.audio`** → Task 3. **§5.2 synthesized pink white-noise** → Tasks 1–2. **§5.3 production pass (loop+loudnorm)** → Tasks 1–2. ✓
- **§6 server contract (render.video + render.audio; server-side url)** → Task 6. ✓
- **§7 live vs transitional (Audio live ~0.6s crossfade; Visual transitional fade)** → Global Constraints + Task 8 (`XFADE_MS`) + Task 7 (`#black` fade). ✓
- **§8 renderer audio layer (A/B crossfade, loops, autoplay gesture)** → Task 8. ✓
- **§9 testing tiers (unit / contract / Playwright E2E)** → Tasks 1,3,4 (unit), 3,6 (contract), 10 (E2E). ✓
- **§11 spec/roadmap impact** → Task 9. ✓
- **§12 flagged decisions (knob feel / noise color / autoplay)** → resolved in Global Constraints + Task 9 record. ✓
