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:
39
backend/app/possession.py
Normal file
39
backend/app/possession.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Possession stability: a single number expressing how cleanly a spirit
|
||||
holds the channel for one reply. Shared contract between the backend audio
|
||||
degradation and the frontend text-glitch renderer (see
|
||||
docs/superpowers/specs/2026-07-23-possession-presentation-design.md)."""
|
||||
|
||||
import random
|
||||
from typing import Callable
|
||||
|
||||
_BASE_BY_RARITY = {
|
||||
"common": 0.35,
|
||||
"uncommon": 0.5,
|
||||
"rare": 0.65,
|
||||
"mythic": 0.8,
|
||||
}
|
||||
|
||||
_MIN_STABILITY = 0.05
|
||||
_MAX_STABILITY = 0.98
|
||||
_MAX_MAGNITUDE_BONUS = 0.15
|
||||
_JITTER_SPAN = 0.1 # rng() in [0, 1) is scaled to +/- this amount
|
||||
|
||||
|
||||
def compute_stability(
|
||||
rarity: str,
|
||||
magnitude: float,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> float:
|
||||
"""How cleanly the spirit holds the channel for this reply, 0.05-0.98.
|
||||
|
||||
Rarer spirits hold the channel more steadily (higher base). A stronger
|
||||
triggering anomaly gives a cleaner line, up to a capped bonus. A final
|
||||
rng-driven jitter of +/- 0.1 keeps it from feeling deterministic to the
|
||||
seeker. `rng` is injectable so a later quantum-RNG source can swap in
|
||||
real entropy without touching call sites.
|
||||
"""
|
||||
base = _BASE_BY_RARITY.get(rarity, _BASE_BY_RARITY["common"])
|
||||
magnitude_bonus = min(_MAX_MAGNITUDE_BONUS, magnitude / 100)
|
||||
jitter = (rng() * 2 - 1) * _JITTER_SPAN
|
||||
stability = base + magnitude_bonus + jitter
|
||||
return max(_MIN_STABILITY, min(_MAX_STABILITY, stability))
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user