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