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

39
backend/app/possession.py Normal file
View 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))

View File

@@ -50,18 +50,48 @@ class PiperTTS:
async def synthesize_spirit_voice( 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: ) -> 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 {} profile = voice_profile or {}
instability = max(0.0, min(1.0, instability))
model_path = Path(settings.piper_voices_dir) / voice.model_file 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) wav = await PiperTTS(str(model_path)).synthesize(text)
return await asyncio.to_thread( return await asyncio.to_thread(
apply_effects, apply_effects,
wav, wav,
noise_level=float(profile.get("noise", 0.03)), noise_level=noise_level,
pitch_semitones=float(profile.get("pitch", 0.0)), pitch_semitones=float(profile.get("pitch", 0.0)),
rate=float(profile.get("rate", 1.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)), echo=float(profile.get("echo", 0.2)),
) )

View File

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

View File

@@ -0,0 +1,69 @@
from app.possession import compute_stability
def test_rarity_ordering_holds_magnitude_and_rng_constant():
# Midpoint rng (0.5) zeroes out jitter, isolating the rarity base.
rng = lambda: 0.5
common = compute_stability("common", magnitude=50, rng=rng)
uncommon = compute_stability("uncommon", magnitude=50, rng=rng)
rare = compute_stability("rare", magnitude=50, rng=rng)
mythic = compute_stability("mythic", magnitude=50, rng=rng)
assert common < uncommon < rare < mythic
def test_unknown_rarity_falls_back_to_common_base():
rng = lambda: 0.5
assert compute_stability("legendary???", magnitude=50, rng=rng) == compute_stability(
"common", magnitude=50, rng=rng
)
def test_magnitude_bonus_increases_stability_up_to_cap():
rng = lambda: 0.5
low = compute_stability("common", magnitude=0, rng=rng)
mid = compute_stability("common", magnitude=10, rng=rng)
high = compute_stability("common", magnitude=1000, rng=rng)
assert low < mid < high
# Bonus caps at 0.15, so magnitude=15 and magnitude=1000 must land the
# same once base and jitter are held constant.
at_cap = compute_stability("common", magnitude=15, rng=rng)
past_cap = compute_stability("common", magnitude=1000, rng=rng)
assert at_cap == past_cap
def test_jitter_scaled_to_plus_minus_point_one():
# rng()=0 -> minimum jitter (-0.1); rng()=1 -> maximum jitter (+0.1).
base = 0.35 # common base
low_jitter = compute_stability("common", magnitude=0, rng=lambda: 0.0)
high_jitter = compute_stability("common", magnitude=0, rng=lambda: 1.0)
assert low_jitter == max(0.05, base - 0.1)
assert high_jitter == min(0.98, base + 0.1)
def test_clamped_at_lower_bound():
# common base 0.35 minus full jitter (0.1) minus nothing else is 0.25,
# comfortably above the floor -- force the floor with a negative-leaning
# setup instead: rarity base + jitter alone can't go below 0.05, but the
# clamp itself must still be exercised directly via a pathological rng.
stability = compute_stability("common", magnitude=0, rng=lambda: -100.0)
assert stability == 0.05
def test_clamped_at_upper_bound():
stability = compute_stability("mythic", magnitude=1000, rng=lambda: 100.0)
assert stability == 0.98
def test_deterministic_with_injected_rng():
rng = lambda: 0.73
first = compute_stability("rare", magnitude=42, rng=rng)
second = compute_stability("rare", magnitude=42, rng=rng)
assert first == second
def test_default_rng_produces_value_in_range():
stability = compute_stability("uncommon", magnitude=25)
assert 0.05 <= stability <= 0.98

View File

