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:
146
frontend/src/lib/bluetooth.test.ts
Normal file
146
frontend/src/lib/bluetooth.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
249
frontend/src/lib/bluetooth.ts
Normal file
249
frontend/src/lib/bluetooth.ts
Normal 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
160
frontend/src/lib/entropy.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/lib/magnetometer.test.ts
Normal file
133
frontend/src/lib/magnetometer.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
216
frontend/src/lib/magnetometer.ts
Normal file
216
frontend/src/lib/magnetometer.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,10 @@ export type Telemetry = {
|
|||||||
dns_ms: number
|
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 =
|
export type ServerFrame =
|
||||||
| { type: 'session'; id: string }
|
| { type: 'session'; id: string }
|
||||||
|
|||||||
@@ -1059,6 +1059,42 @@
|
|||||||
opacity: 0.75;
|
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 {
|
.tx-utterance.speaking .tx-speaker {
|
||||||
animation: flickerAnim 1.2s linear infinite;
|
animation: flickerAnim 1.2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user