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:
479
frontend/src/lib/haunting.ts
Normal file
479
frontend/src/lib/haunting.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user