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

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