Files
qtalker---/frontend/src/pages/SeancePage.tsx
Indiana 00b0a17203 fix: verify all six unproven audit findings — four real, two not
The audit that produced these had every verifier agent die, so none were
confirmed. Checked each against the running system rather than guessing.

1. COOKIE Secure FLAG — REAL, fixed. The Cloudflare Tunnel runs OFF this box
   (observed source 10.30.20.67, 155 requests in the journal) and uvicorn
   only honours X-Forwarded-* from --forwarded-allow-ips, default 127.0.0.1.
   Proven by hitting the LAN IP with X-Forwarded-Proto: https and watching
   Secure vanish from Set-Cookie. Every internet visitor's session cookie
   was going out without it.
   Fixed in the unit drop-in with --proxy-headers and an allow-list scoped
   to the tunnel host — NOT "*", because trusting that header from anywhere
   would let a LAN client forge the IP the per-IP limiters key on. Verified
   both directions: trusted source + header gets Secure, plain LAN http
   correctly does not, and a spoof from an untrusted host is ignored.

2. DOUBLE GUEST ON REMOUNT — REAL but narrow, left alone. The guestAttempted
   ref already covers StrictMode's double-effect (refs survive it). The only
   hole is unmounting during the in-flight request, which needs navigating
   away and back inside ~200ms and costs one unused row. Not worth
   complicating the open door's happy path for.

