Pabloler21 Claude Fable 5 commited on
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`.