// The main séance screen: ghost backdrop, planchette board, mode panels // (wire / evp / radio / ouija / emf), transmission log and direct-contact row. // // Wiring notes: // - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine // itself; this page only enqueues utterance text and polls snapshot() // (non-mutating) for the ouija panel readout. // - Each mode panel is conditionally mounted, so mic / SDR teardown happens // in the panel's unmount cleanup — switching modes or leaving the page // always releases the hardware. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent } from 'react' import { Link, Navigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useAuth } from '../state/auth' import { SeanceProvider, useSeance } from '../state/seance' import type { Toast } from '../state/seance' import { GhostCanvas } from '../three/GhostCanvas' import type { GhostMood, GhostVisualState } from '../three/GhostScene' import { PlanchetteBoard } from '../components/PlanchetteBoard' import { MatrixView } from '../components/MatrixView' import { GraphView } from '../components/GraphView' import { Transcript } from '../components/Transcript' import { EntityCard } from '../components/EntityCard' import { TelemetryReadout } from '../components/TelemetryReadout' import { PlanchetteMachine } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette' import { EvpListener } from '../lib/evp' import { EmfSensorListener } from '../lib/emf' import { isSecureContext, isSupported, RtlSdr, SpectrumAnomalyDetector, SWEEP_END_MHZ, SWEEP_START_MHZ, } from '../lib/sdr' import { persistLanguage, storedLanguage } from '../i18n' import type { Language, Mode } from '../lib/types' import './SeancePage.css' const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf'] // Fallback tab labels for modes the i18n catalogs don't carry yet. const MODE_LABEL_DEFAULTS: Partial> = { emf: 'FIELD' } // Board-area views; labels fall back to these until the i18n JSONs land. const VIEWS = [ { id: 'board', fallback: 'BOARD' }, { id: 'matrix', fallback: 'MATRIX' }, { id: 'graphs', fallback: 'GRAPHS' }, ] as const type BoardView = (typeof VIEWS)[number]['id'] const TOAST_TTL_MS = 6000 /** SDR sample rate must match RtlSdr.open()'s default. */ const SDR_SAMPLE_RATE_HZ = 2_048_000 export function SeancePage() { const { user, checking } = useAuth() const { t } = useTranslation() if (checking) { return (
⟁