@@ -0,0 +1,118 @@
import pytest
import app.tts.piper as piper
from app.tts.voices import VOICES
@pytest.fixture(autouse=True)
def _fake_piper_synth(monkeypatch):
async def _fake_synthesize(self, text):
return b"fake-wav-bytes"
monkeypatch.setattr(piper.PiperTTS, "synthesize", _fake_synthesize)
def _capture_effects_call(monkeypatch):
calls = []
def _fake_apply_effects(wav_bytes, **kwargs):
calls.append(kwargs)
return wav_bytes
monkeypatch.setattr(piper, "apply_effects", _fake_apply_effects)
return calls
@pytest.mark.asyncio
async def test_zero_instability_keeps_profile_defaults(monkeypatch):
calls = _capture_effects_call(monkeypatch)
voice = VOICES["lessac"]
await piper.synthesize_spirit_voice("hello", voice, {"noise": 0.03, "bitcrush": 0})
assert calls[0]["noise_level"] == pytest.approx(0.03)
assert calls[0]["bitcrush_bits"] == 0
@pytest.mark.asyncio
async def test_instability_increases_noise_level(monkeypatch):
calls = _capture_effects_call(monkeypatch)
voice = VOICES["lessac"]
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=1.0
)
# Meaningfully louder than the clean baseline, but not blown out.
assert calls[0]["noise_level"] > 0.03
assert calls[0]["noise_level"] <= 0.1
@pytest.mark.asyncio
async def test_noise_level_scales_monotonically_with_instability(monkeypatch):
voice = VOICES["lessac"]
noise_levels = []
for instability in (0.0, 0.25, 0.5, 0.75, 1.0):
calls = _capture_effects_call(monkeypatch)
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=instability
)
noise_levels.append(calls[0]["noise_level"])
assert noise_levels == sorted(noise_levels)
assert noise_levels[0] < noise_levels[-1]
@pytest.mark.asyncio
async def test_bitcrush_stays_off_below_threshold(monkeypatch):
voice = VOICES["lessac"]
for instability in (0.0, 0.2, 0.5):
calls = _capture_effects_call(monkeypatch)
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=instability
)
assert calls[0]["bitcrush_bits"] == 0
@pytest.mark.asyncio
async def test_bitcrush_kicks_in_above_threshold(monkeypatch):
calls = _capture_effects_call(monkeypatch)
voice = VOICES["lessac"]
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=1.0
)
assert calls[0]["bitcrush_bits"] > 0
assert calls[0]["bitcrush_bits"] < 16
@pytest.mark.asyncio
async def test_instability_is_clamped_to_unit_range(monkeypatch):
calls = _capture_effects_call(monkeypatch)
voice = VOICES["lessac"]
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=5.0
)
clamped_high = calls[0]
calls = _capture_effects_call(monkeypatch)
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 0}, instability=1.0
)
at_one = calls[0]
assert clamped_high == at_one
@pytest.mark.asyncio
async def test_instability_adds_on_top_of_profile_bitcrush(monkeypatch):
calls = _capture_effects_call(monkeypatch)
voice = VOICES["lessac"]
await piper.synthesize_spirit_voice(
"hello", voice, {"noise": 0.03, "bitcrush": 4}, instability=1.0
)
assert calls[0]["bitcrush_bits"] > 4

View File

