Add EMF field mode, Armory shop/waitlist, and ambient haunting layer

Three self-contained features, verified complete and cross-wired
end-to-end (audited: backend 54/54 tests, frontend 110/110 tests,
tsc --noEmit clean, i18n coverage script clean):

- EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing,
  a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own
  fragment prompt persona and full frontend gauge UI.
- Armory (shop/waitlist): pre-order capture page for the future
  Ultimate Quantum Box hardware line, rate-limited public endpoint,
  explicitly no payment collection.
- Haunting layer: ambient possession effects (dread-bed audio, title
  glitching, idle-paced whispers/manifests), respects
  prefers-reduced-motion, mounted once at the app root.

Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID,
explicit selectConfiguration, isSecureContext gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
Indiana
2026-07-21 02:28:14 +00:00
parent c372427ced
commit 0756e677b9
33 changed files with 3148 additions and 48 deletions

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { EmfFieldCore } from './emf'
function makeCore(): EmfFieldCore {
return new EmfFieldCore()
}
describe('EmfFieldCore', () => {
it('establishes a baseline from quiet frames and stays silent', () => {
const core = makeCore()
expect(core.process(0.2, 0)).toBeNull() // first reading only seeds
expect(core.getBaseline()).toBeCloseTo(0.2, 9)
for (let t = 1; t <= 50; t++) {
expect(core.process(0.2, t * 100)).toBeNull()
}
expect(core.getBaseline()).toBeCloseTo(0.2, 9)
})
it('fires exactly one anomaly for a brief spike, with sane values', () => {
const core = makeCore()
for (let t = 0; t <= 20; t++) core.process(0.2, t * 100)
const anomaly = core.process(2.0, 10_000)
expect(anomaly).not.toBeNull()
expect(anomaly!.frequency).toBeCloseTo(0.2, 6) // baseline estimate
expect(anomaly!.magnitude).toBeCloseTo(20, 6) // 20·log10(2.0 / 0.2)
// The spike is gone on the next reading — no further anomaly.
expect(core.process(0.2, 10_100)).toBeNull()
})
it('throttles repeats inside the throttle window and refires after it', () => {
const core = makeCore()
core.process(0.2, 0)
expect(core.process(2.0, 10_000)).not.toBeNull()
// Same spike 1 s later: swallowed by the 2.5 s throttle.
expect(core.process(2.0, 11_000)).toBeNull()
// Past the window it fires again (baseline has crept, but not enough).
expect(core.process(2.0, 12_500)).not.toBeNull()
})
it('requires the spike to clear the absolute floor above a dead-quiet baseline', () => {
const core = makeCore()
for (let t = 0; t <= 20; t++) core.process(0.05, t * 100)
// 0.4 is 8× the baseline but below the 0.6 absolute floor: resting noise.
expect(core.process(0.4, 10_000)).toBeNull()
// 0.8 clears the floor and the factor: a real surge.
expect(core.process(0.8, 10_100)).not.toBeNull()
})
it('absorbs a sustained high field instead of refiring', () => {
const core = makeCore()
core.process(0.2, 0)
let fired = 0
// Sustained surge for 5 s at 100 ms intervals: the first spike fires,
// then the baseline climbs until the "spike" is the new normal — later
// readings are ignored even after the throttle window expires.
for (let t = 10_000; t <= 15_000; t += 100) {
if (core.process(3.0, t)) fired++
}
expect(fired).toBe(1)
expect(core.getBaseline()).toBeGreaterThan(1.2)
})
it('sanitizes garbage readings to zero', () => {
const core = makeCore()
core.process(0.2, 0)
expect(core.process(NaN, 1000)).toBeNull()
expect(core.process(-5, 2000)).toBeNull()
expect(core.getBaseline()!).toBeLessThan(0.2)
})
it('reset() re-arms the baseline and the throttle clock', () => {
const core = makeCore()
core.process(0.2, 0)
expect(core.process(2.0, 10_000)).not.toBeNull()
// Would be throttled without a reset…
expect(core.process(2.0, 10_100)).toBeNull()
core.reset()
expect(core.getBaseline()).toBeNull()
// …so the next spike fires immediately after the baseline re-seeds.
expect(core.process(0.2, 10_200)).toBeNull() // re-seeds baseline
const anomaly = core.process(2.0, 10_300)
expect(anomaly).not.toBeNull()
expect(anomaly!.frequency).toBeCloseTo(0.2, 6)
})
it('respects custom factor, floor and throttle options', () => {
const core = new EmfFieldCore({ spikeFactor: 4, absoluteFloor: 1.5, throttleMs: 1000 })
core.process(0.2, 0)
// 1.0 is 5× the baseline but below the 1.5 floor.
expect(core.process(1.0, 5000)).toBeNull()
// 2.0 clears both and fires; 500 ms later the custom 1 s throttle swallows it.
expect(core.process(2.0, 6000)).not.toBeNull()
expect(core.process(2.0, 6500)).toBeNull()
expect(core.process(2.0, 7000)).not.toBeNull()
})
})