{t('common.loading')}

) } if (!user) return return ( ) } export default SeancePage // ---- the séance itself (rendered inside SeanceProvider) ---- function SeanceSession({ username }: { username: string }) { const { state, audioPlayer, setMode, setPassive, setLanguage, summon, ask, playUtterance, dismissToast, } = useSeance() const { logout } = useAuth() const { t, i18n } = useTranslation() const [departed, setDeparted] = useState(false) const [question, setQuestion] = useState('') const [dossierOpen, setDossierOpen] = useState(true) const [view, setView] = useState('board') // The planchette machine lives for the whole session; the board ticks it. const machineRef = useRef(new PlanchetteMachine()) const [boardSnap, setBoardSnap] = useState(null) // Tell the backend which tongue we whisper in (queued while connecting). useEffect(() => { setLanguage(storedLanguage()) }, [setLanguage]) // Feed every fresh utterance (greetings, fragments, replies) to the board. const lastEnqueuedRef = useRef(null) useEffect(() => { const uts = state.utterances if (uts.length === 0) return const lastSeen = lastEnqueuedRef.current const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen) if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history const fresh = uts.slice(idx + 1) for (const u of fresh) machineRef.current.enqueue(u.text) lastEnqueuedRef.current = uts[uts.length - 1].id }, [state.utterances]) // Poll the machine's snapshot for the ouija readout (board owns ticking). useEffect(() => { let lastSig = '' const id = window.setInterval(() => { const s = machineRef.current.snapshot() const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}` if (sig !== lastSig) { lastSig = sig setBoardSnap(s) } }, 150) return () => window.clearInterval(id) }, []) // Open the dossier whenever a new presence steps through. const entityId = state.entity?.id ?? null useEffect(() => { if (entityId) setDossierOpen(true) }, [entityId]) const ghostVisual = useMemo( () => state.entity ? { hue: state.entity.visual.hue, form: state.entity.visual.form } : null, [state.entity], ) const ghostMood: GhostMood = state.speakingId ? 'speaking' : state.status === 'gathering' || state.status === 'summoning' ? 'gathering' : state.status === 'attuning' ? 'attuning' : 'idle' const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer]) const switchLanguage = (lang: Language) => { setLanguage(lang) void i18n.changeLanguage(lang) persistLanguage(lang) } const handleLogout = () => { void logout() .catch(() => undefined) .finally(() => setDeparted(true)) } const streaming = state.replyStreaming.active const submitQuestion = (e: FormEvent) => { e.preventDefault() if (streaming) return ask(question) setQuestion('') } if (departed) return const entityHue = state.entity?.visual.hue ?? 150 const entity = state.entity return (
{/* ---- top bar ---- */}
⟁ {t('common.appName')}
{t('nav.shop', { defaultValue: 'shop' })} {t('nav.codex')} {username}
{/* ---- veil banner: 'connecting' is a neutral tuning state; only an unstable or dead line raises the warning ---- */} {state.connection === 'connecting' && (
⟁ {t('seance.views.tuning', { defaultValue: 'tuning the veil…' })}
)} {(state.connection === 'unstable' || state.connection === 'closed') && (
⟁ {t(`seance.connection.${state.connection}`)}
)} {/* ---- main: board + side column ---- */}
{/* view switcher: BOARD / MATRIX / GRAPHS (styles in MatrixView.css, buttons reuse the .mode-tab look) */} {/* The PlanchetteMachine lives in this page, so unmounting the board component for another view loses nothing. */} {view === 'board' && } {view === 'matrix' && ( )} {view === 'graphs' && ( )} {state.status && (
{t(`seance.status.${state.status}`)}
)}
{/* ---- bottom: transmission log + direct contact ---- */}
{t('seance.transcriptTitle')} {t('seance.anomaly.count', { count: state.anomalyCount })}
setQuestion(e.target.value)} placeholder={t('seance.askPlaceholder')} disabled={streaming} maxLength={280} aria-label={t('seance.ask')} />
{/* ---- toasts ---- */}
{state.toasts.map((toast) => ( ))}
{/* ---- CRT overlays (above everything, never interactive) ---- */}
) } // ---- toasts ---- function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) { const { t } = useTranslation() useEffect(() => { const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS) return () => window.clearTimeout(id) }, [toast.id, onDismiss]) return (
⚠ {t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
) } // ---- wire panel: raw line telemetry ---- function WirePanel() { const { state } = useSeance() const { t } = useTranslation() return (

{t('seance.anomaly.count', { count: state.anomalyCount })}

