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
11 KiB
Quantumancy Plan 5/7: Spirit Radio — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A spirit box built from a real radio (spec §3.1): the browser claims a user-supplied RTL-SDR dongle over WebUSB, sweeps the FM broadcast band, computes FFT power per bin in JavaScript, and emits anomaly events when a spike jumps the rolling noise floor — each one triggering an Ovilus-style single-word fragment from the fast LLM tier.
Architecture: Everything RF happens client-side, in three pure-ish layers: fft.ts (radix-2 Cooley–Tukey FFT + dB power spectrum from interleaved I/Q), RtlSdr (WebUSB control-transfer driver for RTL2832U + R820T: init sequence, PLL tuning, sample-rate programming, bulk I/Q reads, and a continuous sweep() loop), and SpectrumAnomalyDetector (per-bin EMA noise floor + throttled spike detection). Anomalies flow to the backend as {type: "anomaly", source: "radio", frequency /* MHz */, magnitude /* dB */} over Plan 3's séance channel. The backend needs nothing new — this mode reuses Plan 3's anomaly→fragment pipeline exactly as EVP does (Plan 4).
Tech Stack: TypeScript, WebUSB (navigator.usb), Float64Array DSP; backend unchanged from Plans 3-4.
Global Constraints
- Chromium-only. WebUSB exists only in Chrome/Edge/Brave/Opera; Firefox and Safari lack it entirely.
isSupported()must gate the mode, which self-disables with an in-UI explanation (spec §7) — never a silent failure, never a broken page on other browsers. - Kernel driver contention is expected, not exceptional. Linux's
dvb_usb_rtl28xxuclaims RTL-SDR dongles before WebUSB can; claim failure surfaces the mode's troubleshooting guide link (spec §3.1/§7). Windows+Zadig/WinUSB typically works out of the box. - Fail soft everywhere. Every entry point of the driver treats a thrown error as "this vessel cannot hear the radio dead" — the UI degrades; the séance continues on other modes.
- No new backend surface. Do not add WS frames or REST endpoints;
source: "radio"anomalies already have a home. - Hardware honesty. The driver was structured from public librtlsdr register documentation in an environment with no RTL-SDR attached. It is marked
HARDWARE PASS REQUIREDin the source and must not be called done until a real dongle validates it (see Task 4).
Plan Series
This is 5 of 7 plans implementing the Quantumancy website spec (docs/superpowers/specs/2026-07-20-quantumancy-website-design.md). Plans 3-4 provide the anomaly pipeline this mode feeds.
- Foundation & Auth (complete)
- Frontend, LLM & Realtime Pipeline (complete)
- Wire Ghost mode + Ouija/Planchette UI (complete)
- EVP Listening mode (complete)
- Spirit Radio mode (this plan)
- Codex & entity persistence
- Internationalization (EN/ES)
Task 1: FFT & Power Spectrum
Files:
- Create:
frontend/src/lib/fft.ts
Interfaces:
-
Produces:
nextPow2(n);fftInPlace(re: Float64Array, im: Float64Array)(iterative radix-2, bit-reversal ordered, length must be a power of two);magnitudeSpectrum(re, im) -> Float64Array(first n/2 bins);powerSpectrumDb(iq: Float64Array) -> Float64Array— interleaved[i0, q0, i1, q1, …]in, n/2 dB values out, power normalized by n², floored at 1e-12 before log. -
Step 1: Implement the transform
Plain dependency-free Cooley–Tukey: bit-reversal permutation, then butterfly passes with a twiddle recurrence (no per-butterfly trig). Throws on re/im length mismatch and non-power-of-two input — programming errors should be loud here, not silent spectrum garbage.
- Step 2: I/Q → dB
powerSpectrumDb zero-pads/truncates to nextPow2(iq.length / 2), splits interleaved I/Q into re/im planes, transforms, and returns 10 * log10(power). This is what the sweep loop hands the anomaly detector per tuning step.
- Step 3: Verify —
npx tsc --noEmitclean;src/lib/fft.test.ts(11 tests) validates the transform against known inputs — impulse spectra, cosine peaks landing on the expected bin for two different frequencies, power-of-two enforcement.
Task 2: RtlSdr — WebUSB Driver for RTL2832U + R820T
Files:
- Create:
frontend/src/lib/sdr.ts(driver half)
Interfaces:
-
Produces:
RTL2832U_VENDOR = 0x0bda,RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837];isSupported(): boolean;class RtlSdrwithrequestDevice(),open(sampleRateHz = 2_048_000),close(),setFrequency(hz),setSampleRate(hz),readSamples(bytes) -> Uint8Array,sweep(startHz, endHz, stepHz, cb, fftSize = 512, settleMs = 25),stopSweep(),isOpen. Callback typesRtlSampleBlockandSweepCallbacks {onSpectrum?(centerHz, db), onError?(err)}. -
Step 1: Device selection & claim
requestDevice() filters on the four known RTL2832U product IDs. open() locates the first bulk-IN endpoint, claims the interface (kernel-driver detach is best-effort; failure throws a themed "could not claim the radio dead (interface busy?)" error for the UI's troubleshooting link).
- Step 2: Init sequence (HARDWARE PASS REQUIRED)
Follows librtlsdr's known-good order via demodWrite (paged demod registers) and i2cWrite (tuner registers tunneled through the demod's I2C repeater at 0x1a): soft reset → demod_ctl → suspend/standby off → AGC mode → R820T LNA/mixer/IF power-on → sample rate → initial 98 MHz tune → endpoint reset. Register pokes are commented as unverified against a real device.
- Step 3: Tuning & sample rate (HARDWARE PASS REQUIRED)
setFrequency programs the R820T fractional-N PLL with the 3.57 MHz IF offset against the 28.8 MHz crystal reference (integer part + 16-bit SDM fraction; documented simplification of librtlsdr's exact sdm/vco math). setSampleRate programs the demod resampling ratio (crystal·2²²/hz, 4-aligned).
- Step 4: The sweep loop
sweep() tunes in stepHz steps across [startHz, endHz], waits settleMs, discards one 16 KiB block (PLL settle), reads fftSize·4 bytes of unsigned I/Q, zero-centers to [-1, 1), runs powerSpectrumDb, and emits onSpectrum(centerHz, db). Read errors go to onError and the sweep continues; wrapping past endHz restarts at startHz; stopSweep() exits cleanly from the finally. Exported band constants: SWEEP_START_MHZ = 88, SWEEP_END_MHZ = 108.
- Step 5: Verify —
npx tsc --noEmitclean. Runtime verification deferred to Task 4.
Task 3: SpectrumAnomalyDetector — Rolling Floor Over the Airwaves
Files:
- Create:
frontend/src/lib/sdr.ts(detector half)
Interfaces:
-
Produces:
class SpectrumAnomalyDetector(thresholdDb = 10, throttleMs = 2000, alpha = 0.1)withprocess(centerHz, sampleRateHz, db, nowMs) -> {frequency /* MHz */, magnitude /* dB */} | nullandreset(). Pure and testable: no USB types, injected spectrum + clock. -
Step 1: Floor + spike logic
First spectrum seeds the per-bin EMA floor. Each subsequent spectrum updates the floor (alpha 0.1) and finds the peak deviation; a spike fires when peak ≥ thresholdDb (10 dB over the rolling floor — the radio dead must shout) and ≥ throttleMs (2 s) since the last emission. Bin→frequency maps across the baseband: binHz = sampleRate/2 / bins, offset from center, reported in MHz to match the anomaly frame contract.
- Step 2: Into the séance
The mode page forwards detections as sendAnomaly('radio', frequencyMHz, magnitudeDb). Backend flow is Plan 3 verbatim: anomaly_ack, auto-summon once ≥3 anomalies fingerprint, then spirit_service.fragment('radio', …) — the radio variant of the prompt ("A burst of static at {frequency} MHz, magnitude {magnitude} dB above the noise floor…") with the "a spirit box" system framing.
- Step 3: Verify —
npx tsc --noEmitclean; backend anomaly path covered bytests/test_ws_session.py(test_anomalies_attune_then_produce_fragmentsusessource: "radio", 6/6 PASS).
Task 4: Hardware-in-the-Loop Validation
Files:
-
Modify (expected):
frontend/src/lib/sdr.tsregister sequences after testing -
Step 1: Real-dongle smoke test — PENDING
No RTL-SDR is attached to the build environment, so the driver has never touched silicon. Before this mode is called done: plug an RTL2832U+R820T dongle into a Chromium machine, claim it through the mode UI, and confirm the init sequence completes and bulk I/Q flows. Expect to debug register pokes with librtlsdr -T / a logic analyzer — the source is pre-marked HARDWARE PASS REQUIRED at the file header, the init sequence, and setFrequency.
- Step 2: Sweep & detector tuning — PENDING
With live RF: verify the 88–108 MHz sweep shows real broadcast peaks, calibrate thresholdDb/settleMs against a known station, and confirm anomalies reach the séance (fragment utterances arrive, transcript shows radio entries).
- Step 3: Kernel-claim runbook — PENDING
Validate the failure paths on Linux (dvb_usb_rtl28xxu bound → themed claim error + guide link) and an unsupported browser (mode self-disables with the spec §7 explanation, other modes unaffected).
Testing Status & Self-Review
Spec coverage: WebUSB sweep + browser FFT + spike anomalies → Ovilus fragments (§3.1) → Tasks 1-3. Chromium-gating and self-disable (§3.1, §7) → isSupported() + Task 4 step 3. Driver-claim troubleshooting (§3.1, §7) → open()'s themed claim error. Manual hardware pass before done (§8) → Task 4, pending.
Automated tests: fft.ts is covered by src/lib/fft.test.ts (11 tests, PASS in the 71-test vitest run); the backend side is covered by tests/test_ws_session.py radio-anomaly flow (6/6 in the 45-test suite). Honest gaps: no vitest yet for SpectrumAnomalyDetector (pure and test-ready — the detector logic mirrors the covered EvpDetectorCore), and no automated coverage is possible for the USB path itself.
Placeholder scan: the driver is real code, not a stub — but its register sequences are deliberately labeled unverified, and this plan does not claim otherwise. Task 4's pending checkboxes are the whole truth.
Type consistency: detector output {frequency: MHz, magnitude: dB} matches SeanceApi.sendAnomaly and the anomaly ClientFrame; MHz (radio) vs Hz (EVP) unit mixing is handled server-side by signature_from_anomalies' digit-count bucketing (documented in app/entities.py). powerSpectrumDb output length (n/2) matches SpectrumAnomalyDetector.process's bin math.
Status: IMPLEMENTATION COMPLETE — HARDWARE VERIFICATION PENDING. The mode must not be shipped as "done" until Task 4 passes against a physical RTL-SDR on Chromium.