234
frontend/src/lib/emf.ts Normal file
View File

@@ -0,0 +1,234 @@
// EMF mode: a phone in the seeker's hand becomes a ghost-hunting field meter.
//
// Design: DeviceMotion (accelerationIncludingGravity magnitude wobbling
// around local gravity) and DeviceOrientation (alpha/beta/gamma rate of
// change) are fused into one field-fluctuation strength. `EmfFieldCore`
// keeps a rolling baseline of that strength; a reading that jumps past
// baseline * factor (and an absolute floor, so a resting phone stays
// silent) fires one anomaly, throttled to at most one per ~2.5 s. The
// baseline always adapts, so a sustained high field is absorbed and only
// fresh surges keep knocking.
//
// The pure math lives in `EmfFieldCore` (unit-testable with injected
// numbers); the sensor plumbing lives in `EmfSensorListener` below.
export type EmfAnomaly = {
/** Baseline field-strength estimate at the moment of the spike.
(Carried in the protocol's `frequency` slot.) */
frequency: number
/** Spike ratio above the baseline, dB-ish: 20·log10(spike/baseline). */
magnitude: number
}
export type EmfFieldCoreOptions = {
/** Spike = current > baseline * spikeFactor. */
spikeFactor?: number
/** Spike must also clear this absolute floor (resting-hand noise). */
absoluteFloor?: number
/** Minimum milliseconds between anomaly emissions. */
throttleMs?: number
/** EMA factor for the rolling baseline (0..1, higher = faster adaptation). */
baselineAlpha?: number
}
export class EmfFieldCore {
private baseline: number | null = null
private lastEmit = -Infinity
private readonly spikeFactor: number
private readonly absoluteFloor: number
private readonly throttleMs: number
private readonly baselineAlpha: number
constructor(opts: EmfFieldCoreOptions = {}) {
this.spikeFactor = opts.spikeFactor ?? 2.5
this.absoluteFloor = opts.absoluteFloor ?? 0.6
this.throttleMs = opts.throttleMs ?? 2500
this.baselineAlpha = opts.baselineAlpha ?? 0.05
}
/**
* Feed one fused field-strength reading (>= 0). `nowMs` is the read time.
* Returns an anomaly when a spike fires, else null.
*/
process(strength: number, nowMs: number): EmfAnomaly | null {
const current = Number.isFinite(strength) && strength > 0 ? strength : 0
if (this.baseline === null) {
this.baseline = current // first reading seeds the baseline
return null
}
const baseline = this.baseline
const threshold = Math.max(baseline * this.spikeFactor, this.absoluteFloor)
let anomaly: EmfAnomaly | null = null
if (current > threshold && nowMs - this.lastEmit >= this.throttleMs) {
anomaly = {
frequency: baseline,
magnitude: 20 * Math.log10(current / Math.max(baseline, 1e-6)),
}
this.lastEmit = nowMs
}
// The baseline always adapts — a sustained high field becomes the new
// normal, so only genuine surges keep firing.
this.baseline += this.baselineAlpha * (current - this.baseline)
return anomaly
}
/** Current rolling baseline (the resting field estimate). */
getBaseline(): number | null {
return this.baseline
}
reset(): void {
this.baseline = null
this.lastEmit = -Infinity
}
}
// ---- sensor plumbing ----
export type EmfListenerCallbacks = {
onAnomaly: (a: EmfAnomaly) => void
/** Fused field-strength updates for the UI gauge, throttled to ~10 Hz. */
onReading?: (strength: number, baseline: number | null) => void
onError?: (err: Error) => void
}
/** Slow EMA tracking local gravity from the total acceleration magnitude. */
const GRAVITY_ALPHA = 0.02
/** Fast EMA smoothing the instantaneous deviation signals. */
const DEVIATION_ALPHA = 0.25
/** Degrees-per-event orientation flux is scaled into the motion units. */
const ORIENTATION_WEIGHT = 0.04
/** Gauge callback throttle. */
const GAUGE_THROTTLE_MS = 100
type Permissionable = { requestPermission?: () => Promise<string> }
/** Shortest signed angular delta, for the 0/360° alpha wraparound. */
function angleDelta(deg: number): number {
return ((deg + 540) % 360) - 180
}
export class EmfSensorListener {
private core: EmfFieldCore
private gravity = 9.81
private motionDev = 0
private orientDev = 0
private lastOrient: { alpha: number; beta: number; gamma: number } | null = null
private lastGaugeMs = 0
private running = false
private cb: EmfListenerCallbacks | null = null
constructor(core?: EmfFieldCore) {
this.core = core ?? new EmfFieldCore()
}
get isRunning(): boolean {
return this.running
}
/** False on vessels with no motion senses (most desktops, jsdom). */
static isSupported(): boolean {
return typeof window !== 'undefined' && 'DeviceMotionEvent' in window
}
/** iOS 13+ requires an explicit, gesture-triggered permission grant. */
static needsPermission(): boolean {
if (typeof DeviceMotionEvent === 'undefined') return false
return typeof (DeviceMotionEvent as unknown as Permissionable).requestPermission === 'function'
}
/** Ask for motion + orientation access. Resolves false when refused. */
static async requestPermission(): Promise<boolean> {
try {
const dm = DeviceMotionEvent as unknown as Permissionable
if (typeof dm.requestPermission === 'function') {
if ((await dm.requestPermission()) !== 'granted') return false
}
const dor =
typeof DeviceOrientationEvent === 'undefined'
? undefined
: (DeviceOrientationEvent as unknown as Permissionable)
if (typeof dor?.requestPermission === 'function') {
if ((await dor.requestPermission()) !== 'granted') return false
}
return true
} catch {
return false
}
}
start(cb: EmfListenerCallbacks): void {
if (this.running || !EmfSensorListener.isSupported()) return
this.cb = cb
this.running = true
this.lastGaugeMs = 0
window.addEventListener('devicemotion', this.handleMotion)
window.addEventListener('deviceorientation', this.handleOrient)
}
stop(): void {
if (!this.running) return
this.running = false
window.removeEventListener('devicemotion', this.handleMotion)
window.removeEventListener('deviceorientation', this.handleOrient)
this.cb = null
this.lastOrient = null
}
/** Calibrate: re-seed the baseline from whatever the field does next. */
reset(): void {
this.core.reset()
this.gravity = 9.81
this.motionDev = 0
this.orientDev = 0
}
private readonly handleMotion = (e: DeviceMotionEvent): void => {
const acc = e.accelerationIncludingGravity
if (!acc) return
const { x, y, z } = acc
if (x == null || y == null || z == null) return
const mag = Math.hypot(x, y, z)
// Slow EMA tracks local gravity (~9.81); the deviation from it is the
// hand tremor / field perturbation we actually listen to.
this.gravity += GRAVITY_ALPHA * (mag - this.gravity)
const inst = Math.abs(mag - this.gravity)
this.motionDev += DEVIATION_ALPHA * (inst - this.motionDev)
this.emit()
}
private readonly handleOrient = (e: DeviceOrientationEvent): void => {
const { alpha, beta, gamma } = e
if (alpha == null && beta == null && gamma == null) return
const cur = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 }
const last = this.lastOrient
this.lastOrient = cur
if (!last) return
const inst = Math.hypot(
angleDelta(cur.alpha - last.alpha),
cur.beta - last.beta,
cur.gamma - last.gamma,
)
this.orientDev += DEVIATION_ALPHA * (inst - this.orientDev)
this.emit()
}
/** Fuse both senses into one strength and push it through the core. */
private emit(): void {
const cb = this.cb
if (!this.running || !cb) return
const strength = this.motionDev + this.orientDev * ORIENTATION_WEIGHT
const now = performance.now()
const anomaly = this.core.process(strength, now)
if (anomaly) cb.onAnomaly(anomaly)
if (cb.onReading && now - this.lastGaugeMs >= GAUGE_THROTTLE_MS) {
this.lastGaugeMs = now
cb.onReading(strength, this.core.getBaseline())
}
}
}