{!state.passive && (

{t('seance.wire.hint', { defaultValue: 'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.', })}

)}
) } // ---- EVP panel: microphone voice-band listening ---- function EvpPanel() { const { sendAnomaly } = useSeance() const { t } = useTranslation() const [listening, setListening] = useState(false) const [pending, setPending] = useState(false) const [denied, setDenied] = useState(false) const [bandDb, setBandDb] = useState(null) const listenerRef = useRef(null) const canvasRef = useRef(null) const lastMeterRef = useRef(0) // Release the microphone when the panel unmounts (mode switch / leaving). useEffect( () => () => { const l = listenerRef.current listenerRef.current = null if (l) void l.stop() }, [], ) const drawScope = (db: Float32Array) => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const w = canvas.width const h = canvas.height ctx.clearRect(0, 0, w, h) const n = db.length const step = Math.max(1, Math.floor(n / 160)) const bw = w / (n / step) for (let i = 0, x = 0; i < n; i += step, x++) { const v = db[i] // dB, roughly -160..0 const norm = Math.max(0, Math.min(1, (v + 110) / 80)) const bh = Math.max(1, norm * h) ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})` ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh) } } const start = async () => { if (pending || listening) return setDenied(false) setPending(true) const listener = listenerRef.current ?? new EvpListener() listenerRef.current = listener try { await listener.start({ onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude), onFrame: (db) => { drawScope(db) const now = performance.now() if (now - lastMeterRef.current > 200) { lastMeterRef.current = now // Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT). const n = db.length const lo = Math.floor((300 / 24000) * n) const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n)) let peak = -Infinity for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i] setBandDb(Number.isFinite(peak) ? peak : null) } }, }) if (listenerRef.current === listener) setListening(true) } catch { if (listenerRef.current === listener) listenerRef.current = null setDenied(true) setBandDb(null) } finally { setPending(false) } } const stop = async () => { const l = listenerRef.current listenerRef.current = null if (l) await l.stop() setListening(false) setBandDb(null) } return (
{t('seance.evp.band')} {bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}
{listening && (

{t('seance.evp.listening')}

)} {denied && (

{t('seance.evp.deniedTitle')}

{t('seance.evp.denied')}

)}
) } // ---- radio panel: RTL-SDR FM sweep ---- function RadioPanel() { const { sendAnomaly } = useSeance() const { t } = useTranslation() // WebUSB hides itself entirely outside secure ground (https / localhost). const secure = useMemo(() => isSecureContext(), []) const supported = useMemo(() => isSupported(), []) const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle') const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null) const [tunedMhz, setTunedMhz] = useState(null) const sdrRef = useRef(null) const detectorRef = useRef(null) const canvasRef = useRef(null) const lastTuneUiRef = useRef(0) // Power down the dongle on unmount (mode switch / leaving the page). useEffect( () => () => { const sdr = sdrRef.current sdrRef.current = null detectorRef.current = null if (sdr) { sdr.stopSweep() void sdr.close() } }, [], ) const drawWaterfallRow = (db: Float64Array) => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const w = canvas.width const h = canvas.height // Scroll the existing waterfall down one row, paint the new one on top. ctx.drawImage(canvas, 0, 1) const n = db.length const bw = w / n for (let i = 0; i < n; i++) { const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70)) ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)` ctx.fillRect(i * bw, 0, Math.ceil(bw), 1) } } const stop = useCallback(() => { const sdr = sdrRef.current sdrRef.current = null detectorRef.current = null if (sdr) { sdr.stopSweep() void sdr.close() } setPhase('idle') setTunedMhz(null) }, []) const start = async () => { if (!supported || sdrRef.current || phase === 'busy') return setError(null) setPhase('busy') const sdr = new RtlSdr() try { await sdr.requestDevice() await sdr.open() } catch (err) { const msg = err instanceof Error ? err.message : String(err) const name = err instanceof DOMException ? err.name : '' // NotFoundError: the seeker dismissed the picker — a gentle nudge only. // Security/Network/claim failures: something else is holding the // dongle (kernel DVB driver, missing WinUSB driver) — troubleshoot. setError( name === 'NotFoundError' || /no device selected/i.test(msg) ? { kind: 'cancelled' } : name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg) ? { kind: 'claim' } : { kind: 'other', message: msg }, ) setPhase('idle') void sdr.close() return } sdrRef.current = sdr detectorRef.current = new SpectrumAnomalyDetector() setPhase('sweeping') void sdr .sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, { onSpectrum: (centerHz, db) => { if (sdrRef.current !== sdr) return drawWaterfallRow(db) const now = performance.now() if (now - lastTuneUiRef.current > 250) { lastTuneUiRef.current = now setTunedMhz(centerHz / 1e6) } const det = detectorRef.current if (det) { const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now) if (a) sendAnomaly('radio', a.frequency, a.magnitude) } }, onError: (err) => { if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message }) }, }) .then(() => { if (sdrRef.current === sdr) setPhase('idle') }) .catch((err: unknown) => { if (sdrRef.current === sdr) { setError({ kind: 'other', message: err instanceof Error ? err.message : String(err) }) setPhase('idle') } }) } if (!secure) { return (

{t('seance.radio.unsupportedTitle')}

{t('seance.radio.insecure', { defaultValue: 'the radio dead only speak through consecrated ground — open this site via its https address.', })}

