Pabloler21 Claude Fable 5 commited on
Commit
5f0f188
Β·
1 Parent(s): 4deeed5

docs: voice+awareness spec and plan; gitignore sample wavs

Browse files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.webp filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -226,5 +226,9 @@ assets/*.png
226
  # Visual check harness output
227
  tools/_visual/
228
 
229
- # Synthesized audio previews (sting.py writes these; audio is synth at runtime)
230
  *preview*.wav
 
 
 
 
 
226
  # Visual check harness output
227
  tools/_visual/
228
 
229
+ # Synthesized / sample audio (synth at runtime; never commit binaries)
230
  *preview*.wav
231
+ sample_*.wav
232
+
233
+ # Throwaway TTS validation venv
234
+ .venv-tts/
assets/hollow_almost.webp CHANGED

Git LFS Details

  • SHA256: c638cefd5f2cd8d3f660ca42bde09597555ffc6968a39448f0cea39963936c40
  • Pointer size: 130 Bytes
  • Size of remote file: 19.1 kB
assets/hollow_base.webp CHANGED

Git LFS Details

  • SHA256: cdd6b220c3d586ffd5cf92d263759e75d970b6619b6f1be5c1c51e5f476fa752
  • Pointer size: 130 Bytes
  • Size of remote file: 16.7 kB
assets/hollow_end.webp CHANGED

Git LFS Details

  • SHA256: ad9f49f2e645565cd7ce5fc2873038d44adced1343ff45a808155d13582056df
  • Pointer size: 130 Bytes
  • Size of remote file: 12.6 kB
assets/hollow_peace.webp CHANGED

Git LFS Details

  • SHA256: fc773e9ec74ce14b9b13e797e3825917ee7ef89e370b485593c1fe2f2f9bba02
  • Pointer size: 130 Bytes
  • Size of remote file: 18.9 kB
assets/hollow_rage.webp CHANGED

Git LFS Details

  • SHA256: c638cefd5f2cd8d3f660ca42bde09597555ffc6968a39448f0cea39963936c40
  • Pointer size: 130 Bytes
  • Size of remote file: 19.1 kB
assets/hollow_terror.webp CHANGED

Git LFS Details

  • SHA256: e9cce882d41f7eedf3b19e60a133d26b4f97688da01729faadc84d5ca26d8115
  • Pointer size: 130 Bytes
  • Size of remote file: 17.7 kB
docs/superpowers/plans/2026-06-13-presence-voice-and-awareness.md ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Presence: Voice + Awareness β€” Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
4
+
5
+ **Goal:** Make Hollow feel present. **A)** The child speaks (whispered Kokoro TTS on the recall and on every Hollow line in the finales). **B)** The child is aware (notices how you write, brings up your memories unprompted, and speaks first when you go silent).
6
+
7
+ **Architecture:** Qwen3-8B is untouched. A new CPU-only `voice.py` synthesizes audio via Kokoro-82M with a locked DSP recipe and returns base64 WAV. A new hidden `voice_panel` (gr.HTML) carries the voice `<audio>` on its own channel so it never disturbs the entity panel's stable-HTML heartbeat. B is mostly prompt+state; B1 uses a validated `gr.Timer`.
8
+
9
+ **Both unknowns are validated:** Kokoro runs offline on CPU (voice cast = `af_nicole`, filter 28%); `gr.Timer` fires, reads/writes `gr.State`, and appends to the Chatbot.
10
+
11
+ **Tech stack:** Kokoro-82M (`kokoro` pkg), numpy DSP, Gradio 6.17 (`gr.Timer`, `gr.HTML`), pytest.
12
+
13
+ ---
14
+
15
+ ## Critical background for the implementer
16
+
17
+ - **The local dev venv is Python 3.13; Kokoro requires <3.13.** So `import kokoro` FAILS locally. `voice.py` MUST guard its import: on failure, `speak()` returns `None` and the app runs **silent**. Voice works on the Space (pinned to 3.11). Tests run on the 3.13 venv, so **tests must not require kokoro** β€” mock it / test the guard + the pure DSP.
18
+ - **The entity panel relies on byte-identical HTML across finale steps** so the looped heartbeat audio doesn't remount (see `render.py` build mode). Therefore voice audio MUST NOT go in the entity HTML during finales β€” it goes in a separate `voice_panel`. (On a normal recall turn there's no heartbeat, but we use the same `voice_panel` everywhere for uniformity.)
19
+ - **Output tuples grow 7β†’8.** Every `yield` in `chat()` and the three finale players, plus `_reset`, plus the event `outputs=[...]` lists, gain `voice_panel` as the **last** element. Existing tests index `outs[-1][0..6]`, so appending at index 7 keeps them valid.
20
+ - Audio is inlined as `<audio autoplay src="data:audio/wav;base64,...">` β€” the exact mechanism the chimes/sting use (no JS).
21
+ - Reuse `from sting import _wav` to encode float audio β†’ WAV bytes.
22
+ - Run tests: `.venv/Scripts/python -m pytest -q` (183 currently green; keep them green).
23
+
24
+ ---
25
+
26
+ # PART A β€” VOICE
27
+
28
+ ### Task A1: `voice.py` β€” Kokoro pipeline + locked DSP + guarded `speak()`
29
+
30
+ **Files:** Create `voice.py`; Create `tests/test_voice.py`.
31
+
32
+ - [ ] **Step 1: Write the failing tests** (`tests/test_voice.py`):
33
+
34
+ ```python
35
+ """voice.py must be import-safe even without kokoro (the 3.13 test venv has
36
+ none): the DSP is pure numpy and testable; speak() degrades to None."""
37
+ import numpy as np
38
+
39
+ import voice
40
+
41
+
42
+ def test_dsp_functions_are_pure_numpy():
43
+ x = np.sin(np.linspace(0, 20, 24000)).astype(np.float32)
44
+ up = voice._pitch_up(x, 1.16)
45
+ assert up.dtype == np.float32 and len(up) < len(x) # pitch-up shortens
46
+ fogged = voice._fog(x, intensity=0.28)
47
+ assert np.max(np.abs(fogged)) <= 0.86 # normalized headroom
48
+
49
+
50
+ def test_speak_returns_none_when_pipeline_unavailable():
51
+ # the 3.13 test venv has no kokoro -> _PIPELINE is None -> silent
52
+ if voice._PIPELINE is None:
53
+ assert voice.speak("the sea. i grew up near the sea.") is None
54
+
55
+
56
+ def test_speak_is_guarded_against_errors(monkeypatch):
57
+ # even if a pipeline exists, a synth error must degrade to None, never raise
58
+ monkeypatch.setattr(voice, "_PIPELINE", object()) # a bogus pipeline
59
+ assert voice.speak("anything") is None
60
+ ```
61
+
62
+ - [ ] **Step 2: Run, verify fail**
63
+
64
+ `.venv/Scripts/python -m pytest tests/test_voice.py -q` β†’ ImportError (no `voice`).
65
+
66
+ - [ ] **Step 3: Implement `voice.py`**
67
+
68
+ ```python
69
+ """Hollow's voice: Kokoro-82M whispered through a locked DSP recipe (the
70
+ validated 'af_nicole, 28% fog' cast). CPU only β€” no GPU budget. Import is
71
+ guarded: where kokoro is unavailable (e.g. the Python 3.13 dev venv) the app
72
+ runs silent instead of crashing."""
73
+ import base64
74
+
75
+ import numpy as np
76
+
77
+ from sting import _wav
78
+
79
+ _SR = 24000
80
+ _VOICE = "af_nicole"
81
+ _SPEED = 0.80
82
+ _FILTER_INTENSITY = 0.28 # the "verylight" setting the user picked
83
+
84
+ try:
85
+ from kokoro import KPipeline
86
+ _PIPELINE = KPipeline(lang_code="a")
87
+ except Exception as e: # no kokoro / no model / wrong python -> silent
88
+ print(f"[voice] disabled ({e!r})")
89
+ _PIPELINE = None
90
+
91
+
92
+ def _pitch_up(x, factor):
93
+ n = int(len(x) / factor)
94
+ idx = np.linspace(0, len(x) - 1, n)
95
+ return np.interp(idx, np.arange(len(x)), x).astype(np.float32)
96
+
97
+
98
+ def _breath(x, amount):
99
+ rng = np.random.default_rng(3)
100
+ env = np.abs(x)
101
+ k = np.hanning(441); k /= k.sum()
102
+ env = np.convolve(env, k, mode="same")
103
+ noise = rng.standard_normal(len(x)).astype(np.float32) * env
104
+ return x * (1 - amount) + noise * amount * 2.0
105
+
106
+
107
+ def _fog_reverb(x, taps):
108
+ out = x.copy()
109
+ for delay_s, gain in taps:
110
+ d = int(delay_s * _SR)
111
+ if d < len(x):
112
+ echo = np.zeros_like(x)
113
+ echo[d:] = x[:-d] * gain
114
+ out = out + echo
115
+ return out
116
+
117
+
118
+ def _fog(x, intensity=_FILTER_INTENSITY, pitch=1.16):
119
+ y = _pitch_up(x, pitch)
120
+ y = _breath(y, amount=0.38 * intensity)
121
+ taps = [(0.06, 0.5 * intensity), (0.13, 0.32 * intensity),
122
+ (0.23, 0.18 * intensity)]
123
+ y = _fog_reverb(y, taps)
124
+ return (y / (np.max(np.abs(y)) + 1e-9) * 0.85).astype(np.float32)
125
+
126
+
127
+ def speak(text: str) -> str | None:
128
+ """Synthesize `text` as Hollow's whisper; return base64 WAV, or None if
129
+ voice is unavailable or anything fails (never raises)."""
130
+ if _PIPELINE is None or not text or not text.strip():
131
+ return None
132
+ try:
133
+ audio = None
134
+ for _, _, chunk in _PIPELINE(text, voice=_VOICE, speed=_SPEED):
135
+ audio = chunk if audio is None else np.concatenate([audio, chunk])
136
+ audio = np.asarray(audio, dtype=np.float32)
137
+ audio = audio / (np.max(np.abs(audio)) + 1e-9) * 0.9
138
+ return base64.b64encode(_wav(_fog(audio), _SR)).decode()
139
+ except Exception as e:
140
+ print(f"[voice] speak failed: {e!r}")
141
+ return None
142
+ ```
143
+
144
+ - [ ] **Step 4: Run, verify pass** β†’ `.venv/Scripts/python -m pytest tests/test_voice.py -q` (3 pass; on the 3.13 venv `_PIPELINE is None`).
145
+
146
+ - [ ] **Step 5: Commit**
147
+
148
+ ```bash
149
+ git add voice.py tests/test_voice.py
150
+ git commit -m "feat: voice.py β€” guarded Kokoro whisper with locked DSP
151
+
152
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
153
+ ```
154
+
155
+ ---
156
+
157
+ ### Task A2: Space build β€” deps, system packages, Python pin
158
+
159
+ **Files:** Modify `requirements.txt`; Create `packages.txt`; Modify `README.md`.
160
+
161
+ No unit tests (verified at deploy). This makes the Space able to import Kokoro.
162
+
163
+ - [ ] **Step 1: `requirements.txt`** β€” append:
164
+
165
+ ```
166
+ kokoro
167
+ misaki[en]
168
+ soundfile
169
+ spacy
170
+ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
171
+ ```
172
+
173
+ - [ ] **Step 2: Create `packages.txt`** (HF installs these via apt):
174
+
175
+ ```
176
+ espeak-ng
177
+ ```
178
+
179
+ - [ ] **Step 3: `README.md` frontmatter** β€” add under `app_file: app.py`:
180
+
181
+ ```yaml
182
+ python_version: "3.11"
183
+ ```
184
+
185
+ (Kokoro needs Python <3.13; without this pin the Space may default to 3.13 and the build breaks.)
186
+
187
+ - [ ] **Step 4: Commit**
188
+
189
+ ```bash
190
+ git add requirements.txt packages.txt README.md
191
+ git commit -m "build: Space deps for Kokoro voice (python 3.11, espeak-ng, spacy)
192
+
193
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
194
+ ```
195
+
196
+ ---
197
+
198
+ ### Task A3: `voice_panel` plumbing β€” thread an 8th output everywhere (refactor)
199
+
200
+ **Files:** Modify `app.py`; Modify `tests/test_app.py` (only if a test constructs output tuples).
201
+
202
+ Add a hidden `voice_panel = gr.HTML()` and thread an 8th output (empty string by default) through every yield and event. Keep all 183 tests green. **No voice content yet** β€” this is pure plumbing so later tasks just fill the slot.
203
+
204
+ - [ ] **Step 1:** In the Blocks layout, add after `treasure_panel`:
205
+
206
+ ```python
207
+ voice_panel = gr.HTML(visible=False) # carries Hollow's voice <audio>
208
+ ```
209
+
210
+ - [ ] **Step 2:** Add a module helper near `_PENDING`:
211
+
212
+ ```python
213
+ def _voice_html(b64: str | None) -> str:
214
+ # autoplay the whisper from its own channel so the entity heartbeat HTML
215
+ # stays byte-identical (never remounts). Empty when silent.
216
+ if not b64:
217
+ return ""
218
+ return f'<audio autoplay src="data:audio/wav;base64,{b64}"></audio>'
219
+ ```
220
+
221
+ - [ ] **Step 3:** Append `_voice_html(None)` (i.e. `""`) as the **last** element of every yield tuple in `chat()`, `_play_finale`, `_play_finale_bad`, `_play_finale_good`, and the return tuple of `_reset`. Every tuple goes from 7 to 8 elements, last element `""` for now.
222
+
223
+ - [ ] **Step 4:** Add `voice_panel` as the **last** entry of the `outputs=[...]` list in BOTH `.then(fn=chat, ...)` chains and in `restart_btn.click(...)`.
224
+
225
+ - [ ] **Step 5:** Run the full suite: `.venv/Scripts/python -m pytest -q` β†’ **183 pass** (indices 0–6 unchanged; new index 7 is `""`).
226
+
227
+ - [ ] **Step 6: Commit**
228
+
229
+ ```bash
230
+ git add app.py tests/test_app.py
231
+ git commit -m "refactor: add hidden voice_panel as the 8th output channel
232
+
233
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
234
+ ```
235
+
236
+ ---
237
+
238
+ ### Task A4: Voice on the recall turn + mute toggle
239
+
240
+ **Files:** Modify `app.py`; Modify `tests/test_app.py`.
241
+
242
+ - [ ] **Step 1: Write failing tests** (append to `tests/test_app.py`):
243
+
244
+ ```python
245
+ class TestVoice:
246
+ def test_recall_turn_speaks_the_reply(self, monkeypatch):
247
+ monkeypatch.setattr(app, "run_turn",
248
+ lambda *a, **k: ("the sea. that was me.", '{"affinity_delta":1}'))
249
+ monkeypatch.setattr(app.time, "sleep", lambda s: None)
250
+ monkeypatch.setattr(app, "should_recall", lambda s: (True, "grew up near the sea"))
251
+ monkeypatch.setattr(app, "speak", lambda text: "FAKEB64") # voice available
252
+ state = _state(affinity=60, treasure=["grew up near the sea"], claimed=[], tone=0)
253
+ outs = list(app.chat("hi", state, []))
254
+ assert "FAKEB64" in outs[-1][7] # voice channel carries audio
255
+
256
+ def test_no_voice_when_muted(self, monkeypatch):
257
+ monkeypatch.setattr(app, "run_turn",
258
+ lambda *a, **k: ("the sea. that was me.", '{"affinity_delta":1}'))
259
+ monkeypatch.setattr(app.time, "sleep", lambda s: None)
260
+ monkeypatch.setattr(app, "should_recall", lambda s: (True, "grew up near the sea"))
261
+ monkeypatch.setattr(app, "speak", lambda text: "FAKEB64")
262
+ state = _state(affinity=60, treasure=["grew up near the sea"], claimed=[],
263
+ tone=0, voice_on=False)
264
+ outs = list(app.chat("hi", state, []))
265
+ assert outs[-1][7] == "" # muted -> silent
266
+ ```
267
+
268
+ - [ ] **Step 2: Run, verify fail.**
269
+
270
+ - [ ] **Step 3: Implement.**
271
+ - Import: `from voice import speak` at the top of `app.py`.
272
+ - Add `"voice_on": True` to `_pristine_state()` and every seeded state in `_init_state` (and the test helper `_state` default).
273
+ - On the recall turn in `chat()` (where `do_recall and recall_memory and not turn_failed`), compute `voice = _voice_html(speak(reply)) if state.get("voice_on", True) else ""` and yield it as the 8th element of the final recall yield. For non-recall turns the 8th element stays `""`.
274
+ - Mute toggle UI: add a button in the title row:
275
+ `voice_btn = gr.Button("πŸ”Š", elem_classes="voice-btn", scale=0)`
276
+ and a handler `_toggle_voice(state)` that flips `state["voice_on"]` and returns `(state, gr.update(value="πŸ”Š" if on else "πŸ”‡"))`; wire `voice_btn.click(_toggle_voice, [state], [state, voice_btn])`.
277
+
278
+ - [ ] **Step 4: Run** the recall + full app suite β†’ green.
279
+
280
+ - [ ] **Step 5: Commit**
281
+
282
+ ```bash
283
+ git add app.py tests/test_app.py
284
+ git commit -m "feat: voice on the recall turn + mute toggle
285
+
286
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
287
+ ```
288
+
289
+ ---
290
+
291
+ ### Task A5: Voice on every Hollow finale line
292
+
293
+ **Files:** Modify `app.py`; Modify `tests/test_app.py`.
294
+
295
+ - [ ] **Step 1: Write failing tests:**
296
+
297
+ ```python
298
+ class TestFinaleVoice:
299
+ def test_good_finale_voices_hollow_lines(self, monkeypatch):
300
+ monkeypatch.setattr(app, "speak", lambda text: "FAKEB64")
301
+ outs = _drive(_state(), monkeypatch) # _drive sets voice_on True by default
302
+ assert any("FAKEB64" in o[7] for o in outs) # at least one spoken line
303
+
304
+ def test_finale_voice_respects_mute(self, monkeypatch):
305
+ monkeypatch.setattr(app, "speak", lambda text: "FAKEB64")
306
+ outs = _drive(_state(voice_on=False), monkeypatch)
307
+ assert all(o[7] == "" for o in outs)
308
+ ```
309
+
310
+ - [ ] **Step 2: Run, verify fail.**
311
+
312
+ - [ ] **Step 3: Implement.** In each of the three finale players, for every step where `speaker == "hollow"` (in `_play_finale_good` all steps are hollow; in `_play_finale`/`_play_finale_bad` check `step["speaker"]`), compute the 8th yield element:
313
+
314
+ ```python
315
+ voice = (_voice_html(speak(step["text"]))
316
+ if state.get("voice_on", True) and step["speaker"] == "hollow"
317
+ else "")
318
+ ```
319
+
320
+ and pass `voice` as the last element of that step's `yield`. Visitor lines (`speaker == "visitor"`) and the final locked yield use `""`. The multi-second `step["pause"]` absorbs synth latency.
321
+
322
+ - [ ] **Step 4: Run** the full suite β†’ green.
323
+
324
+ - [ ] **Step 5: Commit**
325
+
326
+ ```bash
327
+ git add app.py tests/test_app.py
328
+ git commit -m "feat: voice every Hollow line in the three finales
329
+
330
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
331
+ ```
332
+
333
+ ---
334
+
335
+ # PART B β€” AWARENESS
336
+
337
+ ### Task B1 (B3 in spec): Notices how you write
338
+
339
+ **Files:** Modify `memory.py`, `character.py`, `app.py`; Modify tests.
340
+
341
+ - [ ] **Step 1: Failing test** (`tests/test_memory.py`):
342
+
343
+ ```python
344
+ from memory import style_signal
345
+
346
+ class TestStyleSignal:
347
+ def test_short_messages_read_as_guarded(self):
348
+ assert style_signal([3, 4, 2]) == "short"
349
+ def test_long_messages_read_as_pouring_out(self):
350
+ assert style_signal([40, 55, 60]) == "long"
351
+ def test_mixed_or_empty_is_neutral(self):
352
+ assert style_signal([]) is None
353
+ ```
354
+
355
+ - [ ] **Step 2: Run, verify fail.**
356
+
357
+ - [ ] **Step 3: Implement** in `memory.py`:
358
+
359
+ ```python
360
+ def style_signal(msg_lengths: list[int]) -> str | None:
361
+ """A coarse read of how the visitor has been writing lately (word counts).
362
+ None when there isn't enough signal."""
363
+ if not msg_lengths:
364
+ return None
365
+ avg = sum(msg_lengths) / len(msg_lengths)
366
+ if avg <= 5:
367
+ return "short"
368
+ if avg >= 30:
369
+ return "long"
370
+ return None
371
+ ```
372
+
373
+ - [ ] **Step 4:** In `character.py`, `build_system_prompt(...)` gains a `style: str | None = None` kwarg. When set, append one line, e.g.:
374
+
375
+ ```python
376
+ if style == "short":
377
+ parts.append("The visitor's messages have been short and guarded. You may quietly notice this β€” name it once, softly, then let it go.")
378
+ elif style == "long":
379
+ parts.append("The visitor has been pouring words out tonight. You may quietly notice how much they're giving you.")
380
+ ```
381
+
382
+ - [ ] **Step 5:** In `app.py` `chat()`: maintain `state["msg_lengths"]` (append `len(user_msg.split())`, cap last 5) and pass `style=style_signal(state["msg_lengths"])` into `build_system_prompt(...)`. Add `"msg_lengths": []` to pristine/seeded states.
383
+
384
+ - [ ] **Step 6:** Run suite β†’ green. **Commit** `feat: B3 β€” Hollow notices how you write`.
385
+
386
+ ---
387
+
388
+ ### Task B2 (B2 in spec): Brings up your memories unprompted
389
+
390
+ **Files:** Modify `memory.py`, `character.py`, `app.py`; Modify tests.
391
+
392
+ - [ ] **Step 1: Failing test** (`tests/test_memory.py`):
393
+
394
+ ```python
395
+ from memory import pick_aware_memory
396
+
397
+ class TestPickAwareMemory:
398
+ def test_picks_an_unclaimed_memory_not_the_recall_one(self):
399
+ s = {"treasure": ["a", "b", "c"], "claimed": ["a"], "last_aware_memory": None}
400
+ m = pick_aware_memory(s, exclude="b")
401
+ assert m in {"c"} # not claimed(a), not excluded(b)
402
+ def test_avoids_repeating_the_last_one(self):
403
+ s = {"treasure": ["a", "b"], "claimed": [], "last_aware_memory": "a"}
404
+ assert pick_aware_memory(s, exclude=None) == "b"
405
+ def test_none_when_nothing_eligible(self):
406
+ s = {"treasure": ["a"], "claimed": ["a"], "last_aware_memory": None}
407
+ assert pick_aware_memory(s, exclude=None) is None
408
+ ```
409
+
410
+ - [ ] **Step 2: Run, verify fail.**
411
+
412
+ - [ ] **Step 3: Implement** in `memory.py`:
413
+
414
+ ```python
415
+ def pick_aware_memory(state: dict, exclude: str | None) -> str | None:
416
+ """An earlier memory Hollow may quietly resurface this turn: unclaimed, not
417
+ the one being recalled, not the one it just resurfaced. None if none fit."""
418
+ claimed = set(state.get("claimed", []))
419
+ last = state.get("last_aware_memory")
420
+ pool = [m for m in state.get("treasure", [])
421
+ if m not in claimed and m != exclude and m != last]
422
+ return pool[-1] if pool else None # newest eligible (deterministic)
423
+ ```
424
+
425
+ - [ ] **Step 4:** `build_system_prompt` gains `aware_memory: str | None = None`; when set, append: `f"Earlier the visitor told you: \"{aware_memory}\". You may bring it up unprompted, gently, as if it's been on your mind."`
426
+
427
+ - [ ] **Step 5:** In `chat()`, only on **non-recall** turns and occasionally (e.g. `state["turn"] % 3 == 0`), set `aware = pick_aware_memory(state, exclude=recall_memory)`, pass it in, and on success set `state["last_aware_memory"] = aware`. Add `"last_aware_memory": None` to states.
428
+
429
+ - [ ] **Step 6:** Run suite β†’ green. **Commit** `feat: B2 β€” Hollow resurfaces your memories unprompted`.
430
+
431
+ ---
432
+
433
+ ### Task B3 (B1 in spec): Speaks first on silence (`gr.Timer`)
434
+
435
+ **Files:** Modify `app.py`, `character.py`; Modify tests.
436
+
437
+ `gr.Timer` is validated. Idle lines are **canned** (no model call). Voice (A) speaks them too.
438
+
439
+ - [ ] **Step 1: Failing tests** (`tests/test_app.py`):
440
+
441
+ ```python
442
+ class TestIdle:
443
+ def test_idle_appends_a_canned_line_when_silent(self, monkeypatch):
444
+ import time as _t
445
+ monkeypatch.setattr(app, "speak", lambda text: None)
446
+ state = _state(affinity=30, treasure=[], claimed=[], tone=0)
447
+ state["last_activity"] = _t.time() - 999 # long idle
448
+ out_state, hist, voice = app._on_idle(state, [{"role": "assistant", "content": "x"}])
449
+ assert hist[-1]["role"] == "assistant"
450
+ assert hist[-1]["content"] in app._IDLE_LINES
451
+
452
+ def test_idle_silent_when_recently_active_or_ended(self, monkeypatch):
453
+ import time as _t
454
+ # recently active -> no line
455
+ s1 = _state(); s1["last_activity"] = _t.time()
456
+ assert app._on_idle(s1, [])[1] == []
457
+ # ended -> no line
458
+ s2 = _state(ended=True); s2["last_activity"] = _t.time() - 999
459
+ assert app._on_idle(s2, [])[1] == []
460
+ ```
461
+
462
+ - [ ] **Step 2: Run, verify fail.**
463
+
464
+ - [ ] **Step 3: Implement** in `app.py`:
465
+
466
+ ```python
467
+ _IDLE_LINES = [
468
+ "...are you still there?",
469
+ "the fog is getting thicker. say something.",
470
+ "don't go. not yet.",
471
+ "i can still feel you out there.",
472
+ ]
473
+ _IDLE_AFTER = 12.0 # seconds of silence before Hollow speaks first
474
+
475
+ def _on_idle(state: dict, history: list):
476
+ import time as _t
477
+ if (state.get("ended") or not history
478
+ or _t.time() - state.get("last_activity", _t.time()) < _IDLE_AFTER):
479
+ return state, history, ""
480
+ state = dict(state)
481
+ i = state.get("idle_count", 0)
482
+ line = _IDLE_LINES[i % len(_IDLE_LINES)]
483
+ state["idle_count"] = i + 1
484
+ state["last_activity"] = _t.time() # wait again before the next one
485
+ history = history + [{"role": "assistant", "content": line}]
486
+ voice = _voice_html(speak(line)) if state.get("voice_on", True) else ""
487
+ return state, history, voice
488
+ ```
489
+
490
+ - [ ] **Step 4:** Reset `state["last_activity"] = time.time()` at the top of `chat()` (on a real user turn) and add `"last_activity": 0.0` / `"idle_count": 0` to pristine/seeded states. In `_start_turn`, also stamp `last_activity`.
491
+
492
+ - [ ] **Step 5:** Wire the timer in the Blocks body:
493
+
494
+ ```python
495
+ idle_timer = gr.Timer(4.0)
496
+ idle_timer.tick(_on_idle, [state, chatbot], [state, chatbot, voice_panel],
497
+ show_progress="hidden")
498
+ ```
499
+
500
+ - [ ] **Step 6:** Run the full suite β†’ green.
501
+
502
+ - [ ] **Step 7: Commit** `feat: B1 β€” Hollow speaks first when you go silent (gr.Timer)`.
503
+
504
+ ---
505
+
506
+ ### Task FINAL: Full verification + deploy
507
+
508
+ - [ ] **Step 1:** `.venv/Scripts/python -m pytest -q` β†’ all green.
509
+ - [ ] **Step 2: Local sanity** (voice is silent locally on 3.13 β€” expected): `HOLLOW_FAST_FINALE=good .venv/Scripts/python app.py`, screenshot, confirm layout intact, mute button present, no errors. Idle line should appear after ~12s of no input.
510
+ - [ ] **Step 3: Voice smoke test (3.12 venv):** in the `.venv-tts` env, `python -c "import voice; print(bool(voice.speak('the sea. that was me.')))"` β†’ `True` (proves the real pipeline path end-to-end).
511
+ - [ ] **Step 4: Deploy** both Spaces; wait ~3–4 min (the Kokoro/spacy build is heavier), Ctrl+F5. On the Space, verify: recall is spoken, finale lines are spoken, mute works, idle fires.
512
+
513
+ ```bash
514
+ git push space main && git push hackathon main
515
+ ```
516
+
517
+ Commit trailer for every commit: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
518
+
519
+ ---
520
+
521
+ ## Self-review notes
522
+ - If the Space build fails, it's almost certainly the Python pin or the spaCy wheel β€” check those first; voice is import-guarded so a *runtime* voice failure never takes down the app, but a *build* failure does.
523
+ - Keep the entity heartbeat HTML stable β€” never put voice audio in the entity panel; it lives in `voice_panel`.
524
+ - B2/B3 must stay occasional (every-N-turns / gated) so they don't flatten the voice into a tic.
525
+ - The idle timer must reset on every user turn and never fire during a finale or after the end.
docs/superpowers/specs/2026-06-13-presence-voice-and-awareness-design.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Presence: Voice + Awareness β€” Design Spec
2
+
3
+ > Goal: make Hollow feel like a *presence*, not a text box. Two additions on top
4
+ > of the existing Qwen3-8B game: **A) the child speaks** (whispered TTS on the
5
+ > recall and the finales) and **B) the child is aware** (speaks first on silence,
6
+ > brings up your memories unprompted, notices how you write).
7
+
8
+ **Status:** design β€” voice validated end-to-end (Kokoro runs offline on CPU,
9
+ voice cast locked). The one remaining technical unknown is `gr.Timer` for B1,
10
+ to be validated before that task is committed.
11
+
12
+ **Non-goals:** no model swap (Qwen3-8B stays β€” it's on-brand for the small-model
13
+ track and the calibrated pacing depends on it). No voice on every reply (latency).
14
+ No sponsor model bolt-ons. No new visual generation (the grayscale fog art is the
15
+ brand).
16
+
17
+ ---
18
+
19
+ ## A β€” Voice (the child whispers)
20
+
21
+ ### What it does
22
+ On the **recall** (Hollow retelling your memory in the first person) and on
23
+ **Hollow's lines during the three finales**, the text is also spoken in a
24
+ whispered, childlike, suffering voice. Hearing your own memory whispered back is
25
+ the wow; the finales become genuinely frightening.
26
+
27
+ ### Locked voice recipe (validated)
28
+ - **Model:** `hexgrad/Kokoro-82M` (Apache-2.0, 82M), `KPipeline(lang_code="a")`,
29
+ voice **`af_nicole`**, `speed=0.80`. Runs on **CPU** β€” no GPU budget consumed.
30
+ - **Post-processing (numpy, the same approach as `sting.py`):**
31
+ - `pitch_up(1.16)` β€” raises toward a child timbre.
32
+ - `breath(amount = 0.38 * 0.28)` β€” light whispered air.
33
+ - `fog_reverb` taps `[(0.06, 0.5*0.28), (0.13, 0.32*0.28), (0.23, 0.18*0.28)]` β€”
34
+ a faint in-the-fog space.
35
+ - i.e. the "verylight" (28%) filter the user picked.
36
+ - Output 24 kHz mono WAV β†’ base64 β†’ inlined `<audio autoplay>` (the exact
37
+ mechanism the chimes/sting already use; **no JS**).
38
+
39
+ ### Where it plugs in
40
+ - **Recall turns:** in `chat()`, when `do_recall and recall_memory`, synthesize
41
+ the reply text and attach the audio to the rendered output (alongside the
42
+ existing `flash_strong` entity render).
43
+ - **Finale lines:** each `speaker == "hollow"` step in the three finale players
44
+ synthesizes its line. The multi-second `pause` between steps absorbs the
45
+ ~1–2 s synthesis time, so pacing is unaffected.
46
+ - **Not** on ordinary replies (keeps every turn fast; the recall is the wow).
47
+
48
+ ### Mute toggle
49
+ A small control (e.g. a πŸ”Š/πŸ”‡ button near the title) sets a `voice_on` flag in
50
+ state. README already warns of sudden sound; default ON, one click to silence.
51
+
52
+ ### Failure safety
53
+ TTS is wrapped: if Kokoro errors or is slow, the turn degrades to **silent**
54
+ (text still shows). Voice never blocks or breaks a turn. The pipeline is loaded
55
+ **once at import** (like the model), guarded so an import failure disables voice
56
+ rather than crashing the app.
57
+
58
+ ### Space build requirements (the real risk surface β€” "don't break the build")
59
+ - **Pin `python_version: "3.11"`** in README frontmatter (Kokoro requires
60
+ Python <3.13; HF default may be 3.13 β†’ would break the build).
61
+ - **`requirements.txt`** adds: `kokoro`, `misaki[en]`, `soundfile`, `spacy`, and
62
+ the spaCy model `en_core_web_sm` (installed via its wheel URL β€” discovered in
63
+ validation that misaki needs it).
64
+ - **`packages.txt`** adds `espeak-ng` as a G2P fallback (validation ran without
65
+ it on Windows via misaki, but include it for OOV safety on the Space).
66
+ - First run downloads the Kokoro weights (~330 MB) β€” happens once at Space build.
67
+
68
+ ---
69
+
70
+ ## B β€” Awareness (the child watches and thinks)
71
+
72
+ Three behaviors, all amplifying the existing "it reads you" theme. B4 (continuity)
73
+ folds into B2 β€” it falls out of the history + B2 naturally.
74
+
75
+ ### B2 β€” Brings up your memories unprompted (prompt + state, low risk)
76
+ Mid-conversation (not only on the scripted recall), Hollow may weave in a
77
+ fragment of an earlier `treasure` memory: *"you said you were afraid of the dark.
78
+ i think about that."*
79
+ - **How:** the system prompt gains an optional "you may quietly recall this
80
+ earlier thing the visitor told you: <memory>" line, picking a random *unclaimed,
81
+ not-currently-recalled* treasure memory, gated so it surfaces only sometimes
82
+ (e.g. a probability or an every-N-turns rule) and never the same one twice in a
83
+ row (track `last_aware_memory` in state).
84
+ - **Risk:** low β€” prompt + one state field. Must not collide with the scripted
85
+ recall (skip on recall turns).
86
+
87
+ ### B3 β€” Notices how you write (prompt + state, low risk)
88
+ Hollow comments on your *style*, making the surveillance explicit: short messages
89
+ β†’ *"you're being careful with me."*; long β†’ *"you have so much to say tonight."*;
90
+ cold β†’ *"you've gone quiet inside."*
91
+ - **How:** track a light rolling signal in state (recent message lengths; the
92
+ existing tone). Inject a hint into the prompt: "the visitor's recent messages
93
+ have been {short|long|warm|cold} β€” you may quietly notice this." The model
94
+ already mimics tone; this nudges it to *name* it. Gated so it's occasional, not
95
+ every turn.
96
+ - **Risk:** low β€” prompt + derived signal, no new deps.
97
+
98
+ ### B1 β€” Speaks first on silence (needs a small tech validation)
99
+ If the visitor is idle for a while, Hollow says something unprompted:
100
+ *"...are you still there? don't go."* / *"the fog is getting thicker. say something."*
101
+ - **How:** a Gradio **`gr.Timer`** (fires every few seconds) checks: is the
102
+ visitor idle past a threshold (an `last_activity` timestamp in state), the game
103
+ not generating, not ended, not mid-finale? If so, append a **canned** in-character
104
+ idle line (a small rotating pool) to the chat β€” **no model call, no GPU**, so it's
105
+ deterministic, instant, and free. Idle lines may also be voiced (A) for extra
106
+ presence.
107
+ - **Gating (critical):** must not fire during generation, during a finale, after
108
+ the game ends, or immediately after the user just spoke. Reset the idle timer on
109
+ every user message and finale step.
110
+ - **Risk:** medium β€” this is the only piece with new wiring. **Validate `gr.Timer`
111
+ first** (does it work with our generator + `gr.State` in Gradio 6.x; does it
112
+ conflict with the `.then()` chains): a throwaway probe, exactly like we did for
113
+ Kokoro, before committing the task.
114
+
115
+ ---
116
+
117
+ ## State additions
118
+ ```python
119
+ # A
120
+ "voice_on": True, # mute toggle
121
+ # B
122
+ "last_aware_memory": None, # B2 β€” avoid repeating the same surfaced memory
123
+ "msg_lengths": [], # B3 β€” rolling recent message lengths (cap ~5)
124
+ "last_activity": <ts>, # B1 β€” idle detection; reset on each user turn
125
+ ```
126
+ All read with `.get(...)` for pre-deploy sessions (existing convention).
127
+
128
+ ## Files touched (anticipated)
129
+ - `voice.py` (new) β€” Kokoro pipeline + the locked post-processing; `speak(text)
130
+ -> base64 wav | None`. Guarded import; CPU only.
131
+ - `render.py` β€” helper to inline a voice `<audio>` (reuse the existing pattern).
132
+ - `engine.py` / `character.py` β€” B2/B3 prompt injection; no model/backend change.
133
+ - `app.py` β€” wire voice into recall + finale players; mute toggle; `gr.Timer` for
134
+ B1; state fields.
135
+ - `requirements.txt`, `packages.txt`, `README.md` (python pin) β€” Space build.
136
+ - `memory.py` β€” B2/B3 small pure helpers (pick-aware-memory, style-signal) + tests.
137
+ - `tests/` β€” voice (mocked/guarded), B2/B3 pure logic, idle-line selection.
138
+
139
+ ## Risks & validations (before/within the plan)
140
+ 1. **`gr.Timer` (B1)** β€” validate with a probe before committing the task.
141
+ 2. **Space build (A)** β€” python pin + the spaCy/misaki/espeak chain is the most
142
+ likely build breaker; stage it carefully and keep voice import-guarded so a
143
+ failure disables voice, never the app.
144
+ 3. **Latency (A)** β€” measure synth time; recall + finale only, pauses absorb it.
145
+ 4. **Off-grid intact** β€” Kokoro is a local model; the off-grid badge stays.
146
+
147
+ ## Out of scope (explicitly)
148
+ Model swap; voice on every reply; sponsor models; generated backgrounds/animations;
149
+ the shareable end-card (plan C β€” deferred).