diff --git a/frontend/src/lib/bluetooth.test.ts b/frontend/src/lib/bluetooth.test.ts new file mode 100644 index 0000000..0755281 --- /dev/null +++ b/frontend/src/lib/bluetooth.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { + BASELINE_TAU_MS, + BleFieldCore, + DISTURBANCE_DB, + MIN_SAMPLES, +} from './bluetooth' + +/** Feed enough steady readings to warm the baseline. */ +function warm(core: BleFieldCore, rssi = -60, startAt = 0, step = 500): number { + let at = startAt + for (let i = 0; i < MIN_SAMPLES; i++) { + core.push(rssi, at) + at += step + } + return at +} + +describe('BleFieldCore', () => { + it('starts cold with no baseline', () => { + const core = new BleFieldCore() + expect(core.baseline).toBeNull() + expect(core.warm).toBe(false) + }) + + it('seeds the baseline from the first reading without reporting a disturbance', () => { + const core = new BleFieldCore() + expect(core.push(-55, 0)).toBeNull() + expect(core.baseline).toBe(-55) + }) + + it('stays silent during warm-up even for a large swing', () => { + // Before the baseline means anything, a difference from it doesn't + // either — otherwise every session opens with a false positive. + const core = new BleFieldCore() + core.push(-60, 0) + expect(core.push(-90, 200)).toBeNull() + expect(core.warm).toBe(false) + }) + + it('reports a disturbance once warm and the threshold is cleared', () => { + const core = new BleFieldCore() + const at = warm(core) + const d = core.push(-60 - DISTURBANCE_DB - 2, at) + expect(d).not.toBeNull() + expect(d!.deviation).toBeLessThan(0) + expect(d!.rssi).toBe(-68) + }) + + it('ignores ordinary multipath jitter below the threshold', () => { + const core = new BleFieldCore() + let at = warm(core) + // +-3dB wander is normal on a stationary link and must not fire. + for (const delta of [2, -3, 1, -2, 3, -1]) { + at += 400 + expect(core.push(-60 + delta, at)).toBeNull() + } + }) + + it('fires on a signal that strengthens as well as one that weakens', () => { + // Reflection off a moving surface can raise RSSI; a detector that only + // watched for attenuation would miss half of real movement. + const core = new BleFieldCore() + const at = warm(core) + const d = core.push(-60 + DISTURBANCE_DB + 3, at) + expect(d).not.toBeNull() + expect(d!.deviation).toBeGreaterThan(0) + }) + + it('normalises severity into 0..1 and saturates for extreme swings', () => { + const core = new BleFieldCore() + const at = warm(core) + const d = core.push(-200, at) + expect(d!.severity).toBe(1) + expect(d!.severity).toBeLessThanOrEqual(1) + }) + + it('scales severity with the size of the deviation', () => { + const small = new BleFieldCore() + let at = warm(small) + const weak = small.push(-60 - DISTURBANCE_DB - 1, at)! + + const big = new BleFieldCore() + at = warm(big) + const strong = big.push(-60 - DISTURBANCE_DB * 2, at)! + + expect(strong.severity).toBeGreaterThan(weak.severity) + }) + + it('absorbs a sustained new level instead of alarming forever', () => { + // Set the phone down somewhere new: the first change is an event, but + // the baseline must follow so it stops screaming. + const core = new BleFieldCore() + let at = warm(core) + at += 500 + expect(core.push(-75, at)).not.toBeNull() + // Several tau later the baseline should have tracked to the new level. + for (let i = 0; i < 12; i++) { + at += BASELINE_TAU_MS / 2 + core.push(-75, at) + } + expect(core.baseline).toBeGreaterThan(-77) + expect(core.baseline).toBeLessThan(-73) + at += 500 + expect(core.push(-75, at)).toBeNull() + }) + + it('treats a long gap as staler than a rapid burst', () => { + // Time-aware EMA: the same reading should move the baseline much more + // after a long silence than during a fast burst. + const fast = new BleFieldCore() + fast.push(-60, 0) + fast.push(-80, 10) + + const slow = new BleFieldCore() + slow.push(-60, 0) + slow.push(-80, BASELINE_TAU_MS * 4) + + expect(slow.baseline!).toBeLessThan(fast.baseline!) + }) + + it('ignores non-finite readings without corrupting the baseline', () => { + const core = new BleFieldCore() + warm(core, -60) + const before = core.baseline + expect(core.push(NaN, 5000)).toBeNull() + expect(core.push(Infinity, 5200)).toBeNull() + expect(core.push(-60, NaN)).toBeNull() + expect(core.baseline).toBe(before) + }) + + it('handles duplicate timestamps without dividing by zero', () => { + const core = new BleFieldCore() + const at = warm(core) + expect(() => core.push(-62, at)).not.toThrow() + expect(Number.isFinite(core.baseline!)).toBe(true) + }) + + it('reset() returns it to a cold state', () => { + const core = new BleFieldCore() + warm(core) + core.reset() + expect(core.baseline).toBeNull() + expect(core.warm).toBe(false) + }) +}) diff --git a/frontend/src/lib/bluetooth.ts b/frontend/src/lib/bluetooth.ts new file mode 100644 index 0000000..173227b --- /dev/null +++ b/frontend/src/lib/bluetooth.ts @@ -0,0 +1,249 @@ +// Web Bluetooth as a presence channel — real RF, real physics. +// +// Why Bluetooth is a legitimate instrument here rather than set dressing: +// BLE advertises in the 2.4GHz ISM band, and the human body is mostly +// water, which absorbs 2.4GHz strongly. That is not folklore — it is the +// same physics that makes a microwave oven work, and the reason your wifi +// gets worse when someone stands between you and the router. So the RSSI +// of a nearby beacon genuinely drops when a body moves into the path, and +// genuinely fluctuates as things move around the room. +// +// That makes signal-strength variance a real, physically-grounded +// proximity/movement signal. We report exactly that and nothing more: this +// module never claims a drop in RSSI *is* a presence, only that the field +// changed. The interpretation belongs to the fiction upstairs; the +// measurement down here stays honest. +// +// Platform reality: Web Bluetooth exists in Chromium (desktop + Android). +// It does NOT exist in any iOS browser — Apple does not ship the API and +// every iOS browser is WebKit underneath. `isSupported()` reflects that +// rather than pretending otherwise. + +// Minimal local declarations for the Web Bluetooth surface we touch. +// TypeScript's DOM lib doesn't ship these, and pulling in +// @types/web-bluetooth for three shapes isn't worth the dependency — +// lib/emf.ts already handles its own non-standard sensor types the same +// way. Only what this module actually calls is declared, so the compiler +// still catches a typo in any of it. +type BluetoothDeviceLike = EventTarget & { + readonly name?: string + watchAdvertisements?: (opts?: { signal?: AbortSignal }) => Promise +} + +type BluetoothLike = { + requestDevice(options: { + acceptAllDevices?: boolean + optionalServices?: string[] + }): Promise +} + +/** An `advertisementreceived` event; `rssi` is optional because the spec + * allows platforms to omit it. */ +type AdvertisementEvent = Event & { rssi?: number } + +function bluetoothApi(): BluetoothLike | null { + const nav = navigator as Navigator & { bluetooth?: BluetoothLike } + return nav.bluetooth ?? null +} + +export type BleReading = { + /** Received signal strength, dBm. Typically -30 (touching) to -100 (far). */ + rssi: number + /** Milliseconds since epoch. */ + at: number +} + +export type BleDisturbance = { + /** How far this reading deviated from the rolling baseline, in dB. */ + deviation: number + /** Absolute deviation normalised 0..1 against DISTURBANCE_DB * 3. */ + severity: number + rssi: number + at: number +} + +/** + * dB of deviation from baseline before a change counts as a disturbance. + * + * Reasoning: BLE RSSI on a stationary link typically wanders +-2-4dB from + * multipath and receiver noise alone. A human body crossing the path + * attenuates 2.4GHz by roughly 3-10dB depending on geometry. 6dB sits + * above the idle noise band but inside what a real body actually causes, + * so it fires for movement without firing constantly for nothing. + */ +export const DISTURBANCE_DB = 6 + +/** EMA time constant for the baseline, ms. Long enough that a body walking + * through registers as a deviation rather than being absorbed; short + * enough that genuinely moving the phone to a new spot re-baselines within + * a few seconds instead of screaming for a minute. */ +export const BASELINE_TAU_MS = 8000 + +/** Readings folded in before deviations are trusted — the baseline needs + * to mean something before a difference from it does. */ +export const MIN_SAMPLES = 4 + +export function isSupported(): boolean { + return typeof navigator !== 'undefined' && typeof bluetoothApi()?.requestDevice === 'function' +} + +/** + * Rolling baseline over RSSI, with the same time-aware EMA shape used by + * coldSpot.ts — advertisement intervals are irregular (a beacon may + * advertise every 100ms or every 2s, and the OS coalesces), so a + * fixed-per-sample alpha would weight a burst and a long gap identically. + * + * Pure and separately testable: no Bluetooth objects appear in here. + */ +export class BleFieldCore { + private mean: number | null = null + private lastAt: number | null = null + private samples = 0 + + get baseline(): number | null { + return this.mean + } + + get warm(): boolean { + return this.samples >= MIN_SAMPLES + } + + /** + * Fold one reading in. Returns a disturbance when the deviation clears + * the threshold and the baseline is warm, else null. Non-finite input is + * a no-op rather than a crash or a poisoned baseline. + */ + push(rssi: number, atMs: number): BleDisturbance | null { + if (!Number.isFinite(rssi) || !Number.isFinite(atMs)) return null + + if (this.mean === null) { + this.mean = rssi + this.lastAt = atMs + this.samples = 1 + return null + } + + const deviation = rssi - this.mean + const warm = this.warm + + const dt = this.lastAt === null ? 0 : atMs - this.lastAt + const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15 + this.mean = this.mean + alpha * deviation + this.lastAt = atMs + this.samples++ + + if (!warm || Math.abs(deviation) < DISTURBANCE_DB) return null + return { + deviation, + severity: Math.min(1, Math.abs(deviation) / (DISTURBANCE_DB * 3)), + rssi, + at: atMs, + } + } + + reset(): void { + this.mean = null + this.lastAt = null + this.samples = 0 + } +} + +export type BleWatchCallbacks = { + onReading?: (r: BleReading) => void + onDisturbance?: (d: BleDisturbance) => void + onError?: (err: Error) => void + /** Fired when the device disconnects or the browser stops advertising + * updates, so the UI can stop claiming a live link. */ + onLost?: () => void +} + +/** + * Watches one user-chosen BLE device's signal strength. + * + * Requires an explicit device pick (browsers mandate a user gesture and a + * chooser — there is deliberately no way to silently scan, which is a + * privacy protection, not a limitation to work around). + */ +export class BleWatcher { + private device: BluetoothDeviceLike | null = null + private core = new BleFieldCore() + private watching = false + private abort: AbortController | null = null + + get isWatching(): boolean { + return this.watching + } + + get deviceName(): string | null { + return this.device?.name ?? null + } + + /** Opens the browser's device chooser. Throws if the seeker cancels. */ + async requestDevice(): Promise { + if (!isSupported()) { + throw new Error('this vessel has no bluetooth sense') + } + const bt = bluetoothApi() + if (!bt) throw new Error('this vessel has no bluetooth sense') + // acceptAllDevices because we don't care what it is — any radio in the + // room is a field to measure. No services are requested, so this grants + // the minimum possible access: we never connect to read characteristics. + this.device = await bt.requestDevice({ + acceptAllDevices: true, + optionalServices: [], + }) + } + + /** + * Begin watching advertisements for RSSI. + * + * `watchAdvertisements()` is the only route to live RSSI without opening + * a GATT connection, and it is still gated behind a flag in some Chrome + * builds — so its absence is reported as a clear, actionable error + * rather than a silent no-op that looks like a dead sensor. + */ + async start(cb: BleWatchCallbacks): Promise { + const device = this.device + if (!device) throw new Error('no device chosen') + if (this.watching) return + + if (typeof device.watchAdvertisements !== 'function') { + throw new Error( + 'this browser cannot listen for bluetooth advertisements — ' + + 'enable chrome://flags/#enable-experimental-web-platform-features', + ) + } + + this.core.reset() + const abort = new AbortController() + this.abort = abort + + device.addEventListener('advertisementreceived', ((event: Event) => { + const adv = event as AdvertisementEvent + if (typeof adv.rssi !== 'number') return + const at = Date.now() + cb.onReading?.({ rssi: adv.rssi, at }) + const disturbance = this.core.push(adv.rssi, at) + if (disturbance) cb.onDisturbance?.(disturbance) + }) as EventListener, { signal: abort.signal }) + + device.addEventListener('gattserverdisconnected', () => cb.onLost?.(), { + signal: abort.signal, + }) + + try { + await device.watchAdvertisements({ signal: abort.signal }) + this.watching = true + } catch (err) { + this.abort = null + throw err instanceof Error ? err : new Error(String(err)) + } + } + + stop(): void { + this.abort?.abort() + this.abort = null + this.watching = false + this.core.reset() + } +} diff --git a/frontend/src/lib/entropy.ts b/frontend/src/lib/entropy.ts new file mode 100644 index 0000000..106a7ea --- /dev/null +++ b/frontend/src/lib/entropy.ts @@ -0,0 +1,160 @@ +// Physical entropy harvesting — the actual source of randomness behind +// contact. +// +// The premise the whole app rests on is that *the room decides*, not a +// seeded PRNG. So the randomness has to come from something physically +// unpredictable that the seeker is genuinely standing in the middle of: +// the thermal and acoustic noise in their microphone's floor, and the +// atmospheric/receiver noise between radio stations. Both are real +// physical processes, and the low-order bits of their FFT magnitudes are +// not predictable even in principle from outside that room. +// +// Why the low bits specifically: the *shape* of a spectrum is highly +// predictable (a room tone, a broadcast carrier) and carries almost no +// entropy. The bottom bits of each magnitude, by contrast, are dominated +// by thermal noise in the ADC and by acoustic/RF noise that no model +// predicts. Taking only those is the difference between harvesting +// randomness and harvesting a fingerprint of the room. +// +// Raw physical bits are always biased, so this does what any honest +// hardware RNG does before trusting them: +// +// 1. extract - keep only the least-significant bit of each magnitude +// 2. debias - Von Neumann: read bits in pairs, 01 -> 0, 10 -> 1, +// discard 00 and 11. Removes any fixed per-bit bias +// regardless of how skewed the source is, at the cost of +// throughput (hence the pool + estimate below). +// 3. condition - SHA-256 the debiased bytes, so even a partly-degenerate +// source produces a uniform-looking digest. +// +// This is deliberately NOT presented as cryptographically strong on its +// own, and the server never trusts it alone — see backend/app/entropy.py, +// which mixes every client contribution with server-side secrets. A client +// that lies about its entropy can therefore bias nothing. + +/** Bits we try to bank before a contribution is considered well-fed. Not a + * security parameter (the server mixes in its own); it's the point past + * which the physical contribution is a meaningful share of the mix. */ +export const ENTROPY_TARGET_BITS = 256 + +/** Hard cap on the pool so a long session can't grow memory without bound. */ +const MAX_POOL_BYTES = 128 + +export type EntropyQuality = 'empty' | 'thin' | 'gathering' | 'rich' + +/** + * Accumulates physical entropy from successive spectrum frames. + * + * Fed by whatever real source is running (microphone FFT, RTL-SDR sweep); + * knows nothing about which, because unpredictability in the bottom bits is + * a property of physical measurement, not of the instrument. + */ +export class EntropyPool { + private bits: number[] = [] + private bytes: number[] = [] + /** Half of a Von Neumann pair, waiting for its partner. */ + private pending: number | null = null + private harvested = 0 + + /** Total debiased bits ever produced — the honest measure of how much + * physical randomness this pool has actually seen. */ + get harvestedBits(): number { + return this.harvested + } + + get quality(): EntropyQuality { + if (this.harvested === 0) return 'empty' + if (this.harvested < ENTROPY_TARGET_BITS / 4) return 'thin' + if (this.harvested < ENTROPY_TARGET_BITS) return 'gathering' + return 'rich' + } + + /** 0..1 progress toward a well-fed contribution, for display. */ + get fill(): number { + return Math.min(1, this.harvested / ENTROPY_TARGET_BITS) + } + + /** + * Fold one spectrum frame in. Only the least-significant bit of each + * finite magnitude is used; non-finite bins (silence can produce + * -Infinity from an AnalyserNode) are skipped rather than contributing a + * constant, which would be pure bias. + */ + addFrame(frame: ArrayLike): void { + for (let i = 0; i < frame.length; i++) { + const v = frame[i] + if (!Number.isFinite(v)) continue + // Scale before truncating: dB values are fractional, and the + // fractional part is exactly where the noise lives. Math.abs keeps + // negative dB values (the normal case) from collapsing sign into the + // low bit. + const scaled = Math.abs(Math.trunc(v * 1000)) + this.pushBit(scaled & 1) + } + } + + private pushBit(bit: number): void { + // Von Neumann debiasing: equal-probability outcomes regardless of the + // source's bias, as long as successive bits are independent. + if (this.pending === null) { + this.pending = bit + return + } + const first = this.pending + this.pending = null + if (first === bit) return // 00 or 11 -> discard, no bias introduced + this.bits.push(first === 0 ? 0 : 1) // 01 -> 0, 10 -> 1 + this.harvested++ + + if (this.bits.length === 8) { + let byte = 0 + for (const b of this.bits) byte = (byte << 1) | b + this.bits.length = 0 + this.bytes.push(byte) + if (this.bytes.length > MAX_POOL_BYTES) { + this.bytes.splice(0, this.bytes.length - MAX_POOL_BYTES) + } + } + } + + /** + * Condition everything gathered so far into a hex digest and reset the + * pool. Returns null if nothing has been harvested, so callers can tell + * "no physical entropy available" apart from "here is a digest of + * nothing" — the server treats those differently. + */ + async drain(): Promise { + if (this.bytes.length === 0) return null + const buf = Uint8Array.from(this.bytes) + this.bytes = [] + this.bits.length = 0 + this.pending = null + // harvested is intentionally NOT reset: it measures the session's + // total physical yield, which is what the UI reports. + const digest = await crypto.subtle.digest('SHA-256', buf) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') + } + + reset(): void { + this.bits.length = 0 + this.bytes = [] + this.pending = null + this.harvested = 0 + } +} + +/** Human-facing label for the pool state — deliberately in the app's voice + * rather than engineering terms, since this is surfaced in the séance UI. */ +export function entropyLabel(quality: EntropyQuality): string { + switch (quality) { + case 'rich': + return 'the air is thick' + case 'gathering': + return 'something is gathering' + case 'thin': + return 'the air is still' + case 'empty': + default: + return 'nothing stirs' + } +} diff --git a/frontend/src/lib/magnetometer.test.ts b/frontend/src/lib/magnetometer.test.ts new file mode 100644 index 0000000..64eb21c --- /dev/null +++ b/frontend/src/lib/magnetometer.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { BASELINE_TAU_MS, MIN_SAMPLES, MagFieldCore, SPIKE_UT } from './magnetometer' + +/** Earth's field at mid-latitude, roughly. */ +const EARTH_UT = 48 + +function warm(core: MagFieldCore, ut = EARTH_UT, startAt = 0, step = 100): number { + let at = startAt + for (let i = 0; i < MIN_SAMPLES; i++) { + core.push(ut, at) + at += step + } + return at +} + +describe('MagFieldCore', () => { + it('starts with no baseline', () => { + const core = new MagFieldCore() + expect(core.baseline).toBeNull() + expect(core.warm).toBe(false) + }) + + it("does not report Earth's steady field as an anomaly", () => { + // The whole point of a baseline: a constant ~48uT background is normal, + // not a haunting. + const core = new MagFieldCore() + let at = warm(core) + for (let i = 0; i < 40; i++) { + at += 100 + expect(core.push(EARTH_UT, at)).toBeNull() + } + }) + + it('ignores sensor noise below the spike threshold', () => { + const core = new MagFieldCore() + let at = warm(core) + for (const jitter of [0.4, -0.7, 0.9, -0.3, 0.6, -0.8]) { + at += 100 + expect(core.push(EARTH_UT + jitter, at)).toBeNull() + } + }) + + it('reports a spike once warm', () => { + const core = new MagFieldCore() + const at = warm(core) + const a = core.push(EARTH_UT + SPIKE_UT + 1, at) + expect(a).not.toBeNull() + expect(a!.deviation).toBeGreaterThan(SPIKE_UT) + }) + + it('stays silent during warm-up', () => { + const core = new MagFieldCore() + core.push(EARTH_UT, 0) + expect(core.push(EARTH_UT + 50, 100)).toBeNull() + }) + + it('detects a drop in field as well as a rise', () => { + // Ferrous mass can shield as well as add; a one-sided detector would + // miss half of what a real EMF meter reacts to. + const core = new MagFieldCore() + const at = warm(core) + const a = core.push(EARTH_UT - SPIKE_UT - 2, at) + expect(a).not.toBeNull() + expect(a!.deviation).toBeLessThan(0) + }) + + it('normalises severity to 0..1 and saturates', () => { + const core = new MagFieldCore() + const at = warm(core) + expect(core.push(EARTH_UT + 500, at)!.severity).toBe(1) + }) + + it('scales severity with deviation size', () => { + const a = new MagFieldCore() + let at = warm(a) + const weak = a.push(EARTH_UT + SPIKE_UT + 0.5, at)! + + const b = new MagFieldCore() + at = warm(b) + const strong = b.push(EARTH_UT + SPIKE_UT * 2.5, at)! + + expect(strong.severity).toBeGreaterThan(weak.severity) + }) + + it('re-baselines after the seeker moves to a new room', () => { + const core = new MagFieldCore() + let at = warm(core) + at += 100 + expect(core.push(EARTH_UT + 10, at)).not.toBeNull() + for (let i = 0; i < 15; i++) { + at += BASELINE_TAU_MS / 2 + core.push(EARTH_UT + 10, at) + } + at += 100 + expect(core.push(EARTH_UT + 10, at)).toBeNull() + }) + + it('is time-aware: a long gap moves the baseline further than a burst', () => { + const fast = new MagFieldCore() + fast.push(EARTH_UT, 0) + fast.push(EARTH_UT + 20, 5) + + const slow = new MagFieldCore() + slow.push(EARTH_UT, 0) + slow.push(EARTH_UT + 20, BASELINE_TAU_MS * 4) + + expect(slow.baseline!).toBeGreaterThan(fast.baseline!) + }) + + it('ignores non-finite input without corrupting state', () => { + const core = new MagFieldCore() + warm(core) + const before = core.baseline + expect(core.push(NaN, 900)).toBeNull() + expect(core.push(EARTH_UT, NaN)).toBeNull() + expect(core.baseline).toBe(before) + }) + + it('survives duplicate timestamps', () => { + const core = new MagFieldCore() + const at = warm(core) + expect(() => core.push(EARTH_UT + 1, at)).not.toThrow() + expect(Number.isFinite(core.baseline!)).toBe(true) + }) + + it('reset() clears it', () => { + const core = new MagFieldCore() + warm(core) + core.reset() + expect(core.baseline).toBeNull() + expect(core.warm).toBe(false) + }) +}) diff --git a/frontend/src/lib/magnetometer.ts b/frontend/src/lib/magnetometer.ts new file mode 100644 index 0000000..cbc9454 --- /dev/null +++ b/frontend/src/lib/magnetometer.ts @@ -0,0 +1,216 @@ +// True magnetometer EMF — actual magnetic field, in microtesla. +// +// The existing EMF mode (lib/emf.ts) infers "field disturbance" from +// DeviceMotion and DeviceOrientation — accelerometer and gyroscope. That +// is a real physical measurement and it works on iOS, but it measures +// *movement*, not magnetism. A phone sitting perfectly still next to a +// running motor reads nothing on it. +// +// This module reads the actual magnetometer via the Generic Sensor API, so +// the "EMF meter" is measuring the thing an EMF meter is supposed to +// measure. Real ghost-hunting EMF meters are just magnetometers, and the +// spikes they famously pick up are genuinely caused by mains wiring, +// motors, speaker magnets, and moving ferrous mass — all of which this +// picks up too, for the same real reasons. +// +// Platform reality: `Magnetometer` is Chromium-only (Android in practice), +// requires HTTPS, and needs the 'magnetometer' permission. It does not +// exist on iOS at all. Callers should fall back to lib/emf.ts's motion +// heuristic rather than showing nothing — which is why this is a separate +// module instead of a rewrite of that one. +// +// Baseline reasoning: Earth's field is ~25-65uT depending on latitude, and +// that constant background is exactly what we must NOT report. What +// matters is deviation from wherever the seeker is standing, so the same +// rolling-EMA-baseline shape used by coldSpot.ts and bluetooth.ts applies +// here too. + +export type MagReading = { + /** Field magnitude in microtesla, sqrt(x^2 + y^2 + z^2). */ + magnitude: number + x: number + y: number + z: number + at: number +} + +export type MagAnomaly = { + /** Deviation from the rolling baseline, in microtesla. */ + deviation: number + /** 0..1 against SPIKE_UT * 3. */ + severity: number + magnitude: number + at: number +} + +/** + * Microtesla of deviation before a reading counts as a spike. + * + * Reasoning: phone magnetometers have roughly +-0.5-1uT of noise, and + * Earth's field is stable to well under that on a stationary device. Mains + * wiring at close range produces on the order of 1-10uT; a speaker magnet + * or motor far more. 3uT clears sensor noise by several times while still + * catching the household sources a real EMF meter reacts to. + */ +export const SPIKE_UT = 3.0 + +/** Baseline time constant, ms. Earth's field doesn't change, but the + * seeker walking to a different room changes their *local* field + * permanently — this absorbs that within a few seconds rather than + * alarming indefinitely. */ +export const BASELINE_TAU_MS = 6000 + +export const MIN_SAMPLES = 5 + +/** Sampling rate. 10Hz is plenty for a field that changes at human speed, + * and materially cheaper on battery than the 60Hz the API will happily + * give you — this runs on a phone the seeker is holding for a long time. */ +export const SAMPLE_HZ = 10 + +export function isSupported(): boolean { + return typeof window !== 'undefined' && 'Magnetometer' in window +} + +/** + * Rolling-baseline core over field magnitude. Pure and separately + * testable — no Sensor objects in here. + */ +export class MagFieldCore { + private mean: number | null = null + private lastAt: number | null = null + private samples = 0 + + get baseline(): number | null { + return this.mean + } + + get warm(): boolean { + return this.samples >= MIN_SAMPLES + } + + push(magnitude: number, atMs: number): MagAnomaly | null { + if (!Number.isFinite(magnitude) || !Number.isFinite(atMs)) return null + + if (this.mean === null) { + this.mean = magnitude + this.lastAt = atMs + this.samples = 1 + return null + } + + const deviation = magnitude - this.mean + const warm = this.warm + + const dt = this.lastAt === null ? 0 : atMs - this.lastAt + const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15 + this.mean = this.mean + alpha * deviation + this.lastAt = atMs + this.samples++ + + if (!warm || Math.abs(deviation) < SPIKE_UT) return null + return { + deviation, + severity: Math.min(1, Math.abs(deviation) / (SPIKE_UT * 3)), + magnitude, + at: atMs, + } + } + + reset(): void { + this.mean = null + this.lastAt = null + this.samples = 0 + } +} + +export type MagListenerCallbacks = { + onReading?: (r: MagReading) => void + onAnomaly?: (a: MagAnomaly) => void + onError?: (err: Error) => void +} + +type MagnetometerLike = { + x: number | null + y: number | null + z: number | null + start(): void + stop(): void + addEventListener(type: string, fn: () => void): void +} + +/** + * Live magnetometer listener. + * + * Permission is requested explicitly where the Permissions API supports + * it, because Chrome otherwise fails the `start()` silently and the UI + * would show a dead meter with no explanation. + */ +export class MagnetometerListener { + private sensor: MagnetometerLike | null = null + private core = new MagFieldCore() + private running = false + + get isRunning(): boolean { + return this.running + } + + async start(cb: MagListenerCallbacks): Promise { + if (this.running) return + if (!isSupported()) { + throw new Error('this vessel has no magnetic sense') + } + + // Ask first where we can; a denied permission should read as "denied", + // not as a sensor that exists but never fires. + const perms = (navigator as Navigator & { permissions?: Permissions }).permissions + if (perms?.query) { + try { + const status = await perms.query({ name: 'magnetometer' as PermissionName }) + if (status.state === 'denied') { + throw new Error('the compass was refused') + } + } catch (err) { + // A browser that doesn't recognise the descriptor throws TypeError + // — that's not a denial, so fall through and let start() decide. + if (err instanceof Error && /refused/.test(err.message)) throw err + } + } + + const Ctor = (window as unknown as { Magnetometer: new (opts: object) => MagnetometerLike }) + .Magnetometer + const sensor = new Ctor({ frequency: SAMPLE_HZ }) + this.core.reset() + + sensor.addEventListener('reading', () => { + const { x, y, z } = sensor + if (x === null || y === null || z === null) return + const magnitude = Math.sqrt(x * x + y * y + z * z) + const at = Date.now() + cb.onReading?.({ magnitude, x, y, z, at }) + const anomaly = this.core.push(magnitude, at) + if (anomaly) cb.onAnomaly?.(anomaly) + }) + + sensor.addEventListener('error', () => { + // Chrome surfaces a NotReadableError here when the hardware is + // missing despite the constructor existing (some tablets). + cb.onError?.(new Error('the compass will not hold still')) + this.stop() + }) + + sensor.start() + this.sensor = sensor + this.running = true + } + + stop(): void { + try { + this.sensor?.stop() + } catch { + /* already stopped or torn down */ + } + this.sensor = null + this.running = false + this.core.reset() + } +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8075c0f..dcf2b73 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -104,7 +104,10 @@ export type Telemetry = { dns_ms: number } -export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply' +// 'manifest' is unprompted speech — the entity speaking with no +// question asked, pulled through by a shift in the room. See +// backend SpiritService.manifest(). +export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply' | 'manifest' export type ServerFrame = | { type: 'session'; id: string } diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index 3479d33..6dd3da8 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -1059,6 +1059,42 @@ opacity: 0.75; } +/* Unprompted speech: nobody asked for this. Treated as an intrusion + rather than a reply — a violet edge marks it as arriving from outside + the conversation, and it sits at full opacity (unlike the deliberately + faded ambient/fragment murmurs) because the unsettling part is that it + is perfectly clear and completely unbidden. */ +.tx-utterance.kind-manifest { + border-left: 2px solid rgba(178, 107, 255, 0.6); + padding-left: 0.6rem; + margin-left: -0.2rem; +} + +.tx-utterance.kind-manifest .tx-text { + color: #ecdcff; + text-shadow: 0 0 9px rgba(178, 107, 255, 0.45); + animation: manifestArrive 620ms ease-out both; +} + +@keyframes manifestArrive { + from { + opacity: 0; + transform: translateX(-5px); + filter: blur(2.5px); + } + to { + opacity: 1; + transform: none; + filter: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .tx-utterance.kind-manifest .tx-text { + animation: none; + } +} + .tx-utterance.speaking .tx-speaker { animation: flickerAnim 1.2s linear infinite; }