// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija), 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 { Transcript } from '../components/Transcript'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import {
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']
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 (
)
}
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)
// 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 ---- */}
{/* ---- unstable-veil banner ---- */}
{state.connection !== 'open' && (
⟁
{t(`seance.connection.${state.connection}`)}
)}
{/* ---- main: board + side column ---- */}
{state.status && (
{t(`seance.status.${state.status}`)}
)}
{entity ? (
setDossierOpen((o) => !o)}
aria-expanded={dossierOpen}
>
{t('seance.entity.title')}
{dossierOpen ? '▾' : '◂'}
{dossierOpen && (
{entity.name}
{entity.epithet}
{t(`seance.entity.rarity.${entity.rarity}`)}
{state.entityIsNew ? (
{t('seance.entity.isNew')}
) : (
{t('seance.entity.known', { count: entity.contact_count })}
)}
{t('seance.entity.persona')}
{entity.persona}
{t('seance.entity.voice')}
id
{entity.voice.voice_id}
pitch
{entity.voice.pitch.toFixed(2)}
rate
{entity.voice.rate.toFixed(2)}
noise
{entity.voice.noise.toFixed(2)}
echo
{entity.voice.echo.toFixed(2)}
{entity.quotes.length > 0 && (
{t('seance.entity.quotes')}
{entity.quotes.slice(0, 4).map((q, i) => (
“{q}”
))}
)}
)}
) : (
{t('seance.entity.none')}
{t('seance.entity.noneHint')}
)}
{state.mode === 'wire' && }
{state.mode === 'evp' && }
{state.mode === 'radio' && }
{state.mode === 'ouija' && }
{/* ---- bottom: transmission log + direct contact ---- */}
{/* ---- 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 })}
onDismiss(toast.id)}
aria-label={t('common.dismiss')}
>
×
)
}
// ---- wire panel: raw line telemetry ----
function WirePanel() {
const { state } = useSeance()
const { t } = useTranslation()
const tel = state.telemetry
return (
{t('seance.telemetry.title')}
{tel ? (
{t('seance.telemetry.jitter')}
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
{t('seance.telemetry.latencyVar')}
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
{t('seance.telemetry.latencyMean')}
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
{t('seance.telemetry.dns')}
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
) : (
{t('common.loading')}
)}
{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')}
)}
void (listening ? stop() : start())}
disabled={pending}
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
)
}
// ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState(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 : ''
setError(
name === 'NotFoundError' || /no device selected/i.test(msg)
? t('seance.radio.noDevice')
: /claim/i.test(msg)
? t('seance.radio.claimFailed')
: 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(err.message)
},
})
.then(() => {
if (sdrRef.current === sdr) setPhase('idle')
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError(err instanceof Error ? err.message : String(err))
setPhase('idle')
}
})
}
if (!supported) {
return (
{t('seance.radio.unsupportedTitle')}
{t('seance.radio.unsupported')}
{t('seance.radio.troubleshoot')}
{t('seance.radio.start')}
)
}
return (
{tunedMhz !== null ? (
{tunedMhz.toFixed(1)} MHz
) : (
—
)}
{phase === 'sweeping' && (
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
)}
{error && (
)}
void (phase === 'sweeping' ? stop() : start())}
disabled={phase === 'busy'}
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
)
}
// ---- 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(' · ')}
)}
)
}