@@ -31,7 +31,7 @@ class FakeSpiritService:
return False return False
async def _fake_synth(text, voice, profile): async def _fake_synth(text, voice, profile, instability=0.0):
return b"RIFFfake wav bytes" return b"RIFFfake wav bytes"
@@ -124,7 +124,8 @@ async def test_question_streams_reply_and_records_history(sync_client, db_sessio
_read_until(ws, "session") _read_until(ws, "session")
ws.send_json({"type": "question", "text": "Are you at peace?"}) ws.send_json({"type": "question", "text": "Are you at peace?"})
_read_until(ws, "entity") # auto-summoned before answering _read_until(ws, "entity") # auto-summoned before answering
_read_until(ws, "reply_start") reply_start = _read_until(ws, "reply_start")
assert 0.05 <= reply_start["stability"] <= 0.98
reply_end = _read_until(ws, "reply_end") reply_end = _read_until(ws, "reply_end")
assert reply_end["text"] == "I am here." assert reply_end["text"] == "I am here."

View File

@@ -0,0 +1,73 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { Transcript } from './Transcript'
function renderStreaming(text: string, stability: number) {
return render(
<Transcript
entries={[]}
streaming={{ active: true, text, stability }}
speakingId={null}
onReplay={() => undefined}
/>,
)
}
describe('Transcript streaming reply', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('renders the raw streamed text (unglitched at stability) when the channel is steady', () => {
renderStreaming('the veil is thin tonight', 0.98)
// At near-max stability the glitch pass leaves it at/near the true text.
expect(screen.getByText(/the veil is thin tonight/)).toBeTruthy()
})
it('applies the low-stability glitch class only when stability is below the threshold', () => {
const { container, rerender } = renderStreaming('the veil is thin', 0.95)
expect(container.querySelector('.possession-glitch')).toBeNull()
rerender(
<Transcript
entries={[]}
streaming={{ active: true, text: 'the veil is thin', stability: 0.3 }}
speakingId={null}
onReplay={() => undefined}
/>,
)
expect(container.querySelector('.possession-glitch')).not.toBeNull()
})
it('ticks the glitch pattern forward while streaming is active, without crashing', () => {
const { container } = renderStreaming('the water remembers everything we forget', 0.1)
const before = container.querySelector('.tx-text.smoke-text')?.textContent
act(() => {
vi.advanceTimersByTime(500)
})
const after = container.querySelector('.tx-text.smoke-text')?.textContent
// Same length always (renderPossessedText never inserts/deletes), and
// ticking is happening (interval fired without throwing).
expect(after?.length).toBe(before?.length)
})
it('stops ticking once streaming goes inactive', () => {
const { rerender } = renderStreaming('hush now', 0.1)
rerender(
<Transcript
entries={[]}
streaming={{ active: false, text: 'hush now', stability: 0.1 }}
speakingId={null}
onReplay={() => undefined}
/>,
)
// No streaming row should be present once inactive.
expect(screen.queryByText('hush now')).toBeNull()
expect(() => vi.advanceTimersByTime(2000)).not.toThrow()
})
})

View File

