Spaces:
Sleeping
Sleeping
Commit Β·
2f02eb9
1
Parent(s): 96e11a2
docs: presence-polish plan (voice-every-reply, idle anger, mute fix, fog-veil, finale speed)
Browse files
docs/superpowers/plans/2026-06-13-presence-polish.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Presence Polish β Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan phase-by-phase. Steps use checkbox (`- [ ]`) syntax. Stay on git branch `feat/voice-awareness`.
|
| 4 |
+
|
| 5 |
+
**Goal:** Five fixes/changes on top of the voice+awareness work: (1) Hollow speaks **every** reply, (2) ignoring Hollow **drops the bond and turns it hostile**, (3) the **mute button actually works**, (4) the child **materializes through fog/darkness instead of a blur** (the blur read as a broken image), (5) the **finale narration is slightly faster**.
|
| 6 |
+
|
| 7 |
+
**Context:** This continues the `feat/voice-awareness` branch (voice via Kokoro on the Space, silent on the local 3.13 `.venv`; voice works in the 3.12 `.venv-tts`). Output tuples for the chat event have 8 elements (index 7 = voice channel). The idle behavior runs on a `gr.Timer` via `_on_idle`. Run tests with `.venv/Scripts/python -m pytest -q` (currently 199 green β keep them green). Voice smoke test runs in `.venv-tts`.
|
| 8 |
+
|
| 9 |
+
**Decisions already made (do not reopen):** materialization = sharp child veiled in fog + darkness (no blur); ignoring = drops affinity + tone AND escalates to hostile lines.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## PHASE 1 β Hollow speaks every reply
|
| 14 |
+
|
| 15 |
+
**Files:** Modify `app.py`, `tests/test_app.py`.
|
| 16 |
+
|
| 17 |
+
Today voice plays only on recall turns. Make every Hollow reply spoken (still gated by the mute flag). Latency cost ~1β2 s/turn is acceptable.
|
| 18 |
+
|
| 19 |
+
- [ ] **Step 1: Failing test** (append to `tests/test_app.py`, class `TestVoice`):
|
| 20 |
+
|
| 21 |
+
```python
|
| 22 |
+
def test_every_reply_is_spoken_not_just_recall(self, monkeypatch):
|
| 23 |
+
monkeypatch.setattr(app, "run_turn",
|
| 24 |
+
lambda *a, **k: ("the wind is cold tonight.", '{"affinity_delta":1}'))
|
| 25 |
+
monkeypatch.setattr(app.time, "sleep", lambda s: None)
|
| 26 |
+
monkeypatch.setattr(app, "should_recall", lambda s: (False, None)) # NO recall
|
| 27 |
+
monkeypatch.setattr(app, "speak", lambda text: "FAKEB64")
|
| 28 |
+
state = _state(affinity=30, treasure=[], claimed=[], tone=0)
|
| 29 |
+
outs = list(app.chat("hello", state, []))
|
| 30 |
+
assert "FAKEB64" in outs[-1][7] # a plain reply is spoken too
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
- [ ] **Step 2: Run, verify fail** β `.venv/Scripts/python -m pytest tests/test_app.py -k every_reply -q` (the non-recall reply currently yields "" at index 7).
|
| 34 |
+
|
| 35 |
+
- [ ] **Step 3: Implement.** In `chat()`, find the normal-turn voice computation (added for recall):
|
| 36 |
+
```python
|
| 37 |
+
voice = (_voice_html(speak(reply))
|
| 38 |
+
if do_recall and recall_memory and not turn_failed
|
| 39 |
+
and state.get("voice_on", True)
|
| 40 |
+
else "")
|
| 41 |
+
```
|
| 42 |
+
Replace with (speak every reply; `speak` returns None safely on failure, and a voiced fog-fallback reply is fine β it's in character):
|
| 43 |
+
```python
|
| 44 |
+
voice = _voice_html(speak(reply)) if state.get("voice_on", True) else ""
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
- [ ] **Step 4: Run** `tests/test_app.py -k Voice` then the full suite β green.
|
| 48 |
+
|
| 49 |
+
- [ ] **Step 5: Commit**
|
| 50 |
+
```bash
|
| 51 |
+
git add app.py tests/test_app.py
|
| 52 |
+
git commit -m "feat: Hollow speaks every reply, not just the recall
|
| 53 |
+
|
| 54 |
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## PHASE 2 β Finale narration slightly faster
|
| 60 |
+
|
| 61 |
+
**Files:** Modify `voice.py`, `app.py`.
|
| 62 |
+
|
| 63 |
+
Two levers: a small TTS speed bump and less padding between finale lines.
|
| 64 |
+
|
| 65 |
+
- [ ] **Step 1:** In `voice.py`, change `_SPEED = 0.80` to `_SPEED = 0.88`.
|
| 66 |
+
|
| 67 |
+
- [ ] **Step 2:** In `app.py` `_voice_and_pause`, reduce the inter-line gap: change `pause = max(pause, _audio_seconds(b64) + 0.4)` to `pause = max(pause, _audio_seconds(b64) + 0.15)`.
|
| 68 |
+
|
| 69 |
+
- [ ] **Step 3:** Run the full suite β green (no test asserts the speed constant; behavior unchanged structurally).
|
| 70 |
+
|
| 71 |
+
- [ ] **Step 4: Commit**
|
| 72 |
+
```bash
|
| 73 |
+
git add voice.py app.py
|
| 74 |
+
git commit -m "tune: slightly faster finale narration (TTS speed 0.88, less gap)
|
| 75 |
+
|
| 76 |
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
> Note for the orchestrator: after deploy, the user re-confirms the voice still sounds right at 0.88 by ear. Easy to revert toward 0.80 if needed.
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## PHASE 3 β Make the mute button work
|
| 84 |
+
|
| 85 |
+
**Files:** Modify `app.py`, `tests/test_app.py`.
|
| 86 |
+
|
| 87 |
+
Root cause: `_toggle_voice` copies the state (`dict(state)`), so a streaming finale keeps its old `voice_on`; and it never clears audio already playing. Fix: mutate in place + clear the voice channel on mute.
|
| 88 |
+
|
| 89 |
+
- [ ] **Step 1: Failing test** (append to `tests/test_app.py`, e.g. new class `TestMute`):
|
| 90 |
+
|
| 91 |
+
```python
|
| 92 |
+
class TestMute:
|
| 93 |
+
def test_toggle_mutates_in_place_and_clears_audio_on_mute(self):
|
| 94 |
+
state = {"voice_on": True}
|
| 95 |
+
new_state, btn, voice = app._toggle_voice(state)
|
| 96 |
+
assert new_state is state # same object (in place)
|
| 97 |
+
assert state["voice_on"] is False # muted
|
| 98 |
+
assert voice == "" # current audio cleared
|
| 99 |
+
# toggle back on
|
| 100 |
+
new_state2, btn2, voice2 = app._toggle_voice(state)
|
| 101 |
+
assert state["voice_on"] is True
|
| 102 |
+
assert not isinstance(voice2, str) # no-op update when un-muting
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
- [ ] **Step 2: Run, verify fail** β current `_toggle_voice` returns a copy and only 2 values.
|
| 106 |
+
|
| 107 |
+
- [ ] **Step 3: Implement.** Replace `_toggle_voice`:
|
| 108 |
+
```python
|
| 109 |
+
def _toggle_voice(state):
|
| 110 |
+
# mutate IN PLACE so a finale already streaming sees the change; clear the
|
| 111 |
+
# voice channel when muting so audio already playing stops immediately
|
| 112 |
+
on = not state.get("voice_on", True)
|
| 113 |
+
state["voice_on"] = on
|
| 114 |
+
voice = gr.update() if on else ""
|
| 115 |
+
return state, gr.update(value="π" if on else "π"), voice
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
- [ ] **Step 4:** Update the wiring to also output `voice_panel`. Find:
|
| 119 |
+
```python
|
| 120 |
+
voice_btn.click(_toggle_voice, [state], [state, voice_btn], show_progress="hidden")
|
| 121 |
+
```
|
| 122 |
+
Replace with:
|
| 123 |
+
```python
|
| 124 |
+
voice_btn.click(_toggle_voice, [state], [state, voice_btn, voice_panel], show_progress="hidden")
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
- [ ] **Step 5:** Run the full suite β green.
|
| 128 |
+
|
| 129 |
+
- [ ] **Step 6: Commit**
|
| 130 |
+
```bash
|
| 131 |
+
git add app.py tests/test_app.py
|
| 132 |
+
git commit -m "fix: mute toggle mutates in place + clears current audio
|
| 133 |
+
|
| 134 |
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
> Orchestrator manual check (in `.venv-tts`): mute during normal play silences the next reply; mute during a finale stops further lines (in-place flag) and clears the current audio. If Gradio serializes the click behind a streaming finale, mute applies as soon as the event runs β document actual behavior.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## PHASE 4 β Ignoring Hollow drops the bond and turns it hostile
|
| 142 |
+
|
| 143 |
+
**Files:** Modify `app.py`, `tests/test_app.py`.
|
| 144 |
+
|
| 145 |
+
The idle nudge becomes tiered (plead β hurt β hostile by `idle_count`) and each nudge lowers affinity + tone. The Bond meter must update live, so `_on_idle` gains a 4th output (`bond_panel`).
|
| 146 |
+
|
| 147 |
+
- [ ] **Step 1: Failing tests** (replace the existing `TestIdle` body's relevant tests; `_on_idle` now returns 4 values):
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
class TestIdle:
|
| 151 |
+
def test_idle_appends_a_canned_line_and_drops_bond(self, monkeypatch):
|
| 152 |
+
import time as _t
|
| 153 |
+
monkeypatch.setattr(app, "speak", lambda text: None)
|
| 154 |
+
state = _state(affinity=30, treasure=[], claimed=[], tone=0)
|
| 155 |
+
state["last_activity"] = _t.time() - 999 # long idle
|
| 156 |
+
state["greeted"] = True
|
| 157 |
+
new_state, hist, voice, bond = app._on_idle(
|
| 158 |
+
state, [{"role": "assistant", "content": "x"}])
|
| 159 |
+
assert hist[-1]["role"] == "assistant"
|
| 160 |
+
assert new_state["affinity"] < 30 # ignoring lowers the bond
|
| 161 |
+
assert new_state["tone"] < 0 # and sours the tone
|
| 162 |
+
assert "bond-meter" in bond # the meter re-rendered
|
| 163 |
+
|
| 164 |
+
def test_idle_escalates_to_hostile(self, monkeypatch):
|
| 165 |
+
import time as _t
|
| 166 |
+
monkeypatch.setattr(app, "speak", lambda text: None)
|
| 167 |
+
state = _state(affinity=40, treasure=[], claimed=[], tone=0)
|
| 168 |
+
state["greeted"] = True
|
| 169 |
+
state["idle_count"] = 5 # already ignored a lot
|
| 170 |
+
state["last_activity"] = _t.time() - 999
|
| 171 |
+
_, hist, _, _ = app._on_idle(state, [{"role": "assistant", "content": "x"}])
|
| 172 |
+
assert hist[-1]["content"] in app._IDLE_TIERS[2] # the hostile tier
|
| 173 |
+
|
| 174 |
+
def test_idle_silent_returns_four_noops_when_active_or_ended(self, monkeypatch):
|
| 175 |
+
import time as _t
|
| 176 |
+
monkeypatch.setattr(app, "speak", lambda text: None)
|
| 177 |
+
s1 = _state(); s1["greeted"] = True; s1["last_activity"] = _t.time()
|
| 178 |
+
out = app._on_idle(s1, [{"role": "assistant", "content": "x"}])
|
| 179 |
+
assert len(out) == 4
|
| 180 |
+
assert not isinstance(out[1], list) # chatbot untouched
|
| 181 |
+
|
| 182 |
+
def test_first_tick_voices_the_opening_once(self, monkeypatch):
|
| 183 |
+
import time as _t
|
| 184 |
+
monkeypatch.setattr(app, "speak", lambda text: "GREETB64")
|
| 185 |
+
state = _state(affinity=20, treasure=[], claimed=[], turn=0)
|
| 186 |
+
new_state, chat_update, voice, bond = app._on_idle(
|
| 187 |
+
state, [{"role": "assistant", "content": OPENING_LINE}])
|
| 188 |
+
assert new_state["greeted"] is True
|
| 189 |
+
assert "GREETB64" in voice
|
| 190 |
+
assert not isinstance(chat_update, list)
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
- [ ] **Step 2: Run, verify fail** β `_on_idle` currently returns 3 values, no tiers, no bond drop.
|
| 194 |
+
|
| 195 |
+
- [ ] **Step 3: Implement.** Replace `_IDLE_LINES` with tiers and add decay constants:
|
| 196 |
+
```python
|
| 197 |
+
_IDLE_TIERS = [
|
| 198 |
+
["...are you still there?", "don't go. not yet."], # plead
|
| 199 |
+
["you're not even listening.", "you said you would stay."], # hurt
|
| 200 |
+
["fine. ignore me. they all do, before the end.", # hostile
|
| 201 |
+
"you think i cannot reach you out there?"],
|
| 202 |
+
]
|
| 203 |
+
_IDLE_AFTER = 12.0 # seconds of silence before Hollow speaks first
|
| 204 |
+
_IDLE_AFFINITY_DROP = 3 # bond lost per ignored nudge
|
| 205 |
+
_IDLE_TONE_DROP = 2 # tone soured per ignored nudge
|
| 206 |
+
```
|
| 207 |
+
Rewrite `_on_idle` to return `(state, chatbot, voice, bond)`:
|
| 208 |
+
```python
|
| 209 |
+
def _on_idle(state: dict, history: list):
|
| 210 |
+
"""gr.Timer tick. Greets once early; otherwise, when the visitor has gone
|
| 211 |
+
quiet, Hollow speaks first β escalating plead -> hurt -> hostile β and each
|
| 212 |
+
nudge lowers the bond and sours the tone. Returns (state, chatbot, voice,
|
| 213 |
+
bond); non-speaking ticks return no-op updates so a streaming finale is
|
| 214 |
+
never clobbered."""
|
| 215 |
+
# greet once, early (plays on the visitor's first gesture)
|
| 216 |
+
if (not state.get("greeted") and not state.get("ended")
|
| 217 |
+
and state.get("turn", 0) == 0):
|
| 218 |
+
state = dict(state)
|
| 219 |
+
state["greeted"] = True
|
| 220 |
+
state["last_activity"] = time.time() # start the idle clock now
|
| 221 |
+
voice = (_voice_html(speak(OPENING_LINE))
|
| 222 |
+
if state.get("voice_on", True) else gr.update())
|
| 223 |
+
return state, gr.update(), voice, gr.update()
|
| 224 |
+
if (state.get("ended") or not history
|
| 225 |
+
or time.time() - state.get("last_activity", time.time()) < _IDLE_AFTER):
|
| 226 |
+
return state, gr.update(), gr.update(), gr.update()
|
| 227 |
+
state = dict(state)
|
| 228 |
+
i = state.get("idle_count", 0)
|
| 229 |
+
tier = _IDLE_TIERS[min(i // 2, len(_IDLE_TIERS) - 1)]
|
| 230 |
+
line = tier[i % len(tier)]
|
| 231 |
+
state["idle_count"] = i + 1
|
| 232 |
+
state["last_activity"] = time.time()
|
| 233 |
+
state["affinity"] = max(0, state.get("affinity", 20) - _IDLE_AFFINITY_DROP)
|
| 234 |
+
state["tone"] = max(-100, state.get("tone", 0) - _IDLE_TONE_DROP)
|
| 235 |
+
history = history + [{"role": "assistant", "content": line}]
|
| 236 |
+
voice = _voice_html(speak(line)) if state.get("voice_on", True) else ""
|
| 237 |
+
bond = _render_bond(state["affinity"], get_tier(state["affinity"]))
|
| 238 |
+
return state, history, voice, bond
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
- [ ] **Step 4:** Update the timer wiring to add `bond_panel` as the 4th output:
|
| 242 |
+
```python
|
| 243 |
+
idle_timer.tick(_on_idle, [state, chatbot],
|
| 244 |
+
[state, chatbot, voice_panel, bond_panel],
|
| 245 |
+
show_progress="hidden")
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
- [ ] **Step 5:** Run the full suite β green. (Confirm the old 3-value idle tests are replaced by the 4-value versions above.)
|
| 249 |
+
|
| 250 |
+
- [ ] **Step 6: Commit**
|
| 251 |
+
```bash
|
| 252 |
+
git add app.py tests/test_app.py
|
| 253 |
+
git commit -m "feat: ignoring Hollow drops the bond and escalates to hostility
|
| 254 |
+
|
| 255 |
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
---
|
| 259 |
+
|
| 260 |
+
## PHASE 5 β Materialize through fog + darkness, not blur
|
| 261 |
+
|
| 262 |
+
**Files:** Modify `render.py`, `tests/test_render.py`.
|
| 263 |
+
|
| 264 |
+
Replace the CSS blur (which reads as a broken/loading image) with brightness (shadow β light) + denser fog at low bond. The child stays sharp.
|
| 265 |
+
|
| 266 |
+
- [ ] **Step 1: Read** `render.py` `render_entity` top (the `t = min(a, 45) / 45` materialization block and the `look = f"filter:blur(...)..."` line, plus the `entity-almost` overlay which also uses `blur`).
|
| 267 |
+
|
| 268 |
+
- [ ] **Step 2: Failing test** (append to `tests/test_render.py`):
|
| 269 |
+
|
| 270 |
+
```python
|
| 271 |
+
class TestMaterialization:
|
| 272 |
+
def test_low_bond_uses_darkness_and_fog_not_blur(self):
|
| 273 |
+
html = render_entity(10, "idle", seq=0) # barely materialized
|
| 274 |
+
assert "blur(" not in html # no loading-look blur
|
| 275 |
+
assert "brightness(" in html # shadowed instead
|
| 276 |
+
def test_high_bond_is_bright_and_clear(self):
|
| 277 |
+
html = render_entity(45, "idle", seq=0)
|
| 278 |
+
assert "brightness(1" in html or "brightness(0.9" in html
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
- [ ] **Step 3: Run, verify fail** (idle render still emits `blur(`).
|
| 282 |
+
|
| 283 |
+
- [ ] **Step 4: Implement.** In `render_entity`, replace the materialization computation:
|
| 284 |
+
```python
|
| 285 |
+
t = min(a, 45) / 45 # fully materialized by bond 45
|
| 286 |
+
# veil the SHARP child in shadow + fog instead of blurring it (a blur reads
|
| 287 |
+
# as a broken/loading image): dark and fog-shrouded at low bond, emerging
|
| 288 |
+
brightness = round(0.4 + 0.6 * t, 2)
|
| 289 |
+
opacity = round(0.7 + 0.3 * t, 2)
|
| 290 |
+
almost_on = a >= 76
|
| 291 |
+
flash_key = f"flash-{seq % 2}"
|
| 292 |
+
|
| 293 |
+
# the fog curtain stays denser while the child is still a shadow
|
| 294 |
+
fog_op = round(1 - 0.7 * t, 2)
|
| 295 |
+
grain_op = round(0.13 - 0.08 * t, 2)
|
| 296 |
+
vig_op = round(1 - 0.5 * t, 2)
|
| 297 |
+
|
| 298 |
+
look = f"filter:brightness({brightness});opacity:{opacity};"
|
| 299 |
+
```
|
| 300 |
+
(Remove the old `blur = ...` line and the old `look = f"filter:blur({blur}px);opacity:{opacity};"`.)
|
| 301 |
+
|
| 302 |
+
Then in the `else` (idle/default) branch, the `entity-almost` overlay currently uses `blur`:
|
| 303 |
+
```python
|
| 304 |
+
f'<img class="entity-img entity-almost" src="{_src("almost")}" '
|
| 305 |
+
f'style="filter:blur({blur}px);opacity:{almost_op};">'
|
| 306 |
+
```
|
| 307 |
+
Change it to brightness too:
|
| 308 |
+
```python
|
| 309 |
+
f'<img class="entity-img entity-almost" src="{_src("almost")}" '
|
| 310 |
+
f'style="filter:brightness({brightness});opacity:{almost_op};">'
|
| 311 |
+
```
|
| 312 |
+
Search the whole function for any remaining reference to the now-deleted `blur` variable and remove/replace it (the fixed modes like rage/end/peace use literal `filter:blur(0px)` strings β those are fine to leave as-is, they're a literal 0 blur on the final faces).
|
| 313 |
+
|
| 314 |
+
- [ ] **Step 5: Run** `tests/test_render.py` β green. Fix any existing render test that asserted `blur(` in the idle render.
|
| 315 |
+
|
| 316 |
+
- [ ] **Step 6: Visual verification (REQUIRED β CSS lies in the harness).** Orchestrator launches the app and screenshots at low and mid bond:
|
| 317 |
+
- `.venv/Scripts/python app.py` (normal start = affinity 20), headless screenshot, Read the PNG: the child should look like a dark shape in fog (intentional), NOT a blurry broken image.
|
| 318 |
+
- Confirm it brightens/clears as bond rises (can seed via `HOLLOW_FAST_FINALE=good` to see a high-bond render, or trust the formula).
|
| 319 |
+
- Kill the port owner after.
|
| 320 |
+
|
| 321 |
+
- [ ] **Step 7: Commit**
|
| 322 |
+
```bash
|
| 323 |
+
git add render.py tests/test_render.py
|
| 324 |
+
git commit -m "feat: materialize the child through fog + shadow, not a blur
|
| 325 |
+
|
| 326 |
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## FINAL β full verification
|
| 332 |
+
|
| 333 |
+
- [ ] **Step 1:** `.venv/Scripts/python -m pytest -q` β all green.
|
| 334 |
+
- [ ] **Step 2: Voice smoke** (`.venv-tts`): `python -c "import voice; print(voice._SPEED, bool(voice.speak('the sea. that was me.')))"` β `0.88 True`.
|
| 335 |
+
- [ ] **Step 3: Live local test** (`.venv-tts`, voice active): play a normal turn (needs Ollama for real replies β `ollama serve` + `qwen3:8b`) and confirm: every reply is spoken; muting silences it; ignoring for ~30 s drops the Bond meter and turns the lines hostile; the child looks fog-shrouded not blurry; the finale narration is a touch faster.
|
| 336 |
+
- [ ] **Step 4: Deploy mirror-first** (orchestrator, with the user's go-ahead): merge `feat/voice-awareness` β `main`, push to the personal mirror (`space`), verify the build + voice live, THEN push to the submission (`hackathon`).
|
| 337 |
+
|
| 338 |
+
```bash
|
| 339 |
+
git push space main # mirror first
|
| 340 |
+
# verify, then:
|
| 341 |
+
git push hackathon main
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
---
|
| 345 |
+
|
| 346 |
+
## Self-review notes
|
| 347 |
+
- Phases are independent; ship order 1β5. If time runs short, 1+3+5 alone are clear wins.
|
| 348 |
+
- `_on_idle` now returns **4** values and the timer outputs **4** β keep them in sync.
|
| 349 |
+
- Never put voice audio in the entity panel (it breaks the heartbeat-stable HTML); voice lives in `voice_panel`.
|
| 350 |
+
- The materialization change is visual β verify against the RUNNING app, never the static harness.
|
| 351 |
+
- Voice is import-guarded: locally (3.13) it's silent; that's expected. All voice assertions in tests monkeypatch `speak`.
|