From 70834ae0ad794d2416dc3db61a91aa16b8cf01e1 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 18:05:34 -0700 Subject: [PATCH] docs(plan): sub-project 3 player alteration core (slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-logic core per design §4/§5/§6/§7 and ROADMAP.md §3. --- .../2026-06-05-player-alteration-core.md | 867 ++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-player-alteration-core.md diff --git a/docs/superpowers/plans/2026-06-05-player-alteration-core.md b/docs/superpowers/plans/2026-06-05-player-alteration-core.md new file mode 100644 index 0000000..fecca65 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-player-alteration-core.md @@ -0,0 +1,867 @@ +# Player Alteration Core 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:** Build the pure-logic core of sub-project 3 (the Player Runtime + alteration engine) — a new `player/` package that turns control-panel state into a render plan and playback transitions, fully unit-tested with all I/O (video/audio/serial) behind injected interfaces. + +**Architecture:** Four small, pure modules mirroring `hef/` conventions (frozen dataclasses, `from __future__ import annotations`, string-constant frozensets, thorough unit tests, no heavy deps). `controls` models the panel state (the serial-contract data shape shared with sub-project 4); `content` resolves the 7-way content dial; `alteration` is the heart — knob vector → `RenderPlan` per design §4/§5; `state` is the player state machine that diffs successive controls into playback transitions (crossfade / fade-to-black / live-update). No mpv, no GPU, no real serial, no white-noise DSP, no v2v pipeline — those are later integration slices. + +**Tech Stack:** Python 3.11+, stdlib only (`dataclasses`, `math`), pytest. Reuses `hef.selection.Coordinate` for the knob vector. + +**Design reference:** `docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md` (§4 alteration engine, §5 mood grade, §6 7-way content dial, §7 intensity) and `docs/ROADMAP.md` §3 (Player Runtime "done when"). + +**Calibration decision (flagged for operator):** brain knobs use `strength = value/4` (0=off, 4=max); mood uses `tone = (light−dark)/4` (equal dark/light = identity per §5). This reads §3's "(2,2,2,2) neutral" as a vestige of the old coordinate-grid center. The calibration lives in three one-line helpers so the convention can be flipped trivially. See the session 0006 transcript's Deferred decisions. + +--- + +## File Structure + +- `player/__init__.py` — package marker (empty). +- `player/controls.py` — `Controls` frozen dataclass (content position + 4 experience knobs + volume + brightness), `CONTENT_POSITIONS`, `validate_controls`, `parse_controls` (from a decoded mapping; wire framing is the separate 3⇄4 serial contract). +- `player/content.py` — `ContentResolution` (audio source + video flag), `AUDIO_SOURCES`, `resolve_content` (the §6 7-row table, single source of truth). +- `player/alteration.py` — `ColorGrade`, `AnalyticalOverlay`, `Restyle`, `RenderPlan`, `plan_alteration(coord)`, and the calibration helpers `_overlay_intensity`, `_restyle_blend`, `_mood_tone`. +- `player/state.py` — `Playback`, `TransitionKind`, `Transition`, `Player` (consumes a stream of `Controls`, emits a `Transition` per update; output sink is injected/duck-typed; base-clip choice is an injected callable). +- `pyproject.toml` — add `"player"` to `[tool.setuptools] packages`. + +Tests (one per module): `tests/test_player_controls.py`, `tests/test_player_content.py`, `tests/test_player_alteration.py`, `tests/test_player_state.py`. + +--- + +## Task 1: Controls model (the serial-contract data shape) + +**Files:** +- Create: `player/__init__.py` +- Create: `player/controls.py` +- Test: `tests/test_player_controls.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_controls.py +import pytest + +from player.controls import ( + Controls, + CONTENT_POSITIONS, + ControlsError, + validate_controls, + parse_controls, +) + + +def test_content_positions_are_the_seven_from_the_spec(): + assert CONTENT_POSITIONS == frozenset( + {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"} + ) + + +def test_valid_controls_pass_validation(): + c = Controls(content="video", left=0, right=4, dark=2, light=2, volume=3, brightness=4) + validate_controls(c) # does not raise + + +def test_invalid_content_position_rejected(): + c = Controls(content="bogus", left=0, right=0, dark=0, light=0, volume=0, brightness=0) + with pytest.raises(ControlsError): + validate_controls(c) + + +@pytest.mark.parametrize("field", ["left", "right", "dark", "light", "volume", "brightness"]) +@pytest.mark.parametrize("bad", [-1, 5, True]) +def test_out_of_range_or_non_int_knob_rejected(field, bad): + kwargs = dict(content="off", left=0, right=0, dark=0, light=0, volume=0, brightness=0) + kwargs[field] = bad + with pytest.raises(ControlsError): + validate_controls(Controls(**kwargs)) + + +def test_parse_controls_from_mapping(): + c = parse_controls( + {"content": "music_video", "left": 1, "right": 2, "dark": 3, "light": 0, "volume": 2, "brightness": 1} + ) + assert c == Controls("music_video", 1, 2, 3, 0, 2, 1) + + +def test_parse_controls_rejects_unknown_keys(): + with pytest.raises(ControlsError): + parse_controls({"content": "off", "left": 0, "right": 0, "dark": 0, + "light": 0, "volume": 0, "brightness": 0, "bogus": 1}) + + +def test_parse_controls_rejects_missing_keys(): + with pytest.raises(ControlsError): + parse_controls({"content": "off", "left": 0}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_controls.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'player'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# player/__init__.py +``` +(empty file) + +```python +# player/controls.py +"""Control-panel state: the data shape read from the Arduino over serial. + +This is the serial-contract payload shared with sub-project 4 (firmware). It +models the full panel from design §6/§7: the 7-way content dial, the four +experience knobs (0..4), and the two intensity levels (volume, brightness). +The wire framing itself is the separate 3<->4 serial contract; this module is +the *decoded* form. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields + +# The seven positions of the content dial (design §6). +CONTENT_POSITIONS = frozenset( + {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"} +) + +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: + content: 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.content not in CONTENT_POSITIONS: + raise ControlsError( + f"invalid content position {c.content!r}; " + f"expected one of {sorted(CONTENT_POSITIONS)}" + ) + 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}" + ) + + +def parse_controls(data: dict) -> Controls: + """Build a validated Controls from a decoded mapping, rejecting unknown or + missing keys.""" + known = {f.name for f in fields(Controls)} + unknown = set(data) - known + if unknown: + raise ControlsError(f"unknown keys: {sorted(unknown)}") + missing = known - set(data) + if missing: + raise ControlsError(f"missing keys: {sorted(missing)}") + c = Controls(**data) + validate_controls(c) + return c +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_player_controls.py -v` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add player/__init__.py player/controls.py tests/test_player_controls.py +git commit -m "feat(player): Controls panel model (serial-contract data shape) + +Sub-project 3 slice 1, per ROADMAP.md §3 and design §6/§7." +``` + +--- + +## Task 2: Content-dial resolution (§6) + +**Files:** +- Create: `player/content.py` +- Test: `tests/test_player_content.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_content.py +import pytest + +from player.content import AUDIO_SOURCES, ContentResolution, resolve_content + + +def test_audio_sources_are_the_four_distinct_sources(): + assert AUDIO_SOURCES == frozenset({"none", "white_noise", "music", "audio_track"}) + + +# The §6 table, row by row: position -> (audio_source, video). +@pytest.mark.parametrize( + "position,audio_source,video", + [ + ("off", "none", False), + ("white_noise", "white_noise", False), + ("music", "music", False), + ("audio_track", "audio_track", False), + ("video", "none", True), + ("music_video", "music", True), + ("audio_video", "audio_track", True), + ], +) +def test_resolve_content_matches_spec_table(position, audio_source, video): + assert resolve_content(position) == ContentResolution(audio_source=audio_source, video=video) + + +def test_off_is_void_state_black_and_silent(): + r = resolve_content("off") + assert r.video is False and r.audio_source == "none" + + +def test_resolve_content_rejects_unknown_position(): + with pytest.raises(ValueError): + resolve_content("bogus") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_content.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'player.content'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# player/content.py +"""Resolve the 7-way content dial into an audio source + video on/off (§6). + +The single source of truth for design §6's table: which audio source plays and +whether the projector shows video, for each dial position. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# The four distinct audio sources. "white_noise" is generated at runtime; +# "music" is the public-domain classical pool; "audio_track" is the clip's own +# field audio; "none" is silence. +AUDIO_SOURCES = frozenset({"none", "white_noise", "music", "audio_track"}) + + +@dataclass(frozen=True) +class ContentResolution: + audio_source: str + video: bool + + +# Design §6, one row per dial position. +_TABLE = { + "off": ContentResolution("none", False), + "white_noise": ContentResolution("white_noise", False), + "music": ContentResolution("music", False), + "audio_track": ContentResolution("audio_track", False), + "video": ContentResolution("none", True), + "music_video": ContentResolution("music", True), + "audio_video": ContentResolution("audio_track", True), +} + + +def resolve_content(position: str) -> ContentResolution: + """Map a content-dial position to its audio source and video flag (§6).""" + try: + return _TABLE[position] + except KeyError: + raise ValueError( + f"unknown content position {position!r}; expected one of {sorted(_TABLE)}" + ) from None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_player_content.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add player/content.py tests/test_player_content.py +git commit -m "feat(player): 7-way content-dial resolution (design §6)" +``` + +--- + +## Task 3: Alteration engine — knob vector to RenderPlan (§4, §5) + +**Files:** +- Create: `player/alteration.py` +- Test: `tests/test_player_alteration.py` + +The engine maps a knob vector `(left, right, dark, light)` to a layered `RenderPlan` per design §4.2: +- **Substrate** transforms (alter pixels): `Restyle` (Right, v2v) blended with `ColorGrade` (mood, Dark/Light). +- **Overlay** on top: `AnalyticalOverlay` (Left, HUD/labels). + +`ColorGrade.tone` is signed: `>0` warm yellow→white (light pole), `<0` cool blue→black (dark pole), `0` identity/raw (§5). Calibration (flagged decision): `overlay = left/4`, `restyle = right/4`, `tone = (light − dark)/4`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_alteration.py +import pytest + +from hef.selection import Coordinate +from player.alteration import ( + AnalyticalOverlay, + ColorGrade, + RenderPlan, + Restyle, + plan_alteration, +) + + +def _coord(left=0, right=0, dark=0, light=0): + return Coordinate(left=left, right=right, dark=dark, light=light) + + +def test_all_zero_knobs_is_the_unaltered_base(): + plan = plan_alteration(_coord()) + assert plan.is_identity + assert plan.overlay.intensity == 0.0 + assert plan.restyle.blend == 0.0 + assert plan.grade.tone == 0.0 + assert plan.grade.is_identity + + +def test_left_drives_the_analytical_overlay_only(): + plan = plan_alteration(_coord(left=4)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 0.0 # Left does not touch the substrate + assert plan.grade.tone == 0.0 + + +def test_right_drives_the_restyle_substrate_only(): + plan = plan_alteration(_coord(right=2)) + assert plan.restyle.blend == 0.5 + assert plan.overlay.intensity == 0.0 # Right does not add overlay + + +def test_left_and_right_stack_not_cancel(): + # design §4.2: whole-brain corner = dreamlike substrate WITH labels on top + plan = plan_alteration(_coord(left=4, right=4)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 1.0 + + +def test_light_pole_grades_warm_positive_tone(): + plan = plan_alteration(_coord(light=4)) + assert plan.grade.tone == 1.0 + assert not plan.grade.is_identity + + +def test_dark_pole_grades_cool_negative_tone(): + plan = plan_alteration(_coord(dark=4)) + assert plan.grade.tone == -1.0 + + +def test_equal_dark_and_light_is_identity_grade(): + # design §5: the mood center is the raw, ungraded footage + assert plan_alteration(_coord(dark=3, light=3)).grade.is_identity + assert plan_alteration(_coord(dark=2, light=2)).grade.is_identity + + +def test_dark_minus_light_sets_intermediate_tone(): + assert plan_alteration(_coord(dark=4, light=2)).grade.tone == pytest.approx(-0.5) + assert plan_alteration(_coord(dark=1, light=3)).grade.tone == pytest.approx(0.5) + + +def test_whole_brain_dark_corner_stacks_grade_substrate_and_overlay(): + # design §4.2 "Dark + analytical": cold measurement over a melancholy scene + plan = plan_alteration(_coord(left=4, right=2, dark=4, light=0)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 0.5 + assert plan.grade.tone == -1.0 + assert not plan.is_identity + + +def test_render_plan_is_frozen(): + plan = plan_alteration(_coord()) + with pytest.raises(Exception): + plan.grade.tone = 0.5 # type: ignore[misc] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_alteration.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'player.alteration'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# player/alteration.py +"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5). + +Replaces the 2026-06-04 nearest-match *selection* with a *transformation* of a +neutral base clip. Given the four experience knobs, it produces three layers +that compose per §4.2: + + - Substrate transforms (alter pixels, blend with each other): + * Restyle -- the Right axis: a pre-baked generative v2v dreamlike restyle. + * ColorGrade -- the mood axis (Dark/Light): a deterministic color grade. + - Overlay (composited on top of the substrate): + * AnalyticalOverlay -- the Left axis: HUD/labels/measurement. + +Left and Right are NOT opposites; they live on different layers and stack +(§4.2). Dark and Light are the two poles of one mood grade whose center is the +identity (§5). + +Calibration note: the knob->strength curves below are the single source of +truth for how a 0..4 position maps to a transform strength. See the session +0006 transcript Deferred decisions for the §3-vs-§4.2/§5 reconciliation. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hef.selection import Coordinate + +KNOB_MAX = 4 # knob full-scale (0..4) + + +@dataclass(frozen=True) +class ColorGrade: + """Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light), + <0 cool blue->black (dark), 0 = identity (raw, ungraded).""" + + tone: float + + @property + def is_identity(self) -> bool: + return self.tone == 0.0 + + +@dataclass(frozen=True) +class AnalyticalOverlay: + """Left axis (§4.1): analytical HUD/annotation/labels, composited on top. + `intensity` 0..1 (0 = no overlay).""" + + intensity: float + + +@dataclass(frozen=True) +class Restyle: + """Right axis (§4.1): pre-baked generative v2v dreamlike substrate. + `blend` 0..1 (0 = raw substrate, no restyle).""" + + blend: float + + +@dataclass(frozen=True) +class RenderPlan: + """The full layered alteration for one knob vector (§4.2).""" + + grade: ColorGrade + overlay: AnalyticalOverlay + restyle: Restyle + + @property + def is_identity(self) -> bool: + """True when the plan leaves the neutral base un-altered.""" + return ( + self.grade.is_identity + and self.overlay.intensity == 0.0 + and self.restyle.blend == 0.0 + ) + + +def _overlay_intensity(left: int) -> float: + """Left knob -> analytical-overlay intensity (0..1).""" + return left / KNOB_MAX + + +def _restyle_blend(right: int) -> float: + """Right knob -> v2v restyle blend (0..1).""" + return right / KNOB_MAX + + +def _mood_tone(dark: int, light: int) -> float: + """(dark, light) -> signed mood grade in [-1, 1]; equal -> 0 identity (§5).""" + return (light - dark) / KNOB_MAX + + +def plan_alteration(coord: Coordinate) -> RenderPlan: + """Map a knob vector to its layered RenderPlan (design §4).""" + return RenderPlan( + grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light)), + overlay=AnalyticalOverlay(intensity=_overlay_intensity(coord.left)), + restyle=Restyle(blend=_restyle_blend(coord.right)), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_player_alteration.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add player/alteration.py tests/test_player_alteration.py +git commit -m "feat(player): alteration engine — knob vector to RenderPlan (design §4/§5)" +``` + +--- + +## Task 4: Player state machine — controls stream to transitions + +**Files:** +- Create: `player/state.py` +- Test: `tests/test_player_state.py` + +The `Player` holds a base-clip library and the current `Playback`. Each `update(controls)` resolves the desired `Playback` (chosen base clip + RenderPlan + content resolution + volume/brightness) and returns the `Transition` from the previous playback: + +- target video off, was on → `FADE_TO_BLACK` +- target video on, was off → `FADE_FROM_BLACK` +- both video on, clip or restyle variant changed → `CROSSFADE` +- both video on, only grade/overlay/level changed → `LIVE_UPDATE` (§4.3: grade + overlay are continuous runtime ops) +- both video off, audio/level changed → `LIVE_UPDATE` +- nothing changed → `NONE` + +Base-clip choice is an injected callable (default: first clip); knobs no longer *select* (the base is neutral) — they *transform*. The library is duck-typed objects with an `.id`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_state.py +from dataclasses import dataclass + +import pytest + +from player.controls import Controls +from player.state import Player, Playback, Transition, TransitionKind + + +@dataclass(frozen=True) +class FakeClip: + id: str + + +LIB = [FakeClip("base-a"), FakeClip("base-b")] + + +def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2): + return Controls(content, left, right, dark, light, volume, brightness) + + +def test_first_update_to_video_fades_in_from_black(): + p = Player(LIB) + t = p.update(_controls(content="video")) + assert t.kind == TransitionKind.FADE_FROM_BLACK + assert t.playback.clip_id == "base-a" + assert t.playback.content.video is True + + +def test_off_from_video_fades_to_black_and_silences(): + p = Player(LIB) + p.update(_controls(content="video")) + t = p.update(_controls(content="off")) + assert t.kind == TransitionKind.FADE_TO_BLACK + assert t.playback.clip_id is None + assert t.playback.content.audio_source == "none" + + +def test_no_change_yields_none_transition(): + p = Player(LIB) + p.update(_controls(content="video", left=1)) + t = p.update(_controls(content="video", left=1)) + assert t.kind == TransitionKind.NONE + + +def test_grade_change_is_a_live_update_not_a_crossfade(): + # design §4.3: the Dark/Light grade is a continuous runtime op + p = Player(LIB) + p.update(_controls(content="video", dark=0, light=0)) + t = p.update(_controls(content="video", dark=4, light=0)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.plan.grade.tone == -1.0 + + +def test_overlay_change_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="video", left=0)) + t = p.update(_controls(content="video", left=4)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.plan.overlay.intensity == 1.0 + + +def test_restyle_change_crossfades_the_substrate(): + # design §4.3: the Right v2v substrate is a pre-baked variant swap + p = Player(LIB) + p.update(_controls(content="video", right=0)) + t = p.update(_controls(content="video", right=4)) + assert t.kind == TransitionKind.CROSSFADE + assert t.playback.plan.restyle.blend == 1.0 + + +def test_volume_only_change_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="video", volume=1)) + t = p.update(_controls(content="video", volume=4)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.volume == 4 + + +def test_audio_source_change_while_black_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="white_noise")) + t = p.update(_controls(content="music")) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.content.audio_source == "music" + + +def test_injected_base_chooser_is_used(): + p = Player(LIB, choose_base=lambda lib: lib[1]) + t = p.update(_controls(content="video")) + assert t.playback.clip_id == "base-b" + + +def test_empty_library_with_video_raises(): + p = Player([]) + with pytest.raises(ValueError): + p.update(_controls(content="video")) + + +def test_off_with_empty_library_is_fine(): + p = Player([]) + t = p.update(_controls(content="off")) + assert t.kind == TransitionKind.NONE # already black at init + assert t.playback.clip_id is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_state.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'player.state'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# player/state.py +"""The player state machine: a stream of Controls -> playback transitions. + +Pure decision logic — no mpv, no audio, no serial. Each update() resolves the +desired Playback (which neutral base clip, how it is altered, what audio plays, +at what levels) and returns the Transition from the previous Playback. The +transition KIND encodes design §4.3: the Dark/Light grade and the Left overlay +are continuous runtime ops (LIVE_UPDATE), whereas swapping the clip or the +pre-baked Right v2v variant needs a CROSSFADE, and toggling video on/off +fades to/from black. + +Knobs no longer *select* a clip (the base library is neutral by construction); +they *transform* it. Which neutral base to show is an injected policy +(`choose_base`), defaulting to the first clip; richer rotation is a later slice. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Optional + +from hef.selection import Coordinate +from player.alteration import RenderPlan, plan_alteration +from player.content import ContentResolution, resolve_content +from player.controls import Controls + + +class TransitionKind: + NONE = "none" + LIVE_UPDATE = "live_update" + CROSSFADE = "crossfade" + FADE_TO_BLACK = "fade_to_black" + FADE_FROM_BLACK = "fade_from_black" + + +@dataclass(frozen=True) +class Playback: + """What should currently be playing.""" + + clip_id: Optional[str] # None = black walls + plan: Optional[RenderPlan] # None when black + content: ContentResolution + volume: int + brightness: int + + +@dataclass(frozen=True) +class Transition: + kind: str + playback: Playback + + +def _first(library): + if not library: + raise ValueError("no base clips available to play video") + return library[0] + + +_BLACK = Playback( + clip_id=None, + plan=None, + content=ContentResolution("none", False), + volume=0, + brightness=0, +) + + +class Player: + def __init__( + self, + base_library, + *, + choose_base: Callable = _first, + approved_only: bool = False, + ): + self._library = list(base_library) + self._choose_base = choose_base + self._approved_only = approved_only # reserved for catalog-backed libs + self._current = _BLACK + + def _resolve(self, controls: Controls) -> Playback: + content = resolve_content(controls.content) + if not content.video: + return Playback( + clip_id=None, + plan=None, + content=content, + volume=controls.volume, + brightness=controls.brightness, + ) + clip = self._choose_base(self._library) + coord = Coordinate(controls.left, controls.right, controls.dark, controls.light) + return Playback( + clip_id=clip.id, + plan=plan_alteration(coord), + content=content, + volume=controls.volume, + brightness=controls.brightness, + ) + + def _classify(self, prev: Playback, nxt: Playback) -> str: + prev_video = prev.clip_id is not None + next_video = nxt.clip_id is not None + if prev == nxt: + return TransitionKind.NONE + if prev_video and not next_video: + return TransitionKind.FADE_TO_BLACK + if next_video and not prev_video: + return TransitionKind.FADE_FROM_BLACK + if next_video and prev_video: + if nxt.clip_id != prev.clip_id or nxt.plan.restyle != prev.plan.restyle: + return TransitionKind.CROSSFADE + return TransitionKind.LIVE_UPDATE + # both black: only audio/levels could have changed + return TransitionKind.LIVE_UPDATE + + def update(self, controls: Controls) -> Transition: + nxt = self._resolve(controls) + kind = self._classify(self._current, nxt) + self._current = nxt + return Transition(kind=kind, playback=nxt) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_player_state.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add player/state.py tests/test_player_state.py +git commit -m "feat(player): player state machine — controls stream to transitions" +``` + +--- + +## Task 5: Register the package and run the full suite + +**Files:** +- Modify: `pyproject.toml` (`[tool.setuptools] packages`) + +- [ ] **Step 1: Add `"player"` to the packages list** + +In `pyproject.toml`, change: + +```toml +[tool.setuptools] +packages = ["hef", "tools", "simulator"] +``` +to: +```toml +[tool.setuptools] +packages = ["hef", "tools", "simulator", "player"] +``` + +- [ ] **Step 2: Run the entire test suite** + +Run: `pytest -q` +Expected: all prior tests still pass plus the new `test_player_*` files; no failures. + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml +git commit -m "build(player): register the player package" +``` + +--- + +## Task 6: Update the roadmap to reflect slice 1 shipped + +**Files:** +- Modify: `docs/ROADMAP.md` (§3 Player Runtime) + +- [ ] **Step 1: Update §3 status and deliverables** + +Under "## 3. Player Runtime (Pi)", note that the **alteration-engine + player-core slice** (pure logic) is done and tested, link this plan, and list the remaining slices (mpv/ffmpeg runtime integration + GPU grade/overlay shaders; real USB-serial reader + the 3⇄4 framing contract; white-noise DSP; offline v2v variant build pipeline; catalog model changes for audio-source + neutral-base flag). Keep the table's status marker for sub-project 3 as in-progress (⏳), not done. + +- [ ] **Step 2: Commit** + +```bash +git add docs/ROADMAP.md +git commit -m "docs(roadmap): sub-project 3 alteration-engine + player-core slice shipped" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §4.1 per-axis alteration → Task 3 (overlay=Left, restyle=Right, grade=mood). ✓ +- §4.2 composition rule (substrate vs overlay; Left+Right stack) → Task 3 tests `test_left_and_right_stack_not_cancel`, `test_whole_brain_dark_corner_*`. ✓ +- §4.3 runtime vs pre-baked (grade/overlay continuous; restyle variant swap) → Task 4 transition kinds (`LIVE_UPDATE` vs `CROSSFADE`). ✓ +- §5 mood grade, center = identity → Task 3 `test_equal_dark_and_light_is_identity_grade`, signed tone. ✓ +- §6 7-way content dial → Task 2 full table. ✓ +- §7 volume + brightness intensity → Task 1 (modeled) + Task 4 (`test_volume_only_change_is_a_live_update`). ✓ +- Roadmap §3 "done when: plays correct segment, loops, crossfades, goes dark on None, testable with serial mocked" → Task 4 (transitions incl. fade-to-black; serial mocked as a `Controls` stream). Looping is implicit (a clip plays until the desired playback changes); explicit loop timing belongs to the mpv integration slice (deferred, noted in Task 6). ✓ (with the runtime-loop portion deferred and documented). + +**Deferred (documented, not gaps):** mpv/ffmpeg + GPU; real serial + framing; white-noise DSP; v2v offline pipeline; catalog audio-source/neutral-base fields; base-clip rotation policy; `approved_only` enforcement against a real catalog (the flag is plumbed but inert here). + +**Placeholder scan:** none — every step has concrete code/commands. + +**Type consistency:** `Controls(content,left,right,dark,light,volume,brightness)` used identically in Tasks 1 & 4; `RenderPlan.{grade,overlay,restyle}` and `.is_identity` consistent across Tasks 3 & 4; `ContentResolution(audio_source,video)` consistent across Tasks 2 & 4; `TransitionKind` constants consistent. `Coordinate(left,right,dark,light)` matches `hef/selection.py`.