View File

@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_GLITCH_TITLES,
HauntBed,
HauntBus,
IdleEscalator,
flickerTitle,
hauntBus,
pickWhisperWord,
type HauntScheduler,
} from './haunting'
describe('pickWhisperWord', () => {
const words = ['cold', 'stay', 'behind you']
it('picks deterministically from rand', () => {
expect(pickWhisperWord(words, () => 0)).toBe('cold')
expect(pickWhisperWord(words, () => 0.5)).toBe('stay')
expect(pickWhisperWord(words, () => 0.999)).toBe('behind you')
})
it('clamps an over-eager rand to the last word', () => {
expect(pickWhisperWord(words, () => 1)).toBe('behind you')
})
it('returns an empty string when there are no words', () => {
expect(pickWhisperWord([], () => 0.5)).toBe('')
})
})
describe('IdleEscalator', () => {
it('stays at level 0 under 30s idle', () => {
const e = new IdleEscalator(1_000)
expect(e.level(1_000)).toBe(0)
expect(e.level(1_000 + 29_999)).toBe(0)
})
it('rises to level 1 between 30s and 90s idle', () => {
const e = new IdleEscalator(0)
expect(e.level(30_000)).toBe(1)
expect(e.level(60_000)).toBe(1)
expect(e.level(89_999)).toBe(1)
})
it('rises to level 2 at 90s idle and beyond', () => {
const e = new IdleEscalator(0)
expect(e.level(90_000)).toBe(2)
expect(e.level(10_000_000)).toBe(2)
})
it('notifyActivity resets the idle clock', () => {
const e = new IdleEscalator(0)
expect(e.level(95_000)).toBe(2)
e.notifyActivity(95_000)
expect(e.level(95_000)).toBe(0)
expect(e.level(95_000 + 31_000)).toBe(1)
expect(e.level(95_000 + 91_000)).toBe(2)
})
})
describe('flickerTitle', () => {
beforeEach(() => {
vi.useFakeTimers()
document.title = 'QUANTUMANCY'
})
afterEach(() => {
vi.useRealTimers()
})
it('leaves the title alone until the first delay elapses', () => {
const stop = flickerTitle({ rand: () => 0 })
vi.advanceTimersByTime(44_999)
expect(document.title).toBe('QUANTUMANCY')
stop()
})
it('swaps to a glitch variant, then restores the original ~2.5s later', () => {
const stop = flickerTitle({ rand: () => 0 })
vi.advanceTimersByTime(45_000)
expect(document.title).toBe(DEFAULT_GLITCH_TITLES[0])
vi.advanceTimersByTime(2_500)
expect(document.title).toBe('QUANTUMANCY')
stop()
})
it('keeps glitching on the jittered cadence', () => {
const stop = flickerTitle({ rand: () => 0.999 })
vi.advanceTimersByTime(90_000)
expect(document.title).not.toBe('QUANTUMANCY')
vi.advanceTimersByTime(2_500)
expect(document.title).toBe('QUANTUMANCY')
vi.advanceTimersByTime(90_000)
expect(document.title).not.toBe('QUANTUMANCY')
stop()
})
it('stop() restores a mid-glitch title and ends the loop', () => {
const stop = flickerTitle({ rand: () => 0 })
vi.advanceTimersByTime(45_000)
expect(document.title).toBe(DEFAULT_GLITCH_TITLES[0])
stop()
expect(document.title).toBe('QUANTUMANCY')
vi.advanceTimersByTime(300_000)
expect(document.title).toBe('QUANTUMANCY')
})
it('accepts an injected scheduler, rand and titles', () => {
const scheduled: Array<{ fn: () => void; ms: number }> = []
const schedule: HauntScheduler = (fn, ms) => {
const entry = { fn, ms }
scheduled.push(entry)
return () => {
const i = scheduled.indexOf(entry)
if (i >= 0) scheduled.splice(i, 1)
}
}
const stop = flickerTitle({ rand: () => 0.5, schedule, titles: ['GLITCHED'] })
expect(scheduled).toHaveLength(1)
expect(scheduled[0].ms).toBe(67_500) // 45s + 0.5 × (90s − 45s)
scheduled.shift()!.fn()
expect(document.title).toBe('GLITCHED')
expect(scheduled).toHaveLength(1)
expect(scheduled[0].ms).toBe(2_500)
scheduled.shift()!.fn()
expect(document.title).toBe('QUANTUMANCY')
expect(scheduled).toHaveLength(1) // the next glitch is already queued
stop()
expect(scheduled).toHaveLength(0) // stop cancels whatever is pending
})
it('re-reads titles from a getter on every glitch', () => {
let pool = ['FIRST']
const stop = flickerTitle({ rand: () => 0, titles: () => pool })
vi.advanceTimersByTime(45_000)
expect(document.title).toBe('FIRST')
vi.advanceTimersByTime(2_500)
pool = ['SECOND']
vi.advanceTimersByTime(45_000)
expect(document.title).toBe('SECOND')
stop()
})
})
describe('HauntBus / hauntBus', () => {
it('delivers typed events to subscribers', () => {
const seen: Array<{ word: string; x: number; y: number }> = []
const off = hauntBus.on('whisper', (d) => seen.push(d))
hauntBus.emit('whisper', { word: 'stay', x: 0.5, y: 0.25 })
expect(seen).toEqual([{ word: 'stay', x: 0.5, y: 0.25 }])
off()
})
it('stops delivering after unsubscribe', () => {
const seen: number[] = []
const off = hauntBus.on('flare', (d) => seen.push(d.strength))
hauntBus.emit('flare', { strength: 0.5 })
off()
hauntBus.emit('flare', { strength: 1 })
expect(seen).toEqual([0.5])
})
it('keeps event types isolated from each other', () => {
const flares: number[] = []
const manifests: number[] = []
const offF = hauntBus.on('flare', (d) => flares.push(d.strength))
const offM = hauntBus.on('manifest', (d) => manifests.push(d.durationMs))
hauntBus.emit('flare', { strength: 0.7 })
expect(flares).toEqual([0.7])
expect(manifests).toEqual([])
offF()
offM()
})
it('supports multiple listeners with individual unsubscribe', () => {
const bus = new HauntBus()
const a: number[] = []
const b: number[] = []
const offA = bus.on('flare', (d) => a.push(d.strength))
bus.on('flare', (d) => b.push(d.strength))
bus.emit('flare', { strength: 0.5 })
offA()
bus.emit('flare', { strength: 0.9 })
expect(a).toEqual([0.5])
expect(b).toEqual([0.5, 0.9])
})
it('emitting with no listeners is a no-op', () => {
const bus = new HauntBus()
expect(() => bus.emit('manifest', { durationMs: 900 })).not.toThrow()
})
})
describe('HauntBed without WebAudio (jsdom)', () => {
it('never throws through its whole lifecycle', () => {
const bed = new HauntBed()
expect(() => {
bed.armOnFirstGesture()
bed.start()
bed.setIntensity(0.5)
bed.setIntensity(1)
bed.stop()
bed.start()
bed.stop()
}).not.toThrow()
})
})

View File

@@ -0,0 +1,479 @@
// The possession engine: framework-free haunting primitives so the browser
// itself feels inhabited. A typed event bus ties the layers together (ghost
// apparition flares, full-screen manifests, fleeting whisper-words); a
// WebAudio dread-bed hums under everything once a gesture allows it; the
// document title glitches when nobody is looking. Everything here is
// jsdom-safe: no AudioContext, no window — no sound, no crash, no complaints.
// ---- event bus ----
export type HauntEventMap = {
/** Ghost apparition flash — strength 0..1. */
flare: { strength: number }
/** Full-screen CRT flicker — how long the veil tears, in ms. */
manifest: { durationMs: number }
/** Fleeting ghost-text at viewport fractions x/y (0..1). */
whisper: { word: string; x: number; y: number }
}
type HauntHandler<T> = (detail: T) => void
/** Tiny typed emitter — one bad listener never silences the others. */
export class HauntBus {
private handlers = new Map<keyof HauntEventMap, Set<HauntHandler<never>>>()
/** Subscribe; returns an unsubscribe function. */
on<K extends keyof HauntEventMap>(type: K, handler: HauntHandler<HauntEventMap[K]>): () => void {
let set = this.handlers.get(type)
if (!set) {
set = new Set()
this.handlers.set(type, set)
}
const h = handler as HauntHandler<never>
set.add(h)
return () => {
set.delete(h)
}
}
/** Dispatch to all current subscribers of `type`. */
emit<K extends keyof HauntEventMap>(type: K, detail: HauntEventMap[K]): void {
const set = this.handlers.get(type)
if (!set) return
// Snapshot so listeners may unsubscribe mid-dispatch.
for (const h of [...set]) (h as HauntHandler<HauntEventMap[K]>)(detail)
}
}
/** The shared bus the haunting layer, ghost scene and board all listen to. */
export const hauntBus = new HauntBus()
// ---- pure helpers ----
/** Pick a whisper word; deterministic given `rand`. Empty list → ''. */
export function pickWhisperWord(words: readonly string[], rand: () => number): string {
if (words.length === 0) return ''
const i = Math.min(words.length - 1, Math.floor(rand() * words.length))
return words[i]
}
/**
* Paces the haunting by how long the seeker has been still:
* 0 under 30s idle, 1 under 90s, 2 beyond.
*/
export class IdleEscalator {
private lastActivity: number
constructor(nowMs: number = Date.now()) {
this.lastActivity = nowMs
}
notifyActivity(nowMs: number = Date.now()): void {
this.lastActivity = nowMs
}
level(nowMs: number = Date.now()): number {
const idle = nowMs - this.lastActivity
if (idle < 30_000) return 0
if (idle < 90_000) return 1
return 2
}
}
// ---- document.title flicker ----
/** Injectable scheduler: run `fn` after `ms`, return a cancel function. */
export type HauntScheduler = (fn: () => void, ms: number) => () => void
const timeoutScheduler: HauntScheduler = (fn, ms) => {
const id = setTimeout(fn, ms)
return () => clearTimeout(id)
}
export const DEFAULT_GLITCH_TITLES: readonly string[] = [
'QUANTUMANCY — it sees you',
'QUANTUM▓NCY — behind you',
'…listening…',
]
export type FlickerTitleOptions = {
rand?: () => number
schedule?: HauntScheduler
/** Glitch variants, or a getter re-read on every glitch (e.g. for i18n). */
titles?: readonly string[] | (() => readonly string[])
doc?: { title: string }
minDelayMs?: number
maxDelayMs?: number
glitchMs?: number
}
/**
* Every 45–90s (jittered) swaps document.title to a glitch variant for
* ~2.5s, then restores whatever was there. Returns a stop function that
* cancels pending work and restores a mid-glitch title.
*/
export function flickerTitle(opts: FlickerTitleOptions = {}): () => void {
const rand = opts.rand ?? Math.random
const schedule = opts.schedule ?? timeoutScheduler
const titles = opts.titles ?? DEFAULT_GLITCH_TITLES
const min = opts.minDelayMs ?? 45_000
const max = opts.maxDelayMs ?? 90_000
const glitchMs = opts.glitchMs ?? 2_500
const doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined)
if (!doc) return () => undefined
let stopped = false
let cancelNext: (() => void) | null = null
let cancelRestore: (() => void) | null = null
let original: string | null = null
const restore = () => {
if (original !== null) {
doc.title = original
original = null
}
}
const scheduleNext = () => {
cancelNext = schedule(() => {
if (stopped) return
const pool = typeof titles === 'function' ? titles() : titles
if (pool.length === 0) {
scheduleNext()
return
}
original = doc.title
doc.title = pool[Math.min(pool.length - 1, Math.floor(rand() * pool.length))]
cancelRestore = schedule(() => {
restore()
if (!stopped) scheduleNext()
}, glitchMs)
}, min + rand() * (max - min))
}
scheduleNext()
return () => {
stopped = true
cancelNext?.()
cancelRestore?.()
restore()
}
}
// ---- WebAudio helpers (all guarded: jsdom has no AudioContext) ----
/** The AudioContext constructor, or null where WebAudio does not exist. */
export function getAudioContextCtor(): typeof AudioContext | null {
if (typeof window === 'undefined') return null
const w = window as unknown as {
AudioContext?: typeof AudioContext
webkitAudioContext?: typeof AudioContext
}
return w.AudioContext ?? w.webkitAudioContext ?? null
}
/**
* Autoplay-policy bridge: runs `onGesture` exactly once on the first
* pointerdown/keydown, then removes itself. Returns a disarm function.
* No WebAudio in this environment → a no-op disposer.
*/
export function armAudioOnFirstGesture(onGesture: () => void): () => void {
if (typeof window === 'undefined' || !getAudioContextCtor()) return () => undefined
let fired = false
const handler = () => {
if (fired) return
fired = true
cleanup()
try {
onGesture()
} catch {
// The haunting must never crash the page it possesses.
}
}
window.addEventListener('pointerdown', handler, { once: true, passive: true })
window.addEventListener('keydown', handler, { once: true })
function cleanup() {
window.removeEventListener('pointerdown', handler)
window.removeEventListener('keydown', handler)
}
return cleanup
}
function makeBrownNoise(ctx: AudioContext, rand: () => number): AudioBuffer {
const len = Math.floor(ctx.sampleRate * 3)
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
const data = buffer.getChannelData(0)
let last = 0
for (let i = 0; i < len; i++) {
const white = rand() * 2 - 1
last = (last + 0.02 * white) / 1.02
data[i] = last * 3.5
}
return buffer
}
/**
* A short filtered noise-tap — the spirit knock. Scheduling on a suspended
* context is harmless (it never sounds), so callers may be lazy.
*/
export function createTick(ctx: AudioContext, when?: number): void {
try {
const at = when ?? ctx.currentTime
const dur = 0.09
const len = Math.max(1, Math.floor(ctx.sampleRate * dur))
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 2
}
const src = ctx.createBufferSource()
src.buffer = buffer
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 1500 + Math.random() * 900
bp.Q.value = 9
const g = ctx.createGain()
g.gain.setValueAtTime(0.4, at)
g.gain.exponentialRampToValueAtTime(0.001, at + dur)
src.connect(bp)
bp.connect(g)
g.connect(ctx.destination)
src.start(at)
src.stop(at + dur + 0.02)
} catch {
// Silence is an acceptable haunting outcome.
}
}
export type HauntBedOptions = {
rand?: () => number
}
/**
* The ambient dread-bed: looped brown noise through a lowpass (300–600Hz)
* with a slow LFO breathing on the gain, a faint detuned dual-sine drone
* (55Hz + 55.7Hz — the 0.7Hz beat you feel more than hear), and occasional
* reversed-swell bursts every 20–40s. Obeys autoplay policy: nothing sounds
* before `armOnFirstGesture()` sees a real gesture. Never throws.
*/
export class HauntBed {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private noiseGain: GainNode | null = null
private noiseBuffer: AudioBuffer | null = null
private noiseSource: AudioBufferSourceNode | null = null
private lfo: OscillatorNode | null = null
private droneOscs: OscillatorNode[] = []
private running = false
private graphBuilt = false
private intensity = 0.2
private swellTimer: ReturnType<typeof setTimeout> | null = null
private disarmGestures: (() => void) | null = null
private readonly rand: () => number
constructor(opts: HauntBedOptions = {}) {
this.rand = opts.rand ?? Math.random
}
/**
* Install one-time first-gesture listeners that create/resume the
* AudioContext. Idempotent; a no-op where WebAudio does not exist.
*/
armOnFirstGesture(): void {
if (this.disarmGestures) return
this.disarmGestures = armAudioOnFirstGesture(() => {
this.ensureContext()
this.wake()
})
}
/** Begin (or mark intent to begin) the bed. Safe to call pre-gesture. */
start(): void {
this.running = true
this.wake()
}
/** Silence the bed, close the context, drop the gesture arm. */
stop(): void {
this.running = false
this.clearSwellTimer()
this.stopGraph()
this.disarmGestures?.()
this.disarmGestures = null
const ctx = this.ctx
this.ctx = null
if (ctx && ctx.state !== 'closed') {
void ctx.close().catch(() => undefined)
}
}
/** 0..1 — raises the noise level and shortens the swell cadence. */
setIntensity(v: number): void {
this.intensity = Math.max(0, Math.min(1, v))
if (this.ctx && this.noiseGain) {
try {
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), this.ctx.currentTime, 0.8)
} catch {
/* ignore */
}
}
}
private noiseLevel(): number {
return 0.05 + this.intensity * 0.16
}
private ensureContext(): void {
if (this.ctx) return
const AC = getAudioContextCtor()
if (!AC) return
try {
this.ctx = new AC()
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
} catch {
this.ctx = null
}
}
private wake(): void {
if (!this.ctx || !this.running || this.graphBuilt) return
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
try {
this.buildGraph()
this.graphBuilt = true
this.scheduleSwell()
} catch {
this.stopGraph()
}
}
private buildGraph(): void {
const ctx = this.ctx
if (!ctx) return
const now = ctx.currentTime
this.master = ctx.createGain()
this.master.gain.value = 0.9
this.master.connect(ctx.destination)
// Brown noise → lowpass (300–600Hz) → breathing gain → master.
this.noiseBuffer = makeBrownNoise(ctx, this.rand)
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
src.loop = true
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 300 + this.rand() * 300
this.noiseGain = ctx.createGain()
this.noiseGain.gain.value = 0
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), now, 2.5)
src.connect(lp)
lp.connect(this.noiseGain)
this.noiseGain.connect(this.master)
src.start()
this.noiseSource = src
// Slow LFO breathing on the noise gain.
this.lfo = ctx.createOscillator()
this.lfo.frequency.value = 0.07
const lfoDepth = ctx.createGain()
lfoDepth.gain.value = 0.035
this.lfo.connect(lfoDepth)
lfoDepth.connect(this.noiseGain.gain)
this.lfo.start()
// Detuned dual-sine drone: 55Hz + 55.7Hz, a slow beating under it all.
for (const freq of [55, 55.7]) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = freq
const g = ctx.createGain()
g.gain.value = 0
g.gain.setTargetAtTime(0.022, now, 3)
osc.connect(g)
g.connect(this.master)
osc.start()
this.droneOscs.push(osc)
}
}
private stopGraph(): void {
try {
this.noiseSource?.stop()
} catch {
/* already stopped */
}
try {
this.lfo?.stop()
} catch {
/* already stopped */
}
for (const osc of this.droneOscs) {
try {
osc.stop()
} catch {
/* already stopped */
}
}
this.droneOscs = []
this.noiseSource = null
this.lfo = null
this.noiseGain = null
this.noiseBuffer = null
try {
this.master?.disconnect()
} catch {
/* ignore */
}
this.master = null
this.graphBuilt = false
}
private scheduleSwell(): void {
this.clearSwellTimer()
if (!this.running || !this.ctx) return
// Every 40s at intensity 0, tightening to 20s at intensity 1, jittered.
const base = 40_000 - this.intensity * 20_000
const delay = base * (0.75 + this.rand() * 0.5)
this.swellTimer = setTimeout(() => {
this.swellTimer = null
this.playSwell()
this.scheduleSwell()
}, delay)
}
private clearSwellTimer(): void {
if (this.swellTimer !== null) {
clearTimeout(this.swellTimer)
this.swellTimer = null
}
}
/** Reversed-swell burst: a slow 1.6s rise cut off almost instantly. */
private playSwell(): void {
const ctx = this.ctx
if (!ctx || !this.master || !this.noiseBuffer || !this.running) return
try {
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 500 + this.rand() * 700
bp.Q.value = 2.5
const g = ctx.createGain()
const now = ctx.currentTime
const peak = 0.05 + this.intensity * 0.12
g.gain.setValueAtTime(0.0001, now)
g.gain.exponentialRampToValueAtTime(peak, now + 1.6)
g.gain.exponentialRampToValueAtTime(0.0001, now + 2.1)
src.connect(bp)
bp.connect(g)
g.connect(this.master)
src.start(now)
src.stop(now + 2.3)
} catch {
/* a swell that never swells */
}
}
}

