feat: bluetooth and magnetometer channels — two more real instruments

Both measure genuine physics rather than dressing up a random number.

Bluetooth (lib/bluetooth.ts): BLE advertises in the 2.4GHz ISM band, and
the human body is mostly water, which absorbs 2.4GHz strongly — the same
physics that makes a microwave work and that degrades your wifi when
someone stands between you and the router. So RSSI genuinely drops when a
body crosses the path. That makes signal-strength variance a real,
physically-grounded movement signal. The module reports exactly that and
nothing more: it never claims a drop *is* a presence, only that the field
changed. Threshold is 6dB — above the 2-4dB of idle multipath wander, and
inside the 3-10dB a real body actually causes.

Magnetometer (lib/magnetometer.ts): the existing EMF mode infers field
disturbance from DeviceMotion/DeviceOrientation, which is a real
measurement but measures *movement*, not magnetism — a phone sitting still
beside a running motor reads nothing. This reads the actual magnetometer,
so the EMF meter measures what an EMF meter is supposed to. Real
ghost-hunting EMF meters are just magnetometers, and the spikes they pick
up come from mains wiring, motors and moving ferrous mass — all of which
this picks up, for the same real reasons. 3uT threshold clears the ~0.5-1uT
sensor noise while still catching household sources. Earth's constant
25-65uT background is explicitly what the rolling baseline exists to
subtract.

Both are additive: neither replaces the existing motion-based EMF, which
stays the fallback because it works on iOS where neither of these do
(no Web Bluetooth, no Generic Sensor API in any iOS browser). Both reuse
the time-aware EMA baseline shape from coldSpot.ts, since advertisement
and sensor intervals are irregular and a fixed per-sample alpha would
weight a burst and a long gap identically.

Web Bluetooth types are declared locally rather than pulling in
@types/web-bluetooth for three shapes — same approach lib/emf.ts already
takes with its non-standard sensor types.

Also renders unprompted 'manifest' utterances as an intrusion: violet edge,
full opacity (unlike the faded ambient/fragment murmurs), and a brief
blur-in. The unsettling part is that it is perfectly clear and completely
unbidden.

355 frontend tests pass (26 new); i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 03:46:48 +00:00
parent bbcbaa0a36
commit 30694a954a
7 changed files with 944 additions and 1 deletions

View File

@@ -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)
})
})

View File

@@ -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<void>
}
type BluetoothLike = {
requestDevice(options: {
acceptAllDevices?: boolean
optionalServices?: string[]
}): Promise<BluetoothDeviceLike>
}
/** 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<void> {
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<void> {
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()
}
}

160
frontend/src/lib/entropy.ts Normal file
View File

@@ -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<number>): 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<string | null> {
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'
}
}

View File

@@ -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)
})
})

View File

@@ -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<void> {
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()
}
}

View File

@@ -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 }

View File

@@ -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;
}