feat: possession presentation layer — glitchy text + degraded audio

First sub-project of the "make contact feel real" arc (spec:
docs/superpowers/specs/2026-07-23-possession-presentation-design.md).
Direct Contact replies now feel like a spirit fighting through static to
hold the channel rather than a plain chat bubble:

- backend/app/possession.py: compute_stability(rarity, magnitude, rng) —
  a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly =
  cleaner signal), rng injectable for a later quantum-RNG source.
- ws.py sends stability on reply_start; audio synthesis for that reply gets
  noise/bitcrush scaled by instability (1 - stability) via a new
  instability param on synthesize_spirit_voice — effects.py itself is
  untouched, only the params fed into it.
- frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic
  (tick-seeded, no Math.random) text corruption with self-correcting
  glitch bursts, wired into Transcript.tsx's streaming reply display.

Stored transcript/reply text is unaffected — this is presentation only.
78/78 backend, 137/137 frontend tests passing.
This commit is contained in:
Indiana
2026-07-23 05:48:02 +00:00
parent bf8f352910
commit 58c30b7273
14 changed files with 638 additions and 21 deletions

View File

@@ -36,6 +36,7 @@ from app.models.contact_session import ContactSession
from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.event import Event
from app.possession import compute_stability
from app.rate_limit import RateLimiter, resolve_client_ip
from app.telemetry import detect_wire_spike, sample_network
from app.tts.piper import synthesize_spirit_voice
@@ -147,7 +148,9 @@ async def _record_event(
return event.id
async def _speak(state: SeanceState, kind: str, text: str) -> None:
async def _speak(
state: SeanceState, kind: str, text: str, instability: float = 0.0
) -> None:
"""Persist an utterance, push its text immediately, synthesize audio in
the background, and push the audio URL when the effects chain finishes."""
event_id = await _record_event(
@@ -167,7 +170,9 @@ async def _speak(state: SeanceState, kind: str, text: str) -> None:
try:
voice_profile = state.entity.get("voice", {}) if state.entity else {}
voice = pick_voice(voice_profile.get("voice_id"), state.language)
wav = await synthesize_spirit_voice(text, voice, voice_profile)
wav = await synthesize_spirit_voice(
text, voice, voice_profile, instability=instability
)
filename = f"{event_id}.wav"
_audio_dir().joinpath(filename).write_bytes(wav)
async with session_maker() as db:
@@ -323,7 +328,12 @@ async def _handle_question(state: SeanceState, text: str) -> None:
text = text.strip()[:500]
await _record_event(state.session_id, "question", text=text)
await state.send_queue.put({"type": "status", "state": "gathering"})
await state.send_queue.put({"type": "reply_start"})
last_magnitude = state.anomalies[-1].get("magnitude") if state.anomalies else 50
if last_magnitude is None:
last_magnitude = 50
stability = compute_stability(state.entity["rarity"], float(last_magnitude))
await state.send_queue.put({"type": "reply_start", "stability": stability})
reply_parts: list[str] = []
try:
@@ -350,7 +360,7 @@ async def _handle_question(state: SeanceState, text: str) -> None:
state.history = state.history[-8:]
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
if reply:
await _speak(state, "reply", reply)
await _speak(state, "reply", reply, instability=1 - stability)
async def _ambient_loop(state: SeanceState) -> None: