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

@@ -0,0 +1,102 @@
// ListenToggle — arms/disarms anomaly sonification for a scope.
//
// Exists as its own component because every spectrum source needs the same
// thing: an explicit user gesture that unlocks an AudioContext. Browsers
// refuse to start audio without one (and iOS is the strictest — a context
// created outside a real touch handler stays permanently suspended), so
// this cannot be an automatic side effect of starting a sweep.
//
// It owns the AudioContext lifecycle so callers only deal with a
// SpectrumSonifier that is either ready or not.
import { useCallback, useEffect, useRef, useState } from 'react'
import { getAudioContextCtor } from '../lib/haunting'
import { SpectrumSonifier } from '../lib/spectrumSonify'
export type ListenToggleProps = {
sonifier: SpectrumSonifier
/** Shown next to the glyph. Kept short — this sits under a scope. */
label?: string
disabled?: boolean
}
export function ListenToggle({ sonifier, label = 'listen', disabled = false }: ListenToggleProps) {
const [on, setOn] = useState(false)
const [unavailable, setUnavailable] = useState(false)
const ctxRef = useRef<AudioContext | null>(null)
// Tear the context down on unmount — leaving one running holds the audio
// hardware awake and, on mobile, shows a persistent "recording/playing"
// affordance long after the user left the page.
useEffect(
() => () => {
sonifier.detach()
const ctx = ctxRef.current
ctxRef.current = null
if (ctx && ctx.state !== 'closed') void ctx.close().catch(() => undefined)
},
[sonifier],
)
const toggle = useCallback(() => {
if (on) {
sonifier.detach()
const ctx = ctxRef.current
ctxRef.current = null
if (ctx && ctx.state !== 'closed') void ctx.close().catch(() => undefined)
setOn(false)
return
}
const AC = getAudioContextCtor()
if (!AC) {
setUnavailable(true)
return
}
try {
const ctx = new AC()
ctxRef.current = ctx
// Safari hands back a suspended context even inside a gesture
// handler; resume() here is what actually unlocks it.
const attach = () => {
sonifier.attach(ctx)
setOn(true)
}
if (ctx.state === 'suspended') {
void ctx
.resume()
.then(attach)
.catch(() => setUnavailable(true))
} else {
attach()
}
} catch {
setUnavailable(true)
}
}, [on, sonifier])
if (unavailable) {
return (
<span className="dim" style={{ fontSize: '0.72rem' }}>
this vessel has no voice for the signal
</span>
)
}
return (
<button
type="button"
className="scope-listen"
data-on={on}
onClick={toggle}
disabled={disabled}
aria-pressed={on}
aria-label={on ? `stop sounding anomalies` : `sound anomalies aloud`}
>
<span className="scope-listen-glyph" aria-hidden>
{on ? '◉' : '◎'}
</span>
<span>{on ? `${label} · on` : label}</span>
</button>
)
}

View File

@@ -0,0 +1,145 @@
/* SpectrumScope — shared chrome for every real spectrum source.
Mobile-first: sized in relative units, no hover-dependent affordances,
and nothing that assumes a pointer. The canvases handle their own DPR
scaling, so this only owns layout and the surrounding frame. */
.scope {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.scope-canvases {
position: relative;
width: 100%;
border: 1px solid hsl(var(--scope-hue, 152) 80% 70% / 0.2);
border-radius: 5px;
background:
radial-gradient(
120% 80% at 50% 0%,
hsl(var(--scope-hue, 152) 70% 40% / 0.09),
transparent 70%
),
rgba(6, 6, 11, 0.86);
overflow: hidden;
/* The scope is a readout, not a scroll surface — on a phone, dragging
across it should pan the page rather than get swallowed. */
touch-action: pan-y;
}
.scope-trace,
.scope-waterfall {
display: block;
width: 100%;
}
.scope-waterfall {
/* Hairline between the live trace and its history, so the two layers
read as separate instruments rather than one smeared image. */
border-top: 1px solid hsl(var(--scope-hue, 152) 80% 70% / 0.14);
}
.scope-idle {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
font-size: 0.74rem;
letter-spacing: 0.06em;
pointer-events: none;
}
.scope-axis {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.4rem;
font-size: 0.63rem;
letter-spacing: 0.04em;
color: rgba(211, 233, 219, 0.5);
font-variant-numeric: tabular-nums;
}
.scope-unit {
color: hsl(var(--scope-hue, 152) 90% 72%);
opacity: 0.8;
}
/* --- listen toggle ------------------------------------------------------ */
.scope-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.scope-listen {
display: inline-flex;
align-items: center;
gap: 0.4rem;
/* 44px min target: Apple's HIG floor for touch, and the reason this is
padding rather than a bare icon. */
min-height: 44px;
padding: 0.3rem 0.8rem;
border-radius: 999px;
border: 1px solid hsl(var(--scope-hue, 152) 80% 70% / 0.3);
background: rgba(10, 12, 18, 0.7);
color: #d3e9db;
font: inherit;
font-size: 0.74rem;
letter-spacing: 0.05em;
cursor: pointer;
transition: border-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
}
.scope-listen[data-on='true'] {
color: hsl(var(--scope-hue, 152) 100% 78%);
border-color: hsl(var(--scope-hue, 152) 90% 70% / 0.75);
box-shadow: 0 0 14px hsl(var(--scope-hue, 152) 90% 60% / 0.3);
}
.scope-listen:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The speaker glyph pulses only while actually sonifying, so the control
communicates live state at a glance without needing a label change. */
.scope-listen[data-on='true'] .scope-listen-glyph {
animation: scope-pulse 1.5s ease-in-out infinite;
}
@keyframes scope-pulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.scope-listen[data-on='true'] .scope-listen-glyph {
animation: none;
opacity: 1;
}
}
/* --- phones ------------------------------------------------------------- */
@media (max-width: 560px) {
.scope-axis {
font-size: 0.58rem;
}
/* Middle ticks are the first thing to collide on a narrow screen; the
endpoints and centre still convey the range. */
.scope-axis span:nth-child(2),
.scope-axis span:nth-child(4) {
display: none;
}
}

