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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user