@@ -1,14 +1,20 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TranscriptEntry } from '../state/seance' import type { TranscriptEntry } from '../state/seance'
import { renderPossessedText } from '../lib/possession'
export type TranscriptProps = { export type TranscriptProps = {
entries: TranscriptEntry[] entries: TranscriptEntry[]
streaming: { active: boolean; text: string } streaming: { active: boolean; text: string; stability: number }
speakingId: string | null speakingId: string | null
onReplay: (utteranceId: string) => void onReplay: (utteranceId: string) => void
} }
/** Below this the possession is rough enough to warrant the glitch CSS treatment. */
const LOW_STABILITY_THRESHOLD = 0.85
/** How often the glitch pattern reshuffles while a reply streams in, in ms. */
const GLITCH_TICK_MS = 90
function fmtFreq(source: string, f: number): string { function fmtFreq(source: string, f: number): string {
// EVP reports Hz (voice band); radio reports MHz (FM band); emf reports a // EVP reports Hz (voice band); radio reports MHz (FM band); emf reports a
// field-strength estimate; wire is ambient. // field-strength estimate; wire is ambient.
@@ -22,12 +28,24 @@ function fmtFreq(source: string, f: number): string {
export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) { export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) {
const { t } = useTranslation() const { t } = useTranslation()
const boxRef = useRef<HTMLDivElement | null>(null) const boxRef = useRef<HTMLDivElement | null>(null)
const [glitchTick, setGlitchTick] = useState(0)
useEffect(() => { useEffect(() => {
const box = boxRef.current const box = boxRef.current
if (box) box.scrollTop = box.scrollHeight if (box) box.scrollTop = box.scrollHeight
}, [entries.length, streaming.text]) }, [entries.length, streaming.text])
// Reshuffle the possession glitch pattern on a steady beat while a reply
// is streaming in — the tick counter is the only "randomness" source
// renderPossessedText sees, so this is what makes bursts/stutters read as
// live rather than a single static-mangled snapshot.
useEffect(() => {
if (!streaming.active) return
setGlitchTick(0)
const id = setInterval(() => setGlitchTick((n) => n + 1), GLITCH_TICK_MS)
return () => clearInterval(id)
}, [streaming.active])
return ( return (
<div className="transcript" ref={boxRef} aria-live="polite"> <div className="transcript" ref={boxRef} aria-live="polite">
{entries.length === 0 && !streaming.active && ( {entries.length === 0 && !streaming.active && (
@@ -80,7 +98,13 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr
{streaming.active && ( {streaming.active && (
<div className="tx-row tx-streaming"> <div className="tx-row tx-streaming">
<span className="tx-stream-label">{t('seance.streaming')}</span> <span className="tx-stream-label">{t('seance.streaming')}</span>
<span className="tx-text smoke-text">{streaming.text}</span> <span
className={`tx-text smoke-text${
streaming.stability < LOW_STABILITY_THRESHOLD ? ' possession-glitch' : ''
}`}
>
{renderPossessedText(streaming.text, streaming.stability, glitchTick)}
</span>
<span className="tx-cursor">▌</span> <span className="tx-cursor">▌</span>
</div> </div>
)} )}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { GLITCH_GLYPHS, renderPossessedText } from './possession'
const SAMPLE = 'the veil grows thin between us tonight, listener'
function diffCount(a: string, b: string): number {
let n = 0
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++
return n
}
describe('renderPossessedText', () => {
it('is deterministic for a fixed (text, stability, tick)', () => {
for (const tick of [0, 1, 7, 42, 999]) {
const a = renderPossessedText(SAMPLE, 0.4, tick)
const b = renderPossessedText(SAMPLE, 0.4, tick)
expect(a).toBe(b)
}
})
it('never changes the length of the input', () => {
for (const tick of [0, 3, 50]) {
for (const stability of [0.05, 0.5, 0.98]) {
expect(renderPossessedText(SAMPLE, stability, tick).length).toBe(SAMPLE.length)
}
}
})
it('produces measurably more corrupted characters at low stability than high stability', () => {
let lowTotal = 0
let highTotal = 0
for (let tick = 0; tick < 60; tick++) {
lowTotal += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.1, tick))
highTotal += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.95, tick))
}
expect(lowTotal).toBeGreaterThan(highTotal * 5)
expect(lowTotal).toBeGreaterThan(0)
})
it('stays very close to (or equal to) the true text at very high stability', () => {
let total = 0
let corrupted = 0
for (let tick = 0; tick < 40; tick++) {
total += SAMPLE.length
corrupted += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.95, tick))
}
// Subtle flicker only: well under 5% of characters differ on average.
expect(corrupted / total).toBeLessThan(0.05)
})
it('leaves whitespace untouched at any stability', () => {
for (const stability of [0.05, 0.3, 0.98]) {
for (let tick = 0; tick < 20; tick++) {
const out = renderPossessedText(SAMPLE, stability, tick)
for (let i = 0; i < SAMPLE.length; i++) {
if (/\s/.test(SAMPLE[i])) expect(out[i]).toBe(SAMPLE[i])
}
}
}
})
it('only substitutes characters with known glitch glyphs or an echoed neighbour', () => {
const allowed = new Set(GLITCH_GLYPHS as readonly string[])
for (let tick = 0; tick < 30; tick++) {
const out = renderPossessedText(SAMPLE, 0.05, tick)
for (let i = 0; i < out.length; i++) {
if (out[i] === SAMPLE[i]) continue
const isGlyph = allowed.has(out[i])
const isEcho = i > 0 && out[i] === out[i - 1]
expect(isGlyph || isEcho).toBe(true)
}
}
})
it('never crashes on empty string input', () => {
expect(renderPossessedText('', 0.5, 0)).toBe('')
expect(renderPossessedText('', 0.05, 12345)).toBe('')
expect(renderPossessedText('', 0.98, 0)).toBe('')
})
it('never crashes at tick 0', () => {
expect(() => renderPossessedText(SAMPLE, 0.05, 0)).not.toThrow()
expect(renderPossessedText(SAMPLE, 0.05, 0).length).toBe(SAMPLE.length)
})
it('clamps out-of-range stability instead of throwing', () => {
expect(() => renderPossessedText(SAMPLE, 5, 0)).not.toThrow()
expect(() => renderPossessedText(SAMPLE, -3, 0)).not.toThrow()
expect(() => renderPossessedText(SAMPLE, Number.NaN, 0)).not.toThrow()
})
})

View File

@@ -0,0 +1,125 @@
// The possession renderer: turns the true, already-streamed reply text into
// a display string that looks like a spirit fighting through static to hold
// the channel. Purely presentational — the stored transcript/reply text
// (state.replyStreaming.text, and later the committed utterance) is never
// touched; only what gets painted to the screen is glitched.
//
// Must stay pure and fully deterministic: same (text, stability, tick) in ->
// same string out, every time, so it's trivially unit-testable and so two
// renders of the same tick (e.g. StrictMode double-invoke) never visibly
// differ. All "randomness" is a deterministic hash keyed on the tick and
// the character index — this module never calls Math.random().
/**
* Static-flavoured glyphs used for corrupted characters, matching the
* terminal/occult vocabulary already on screen (⚡ ⟁ ▌ in Transcript.tsx).
* Whitespace is never corrupted (see renderPossessedText) so these only
* ever land inside words, keeping the line readable even when heavily
* glitched.
*/
export const GLITCH_GLYPHS = ['█', '▓', '▒', '░', '▚', '▞', '╳', '§', '¤'] as const
/**
* Deterministic 32-bit hash of (tick, index, salt) folded into [0, 1).
* Not cryptographic — just a cheap avalanche so nearby ticks/indices don't
* produce visibly correlated output. This is the *only* source of
* pseudo-randomness in this module; every caller must route through it (or
* a function that does) instead of Math.random().
*/
function hash01(tick: number, index: number, salt: number): number {
let h = (tick * 374761393 + index * 668265263 + salt * 2246822519) >>> 0
h = Math.imul(h ^ (h >>> 15), 2246822519) >>> 0
h = Math.imul(h ^ (h >>> 13), 3266489917) >>> 0
h ^= h >>> 16
return (h >>> 0) / 4294967296
}
function pickGlyph(tick: number, index: number): string {
const i = Math.floor(hash01(tick, index, 5) * GLITCH_GLYPHS.length)
return GLITCH_GLYPHS[Math.min(GLITCH_GLYPHS.length - 1, i)]
}
/** Clamp to the contract's documented stability range. */
function clampStability(stability: number): number {
if (Number.isNaN(stability)) return 1
return Math.max(0.05, Math.min(0.98, stability))
}
/**
* Per-character corruption probability for a given stability. Curved
* (instability^1.6) so the high end (stability > ~0.85) drops off fast into
* a barely-there flicker, while the low end climbs toward frequent,
* sustained corruption.
*/
function corruptionChance(stability: number): number {
const instability = 1 - stability
return Math.min(0.9, instability ** 1.6 * 0.6)
}
/**
* How many *additional* characters a corruption burst eats once it starts,
* scaled so low stability produces longer runs of static instead of single
* blipped characters.
*/
function burstSpan(stability: number, tick: number, index: number): number {
const instability = 1 - stability
const maxExtra = Math.round(instability * 4) // 0 (clean) .. 4 (chaos)
if (maxExtra <= 0) return 0
return Math.floor(hash01(tick, index, 11) * (maxExtra + 1))
}
/**
* Render `trueText` (the accumulated streamed reply so far) as it should
* appear on screen this frame: a mix of clean characters, glyph static, and
* brief stutters (a character echoing the one before it), driven entirely
* by `stability` (0.05-0.98, contract range — clamped defensively) and
* `tick` (an incrementing frame/interval counter local to the caller).
*
* Same length as `trueText` always — corruption replaces characters in
* place rather than inserting/deleting, so cursor position and layout stay
* stable while the content glitches. Whitespace is never corrupted.
*
* Deterministic: calling this twice with identical arguments always
* returns the identical string. Advancing `tick` (as the caller's ticking
* effect does while streaming.active is true) reshuffles which characters
* are affected, which is what makes bursts and stutters read as transient
* and self-correcting rather than a permanently mangled line.
*/
export function renderPossessedText(trueText: string, stability: number, tick: number): string {
if (!trueText) return trueText
const s = clampStability(stability)
const pCorrupt = corruptionChance(s)
if (pCorrupt <= 0) return trueText
const t = Math.max(0, Math.floor(tick) || 0)
const chars = trueText.split('')
let burstRemaining = 0
for (let i = 0; i < chars.length; i++) {
const ch = chars[i]
if (/\s/.test(ch)) {
// Never corrupt whitespace — keeps words legible even at low
// stability, and a burst never "restarts" mid-space.
burstRemaining = 0
continue
}
let corrupt = burstRemaining > 0
if (!corrupt) {
corrupt = hash01(t, i, 1) < pCorrupt
if (corrupt) burstRemaining = burstSpan(s, t, i)
} else {
burstRemaining--
}
if (!corrupt) continue
// A minority of corrupted characters stutter (echo the previous
// printable character) instead of turning to static glyph, giving the
// "brief stutter" texture the design calls for.
const useStutter = i > 0 && hash01(t, i, 23) < 0.35
chars[i] = useStutter ? chars[i - 1] : pickGlyph(t, i)
}
return chars.join('')
}

View File

@@ -93,7 +93,7 @@ export type ServerFrame =
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number } | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null } | { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
| { type: 'audio'; id: string; url: string } | { type: 'audio'; id: string; url: string }
| { type: 'reply_start' } | { type: 'reply_start'; stability?: number }
| { type: 'reply_token'; token: string } | { type: 'reply_token'; token: string }
| { type: 'reply_end'; id: string; text: string } | { type: 'reply_end'; id: string; text: string }
| { type: 'telemetry' } & Telemetry | { type: 'telemetry' } & Telemetry

View File

@@ -1095,6 +1095,25 @@
text-shadow: 0 0 10px rgba(124, 255, 178, 0.45); text-shadow: 0 0 10px rgba(124, 255, 178, 0.45);
} }
/* Low-stability possession: the channel is unsteady, so the streamed text
itself trembles — kept subtle (opacity/skew jitter, no color change) so
the glitched characters underneath stay legible. */
.possession-glitch {
display: inline-block;
animation: possessionJitter 0.6s steps(2, end) infinite;
text-shadow:
0 0 10px rgba(124, 255, 178, 0.45),
1px 0 rgba(178, 107, 255, 0.35),
-1px 0 rgba(255, 59, 92, 0.25);
}
@keyframes possessionJitter {
0%, 100% { opacity: 1; transform: translateX(0); }
20% { opacity: 0.86; transform: translateX(-0.5px); }
45% { opacity: 1; transform: translateX(0.5px); }
70% { opacity: 0.9; transform: translateX(0); }
}
.tx-cursor { .tx-cursor {
color: #7cffb2; color: #7cffb2;
animation: cursorBlink 0.8s steps(1) infinite; animation: cursorBlink 0.8s steps(1) infinite;

View File

@@ -92,15 +92,15 @@ describe('seanceReducer', () => {
let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity }) let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity })
state = frame(state, { type: 'reply_start' }) state = frame(state, { type: 'reply_start' })
expect(state.replyStreaming).toEqual({ active: true, text: '' }) expect(state.replyStreaming).toEqual({ active: true, text: '', stability: 1.0 })
state = frame(state, { type: 'reply_token', token: 'the veil ' }) state = frame(state, { type: 'reply_token', token: 'the veil ' })
state = frame(state, { type: 'reply_token', token: 'is thin' }) state = frame(state, { type: 'reply_token', token: 'is thin' })
expect(state.replyStreaming).toEqual({ active: true, text: 'the veil is thin' }) expect(state.replyStreaming).toEqual({ active: true, text: 'the veil is thin', stability: 1.0 })
expect(state.utterances).toEqual([]) // nothing committed while streaming expect(state.utterances).toEqual([]) // nothing committed while streaming
state = frame(state, { type: 'reply_end', id: 'u-9', text: 'the veil is thin' }) state = frame(state, { type: 'reply_end', id: 'u-9', text: 'the veil is thin' })
expect(state.replyStreaming).toEqual({ active: false, text: '' }) expect(state.replyStreaming).toEqual({ active: false, text: '', stability: 1.0 })
expect(state.utterances).toHaveLength(1) expect(state.utterances).toHaveLength(1)
expect(state.utterances[0]).toMatchObject({ expect(state.utterances[0]).toMatchObject({
id: 'u-9', id: 'u-9',
@@ -117,6 +117,16 @@ describe('seanceReducer', () => {
}) })
}) })
it('carries a reply_start stability into replyStreaming', () => {
const state = frame(initialSeanceState, { type: 'reply_start', stability: 0.62 })
expect(state.replyStreaming.stability).toBe(0.62)
})
it('defaults stability to 1.0 when reply_start omits it (pre-rollout backend frames)', () => {
const state = frame(initialSeanceState, { type: 'reply_start' })
expect(state.replyStreaming.stability).toBe(1.0)
})
it('falls back to a null speaker for replies without a summoned entity', () => { it('falls back to a null speaker for replies without a summoned entity', () => {
let state = frame(initialSeanceState, { type: 'reply_start' }) let state = frame(initialSeanceState, { type: 'reply_start' })
state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' }) state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' })

View File

@@ -63,7 +63,7 @@ export type SeanceState = {
utterances: Utterance[] utterances: Utterance[]
anomalies: AnomalyEntry[] anomalies: AnomalyEntry[]
transcript: TranscriptEntry[] transcript: TranscriptEntry[]
replyStreaming: { active: boolean; text: string } replyStreaming: { active: boolean; text: string; stability: number }
telemetry: Telemetry | null telemetry: Telemetry | null
toasts: Toast[] toasts: Toast[]
speakingId: string | null speakingId: string | null
@@ -82,7 +82,7 @@ export const initialSeanceState: SeanceState = {
utterances: [], utterances: [],
anomalies: [], anomalies: [],
transcript: [], transcript: [],
replyStreaming: { active: false, text: '' }, replyStreaming: { active: false, text: '', stability: 1.0 },
telemetry: null, telemetry: null,
toasts: [], toasts: [],
speakingId: null, speakingId: null,
@@ -295,12 +295,20 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
} }
case 'reply_start': case 'reply_start':
return { ...state, replyStreaming: { active: true, text: '' } } return {
...state,
replyStreaming: {
active: true,
text: '',
stability: frame.stability ?? 1.0,
},
}
case 'reply_token': case 'reply_token':
return { return {
...state, ...state,
replyStreaming: { replyStreaming: {
...state.replyStreaming,
active: true, active: true,
text: state.replyStreaming.text + frame.token, text: state.replyStreaming.text + frame.token,
}, },
@@ -318,7 +326,7 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
return { return {
...state, ...state,
status: null, status: null,
replyStreaming: { active: false, text: '' }, replyStreaming: { active: false, text: '', stability: 1.0 },
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP), utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
transcript: pushCapped( transcript: pushCapped(
state.transcript, state.transcript,