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:
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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user