3. SILENT REDIRECT WHEN RATE-LIMITED — REAL, fixed. A visitor whose guest
   provisioning was refused got bounced to /enter with no explanation — and
   at 5/hour/IP a household or cafe behind one NAT reaches that easily. The
   failure reason (the backend's own in-fiction line) now rides along in
   router state and /enter shows it, so nobody is silently handed a login
   form they never asked for.

4. RATE LIMITER KEYS NEVER EVICTED — REAL, fixed. defaultdict entries
   survived forever even once their hit list emptied. The open door made
   this materially worse: every visitor is now a real account, so every
   visitor permanently added a key across eleven limiter instances. Added an
   opportunistic sweep every 512 admitted calls — no background task, cost
   lands on whoever generates the load. Three tests; verified they catch it
   by disabling the sweep and watching one fail.

5. SUMMON RACE vs TELEMETRY — REAL, fixed. Nothing serialised summoning.
   _handle_anomaly checks `state.entity is None` then awaits a summon
   containing a multi-second LLM mint, and the ESP32's HTTP ingestion path
   calls _handle_anomaly on the SAME SeanceState — which is the entire point
   of the device integration. Both could pass the check: two entities
   minted, two essence credits, two item rolls, state.entity clobbered by
   whichever finished last. Now guarded by a per-session asyncio.Lock.

6. LEGACY ENTITIES STUCK AT DEFAULT TRAITS — mechanism REAL, zero rows
   affected here. The ALTER defaults traits to '{}' with no backfill and
   roll_traits only runs at mint, so a pre-migration spirit would read 0.5
   for everything — making `trust` always correct and `cross_over`
   unreachable. This install has 0 such rows. Added a signature-seeded
   backfill anyway, guarded to empty-traits rows so it can never touch a
   spirit that already has a real nature.

(A seventh claim from the same batch — that iOS EMF is silently dead — was
refuted earlier and deliberately left untouched.)

34 targeted tests pass; deployed and verified live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:13:46 +00:00

1278 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { RitualPanel } from '../components/RitualPanel'
import { DeviceWhisper } from '../components/DeviceWhisper'
import { JudgmentPanel } from '../components/JudgmentPanel'
import { TelemetryReadout } from '../components/TelemetryReadout'
import { VeilConditions } from '../components/VeilConditions'
import { ModeHint } from '../components/ModeHint'
import { CameraPanel } from '../components/CameraPanel'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import { SpectrumScope } from '../components/SpectrumScope'
import type { ScopeAnomaly } from '../components/SpectrumScope'
import { ListenToggle } from '../components/ListenToggle'
import { SpectrumSonifier } from '../lib/spectrumSonify'
import { EmfSensorListener } from '../lib/emf'
import {
MagnetometerListener,
isSupported as magIsSupported,
} from '../lib/magnetometer'
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', 'camera']
// Fallback tab labels for modes the i18n catalogs don't carry yet.
const MODE_LABEL_DEFAULTS: Partial<Record<Mode, string>> = { emf: 'FIELD', camera: 'LENS' }
// 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, guest } = useAuth()
const { t } = useTranslation()
// Why auto-provisioning failed, carried to /enter so the seeker is told
// what happened instead of being dropped on a bare login form.
const [guestFailure, setGuestFailure] = useState<string | null>(null)
// One auto-attempt only. Guards React StrictMode's double-effect and
// stops a failed attempt (rate limit / offline) from retrying forever.
const guestAttempted = useRef(false)
// The open door. There is no sign-in wall: if nobody is authenticated
// once the session check settles, silently provision a wanderer — a real
// guest account (POST /auth/guest) — and drop straight into the séance.
// Point and shoot. Registering later is how a seeker *keeps* what they
// find and unlocks device pairing; it was never meant to be the toll to
// get in. A returning visitor already has a cookie, so `user` is set and
// this never fires for them.
useEffect(() => {
if (checking || user || guestAttempted.current) return
guestAttempted.current = true
void guest().catch((err: unknown) =>
// The backend's own in-fiction detail (e.g. the veil-is-crowded
// rate-limit line) is already the thrown message — pass it along
// rather than inventing a second wording for the same condition.
setGuestFailure(err instanceof Error && err.message ? err.message : 'unknown'),
)
}, [checking, user, guest])
// Still resolving a session, or provisioning the wanderer: hold the veil.
if (checking || (!user && !guestFailure)) {
return (
<div className="seance-loading" role="status">
<div className="loading-sigil" aria-hidden>
⟁
</div>
<p className="loading-text">{t('common.loading')}</p>
</div>
)
}
// Auto-provision failed (the veil is genuinely crowded, or offline). Fall
// back to the manual door — but carry the reason, so /enter can explain
// rather than silently presenting a login form to someone who never asked
// for one. Verified reachable: the guest limiter is 5/hour/IP, which a
// household or cafe behind one NAT address reaches easily.
if (!user) {
return <Navigate to="/enter" replace state={{ guestFailure }} />
}
return (
<SeanceProvider>
<SeanceSession username={user.username} />
</SeanceProvider>
)
}
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)
// Guests are recognized by their generated name prefix — no backend flag.
// Dismissal is per-visit on purpose: the nudge should return next séance.
const isWanderer = username.startsWith('wanderer-')
const [wandererNoteDismissed, setWandererNoteDismissed] = useState(false)
const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('board')
// The planchette machine lives for the whole session; the board ticks it.
const machineRef = useRef(new PlanchetteMachine())
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(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<string | null>(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<GhostVisualState | null>(
() =>
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 <Navigate to="/" replace />
const entityHue = state.entity?.visual.hue ?? 150
const entity = state.entity
return (
<div className="seance-page">
<GhostCanvas
className="seance-ghost"
visual={ghostVisual}
mood={ghostMood}
getAmplitude={getAmplitude}
/>
{/* ---- top bar ---- */}
<header className="seance-topbar">
<div className="seance-brand">
<span className="brand-sigil" aria-hidden>
⟁
</span>
{t('common.appName')}
</div>
<nav className="seance-modes" aria-label="modes">
{MODES.map((m) => (
<button
key={m}
type="button"
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
</button>
))}
</nav>
<div className="seance-top-right">
<label className="passive-toggle" title={t('seance.passive')}>
<input
type="checkbox"
checked={state.passive}
onChange={(e) => setPassive(e.target.checked)}
/>
<span className="passive-track" aria-hidden />
<span className="passive-label">{t('seance.passive')}</span>
</label>
<div className="lang-toggle" role="group" aria-label="language">
<button
type="button"
className={state.language === 'en' ? 'active' : ''}
onClick={() => switchLanguage('en')}
>
EN
</button>
<button
type="button"
className={state.language === 'es' ? 'active' : ''}
onClick={() => switchLanguage('es')}
>
ES
</button>
</div>
<Link to="/shop" className="seance-codex-link">
{t('nav.shop', { defaultValue: 'shop' })}
</Link>
<Link to="/codex" className="seance-codex-link">
{t('nav.codex')}
</Link>
<Link to="/log" className="seance-codex-link">
{t('nav.log', { defaultValue: 'log' })}
</Link>
<Link to="/inventory" className="seance-codex-link">
{t('nav.inventory', { defaultValue: 'inventory' })}
</Link>
<Link to="/devices" className="seance-codex-link">
{t('nav.devices', { defaultValue: 'devices' })}
</Link>
<span className="seance-user" title={username}>
{username}
</span>
<button type="button" className="seance-logout" onClick={handleLogout}>
{t('nav.logout')}
</button>
<span
className={`conn-dot conn-${state.connection}`}
title={t(`seance.connection.${state.connection}`)}
aria-label={t(`seance.connection.${state.connection}`)}
/>
</div>
</header>
{/* ---- veil banner: 'connecting' is a neutral tuning state;
only an unstable or dead line raises the warning ---- */}
{state.connection === 'connecting' && (
<div className="seance-conn-banner conn-connecting" role="status">
<span className="conn-glyph" aria-hidden>
⟁
</span>
{t('seance.views.tuning', { defaultValue: 'tuning the veil…' })}
</div>
)}
{(state.connection === 'unstable' || state.connection === 'closed') && (
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
<span className="conn-glyph" aria-hidden>
⟁
</span>
{t(`seance.connection.${state.connection}`)}
</div>
)}
{/* ---- wanderer note: guests can claim a permanent name ---- */}
{isWanderer && !wandererNoteDismissed && (
<div className="seance-wanderer-note" role="note">
<span>{t('seance.wanderer.note')}</span>{' '}
<Link to="/enter">{t('seance.wanderer.claim')}</Link>
<button
type="button"
className="wanderer-note-dismiss"
aria-label={t('seance.wanderer.dismiss')}
onClick={() => setWandererNoteDismissed(true)}
>
×
</button>
</div>
)}
{/* ---- main: board + side column ---- */}
<main className="seance-main">
<section className="seance-board-wrap">
{/* view switcher: BOARD / MATRIX / GRAPHS (styles in MatrixView.css,
buttons reuse the .mode-tab look) */}
<nav className="seance-view-tabs" aria-label="views">
{VIEWS.map((v) => (
<button
key={v.id}
type="button"
className={`mode-tab ${view === v.id ? 'active' : ''}`}
onClick={() => setView(v.id)}
>
{t(`seance.views.${v.id}`, { defaultValue: v.fallback })}
</button>
))}
</nav>
{/* The PlanchetteMachine lives in this page, so unmounting the board
component for another view loses nothing. */}
{view === 'board' && <PlanchetteBoard machine={machineRef.current} hue={entityHue} />}
{view === 'matrix' && (
<MatrixView
anomalies={state.anomalies}
utterances={state.utterances}
telemetry={state.telemetry}
entityName={state.entity?.name ?? null}
active={view === 'matrix'}
/>
)}
{view === 'graphs' && (
<GraphView
telemetry={state.telemetry}
anomalies={state.anomalies}
anomalyCount={state.anomalyCount}
entityName={state.entity?.name ?? null}
active={view === 'graphs'}
/>
)}
{state.status && (
<div className="seance-status-overlay" aria-live="polite">
<span className="status-sigil" aria-hidden />
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
</div>
)}
</section>
<aside className="seance-side">
<EntityCard
entity={entity}
isNew={state.entityIsNew}
open={dossierOpen}
onToggleOpen={() => setDossierOpen((o) => !o)}
/>
<VeilConditions />
<DeviceWhisper />
<RitualPanel />
<JudgmentPanel />
<section className="mode-panel">
{state.mode === 'wire' && <WirePanel />}
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
{state.mode === 'emf' && <EmfPanel />}
{state.mode === 'camera' && <CameraPanel />}
</section>
</aside>
</main>
{/* ---- bottom: transmission log + direct contact ---- */}
<footer className="seance-bottom">
<div className="seance-transcript-wrap">
<div className="seance-transcript-title">
<span>{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
{t('seance.anomaly.count', { count: state.anomalyCount })}
</span>
</div>
<Transcript
entries={state.transcript}
streaming={state.replyStreaming}
speakingId={state.speakingId}
onReplay={playUtterance}
/>
</div>
<form className="seance-contact" onSubmit={submitQuestion}>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={t('seance.askPlaceholder')}
disabled={streaming}
maxLength={280}
aria-label={t('seance.ask')}
/>
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
{t('seance.ask')}
</button>
<button
type="button"
className="summon-btn"
onClick={summon}
disabled={state.status === 'summoning'}
>
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
</button>
</form>
</footer>
{/* ---- toasts ---- */}
<div className="seance-toasts" aria-live="polite">
{state.toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</div>
{/* ---- CRT overlays (above everything, never interactive) ---- */}
<div className="seance-scanlines" aria-hidden />
<div className="seance-vignette" aria-hidden />
</div>
)
}
// ---- 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 (
<div className="seance-toast" role="alert">
<span className="toast-glyph" aria-hidden>
⚠
</span>
<span className="toast-msg">
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
</span>
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label={t('common.dismiss')}
>
×
</button>
</div>
)
}
// ---- wire panel: raw line telemetry ----
function WirePanel() {
const { state } = useSeance()
const { t } = useTranslation()
return (
<div className="wire-panel">
<TelemetryReadout telemetry={state.telemetry} />
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
{!state.passive && (
<p className="wire-hint dim">
{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.',
})}
</p>
)}
<ModeHint mode="wire" suppressed={state.passive} />
</div>
)
}
// ---- EVP panel: microphone voice-band listening ----
function EvpPanel() {
const { sendAnomaly, entropy } = useSeance()
const { user } = useAuth()
const { t } = useTranslation()
// "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP
// detector's threshold so fainter deviations register — see lib/evp.ts.
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
// Why the mic isn't open, so the notice can be truthful about the cause
// instead of always claiming the seeker refused it.
const [micFailure, setMicFailure] = useState<
'denied' | 'insecure' | 'absent' | 'busy' | null
>(null)
const [bandDb, setBandDb] = useState<number | null>(null)
const [nyquist, setNyquist] = useState(24000)
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
const binsRef = useRef<Float64Array | null>(null)
const listenerRef = useRef<EvpListener | null>(null)
const lastMeterRef = useRef(0)
const scratchRef = useRef<[Float64Array, Float64Array] | null>(null)
const sonifierRef = useRef(new SpectrumSonifier())
// Mirrors `nyquist` state for use inside the rAF callbacks, which close
// over the render that started the listener and would otherwise keep
// reading the pre-start default forever.
const nyquistRef = useRef(24000)
// Release the microphone when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) void l.stop()
},
[],
)
const start = async () => {
if (pending || listening) return
setMicFailure(null)
setPending(true)
const listener = listenerRef.current ?? new EvpListener()
listenerRef.current = listener
try {
await listener.start({
onAnomaly: (a) => {
sendAnomaly('evp', a.frequency, a.magnitude)
const at = performance.now()
// Cap the marker list: a noisy room can fire faster than markers
// expire, and an unbounded array would grow for the whole session.
setMarkers((prev) => [...prev.slice(-11), { freq: a.frequency, magnitude: a.magnitude, at }])
sonifierRef.current.ping(a.frequency, a.magnitude, { startHz: 0, endHz: nyquistRef.current }, at)
},
onFrame: (db) => {
// Double-buffer: the scope may be mid-draw on the other buffer,
// so writing into the one it isn't holding avoids tearing. Two
// fixed arrays also means zero allocation per frame — allocating
// a typed array 60x/sec is exactly the garbage that causes
// visible jank on a mid-range phone.
const pair = scratchRef.current
const buffers =
pair && pair[0].length === db.length
? pair
: ([new Float64Array(db.length), new Float64Array(db.length)] as [
Float64Array,
Float64Array,
])
scratchRef.current = buffers
const out = buffers[0] === binsRef.current ? buffers[1] : buffers[0]
for (let i = 0; i < db.length; i++) out[i] = db[i]
binsRef.current = out
// Real thermal/acoustic noise from this room, banked toward the
// next summon — see lib/entropy.ts.
entropy.addFrame(db)
const now = performance.now()
if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now
// Voice band ≈ 300–3400 Hz, mapped through the *actual* Nyquist
// rather than an assumed 48 kHz.
const nyq = nyquistRef.current
const n = db.length
const lo = Math.floor((300 / nyq) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / nyq) * n))
let peak = -Infinity
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
setBandDb(Number.isFinite(peak) ? peak : null)
}
},
}, { hasListeningTool })
if (listenerRef.current === listener) {
const nyq = listener.nyquistHz
if (nyq) {
nyquistRef.current = nyq
setNyquist(nyq)
}
setListening(true)
}
} catch (err) {
if (listenerRef.current === listener) listenerRef.current = null
// Blaming the seeker for a refusal that never happened sends them to
// fix the wrong thing. getUserMedia's DOMException names distinguish
// the real causes, so each one gets truthful, actionable copy:
// NotAllowedError is a genuine denial; NotFoundError means there's no
// microphone at all; NotReadableError means another app holds it; and
// on a bare http:// origin mediaDevices is simply absent.
const name = err instanceof DOMException ? err.name : ''
const noMediaApi =
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
setMicFailure(
noMediaApi || name === 'SecurityError'
? 'insecure'
: name === 'NotFoundError' || name === 'OverconstrainedError'
? 'absent'
: name === 'NotReadableError' || name === 'AbortError'
? 'busy'
: 'denied',
)
setBandDb(null)
} finally {
setPending(false)
}
}
const stop = async () => {
const l = listenerRef.current
listenerRef.current = null
if (l) await l.stop()
setListening(false)
setBandDb(null)
binsRef.current = null
setMarkers([])
}
return (
<div className="evp-panel">
<SpectrumScope
binsRef={binsRef}
active={listening}
axisMin={0}
axisMax={nyquist}
unitLabel="Hz"
formatTick={(v) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
// Browser AnalyserNode dB values run roughly -100 (silence) to 0
// (clipping); a real room sits well down that range.
dbFloor={-100}
dbCeil={-18}
anomalies={markers}
hue={150}
label={t('seance.evp.scopeLabel', {
defaultValue: 'live audio spectrum from the microphone',
})}
/>
<div className="evp-readout scope-controls">
<span className="dim">{t('seance.evp.band')}</span>
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
<ListenToggle sonifier={sonifierRef.current} disabled={!listening} />
</div>
{listening && (
<p className="evp-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.evp.listening')}
</p>
)}
{micFailure && (
<div className="panel-notice error">
<h4>{t(`seance.evp.fail.${micFailure}Title`)}</h4>
<p>{t(`seance.evp.fail.${micFailure}`)}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (listening ? stop() : start())}
disabled={pending}
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
<ModeHint mode="evp" suppressed={listening || pending} />
</div>
)
}
// ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() {
const { sendAnomaly, entropy } = 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<number | null>(null)
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
// Written per sweep step, read by the scope's own rAF loop — see
// SpectrumScope's binsRef doc for why this isn't state.
const binsRef = useRef<Float64Array | null>(null)
const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
const lastTuneUiRef = useRef(0)
const sonifierRef = useRef(new SpectrumSonifier())
// False once this panel unmounts, so an open sequence still awaiting the
// device picker can tell it was abandoned — sdrRef alone can't cover that
// window, because it isn't assigned until after the awaits complete.
const mountedRef = useRef(true)
// Power down the dongle on unmount (mode switch / leaving the page).
useEffect(
() => () => {
mountedRef.current = false
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
},
[],
)
const stop = useCallback(() => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
setPhase('idle')
setTunedMhz(null)
binsRef.current = null
setMarkers([])
}, [])
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
}
// requestDevice() waits on the browser's device picker and open() runs
// ~20 sequential control transfers, so the seeker has ample time to
// switch modes or leave. Without this the dongle would be left claimed
// and sweeping forever against an unmounted panel, and could only be
// freed by closing the tab.
if (!mountedRef.current) {
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
// powerSpectrumDb() allocates a fresh array per sweep step, so
// this is already a distinct object each time — the scope uses
// that identity change to know a new row is ready.
binsRef.current = db
entropy.addFrame(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) {
// `a.frequency` is already in MHz (see SpectrumAnomalyDetector).
sendAnomaly('radio', a.frequency, a.magnitude)
setMarkers((prev) => [
...prev.slice(-11),
{ freq: a.frequency, magnitude: a.magnitude, at: now },
])
sonifierRef.current.ping(
a.frequency,
a.magnitude,
{ startHz: SWEEP_START_MHZ, endHz: SWEEP_END_MHZ },
now,
)
}
}
},
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 (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>
{t('seance.radio.insecure', {
defaultValue:
'the radio dead only speak through consecrated ground — open this site via its https address.',
})}
</p>
<p className="dim troubleshoot">
{t('seance.radio.insecureHint', {
defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
if (!supported) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>{t('seance.radio.unsupported')}</p>
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
return (
<div className="radio-panel">
<SpectrumScope
binsRef={binsRef}
active={phase === 'sweeping'}
// The sweep walks the whole band, so the axis is the band itself —
// markers are placed by the anomaly's absolute MHz, which is what
// the detector reports.
axisMin={SWEEP_START_MHZ}
axisMax={SWEEP_END_MHZ}
unitLabel="MHz"
formatTick={(v) => v.toFixed(0)}
dbFloor={-95}
dbCeil={-25}
anomalies={markers}
hue={272}
label={t('seance.radio.scopeLabel', {
defaultValue: 'live radio spectrum from the receiver',
})}
/>
<div className="radio-readout scope-controls">
{tunedMhz !== null ? (
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
) : (
<span className="dim">—</span>
)}
{phase === 'sweeping' && (
<span className="dim flicker">
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
</span>
)}
<ListenToggle sonifier={sonifierRef.current} disabled={phase !== 'sweeping'} />
</div>
{error?.kind === 'cancelled' && (
<div className="panel-notice">
<p>{t('seance.radio.noDevice')}</p>
</div>
)}
{error?.kind === 'claim' && (
<div className="panel-notice error">
<h4>{t('seance.radio.claimFailed')}</h4>
<p>
{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.',
})}
</p>
<button type="button" className="panel-subaction" onClick={() => void start()}>
{t('seance.radio.tryAgain', { defaultValue: 'try again' })}
</button>
</div>
)}
{error?.kind === 'other' && (
<div className="panel-notice error">
<h4>{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}</h4>
<p>{error.message}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (phase === 'sweeping' ? stop() : start())}
disabled={phase === 'busy'}
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
<ModeHint mode="radio" suppressed={phase !== 'idle'} />
</div>
)
}
// ---- ouija panel: what the planchette is doing ----
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
const { t } = useTranslation()
const word = snapshot?.word ?? null
return (
<div className="ouija-panel">
{word ? (
<div className="ouija-spelling">
<span className="dim ouija-spelling-label">
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
</span>
<div className="ouija-word" aria-live="polite">
{word.split('').map((ch, i) => (
<span
key={i}
className={
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
}
>
{ch}
</span>
))}
</div>
</div>
) : (
<p className="dim ouija-drift">
{t('seance.ouija.drift', {
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
})}
</p>
)}
{snapshot && snapshot.spelled.length > 0 && (
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
)}
<ModeHint mode="ouija" suppressed={word !== null} />
</div>
)
}
// ---- 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, entropy } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
// A true magnetometer measures the magnetic field itself; the motion
// listener only infers disturbance from movement, so a phone sitting
// still beside a running motor reads nothing on it. Where the hardware
// exists (Chromium/Android) we run both — this is additive, and the
// motion path stays the only option on iOS.
const magSupported = useMemo(() => magIsSupported(), [])
const [magField, setMagField] = useState<number | null>(null)
const magRef = useRef<MagnetometerListener | null>(null)
useEffect(
() => () => {
const m = magRef.current
magRef.current = null
if (m) m.stop()
},
[],
)
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [field, setField] = useState<number | null>(null)
const [baseline, setBaseline] = useState<number | null>(null)
const listenerRef = useRef<EmfSensorListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(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 startMagnetometer = () => {
if (!magSupported || magRef.current) return
const mag = new MagnetometerListener()
magRef.current = mag
void mag
.start({
onReading: (r) => {
setMagField(r.magnitude)
// Real magnetic-field jitter is genuine physical noise — bank it
// toward the next summon like every other live sensor.
entropy.addFrame([r.x, r.y, r.z])
},
onAnomaly: (a) => sendAnomaly('emf', a.magnitude, Math.abs(a.deviation)),
})
.catch(() => {
// No magnetometer permission or no hardware: the motion listener
// still carries the mode, so this stays silent.
magRef.current = null
})
}
const stopMagnetometer = () => {
const m = magRef.current
magRef.current = null
if (m) m.stop()
setMagField(null)
}
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)
startMagnetometer()
} finally {
setPending(false)
}
}
const stop = () => {
const l = listenerRef.current
if (l) l.stop()
setListening(false)
stopMagnetometer()
setField(null)
setBaseline(null)
drawGauge(0)
}
const calibrate = () => {
listenerRef.current?.reset()
}
if (!supported) {
return (
<div className="panel-notice">
<h4>
{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}
</h4>
<p>
{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.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
</div>
)
}
return (
<div className="emf-panel">
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
{magField !== null && (
<div className="emf-readout">
<span className="dim">
{t('seance.emf.trueField', { defaultValue: 'magnetic field' })}
</span>
<span className="evp-level">{magField.toFixed(1)} µT</span>
</div>
)}
<div className="emf-readout">
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
<span className="emf-baseline dim">
{t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '}
{baseline !== null ? baseline.toFixed(2) : '—'}
</span>
</div>
{listening && (
<p className="emf-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}</h4>
<p>
{t('seance.emf.denied', {
defaultValue:
'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.',
})}
</p>
</div>
)}
<div className="emf-actions">
<button
type="button"
className="panel-action"
onClick={() => (listening ? stop() : void start())}
disabled={pending}
>
{listening
? t('seance.emf.stop', { defaultValue: 'ground the meter' })
: t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
<button
type="button"
className="panel-subaction"
onClick={calibrate}
disabled={!listening}
>
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
</button>
</div>
<ModeHint mode="emf" suppressed={listening || pending} />
</div>
)
}