From 58c30b7273282605acacd4ef2e54d74412cc95b8 Mon Sep 17 00:00:00 2001 From: Indiana Date: Thu, 23 Jul 2026 05:48:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20possession=20presentation=20layer=20?= =?UTF-8?q?=E2=80=94=20glitchy=20text=20+=20degraded=20audio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/possession.py | 39 ++++++ backend/app/tts/piper.py | 38 +++++- backend/app/ws.py | 18 ++- backend/tests/test_possession.py | 69 +++++++++++ backend/tests/test_tts_piper.py | 118 ++++++++++++++++++ backend/tests/test_ws_session.py | 5 +- frontend/src/components/Transcript.test.tsx | 73 ++++++++++++ frontend/src/components/Transcript.tsx | 30 ++++- frontend/src/lib/possession.test.ts | 91 ++++++++++++++ frontend/src/lib/possession.ts | 125 ++++++++++++++++++++ frontend/src/lib/types.ts | 2 +- frontend/src/pages/SeancePage.css | 19 +++ frontend/src/state/seance.test.ts | 16 ++- frontend/src/state/seance.tsx | 16 ++- 14 files changed, 638 insertions(+), 21 deletions(-) create mode 100644 backend/app/possession.py create mode 100644 backend/tests/test_possession.py create mode 100644 backend/tests/test_tts_piper.py create mode 100644 frontend/src/components/Transcript.test.tsx create mode 100644 frontend/src/lib/possession.test.ts create mode 100644 frontend/src/lib/possession.ts diff --git a/backend/app/possession.py b/backend/app/possession.py new file mode 100644 index 0000000..8b085cb --- /dev/null +++ b/backend/app/possession.py @@ -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)) diff --git a/backend/app/tts/piper.py b/backend/app/tts/piper.py index 00faeb4..7d2caac 100644 --- a/backend/app/tts/piper.py +++ b/backend/app/tts/piper.py @@ -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)), ) diff --git a/backend/app/ws.py b/backend/app/ws.py index 1ea4e36..9200fec 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -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: diff --git a/backend/tests/test_possession.py b/backend/tests/test_possession.py new file mode 100644 index 0000000..364283a --- /dev/null +++ b/backend/tests/test_possession.py @@ -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 diff --git a/backend/tests/test_tts_piper.py b/backend/tests/test_tts_piper.py new file mode 100644 index 0000000..9dc2e93 --- /dev/null +++ b/backend/tests/test_tts_piper.py @@ -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 diff --git a/backend/tests/test_ws_session.py b/backend/tests/test_ws_session.py index b06a9cc..274d4c2 100644 --- a/backend/tests/test_ws_session.py +++ b/backend/tests/test_ws_session.py @@ -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." diff --git a/frontend/src/components/Transcript.test.tsx b/frontend/src/components/Transcript.test.tsx new file mode 100644 index 0000000..661540a --- /dev/null +++ b/frontend/src/components/Transcript.test.tsx @@ -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( + 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( + 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( + undefined} + />, + ) + // No streaming row should be present once inactive. + expect(screen.queryByText('hush now')).toBeNull() + expect(() => vi.advanceTimersByTime(2000)).not.toThrow() + }) +}) diff --git a/frontend/src/components/Transcript.tsx b/frontend/src/components/Transcript.tsx index aa9be87..69ce091 100644 --- a/frontend/src/components/Transcript.tsx +++ b/frontend/src/components/Transcript.tsx @@ -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(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 (
{entries.length === 0 && !streaming.active && ( @@ -80,7 +98,13 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr {streaming.active && (
{t('seance.streaming')} - {streaming.text} + + {renderPossessedText(streaming.text, streaming.stability, glitchTick)} + ▌
)} diff --git a/frontend/src/lib/possession.test.ts b/frontend/src/lib/possession.test.ts new file mode 100644 index 0000000..81be7d2 --- /dev/null +++ b/frontend/src/lib/possession.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/possession.ts b/frontend/src/lib/possession.ts new file mode 100644 index 0000000..8411337 --- /dev/null +++ b/frontend/src/lib/possession.ts @@ -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('') +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 79b171a..25a2ef0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index 736d634..3479d33 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -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; diff --git a/frontend/src/state/seance.test.ts b/frontend/src/state/seance.test.ts index 57c6e8a..4f5c584 100644 --- a/frontend/src/state/seance.test.ts +++ b/frontend/src/state/seance.test.ts @@ -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' }) diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index a383375..f078688 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -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,