diff --git a/frontend/src/components/ListenToggle.tsx b/frontend/src/components/ListenToggle.tsx new file mode 100644 index 0000000..8c7501b --- /dev/null +++ b/frontend/src/components/ListenToggle.tsx @@ -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(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 ( + + this vessel has no voice for the signal + + ) + } + + return ( + + ) +} diff --git a/frontend/src/components/SpectrumScope.css b/frontend/src/components/SpectrumScope.css new file mode 100644 index 0000000..5237f81 --- /dev/null +++ b/frontend/src/components/SpectrumScope.css @@ -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; + } +} diff --git a/frontend/src/components/SpectrumScope.tsx b/frontend/src/components/SpectrumScope.tsx new file mode 100644 index 0000000..b90420b --- /dev/null +++ b/frontend/src/components/SpectrumScope.tsx @@ -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(null) + const traceRef = useRef(null) + const fallRef = useRef(null) + + const peakRef = useRef(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([]) + 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(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 ( +
+
+ + + {!active &&

awaiting signal…

} +
+
+ {ticks.map((v, i) => ( + {fmt(v)} + ))} + {unitLabel} +
+
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 5ad3aff..9c169a5 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 70da8bc..d09624b 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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", diff --git a/frontend/src/lib/evp.ts b/frontend/src/lib/evp.ts index b199fec..132a8c0 100644 --- a/frontend/src/lib/evp.ts +++ b/frontend/src/lib/evp.ts @@ -178,6 +178,22 @@ export class EvpListener { 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 { if (this.running) return const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) diff --git a/frontend/src/lib/spectrumSonify.test.ts b/frontend/src/lib/spectrumSonify.test.ts new file mode 100644 index 0000000..4f65248 --- /dev/null +++ b/frontend/src/lib/spectrumSonify.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/spectrumSonify.ts b/frontend/src/lib/spectrumSonify.ts new file mode 100644 index 0000000..9d04393 --- /dev/null +++ b/frontend/src/lib/spectrumSonify.ts @@ -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 */ + } + } +} diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index c9df072..56369b9 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -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(null) + const [nyquist, setNyquist] = useState(24000) + const [markers, setMarkers] = useState([]) + const binsRef = useRef(null) const listenerRef = useRef(null) - const canvasRef = useRef(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 (
- -
+ (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', + })} + /> +
{t('seance.evp.band')} {bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'} +
{listening && (

@@ -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(null) + const [markers, setMarkers] = useState([]) + // 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(null) const sdrRef = useRef(null) const detectorRef = useRef(null) - const canvasRef = useRef(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 (

- -
+ v.toFixed(0)} + dbFloor={-95} + dbCeil={-25} + anomalies={markers} + hue={272} + label={t('seance.radio.scopeLabel', { + defaultValue: 'live radio spectrum from the receiver', + })} + /> +
{tunedMhz !== null ? ( {tunedMhz.toFixed(1)} MHz ) : ( @@ -761,6 +826,7 @@ function RadioPanel() { {t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })} )} +
{error?.kind === 'cancelled' && (