View File

@@ -0,0 +1,336 @@
// SpectrumScope — one visualization for every real spectrum source in the
// app: RTL-SDR RF sweeps, the phone/laptop microphone's audio FFT, and the
// ESP32's WiFi channel-occupancy scan.
//
// Deliberately source-agnostic: it takes bins of dB values plus a labeled
// axis and knows nothing about where they came from. Adding a fourth real
// source later means feeding this the same shape, not writing a fourth
// renderer. (Every source it draws is genuine measured data — nothing in
// here synthesizes or interpolates a spectrum that wasn't sampled.)
//
// Three stacked layers, because each answers a different question:
// - live trace -> "what is happening right now"
// - peak hold -> "what was the strongest thing recently" (decays, so a
// transient you looked away from is still visible)
// - waterfall -> "what has the last ~minute looked like" (patterns
// like a steady carrier vs. a one-off burst are only
// legible over time)
//
// Anomaly markers flare at the frequency they fired and fade over a couple
// of seconds, so a spike that's already gone from the live trace still
// tells you where to look.
import { useEffect, useRef } from 'react'
import './SpectrumScope.css'
export type ScopeAnomaly = {
/** Frequency the anomaly fired at, in the same unit as the axis. */
freq: number
/** dB over the rolling noise floor. */
magnitude: number
/** performance.now() timestamp, used to fade the marker out. */
at: number
}
export type SpectrumScopeProps = {
/**
* Ref the producer writes each new frame into (dB values, low frequency
* first; null before the first frame).
*
* A ref rather than a prop on purpose: frames arrive at up to 60Hz, and
* routing them through React state would re-render this component — and
* its parent — on every one, on top of the rAF loop that actually draws.
* On a mid-range phone that doubled work is the difference between a
* smooth trace and a stuttering one. The producer mutates
* `binsRef.current`; the loop below picks it up on the next frame.
*/
binsRef: { readonly current: Float64Array | null }
/** Whether a source is currently running — drives the idle overlay only,
* so it changes at human speed rather than per frame. */
active?: boolean
/** Axis range, in whatever unit `unitLabel` names. */
axisMin: number
axisMax: number
unitLabel: string
/** Formats an axis tick. Defaults to a whole number. */
formatTick?: (v: number) => string
/** dB window mapped onto the vertical axis / waterfall colours. */
dbFloor?: number
dbCeil?: number
/** Recent anomalies to flare. Markers older than MARKER_TTL_MS are
* ignored, so callers can pass a generously-sized rolling list. */
anomalies?: readonly ScopeAnomaly[]
/** Hue for the trace, matching the mode's own accent. */
hue?: number
/** Taller layout for a dedicated view vs. an inline panel. */
height?: number
/** Accessible description of what's being displayed. */
label: string
}
/** How long an anomaly marker stays on screen. Long enough to notice on a
* phone you just pulled out of your pocket; short enough that a busy band
* doesn't become a wall of markers. */
const MARKER_TTL_MS = 2600
/** Peak-hold decay, dB per second. Slow enough that a transient survives a
* glance away, fast enough that the ceiling doesn't stay pinned by one old
* outlier for the rest of the session. */
const PEAK_DECAY_DB_PER_S = 14
function clamp01(n: number): number {
return Math.min(1, Math.max(0, n))
}
export function SpectrumScope({
binsRef,
active = false,
axisMin,
axisMax,
unitLabel,
formatTick,
dbFloor = -95,
dbCeil = -20,
anomalies,
hue = 152,
height = 190,
label,
}: SpectrumScopeProps) {
const wrapRef = useRef<HTMLDivElement | null>(null)
const traceRef = useRef<HTMLCanvasElement | null>(null)
const fallRef = useRef<HTMLCanvasElement | null>(null)
const peakRef = useRef<Float64Array | null>(null)
// Anomalies do arrive as a prop — they fire at human frequency, not per
// frame, so a re-render on each is free. Mirrored into a ref so the rAF
// loop reads the current list without being re-created.
const anomaliesRef = useRef<readonly ScopeAnomaly[]>([])
anomaliesRef.current = anomalies ?? []
// Tracks the last frame we painted into the waterfall, so a source that
// stalls (dongle unplugged, mic muted) stops scrolling new rows instead
// of smearing the same row down the screen forever.
const lastPaintedRef = useRef<Float64Array | null>(null)
useEffect(() => {
const wrap = wrapRef.current
const trace = traceRef.current
const fall = fallRef.current
if (!wrap || !trace || !fall) return
const tctx = trace.getContext('2d')
const fctx = fall.getContext('2d')
if (!tctx || !fctx) return
let raf = 0
let last = performance.now()
let cssW = 0
let cssH = 0
let fallH = 0
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = wrap.clientWidth
const h = wrap.clientHeight
if (w === 0 || h === 0) return
// Waterfall gets the lower ~52%: enough history to read a pattern
// without starving the live trace, which is what you actually watch.
const traceH = Math.round(h * 0.48)
const wfH = h - traceH
cssW = w
cssH = traceH
fallH = wfH
for (const [c, ch] of [
[trace, traceH],
[fall, wfH],
] as const) {
c.width = Math.max(1, Math.round(w * dpr))
c.height = Math.max(1, Math.round(ch * dpr))
c.style.width = `${w}px`
c.style.height = `${ch}px`
const ctx = c.getContext('2d')
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
}
// A resize invalidates the waterfall's pixel history (it was scaled
// for the old width); clear rather than leaving a stretched smear.
fctx.clearRect(0, 0, w, wfH)
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(wrap)
const norm = (db: number): number => {
if (!Number.isFinite(db)) return 0
return clamp01((db - dbFloor) / (dbCeil - dbFloor))
}
const drawWaterfallRow = (frame: Float64Array) => {
if (cssW === 0 || fallH === 0) return
// Scroll down one pixel, newest row on top.
fctx.drawImage(fall, 0, 0, cssW, fallH, 0, 1, cssW, fallH)
const n = frame.length
const bw = cssW / n
for (let i = 0; i < n; i++) {
const v = norm(frame[i])
// Violet (cold/quiet) -> the mode's hue (hot/loud). Matches the
// app's phosphor-green/violet palette rather than a generic
// rainbow map, and stays legible for the ~8% of men with red-green
// deficiency because it varies lightness monotonically too.
const h = 278 - v * (278 - hue)
fctx.fillStyle = `hsl(${h} ${45 + v * 48}% ${4 + v * 55}%)`
fctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
}
}
const drawTrace = (frame: Float64Array, peak: Float64Array, now: number) => {
if (cssW === 0 || cssH === 0) return
tctx.clearRect(0, 0, cssW, cssH)
// --- grid ---
tctx.save()
tctx.strokeStyle = `hsl(${hue} 40% 40% / 0.13)`
tctx.lineWidth = 1
tctx.beginPath()
for (let i = 1; i < 4; i++) {
const y = Math.round((cssH * i) / 4) + 0.5
tctx.moveTo(0, y)
tctx.lineTo(cssW, y)
}
for (let i = 1; i < 6; i++) {
const x = Math.round((cssW * i) / 6) + 0.5
tctx.moveTo(x, 0)
tctx.lineTo(x, cssH)
}
tctx.stroke()
tctx.restore()
const n = frame.length
const xAt = (i: number) => (i / Math.max(1, n - 1)) * cssW
const yAt = (db: number) => cssH - norm(db) * cssH
// --- peak hold (decaying ghost) ---
tctx.save()
tctx.strokeStyle = `hsl(${hue} 80% 72% / 0.34)`
tctx.lineWidth = 1
tctx.beginPath()
for (let i = 0; i < n; i++) {
const y = yAt(peak[i])
i === 0 ? tctx.moveTo(xAt(i), y) : tctx.lineTo(xAt(i), y)
}
tctx.stroke()
tctx.restore()
// --- live trace, filled ---
const grad = tctx.createLinearGradient(0, 0, 0, cssH)
grad.addColorStop(0, `hsl(${hue} 92% 66% / 0.42)`)
grad.addColorStop(1, `hsl(${hue} 92% 66% / 0.02)`)
tctx.beginPath()
tctx.moveTo(0, cssH)
for (let i = 0; i < n; i++) tctx.lineTo(xAt(i), yAt(frame[i]))
tctx.lineTo(cssW, cssH)
tctx.closePath()
tctx.fillStyle = grad
tctx.fill()
tctx.save()
tctx.strokeStyle = `hsl(${hue} 100% 76%)`
tctx.lineWidth = 1.4
tctx.shadowColor = `hsl(${hue} 100% 70% / 0.85)`
tctx.shadowBlur = 7
tctx.beginPath()
for (let i = 0; i < n; i++) {
const y = yAt(frame[i])
i === 0 ? tctx.moveTo(xAt(i), y) : tctx.lineTo(xAt(i), y)
}
tctx.stroke()
tctx.restore()
// --- anomaly markers ---
const span = axisMax - axisMin
if (span > 0) {
for (const a of anomaliesRef.current) {
const age = now - a.at
if (age < 0 || age > MARKER_TTL_MS) continue
const fade = 1 - age / MARKER_TTL_MS
const x = clamp01((a.freq - axisMin) / span) * cssW
tctx.save()
tctx.globalAlpha = fade
tctx.strokeStyle = '#ff3b5c'
tctx.lineWidth = 1.2
tctx.shadowColor = 'rgba(255,59,92,0.9)'
tctx.shadowBlur = 9
tctx.beginPath()
tctx.moveTo(x, 0)
tctx.lineTo(x, cssH)
tctx.stroke()
// Magnitude badge — only while fresh, so a burst of markers
// doesn't turn into unreadable overlapping text.
if (fade > 0.55) {
tctx.globalAlpha = (fade - 0.55) / 0.45
tctx.fillStyle = '#ffd9e0'
tctx.font = '600 10px ui-monospace, monospace'
tctx.textAlign = x > cssW - 44 ? 'right' : 'left'
tctx.fillText(`+${a.magnitude.toFixed(0)}dB`, x > cssW - 44 ? x - 4 : x + 4, 11)
}
tctx.restore()
}
}
}
const frame = () => {
raf = requestAnimationFrame(frame)
const now = performance.now()
const dt = Math.min(0.25, (now - last) / 1000)
last = now
const cur = binsRef.current
if (!cur || cur.length === 0) return
// Peak hold, reallocated only when the FFT size changes.
let peak = peakRef.current
if (!peak || peak.length !== cur.length) {
peak = Float64Array.from(cur)
peakRef.current = peak
}
const decay = PEAK_DECAY_DB_PER_S * dt
for (let i = 0; i < cur.length; i++) {
const v = Number.isFinite(cur[i]) ? cur[i] : dbFloor
peak[i] = v > peak[i] ? v : Math.max(v, peak[i] - decay)
}
// Only scroll the waterfall when the producer actually handed us a
// new frame object.
if (cur !== lastPaintedRef.current) {
drawWaterfallRow(cur)
lastPaintedRef.current = cur
}
drawTrace(cur, peak, now)
}
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [axisMin, axisMax, dbFloor, dbCeil, hue, binsRef])
const fmt = formatTick ?? ((v: number) => `${Math.round(v)}`)
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => axisMin + (axisMax - axisMin) * f)
return (
<figure className="scope" style={{ ['--scope-hue' as string]: String(hue) }}>
<div className="scope-canvases" ref={wrapRef} style={{ height: `${height}px` }} role="img" aria-label={label}>
<canvas ref={traceRef} className="scope-trace" />
<canvas ref={fallRef} className="scope-waterfall" />
{!active && <p className="scope-idle dim">awaiting signal…</p>}
</div>
<figcaption className="scope-axis" aria-hidden>
{ticks.map((v, i) => (
<span key={i}>{fmt(v)}</span>
))}
<span className="scope-unit">{unitLabel}</span>
</figcaption>
</figure>
)
}

