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:
|
||||
|
||||
69
backend/tests/test_possession.py
Normal file
69
backend/tests/test_possession.py
Normal 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
|
||||
118
backend/tests/test_tts_piper.py
Normal file
118
backend/tests/test_tts_piper.py
Normal 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
|
||||
@@ -31,7 +31,7 @@ class FakeSpiritService:
|
||||
return False
|
||||
|
||||
|
||||
async def _fake_synth(text, voice, profile):
|
||||
async def _fake_synth(text, voice, profile, instability=0.0):
|
||||
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")
|
||||
ws.send_json({"type": "question", "text": "Are you at peace?"})
|
||||
_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")
|
||||
assert reply_end["text"] == "I am here."
|
||||
|
||||
|
||||
73
frontend/src/components/Transcript.test.tsx
Normal file
73
frontend/src/components/Transcript.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TranscriptEntry } from '../state/seance'
|
||||
import { renderPossessedText } from '../lib/possession'
|
||||
|
||||
export type TranscriptProps = {
|
||||
entries: TranscriptEntry[]
|
||||
streaming: { active: boolean; text: string }
|
||||
streaming: { active: boolean; text: string; stability: number }
|
||||
speakingId: string | null
|
||||
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 {
|
||||
// EVP reports Hz (voice band); radio reports MHz (FM band); emf reports a
|
||||
// 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) {
|
||||
const { t } = useTranslation()
|
||||
const boxRef = useRef<HTMLDivElement | null>(null)
|
||||
const [glitchTick, setGlitchTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const box = boxRef.current
|
||||
if (box) box.scrollTop = box.scrollHeight
|
||||
}, [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 (
|
||||
<div className="transcript" ref={boxRef} aria-live="polite">
|
||||
{entries.length === 0 && !streaming.active && (
|
||||
@@ -80,7 +98,13 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr
|
||||
{streaming.active && (
|
||||
<div className="tx-row tx-streaming">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
91
frontend/src/lib/possession.test.ts
Normal file
91
frontend/src/lib/possession.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
125
frontend/src/lib/possession.ts
Normal file
125
frontend/src/lib/possession.ts
Normal 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('')
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export type ServerFrame =
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
||||
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
||||
| { type: 'audio'; id: string; url: string }
|
||||
| { type: 'reply_start' }
|
||||
| { type: 'reply_start'; stability?: number }
|
||||
| { type: 'reply_token'; token: string }
|
||||
| { type: 'reply_end'; id: string; text: string }
|
||||
| { type: 'telemetry' } & Telemetry
|
||||
|
||||
@@ -1095,6 +1095,25 @@
|
||||
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 {
|
||||
color: #7cffb2;
|
||||
animation: cursorBlink 0.8s steps(1) infinite;
|
||||
|
||||
@@ -92,15 +92,15 @@ describe('seanceReducer', () => {
|
||||
let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity })
|
||||
|
||||
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: '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
|
||||
|
||||
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[0]).toMatchObject({
|
||||
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', () => {
|
||||
let state = frame(initialSeanceState, { type: 'reply_start' })
|
||||
state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' })
|
||||
|
||||
@@ -63,7 +63,7 @@ export type SeanceState = {
|
||||
utterances: Utterance[]
|
||||
anomalies: AnomalyEntry[]
|
||||
transcript: TranscriptEntry[]
|
||||
replyStreaming: { active: boolean; text: string }
|
||||
replyStreaming: { active: boolean; text: string; stability: number }
|
||||
telemetry: Telemetry | null
|
||||
toasts: Toast[]
|
||||
speakingId: string | null
|
||||
@@ -82,7 +82,7 @@ export const initialSeanceState: SeanceState = {
|
||||
utterances: [],
|
||||
anomalies: [],
|
||||
transcript: [],
|
||||
replyStreaming: { active: false, text: '' },
|
||||
replyStreaming: { active: false, text: '', stability: 1.0 },
|
||||
telemetry: null,
|
||||
toasts: [],
|
||||
speakingId: null,
|
||||
@@ -295,12 +295,20 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
||||
}
|
||||
|
||||
case 'reply_start':
|
||||
return { ...state, replyStreaming: { active: true, text: '' } }
|
||||
return {
|
||||
...state,
|
||||
replyStreaming: {
|
||||
active: true,
|
||||
text: '',
|
||||
stability: frame.stability ?? 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
case 'reply_token':
|
||||
return {
|
||||
...state,
|
||||
replyStreaming: {
|
||||
...state.replyStreaming,
|
||||
active: true,
|
||||
text: state.replyStreaming.text + frame.token,
|
||||
},
|
||||
@@ -318,7 +326,7 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
||||
return {
|
||||
...state,
|
||||
status: null,
|
||||
replyStreaming: { active: false, text: '' },
|
||||
replyStreaming: { active: false, text: '', stability: 1.0 },
|
||||
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
|
||||
Reference in New Issue
Block a user