View File

@@ -29,6 +29,9 @@ export type SweepCallbacks = {
// USB identification for RTL2832U dongles.
export const RTL2832U_VENDOR = 0x0bda
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
// Terratec (and other rebadged) dongles share the RTL2832U guts but report a
// different vendor id — they speak the same protocol once claimed.
export const TERRATEC_VENDOR = 0x0ccd
// Request types used by librtlsdr.
const CTRL_IN = 0xc0
@@ -38,8 +41,14 @@ const USB_EPA = 0x02
const SYS = 0x09
const PAGE_USB = 0x01
/** WebUSB only exists in secure contexts (https, or localhost). */
export function isSecureContext(): boolean {
return typeof window !== 'undefined' && window.isSecureContext === true
}
export function isSupported(): boolean {
return (
isSecureContext() &&
typeof navigator !== 'undefined' &&
'usb' in navigator &&
typeof navigator.usb?.requestDevice === 'function'
@@ -62,12 +71,9 @@ export class RtlSdr {
if (!isSupported()) {
throw new Error('WebUSB is not available in this vessel (Chromium required)')
}
const filters = [
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
]
// Vendor-only filters: many dongles report product ids outside the
// handful we know, so filtering by productId hides them from the picker.
const filters = [{ vendorId: RTL2832U_VENDOR }, { vendorId: TERRATEC_VENDOR }]
this.device = await navigator.usb.requestDevice({ filters })
}
@@ -78,6 +84,17 @@ export class RtlSdr {
this.sampleRate = sampleRateHz
await dev.open()
// WebUSB populates `configuration` (interfaces, endpoints) only after a
// configuration is explicitly selected — without this, most dongles
// report a null configuration and everything downstream fails.
try {
await dev.selectConfiguration(1)
} catch (err) {
// Tolerable only if the device is already configured (some platforms
// refuse to re-select the active configuration).
if (!dev.configuration) throw err
}
// Find the first bulk-IN endpoint.
const iface = dev.configuration?.interfaces[0]
const alt = iface?.alternates[0]

View File

@@ -1,7 +1,7 @@
// Shared protocol + domain types for the Quantumancy frontend.
// Mirrors the backend contract exactly — do not invent changes.
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija'
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija' | 'emf'
export type Language = 'en' | 'es'
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
@@ -68,7 +68,7 @@ export type ClientFrame =
| { type: 'set_mode'; mode: Mode }
| { type: 'language'; language: Language }
| { type: 'summon' }
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'question'; text: string }
| { type: 'passive'; enabled: boolean }
@@ -90,7 +90,7 @@ export type ServerFrame =
| { type: 'status'; state: SessionStatus }
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
| { type: 'anomaly_ack'; count: number }
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
| { type: 'audio'; id: string; url: string }
| { type: 'reply_start' }