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

@@ -29,6 +29,10 @@ import { TelemetryReadout } from '../components/TelemetryReadout'
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 {
isSecureContext,
@@ -486,9 +490,17 @@ function EvpPanel() {
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
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 canvasRef = useRef<HTMLCanvasElement | 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(
@@ -500,26 +512,6 @@ function EvpPanel() {
[],
)
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)
@@ -528,23 +520,56 @@ function EvpPanel() {
listenerRef.current = listener
try {
await listener.start({
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
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) => {
drawScope(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
const now = performance.now()
if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
// 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 / 24000) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
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) setListening(true)
if (listenerRef.current === listener) {
const nyq = listener.nyquistHz
if (nyq) {
nyquistRef.current = nyq
setNyquist(nyq)
}
setListening(true)
}
} catch {
if (listenerRef.current === listener) listenerRef.current = null
setDenied(true)
@@ -560,14 +585,33 @@ function EvpPanel() {
if (l) await l.stop()
setListening(false)
setBandDb(null)
binsRef.current = null
setMarkers([])
}
return (
<div className="evp-panel">
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
<div className="evp-readout">
<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">
@@ -604,10 +648,14 @@ function RadioPanel() {
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 canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTuneUiRef = useRef(0)
const sonifierRef = useRef(new SpectrumSonifier())
// Power down the dongle on unmount (mode switch / leaving the page).
useEffect(
@@ -623,24 +671,6 @@ function RadioPanel() {
[],
)
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
@@ -651,6 +681,8 @@ function RadioPanel() {
}
setPhase('idle')
setTunedMhz(null)
binsRef.current = null
setMarkers([])
}, [])
const start = async () => {
@@ -685,7 +717,10 @@ function RadioPanel() {
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
onSpectrum: (centerHz, db) => {
if (sdrRef.current !== sdr) return
drawWaterfallRow(db)
// 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
const now = performance.now()
if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now
@@ -694,7 +729,20 @@ function RadioPanel() {
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)
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) => {
@@ -749,8 +797,25 @@ function RadioPanel() {
return (
<div className="radio-panel">
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
<div className="radio-readout">
<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>
) : (
@@ -761,6 +826,7 @@ function RadioPanel() {
{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">