{t('seance.radio.insecureHint', { defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.', })}

) } if (!supported) { return (

{t('seance.radio.unsupportedTitle')}

{t('seance.radio.unsupported')}

{t('seance.radio.troubleshoot')}

) } return (
{tunedMhz !== null ? ( {tunedMhz.toFixed(1)} MHz ) : ( — )} {phase === 'sweeping' && ( {t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })} )}
{error?.kind === 'cancelled' && (

{t('seance.radio.noDevice')}

)} {error?.kind === 'claim' && (

{t('seance.radio.claimFailed')}

{t('seance.radio.claimFix', { defaultValue: 'linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.', })}

)} {error?.kind === 'other' && (

{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}

{error.message}

)}
) } // ---- ouija panel: what the planchette is doing ---- function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) { const { t } = useTranslation() const word = snapshot?.word ?? null return (
{word ? (
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
{word.split('').map((ch, i) => ( {ch} ))}
) : (

{t('seance.ouija.drift', { defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.', })}

)} {snapshot && snapshot.spelled.length > 0 && (

{snapshot.spelled.slice(-6).join(' · ')}

)}
) } // ---- EMF panel: the phone becomes a field meter ---- /** Highest field strength the gauge renders before pinning the needle. */ const EMF_GAUGE_MAX = 6 function EmfPanel() { const { sendAnomaly } = useSeance() const { t } = useTranslation() const supported = useMemo(() => EmfSensorListener.isSupported(), []) const [listening, setListening] = useState(false) const [pending, setPending] = useState(false) const [denied, setDenied] = useState(false) const [field, setField] = useState(null) const [baseline, setBaseline] = useState(null) const listenerRef = useRef(null) const canvasRef = useRef(null) // Drop the sensor listeners when the panel unmounts (mode switch / leaving). useEffect( () => () => { const l = listenerRef.current listenerRef.current = null if (l) l.stop() }, [], ) const drawGauge = (strength: number) => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const w = canvas.width const h = canvas.height ctx.clearRect(0, 0, w, h) const cx = w / 2 const cy = h - 14 const r = Math.min(w / 2 - 14, h - 30) const norm = Math.max(0, Math.min(1, strength / EMF_GAUGE_MAX)) const hue = 150 + norm * 122 // phosphor at rest → violet when it surges // Track arc. ctx.lineWidth = 10 ctx.lineCap = 'round' ctx.strokeStyle = 'rgba(124, 255, 178, 0.12)' ctx.beginPath() ctx.arc(cx, cy, r, Math.PI, 2 * Math.PI) ctx.stroke() // Lit arc. if (norm > 0.004) { ctx.strokeStyle = `hsla(${hue} 90% 68% / 0.92)` ctx.shadowColor = `hsla(${hue} 90% 68% / 0.8)` ctx.shadowBlur = 12 ctx.beginPath() ctx.arc(cx, cy, r, Math.PI, Math.PI + norm * Math.PI) ctx.stroke() ctx.shadowBlur = 0 } // Tick marks. ctx.lineWidth = 1 ctx.strokeStyle = 'rgba(211, 233, 219, 0.25)' for (let i = 0; i <= 8; i++) { const a = Math.PI + (i / 8) * Math.PI ctx.beginPath() ctx.moveTo(cx + Math.cos(a) * (r - 10), cy + Math.sin(a) * (r - 10)) ctx.lineTo(cx + Math.cos(a) * (r - 15), cy + Math.sin(a) * (r - 15)) ctx.stroke() } // Needle. const ang = Math.PI + norm * Math.PI ctx.lineWidth = 2 ctx.strokeStyle = `hsla(${hue} 85% 78% / 0.95)` ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(cx + Math.cos(ang) * (r - 20), cy + Math.sin(ang) * (r - 20)) ctx.stroke() ctx.fillStyle = '#7cffb2' ctx.beginPath() ctx.arc(cx, cy, 3, 0, 2 * Math.PI) ctx.fill() } // Paint the resting needle once (and whenever support is re-checked). useEffect(() => { if (supported) drawGauge(0) }, [supported]) const start = async () => { if (pending || listening || !supported) return setDenied(false) setPending(true) const listener = listenerRef.current ?? new EmfSensorListener() listenerRef.current = listener try { // iOS gates the senses behind an explicit, gesture-triggered grant. if (EmfSensorListener.needsPermission()) { const granted = await EmfSensorListener.requestPermission() if (!granted) { if (listenerRef.current === listener) setDenied(true) return } } if (listenerRef.current !== listener) return // unmounted mid-permission listener.start({ onAnomaly: (a) => sendAnomaly('emf', a.frequency, a.magnitude), onReading: (strength, base) => { drawGauge(strength) setField(strength) setBaseline(base) }, }) setListening(true) } finally { setPending(false) } } const stop = () => { const l = listenerRef.current if (l) l.stop() setListening(false) setField(null) setBaseline(null) drawGauge(0) } const calibrate = () => { listenerRef.current?.reset() } if (!supported) { return (

{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}

{t('seance.emf.unsupported', { defaultValue: 'a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.', })}

) } return (
{field !== null ? field.toFixed(2) : '—'} {t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '} {baseline !== null ? baseline.toFixed(2) : '—'}
{listening && (

{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}

)} {denied && (

{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}

{t('seance.emf.denied', { defaultValue: 'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.', })}

)}
) }