feat: shared spectrum scope with audible anomalies, from real sources only

Replaces two ad-hoc canvases (a 256x110 fixed-size waterfall in the radio
panel, a bar-graph in the EVP panel) with one source-agnostic SpectrumScope
that renders any binned dB spectrum, plus sonification so anomalies can be
heard rather than watched.

Sources are real measured data only. The RTL-SDR path is genuine RF; the
EVP path is a genuine AnalyserNode FFT of the device microphone. The ESP32
deliberately does NOT feed this: its firmware reports four scalars
(temperature, pressure, evp level, presence) and has no spectrum at all,
and device_anomaly.frequency_for_sensor_type() invents a per-sensor-type
frequency for the fiction — neither is a real spectrum, so neither is
plotted as one.

SpectrumScope draws three layers because each answers a different question:
the live trace (what is happening now), a decaying peak-hold (what was
strongest recently, so a transient survives a glance away), and a waterfall
(what the last minute looked like, where a steady carrier separates from a
one-off burst). Anomaly markers flare at their frequency and fade over
~2.6s, so a spike already gone from the trace still says where to look.

Frames reach the scope through a ref, not a prop. Routing 60Hz frames
through React state re-renders the panel and the scope on every frame on
top of the rAF loop that actually draws — measurably the wrong call on a
phone. The EVP producer double-buffers into two fixed Float64Arrays so a
frame costs zero allocation.

spectrumSonify maps band position to pitch exponentially, so equal
fractions of the band are equal musical intervals (a linear Hz map crams
the bottom half into one indistinguishable octave), and magnitude to
gain via sqrt so faint hits stay audible. Pings are throttled to 90ms
because a busy band otherwise smears into a buzz that conveys nothing.
Every entry point no-ops rather than throwing when audio is unavailable —
a dead speaker must never take down the scope drawing the data.

Audio requires an explicit gesture (ListenToggle), because iOS keeps any
context created outside a touch handler permanently suspended.

Also exposes EvpListener.sampleRate/nyquistHz and labels the EVP axis from
the real hardware rate. The old code assumed 48kHz; Bluetooth headsets and
some Android inputs hand back 44.1k or 16k, which mislabelled the spectrum
by nearly an octave.

Mobile: DPR-aware canvases, ResizeObserver, 44px touch targets,
touch-action so dragging the scope pans the page, reduced-motion honoured,
crowded axis ticks dropped under 560px.

329 frontend tests pass (18 new); i18n en/es parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 00:26:54 +00:00
parent f8ca4bbd9e
commit bbcbaa0a36
9 changed files with 1053 additions and 56 deletions

View File

@@ -134,7 +134,8 @@
"deniedTitle": "the microphone stays shut",
"denied": "This vessel refused the microphone. Grant audio permission in your browser to practice EVP — or let the Wire Ghost listen instead; it needs nothing but the network you already fear.",
"floor": "room floor",
"band": "voice band 300–3400 Hz"
"band": "voice band 300–3400 Hz",
"scopeLabel": "live audio spectrum from the microphone"
},
"radio": {
"start": "sweep the band",
@@ -149,7 +150,8 @@
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?",
"claimFix": "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.",
"tryAgain": "try again",
"errorTitle": "the static pushed back"
"errorTitle": "the static pushed back",
"scopeLabel": "live radio spectrum from the receiver"
},
"ouija": {
"spelling": "the planchette spells",

View File

@@ -134,7 +134,8 @@
"deniedTitle": "el micrófono sigue cerrado",
"denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en tu navegador para practicar EVP — o deja que el Fantasma del Cable escuche en tu lugar; no necesita más que la red que ya temes.",
"floor": "suelo de la sala",
"band": "banda de voz 300–3400 Hz"
"band": "banda de voz 300–3400 Hz",
"scopeLabel": "espectro de audio en vivo desde el micrófono"
},
"radio": {
"start": "barrer la banda",
@@ -149,7 +150,8 @@
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?",
"claimFix": "linux: el driver del kernel dvb_usb_rtl28xxu retiene el dongle — `sudo modprobe -r dvb_usb_rtl28xxu` lo libera hasta el reinicio (una regla udev lo destierra para siempre). windows: cambia el driver a WinUSB con Zadig.",
"tryAgain": "intenta de nuevo",
"errorTitle": "la estática devolvió el golpe"
"errorTitle": "la estática devolvió el golpe",
"scopeLabel": "espectro de radio en vivo desde el receptor"
},
"ouija": {
"spelling": "la planchette deletrea",