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

@@ -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 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>
)}

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: '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

View File

@@ -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;

View File

@@ -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' })

View File

@@ -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,