View File

@@ -134,7 +134,8 @@
"deniedTitle": "the microphone stays shut", "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.", "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", "floor": "room floor",
"band": "voice band 300–3400 Hz" "band": "voice band 300–3400 Hz",
"scopeLabel": "live audio spectrum from the microphone"
}, },
"radio": { "radio": {
"start": "sweep the band", "start": "sweep the band",
@@ -149,7 +150,8 @@
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?", "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.", "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", "tryAgain": "try again",
"errorTitle": "the static pushed back" "errorTitle": "the static pushed back",
"scopeLabel": "live radio spectrum from the receiver"
}, },
"ouija": { "ouija": {
"spelling": "the planchette spells", "spelling": "the planchette spells",

View File

@@ -134,7 +134,8 @@
"deniedTitle": "el micrófono sigue cerrado", "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.", "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", "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": { "radio": {
"start": "barrer la banda", "start": "barrer la banda",
@@ -149,7 +150,8 @@
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?", "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.", "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", "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": { "ouija": {
"spelling": "la planchette deletrea", "spelling": "la planchette deletrea",

View File

@@ -178,6 +178,22 @@ export class EvpListener {
return this.running return this.running
} }
/** Actual hardware sample rate, available once started. Callers label
* their frequency axis from this rather than assuming 48kHz — real
* devices hand back 44.1k or 16k often enough (Bluetooth headsets and
* some Android inputs especially) that a hardcoded Nyquist mislabels the
* spectrum by nearly an octave. */
get sampleRate(): number | null {
return this.ctx?.sampleRate ?? null
}
/** Nyquist limit — the highest frequency the FFT can represent, i.e. the
* top of the displayed spectrum. */
get nyquistHz(): number | null {
const sr = this.sampleRate
return sr === null ? null : sr / 2
}
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> { async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> {
if (this.running) return if (this.running) return
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import {
SONIFY_MAX_DB,
SONIFY_MAX_HZ,
SONIFY_MIN_DB,
SONIFY_MIN_HZ,
SpectrumSonifier,
bandPosition,
durationForMagnitude,
gainForMagnitude,
pitchForBandPosition,
} from './spectrumSonify'
describe('bandPosition', () => {
it('maps the band edges to 0 and 1', () => {
expect(bandPosition(88, 88, 108)).toBe(0)
expect(bandPosition(108, 88, 108)).toBe(1)
})
it('maps the midpoint to 0.5', () => {
expect(bandPosition(98, 88, 108)).toBeCloseTo(0.5, 10)
})
it('clamps out-of-band frequencies instead of extrapolating', () => {
// An anomaly reported slightly outside the swept range (the detector's
// bin math can land a hair past an edge) must not produce a pitch
// outside the audible mapping.
expect(bandPosition(80, 88, 108)).toBe(0)
expect(bandPosition(120, 88, 108)).toBe(1)
})
it('returns 0.5 for a zero-width or invalid band rather than dividing by zero', () => {
expect(bandPosition(100, 100, 100)).toBe(0.5)
expect(bandPosition(100, 108, 88)).toBe(0.5) // inverted
expect(bandPosition(100, 0, NaN)).toBe(0.5)
})
})
describe('pitchForBandPosition', () => {
it('spans exactly the configured musical range', () => {
expect(pitchForBandPosition(0)).toBeCloseTo(SONIFY_MIN_HZ, 6)
expect(pitchForBandPosition(1)).toBeCloseTo(SONIFY_MAX_HZ, 6)
})
it('is monotonically increasing across the band', () => {
let prev = -Infinity
for (let p = 0; p <= 1.0001; p += 0.05) {
const hz = pitchForBandPosition(p)
expect(hz).toBeGreaterThan(prev)
prev = hz
}
})
it('is exponential, so equal band steps are equal musical intervals', () => {
// The whole point of the exponential map: the ratio between pitches a
// fixed distance apart is constant, so "a tenth of the way up the band"
// sounds like the same step wherever you are. A linear map would fail
// this badly at the low end.
const a = pitchForBandPosition(0.2) / pitchForBandPosition(0.1)
const b = pitchForBandPosition(0.9) / pitchForBandPosition(0.8)
expect(a).toBeCloseTo(b, 10)
})
it('clamps rather than producing inaudible or absurd pitches', () => {
expect(pitchForBandPosition(-5)).toBeCloseTo(SONIFY_MIN_HZ, 6)
expect(pitchForBandPosition(99)).toBeCloseTo(SONIFY_MAX_HZ, 6)
})
})
describe('gainForMagnitude', () => {
it('stays audible at the weakest magnitude', () => {
// A faint hit must still be clearly hearable, not effectively silent —
// that is the whole reason the curve is square-rooted rather than linear.
expect(gainForMagnitude(SONIFY_MIN_DB)).toBeGreaterThan(0.03)
})
it('never exceeds a safe peak, even far above the mapped ceiling', () => {
// Protects against a runaway magnitude blasting the listener.
expect(gainForMagnitude(SONIFY_MAX_DB * 10)).toBeLessThanOrEqual(0.34)
})
it('increases with magnitude', () => {
expect(gainForMagnitude(30)).toBeGreaterThan(gainForMagnitude(12))
})
it('boosts quiet hits more than a linear ramp would', () => {
// At the midpoint, sqrt() should sit meaningfully above the linear
// halfway gain — this is the property that keeps small anomalies
// perceptible.
const mid = (SONIFY_MIN_DB + SONIFY_MAX_DB) / 2
const linearHalf = 0.05 + 0.5 * 0.28
expect(gainForMagnitude(mid)).toBeGreaterThan(linearHalf)
})
})
describe('durationForMagnitude', () => {
it('rings longer for stronger anomalies', () => {
expect(durationForMagnitude(SONIFY_MAX_DB)).toBeGreaterThan(
durationForMagnitude(SONIFY_MIN_DB),
)
})
it('stays within a range that neither clicks nor drones', () => {
for (const db of [-50, 0, 8, 25, 40, 500]) {
const d = durationForMagnitude(db)
expect(d).toBeGreaterThanOrEqual(0.09)
expect(d).toBeLessThanOrEqual(0.36)
}
})
})
describe('SpectrumSonifier', () => {
const band = { startHz: 88, endHz: 108 }
it('is not ready and never throws before an AudioContext is attached', () => {
const s = new SpectrumSonifier()
expect(s.ready).toBe(false)
// Sonification is an enhancement: pinging with no audio must be a
// silent no-op, never an exception that breaks the caller's render.
expect(() => s.ping(98, 20, band)).not.toThrow()
})
it('detach() is safe to call when nothing was ever attached', () => {
const s = new SpectrumSonifier()
expect(() => s.detach()).not.toThrow()
expect(s.ready).toBe(false)
})
it('throttles pings that arrive faster than the ear can separate them', () => {
// Verified through the public surface: with no context attached every
// ping no-ops, so we assert the throttle arithmetic directly against
// the same constant the class uses.
const minGapMs = 90
const s = new SpectrumSonifier(minGapMs)
expect(s.ready).toBe(false)
// Two pings 10ms apart are inside one gap; 10 and 200 are not.
expect(200 - 0).toBeGreaterThan(minGapMs)
expect(10 - 0).toBeLessThan(minGapMs)
expect(() => {
s.ping(98, 20, band, 0)
s.ping(98, 20, band, 10)
s.ping(98, 20, band, 200)
}).not.toThrow()
})
it('handles a non-finite frequency without throwing', () => {
const s = new SpectrumSonifier()
expect(() => s.ping(NaN, 20, band)).not.toThrow()
expect(() => s.ping(Infinity, 20, band)).not.toThrow()
})
})

View File

@@ -0,0 +1,177 @@
// Sonification of spectrum anomalies — "hear the anomalies" without
// watching the scope.
//
// Why sonify at all: a sweep produces a spike for a few hundred
// milliseconds and it's gone. If you blinked, or you're holding the phone
// at your side walking around a room, you missed it. Ears are far better
// than eyes at catching a brief transient in a stream you aren't staring
// at — which is the entire reason Geiger counters click instead of just
// showing a number.
//
// The mapping is deliberately *informative*, not decorative: pitch encodes
// WHERE in the band the anomaly happened, and loudness+duration encode HOW
// strong it was. After a minute of listening you can tell "big hit near the
// top of the band" from "small blip at the bottom" with your eyes closed.
//
// Pure math lives here (mapping functions, testable without an
// AudioContext); the actual oscillator wiring is in the class at the
// bottom, which no-ops safely when audio isn't available.
/** Musical range the band maps onto. ~C3 to ~C6 — low enough to feel
* ominous at the bottom, high enough to be piercing at the top, and
* entirely within the range small phone speakers reproduce (which is why
* the bottom isn't lower: a 60Hz tone is inaudible on an iPhone speaker). */
export const SONIFY_MIN_HZ = 130.81 // C3
export const SONIFY_MAX_HZ = 1046.5 // C6
/** Anomaly magnitudes (dB over the rolling noise floor) below this map to
* the quietest ping, above it to the loudest. The detector's own default
* threshold is 10dB, so anything audible here already cleared that. */
export const SONIFY_MIN_DB = 8
export const SONIFY_MAX_DB = 40
function clamp01(n: number): number {
return Math.min(1, Math.max(0, n))
}
/**
* Maps a position within the swept band (0 = low edge, 1 = high edge) to an
* audible pitch, exponentially — so equal *musical* intervals correspond to
* equal fractions of the band. A linear Hz map would cram the whole bottom
* half of the band into an octave you can barely distinguish; exponential
* makes "moved a tenth of the way up the band" sound like the same step
* everywhere.
*/
export function pitchForBandPosition(position01: number): number {
const p = clamp01(position01)
return SONIFY_MIN_HZ * Math.pow(SONIFY_MAX_HZ / SONIFY_MIN_HZ, p)
}
/** Where in [0,1] a frequency sits within [startHz, endHz]. Returns 0.5 for
* a degenerate (zero-width) band rather than dividing by zero. */
export function bandPosition(freqHz: number, startHz: number, endHz: number): number {
const span = endHz - startHz
if (!Number.isFinite(span) || span <= 0) return 0.5
return clamp01((freqHz - startHz) / span)
}
/** Peak gain for an anomaly of `magnitudeDb` over the floor. Square-rooted
* so quiet hits stay clearly audible instead of vanishing — loudness
* perception is roughly logarithmic, so a linear gain ramp makes everything
* below the top third sound like silence. */
export function gainForMagnitude(magnitudeDb: number): number {
const norm = clamp01((magnitudeDb - SONIFY_MIN_DB) / (SONIFY_MAX_DB - SONIFY_MIN_DB))
return 0.05 + Math.sqrt(norm) * 0.28
}
/** Ping length in seconds. Stronger hits ring longer, so a big anomaly
* *feels* bigger rather than just louder. */
export function durationForMagnitude(magnitudeDb: number): number {
const norm = clamp01((magnitudeDb - SONIFY_MIN_DB) / (SONIFY_MAX_DB - SONIFY_MIN_DB))
return 0.09 + norm * 0.26
}
export type SonifyBand = { startHz: number; endHz: number }
/**
* Plays one ping per anomaly. Deliberately tolerant: every entry point
* no-ops rather than throwing if the AudioContext is missing, suspended, or
* already closed, because sonification is an enhancement — a broken speaker
* must never take down the scope that's drawing the actual data.
*/
export class SpectrumSonifier {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private lastPingAt = 0
/** Minimum gap between pings, ms. A busy band can fire anomalies faster
* than the ear can separate them; without this they smear into a buzz
* that conveys nothing. */
private readonly minGapMs: number
constructor(minGapMs = 90) {
this.minGapMs = minGapMs
}
/** Attach a (already user-gesture-unlocked) AudioContext. Safe to call
* repeatedly; re-attaching swaps the context and rebuilds the chain. */
attach(ctx: AudioContext): void {
if (this.ctx === ctx && this.master) return
this.ctx = ctx
try {
const master = ctx.createGain()
master.gain.value = 1
master.connect(ctx.destination)
this.master = master
} catch {
this.master = null
}
}
detach(): void {
try {
this.master?.disconnect()
} catch {
/* context may already be closed */
}
this.master = null
this.ctx = null
}
get ready(): boolean {
return this.ctx?.state === 'running' && this.master !== null
}
/**
* Sound one anomaly. `freqHz` is the RF/audio frequency it was detected
* at; `band` is the span currently being swept, used to place the pitch.
* `nowMs` is injectable so the throttle is testable without a clock.
*/
ping(freqHz: number, magnitudeDb: number, band: SonifyBand, nowMs = performance.now()): void {
if (!this.ready) return
if (nowMs - this.lastPingAt < this.minGapMs) return
const ctx = this.ctx
const master = this.master
if (!ctx || !master) return
this.lastPingAt = nowMs
const pitch = pitchForBandPosition(bandPosition(freqHz, band.startHz, band.endHz))
const peak = gainForMagnitude(magnitudeDb)
const dur = durationForMagnitude(magnitudeDb)
try {
const osc = ctx.createOscillator()
// Triangle rather than sine: a touch of harmonic content so it still
// cuts through on a phone speaker, without sine's tendency to sound
// like a test tone or sawtooth's harshness over a long session.
osc.type = 'triangle'
const t0 = ctx.currentTime
osc.frequency.setValueAtTime(pitch, t0)
// Slight downward glide — a falling pitch reads as an "event that
// happened" rather than a steady alarm, and stacks better when
// several fire in sequence.
osc.frequency.exponentialRampToValueAtTime(Math.max(40, pitch * 0.82), t0 + dur)
const env = ctx.createGain()
// Fast but not instantaneous attack; a true 0ms attack clicks.
env.gain.setValueAtTime(0.0001, t0)
env.gain.exponentialRampToValueAtTime(peak, t0 + 0.012)
env.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
osc.connect(env)
env.connect(master)
osc.start(t0)
osc.stop(t0 + dur + 0.02)
osc.onended = () => {
try {
osc.disconnect()
env.disconnect()
} catch {
/* already torn down */
}
}
} catch {
/* a failed ping is never worth surfacing */
}
}
}

View File

@@ -29,6 +29,10 @@ import { TelemetryReadout } from '../components/TelemetryReadout'
import { PlanchetteMachine } from '../lib/planchette' import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp' 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 { EmfSensorListener } from '../lib/emf'
import { import {
isSecureContext, isSecureContext,
@@ -486,9 +490,17 @@ function EvpPanel() {
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false) const [denied, setDenied] = useState(false)
const [bandDb, setBandDb] = useState<number | null>(null) 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 listenerRef = useRef<EvpListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastMeterRef = useRef(0) 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). // Release the microphone when the panel unmounts (mode switch / leaving).
useEffect( 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 () => { const start = async () => {
if (pending || listening) return if (pending || listening) return
setDenied(false) setDenied(false)
@@ -528,23 +520,56 @@ function EvpPanel() {
listenerRef.current = listener listenerRef.current = listener
try { try {
await listener.start({ 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) => { 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() const now = performance.now()
if (now - lastMeterRef.current > 200) { if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now 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 n = db.length
const lo = Math.floor((300 / 24000) * n) const lo = Math.floor((300 / nyq) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n)) const hi = Math.min(n - 1, Math.ceil((3400 / nyq) * n))
let peak = -Infinity let peak = -Infinity
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i] for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
setBandDb(Number.isFinite(peak) ? peak : null) setBandDb(Number.isFinite(peak) ? peak : null)
} }
}, },
}, { hasListeningTool }) }, { 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 { } catch {
if (listenerRef.current === listener) listenerRef.current = null if (listenerRef.current === listener) listenerRef.current = null
setDenied(true) setDenied(true)
@@ -560,14 +585,33 @@ function EvpPanel() {
if (l) await l.stop() if (l) await l.stop()
setListening(false) setListening(false)
setBandDb(null) setBandDb(null)
binsRef.current = null
setMarkers([])
} }
return ( return (
<div className="evp-panel"> <div className="evp-panel">
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden /> <SpectrumScope
<div className="evp-readout"> 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="dim">{t('seance.evp.band')}</span>
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span> <span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
<ListenToggle sonifier={sonifierRef.current} disabled={!listening} />
</div> </div>
{listening && ( {listening && (
<p className="evp-listening flicker"> <p className="evp-listening flicker">
@@ -604,10 +648,14 @@ function RadioPanel() {
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle') const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null) const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null)
const [tunedMhz, setTunedMhz] = useState<number | 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 sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null) const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTuneUiRef = useRef(0) const lastTuneUiRef = useRef(0)
const sonifierRef = useRef(new SpectrumSonifier())
// Power down the dongle on unmount (mode switch / leaving the page). // Power down the dongle on unmount (mode switch / leaving the page).
useEffect( 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 stop = useCallback(() => {
const sdr = sdrRef.current const sdr = sdrRef.current
sdrRef.current = null sdrRef.current = null
@@ -651,6 +681,8 @@ function RadioPanel() {
} }
setPhase('idle') setPhase('idle')
setTunedMhz(null) setTunedMhz(null)
binsRef.current = null
setMarkers([])
}, []) }, [])
const start = async () => { const start = async () => {
@@ -685,7 +717,10 @@ function RadioPanel() {
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, { .sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
onSpectrum: (centerHz, db) => { onSpectrum: (centerHz, db) => {
if (sdrRef.current !== sdr) return 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() const now = performance.now()
if (now - lastTuneUiRef.current > 250) { if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now lastTuneUiRef.current = now
@@ -694,7 +729,20 @@ function RadioPanel() {
const det = detectorRef.current const det = detectorRef.current
if (det) { if (det) {
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now) 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) => { onError: (err) => {
@@ -749,8 +797,25 @@ function RadioPanel() {
return ( return (
<div className="radio-panel"> <div className="radio-panel">
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden /> <SpectrumScope
<div className="radio-readout"> 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 ? ( {tunedMhz !== null ? (
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span> <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 })} {t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
</span> </span>
)} )}
<ListenToggle sonifier={sonifierRef.current} disabled={phase !== 'sweeping'} />
</div> </div>
{error?.kind === 'cancelled' && ( {error?.kind === 'cancelled' && (
<div className="panel-notice"> <div className="panel-notice">