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,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 */
}
}
}