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

@@ -50,18 +50,48 @@ class PiperTTS:
async def synthesize_spirit_voice(
text: str, voice: Voice, voice_profile: dict | None = None
text: str,
voice: Voice,
voice_profile: dict | None = None,
instability: float = 0.0,
) -> bytes:
"""One call: Piper synth + the spirit's signature effects chain."""
"""One call: Piper synth + the spirit's signature effects chain.
`instability` (0.0-1.0, i.e. `1 - stability`) scales up the noise and
bitcrush fed into the effects chain when the spirit is fighting to hold
the channel — a shaky possession sounds shakier. It never touches
`apply_effects` itself, only the values handed to it here.
"""
profile = voice_profile or {}
instability = max(0.0, min(1.0, instability))
model_path = Path(settings.piper_voices_dir) / voice.model_file
base_noise = float(profile.get("noise", 0.03))
# Noise ramps linearly across the whole range so degradation is audible
# from the first sign of instability, but doubling the base level at
# instability=1.0 (rather than, say, 5x) keeps the words intelligible
# even at a full-static possession — this is texture, not white noise.
noise_level = base_noise + instability * base_noise
base_bitcrush = int(profile.get("bitcrush", 0))
# Bitcrush only kicks in once instability crosses ~0.5 -- a slightly
# unstable channel just sounds noisier, not crunchy/robotic. Above that
# threshold it ramps up to +6 bits of crush on top of whatever the
# voice's own profile already applies, capped at 15 (apply_effects only
# crushes when 0 < bits < 16).
if instability > 0.5:
bitcrush_bonus = round((instability - 0.5) / 0.5 * 6)
else:
bitcrush_bonus = 0
bitcrush_bits = min(15, base_bitcrush + bitcrush_bonus)
wav = await PiperTTS(str(model_path)).synthesize(text)
return await asyncio.to_thread(
apply_effects,
wav,
noise_level=float(profile.get("noise", 0.03)),
noise_level=noise_level,
pitch_semitones=float(profile.get("pitch", 0.0)),
rate=float(profile.get("rate", 1.0)),
bitcrush_bits=int(profile.get("bitcrush", 0)),
bitcrush_bits=bitcrush_bits,
echo=float(profile.get("echo", 0.2)),
)