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:
104
frontend/src/lib/emf.test.ts
Normal file
104
frontend/src/lib/emf.test.ts
Normal 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
234
frontend/src/lib/emf.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
210
frontend/src/lib/haunting.test.ts
Normal file
210
frontend/src/lib/haunting.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user