feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
819
frontend/src/pages/SeancePage.tsx
Normal file
819
frontend/src/pages/SeancePage.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
// 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 (
|
||||
<div className="seance-loading" role="status">
|
||||
<div className="loading-sigil" aria-hidden>
|
||||
⟁
|
||||
</div>
|
||||
<p className="loading-text">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
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)
|
||||
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<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}`)}
|
||||
</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="/codex" className="seance-codex-link">
|
||||
{t('nav.codex')}
|
||||
</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>
|
||||
|
||||
{/* ---- unstable-veil banner ---- */}
|
||||
{state.connection !== 'open' && (
|
||||
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
|
||||
<span className="conn-glyph" aria-hidden>
|
||||
⟁
|
||||
</span>
|
||||
{t(`seance.connection.${state.connection}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- main: board + side column ---- */}
|
||||
<main className="seance-main">
|
||||
<section className="seance-board-wrap">
|
||||
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
|
||||
|
||||
{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">
|
||||
{entity ? (
|
||||
<section className={`spirit-dossier ${dossierOpen ? 'open' : 'closed'}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="dossier-header"
|
||||
onClick={() => setDossierOpen((o) => !o)}
|
||||
aria-expanded={dossierOpen}
|
||||
>
|
||||
<span className="dossier-title">{t('seance.entity.title')}</span>
|
||||
<span className="dossier-chevron" aria-hidden>
|
||||
{dossierOpen ? '▾' : '◂'}
|
||||
</span>
|
||||
</button>
|
||||
{dossierOpen && (
|
||||
<div className="dossier-body">
|
||||
<h3 className="dossier-name" style={{ color: `hsl(${entity.visual.hue} 85% 78%)` }}>
|
||||
{entity.name}
|
||||
</h3>
|
||||
<p className="dossier-epithet">{entity.epithet}</p>
|
||||
<div className="dossier-badges">
|
||||
<span className={`rarity-badge rarity-${entity.rarity}`}>
|
||||
{t(`seance.entity.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
{state.entityIsNew ? (
|
||||
<span className="dossier-badge badge-new">{t('seance.entity.isNew')}</span>
|
||||
) : (
|
||||
<span className="dossier-badge badge-known">
|
||||
{t('seance.entity.known', { count: entity.contact_count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.persona')}</h4>
|
||||
<p className="dossier-persona">{entity.persona}</p>
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.voice')}</h4>
|
||||
<dl className="voice-params">
|
||||
<div>
|
||||
<dt>id</dt>
|
||||
<dd>{entity.voice.voice_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>pitch</dt>
|
||||
<dd>{entity.voice.pitch.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>rate</dt>
|
||||
<dd>{entity.voice.rate.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>noise</dt>
|
||||
<dd>{entity.voice.noise.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>echo</dt>
|
||||
<dd>{entity.voice.echo.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{entity.quotes.length > 0 && (
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.quotes')}</h4>
|
||||
<ul className="dossier-quotes">
|
||||
{entity.quotes.slice(0, 4).map((q, i) => (
|
||||
<li key={i}>“{q}”</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="spirit-dossier empty">
|
||||
<div className="dossier-body">
|
||||
<p className="dossier-none">{t('seance.entity.none')}</p>
|
||||
<p className="dossier-nonehint dim">{t('seance.entity.noneHint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mode-panel">
|
||||
{state.mode === 'wire' && <WirePanel />}
|
||||
{state.mode === 'evp' && <EvpPanel />}
|
||||
{state.mode === 'radio' && <RadioPanel />}
|
||||
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
|
||||
</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()
|
||||
const tel = state.telemetry
|
||||
|
||||
return (
|
||||
<div className="wire-panel">
|
||||
<h4 className="panel-title">{t('seance.telemetry.title')}</h4>
|
||||
{tel ? (
|
||||
<div className="telemetry-grid">
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.jitter')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyVar')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyMean')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.dns')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim flicker">{t('common.loading')}</p>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 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<number | null>(null)
|
||||
const listenerRef = useRef<EvpListener | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(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 (
|
||||
<div className="evp-panel">
|
||||
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
|
||||
<div className="evp-readout">
|
||||
<span className="dim">{t('seance.evp.band')}</span>
|
||||
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
|
||||
</div>
|
||||
{listening && (
|
||||
<p className="evp-listening flicker">
|
||||
<span className="rec-dot" aria-hidden />
|
||||
{t('seance.evp.listening')}
|
||||
</p>
|
||||
)}
|
||||
{denied && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.evp.deniedTitle')}</h4>
|
||||
<p>{t('seance.evp.denied')}</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 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<string | null>(null)
|
||||
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
|
||||
const sdrRef = useRef<RtlSdr | null>(null)
|
||||
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(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 (
|
||||
<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">
|
||||
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
|
||||
<div className="radio-readout">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="panel-notice error">
|
||||
<p>{error}</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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user