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
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
// Best-effort WebUSB driver for RTL2832U + R820T("T") based SDR dongles.
|
|
//
|
|
// ⚠️ HARDWARE PASS REQUIRED: this driver is structured from the public
|
|
// librtlsdr register documentation and has NOT been validated against a real
|
|
// device in this environment (no RTL-SDR attached). The control-transfer
|
|
// sequences below follow the known-good init order (demod power-up, R820T
|
|
// tuner init via I2C repeater, sample-rate set, FIR, bulk streaming) but
|
|
// expect to debug register pokes with a logic analyzer / librtlsdr -T.
|
|
// Every entry point fails soft: callers must treat any thrown error as
|
|
// "this vessel cannot hear the radio dead" and degrade gracefully.
|
|
|
|
import { powerSpectrumDb } from './fft'
|
|
|
|
export type RtlSampleBlock = {
|
|
/** Center frequency this block was captured at, Hz. */
|
|
centerHz: number
|
|
/** Sample rate used, Hz. */
|
|
sampleRateHz: number
|
|
/** Interleaved unsigned I/Q bytes, zero-centered: [i,q,i,q…] */
|
|
iq: Uint8Array
|
|
}
|
|
|
|
export type SweepCallbacks = {
|
|
/** Per-tune power spectrum (dB values, fftSize/2 entries). */
|
|
onSpectrum?: (centerHz: number, db: Float64Array) => void
|
|
onError?: (err: Error) => void
|
|
}
|
|
|
|
// 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
|
|
const CTRL_OUT = 0x40
|
|
const DEMOD = 0x03
|
|
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'
|
|
)
|
|
}
|
|
|
|
export class RtlSdr {
|
|
private device: USBDevice | null = null
|
|
private interfaceNumber = 0
|
|
private endpointIn = 0x81
|
|
private running = false
|
|
private sampleRate = 2_048_000
|
|
|
|
get isOpen(): boolean {
|
|
return this.device?.opened ?? false
|
|
}
|
|
|
|
/** Ask the browser for an RTL2832U device. Throws if none chosen/found. */
|
|
async requestDevice(): Promise<void> {
|
|
if (!isSupported()) {
|
|
throw new Error('WebUSB is not available in this vessel (Chromium required)')
|
|
}
|
|
// 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 })
|
|
}
|
|
|
|
/** Open, claim, and run the RTL2832U + R820T init sequence. */
|
|
async open(sampleRateHz = 2_048_000): Promise<void> {
|
|
const dev = this.device
|
|
if (!dev) throw new Error('no device selected')
|
|
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]
|
|
const ep = alt?.endpoints.find((e) => e.direction === 'in' && e.type === 'bulk')
|
|
if (iface && alt && ep) {
|
|
this.interfaceNumber = iface.interfaceNumber
|
|
this.endpointIn = ep.endpointNumber
|
|
}
|
|
|
|
// Detach kernel driver (Linux) — ignore failure: may not be supported.
|
|
await this.claim()
|
|
|
|
// --- Init sequence (HARDWARE PASS REQUIRED) ---
|
|
// Order follows librtlsdr: USB reset → demod init → tuner I2C init.
|
|
await this.demodWrite(1, 0x01, 0x14, 1) // soft reset
|
|
await this.demodWrite(1, 0x01, 0x10, 1)
|
|
await this.demodWrite(0, 0x01, 0x08, 2) // demod_ctl
|
|
await this.demodWrite(0, 0x06, 0x80, 1)
|
|
await this.demodWrite(1, 0x15, 0x00, 1) // suspend off
|
|
await this.demodWrite(1, 0x16, 0x00, 1)
|
|
await this.demodWrite(1, 0x17, 0x00, 1)
|
|
await this.demodWrite(1, 0x18, 0x00, 1)
|
|
await this.demodWrite(1, 0x19, 0x00, 1)
|
|
await this.demodWrite(1, 0x1a, 0x00, 1)
|
|
await this.demodWrite(1, 0x1b, 0x00, 1)
|
|
await this.demodWrite(1, 0x1c, 0x00, 1)
|
|
await this.demodWrite(1, 0x0d, 0x83, 1) // standby off
|
|
await this.demodWrite(1, 0x0b, 0x1b, 1) // AGC mode
|
|
|
|
// Power on tuner through I2C repeater (R820T at 0x1a).
|
|
await this.i2cWrite(0x1a, 0x05, 0x8f) // LNA power on
|
|
await this.i2cWrite(0x1a, 0x08, 0x80) // mixer
|
|
await this.i2cWrite(0x1a, 0x0a, 0x10) // IF filter
|
|
|
|
await this.setSampleRate(this.sampleRate)
|
|
await this.setFrequency(98_000_000)
|
|
|
|
// Reset endpoint before streaming.
|
|
await this.writeReg(USB_EPA, 0x0001, 0xffff, 2)
|
|
await this.demodWrite(1, 0x02, 0x00, 1)
|
|
await this.demodWrite(0, 0x02, 0x40, 2)
|
|
await this.demodWrite(1, 0x02, 0x00, 1) // enable test mode off
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.running = false
|
|
const dev = this.device
|
|
if (dev?.opened) {
|
|
try {
|
|
await this.demodWrite(1, 0x01, 0x10, 1) // suspend
|
|
} catch {
|
|
/* device may already be gone */
|
|
}
|
|
await dev.releaseInterface(this.interfaceNumber).catch(() => undefined)
|
|
await dev.close().catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
/** Tune the R820T mixer PLL. Hz. (HARDWARE PASS REQUIRED) */
|
|
async setFrequency(hz: number): Promise<void> {
|
|
// R820T fractional-N PLL programming via I2C. Simplified to the
|
|
// integer part + common divider ratio; real librtlsdr computes the
|
|
// exact sdm/vco from a 28.8MHz crystal reference. Marked for hardware.
|
|
const loHz = hz + 3_570_000 // R820T IF offset
|
|
const ref = 28_800_000
|
|
const mixDiv = 2
|
|
const nint = Math.floor(loHz / (ref * mixDiv))
|
|
const vco = loHz % (ref * mixDiv)
|
|
const sdm = Math.min(0xffff, Math.floor((vco * 65536) / (ref * mixDiv)))
|
|
const reg = nint & 0x3f
|
|
await this.i2cWrite(0x1a, 0x10, reg)
|
|
await this.i2cWrite(0x1a, 0x11, (sdm >> 8) & 0xff)
|
|
await this.i2cWrite(0x1a, 0x12, sdm & 0xff)
|
|
}
|
|
|
|
/** Program demod resampling rate for the requested sample rate. */
|
|
async setSampleRate(hz: number): Promise<void> {
|
|
const crystal = 28_800_000
|
|
const rsampRatio = Math.floor(((crystal * 2 ** 22) / hz) & 0x0ffffffc)
|
|
await this.demodWrite(1, 0x9f, (rsampRatio >> 16) & 0xffff, 2)
|
|
await this.demodWrite(1, 0xa1, rsampRatio & 0xffff, 2)
|
|
this.sampleRate = hz
|
|
}
|
|
|
|
/** Read one block of I/Q samples. Length must be multiple of 512. */
|
|
async readSamples(bytes: number): Promise<Uint8Array> {
|
|
const dev = this.device
|
|
if (!dev?.opened) throw new Error('device not open')
|
|
const res = await dev.transferIn(this.endpointIn, bytes)
|
|
if (!res.data) throw new Error('bulk read failed')
|
|
return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength)
|
|
}
|
|
|
|
/**
|
|
* Continuous sweep across [startHz, endHz] in `stepHz` steps, emitting a
|
|
* power spectrum per tuning step. Runs until `stopSweep()`.
|
|
*/
|
|
async sweep(
|
|
startHz: number,
|
|
endHz: number,
|
|
stepHz: number,
|
|
cb: SweepCallbacks,
|
|
fftSize = 512,
|
|
settleMs = 25,
|
|
): Promise<void> {
|
|
if (this.running) return
|
|
this.running = true
|
|
let hz = startHz
|
|
const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq
|
|
try {
|
|
while (this.running) {
|
|
await this.setFrequency(hz)
|
|
await new Promise((r) => setTimeout(r, settleMs))
|
|
try {
|
|
await this.readSamples(16384) // discard: PLL settle
|
|
const iq = await this.readSamples(readBytes)
|
|
const f64 = new Float64Array(iq.length)
|
|
for (let i = 0; i < iq.length; i++) f64[i] = (iq[i] - 127.5) / 128
|
|
const db = powerSpectrumDb(f64)
|
|
cb.onSpectrum?.(hz, db)
|
|
} catch (err) {
|
|
cb.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
}
|
|
hz += stepHz
|
|
if (hz > endHz) hz = startHz
|
|
if (!this.running) break
|
|
}
|
|
} finally {
|
|
this.running = false
|
|
}
|
|
}
|
|
|
|
stopSweep(): void {
|
|
this.running = false
|
|
}
|
|
|
|
// ---- Low-level USB helpers (private; HARDWARE PASS REQUIRED) ----
|
|
|
|
private async claim(): Promise<void> {
|
|
const dev = this.device
|
|
if (!dev) throw new Error('no device')
|
|
try {
|
|
// Best-effort kernel driver detach; unsupported on some platforms.
|
|
const anyDev = dev as unknown as {
|
|
claimInterface(n: number): Promise<void>
|
|
}
|
|
await anyDev.claimInterface(this.interfaceNumber)
|
|
} catch (err) {
|
|
throw new Error(
|
|
`could not claim the radio dead (interface busy?) — ${String(err)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
private async writeReg(
|
|
block: number,
|
|
address: number,
|
|
value: number,
|
|
length: number,
|
|
): Promise<void> {
|
|
const dev = this.device
|
|
if (!dev) throw new Error('no device')
|
|
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
|
await dev.controlTransferOut({
|
|
requestType: 'vendor',
|
|
recipient: 'device',
|
|
request: 0,
|
|
value: (block << 8) | 0x10,
|
|
index: address,
|
|
}, data.subarray(0, length))
|
|
}
|
|
|
|
private async demodWrite(
|
|
page: number,
|
|
address: number,
|
|
value: number,
|
|
length: number,
|
|
): Promise<void> {
|
|
// Demod registers are paged: index = (page << 8) | address.
|
|
const dev = this.device
|
|
if (!dev) throw new Error('no device')
|
|
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
|
await dev.controlTransferOut({
|
|
requestType: 'vendor',
|
|
recipient: 'device',
|
|
request: 0,
|
|
value: (DEMOD << 8) | 0x10,
|
|
index: (page << 8) | address,
|
|
}, data.subarray(0, length))
|
|
}
|
|
|
|
private async i2cWrite(i2cAddr: number, reg: number, value: number): Promise<void> {
|
|
// I2C repeater: librtlsdr writes tuner registers by tunneling through
|
|
// the demod's I2C master. Simplified single-byte write.
|
|
const dev = this.device
|
|
if (!dev) throw new Error('no device')
|
|
await this.demodWrite(1, 0x02, 0x41, 1) // repeater on
|
|
await dev.controlTransferOut({
|
|
requestType: 'vendor',
|
|
recipient: 'device',
|
|
request: 0,
|
|
value: (0x02 << 8) | 0x10,
|
|
index: (i2cAddr << 8) | reg,
|
|
}, new Uint8Array([value & 0xff]))
|
|
await this.demodWrite(1, 0x02, 0x01, 1) // repeater off
|
|
}
|
|
|
|
// Silence unused-warnings for constants kept for the hardware pass.
|
|
private static readonly _refs = { CTRL_IN, CTRL_OUT, SYS, PAGE_USB }
|
|
}
|
|
|
|
/** Rolling noise-floor + spike detector over sweep spectra (pure, testable). */
|
|
export class SpectrumAnomalyDetector {
|
|
private floor: Float64Array | null = null
|
|
private lastEmit = -Infinity
|
|
constructor(
|
|
private readonly thresholdDb = 10,
|
|
private readonly throttleMs = 2000,
|
|
private readonly alpha = 0.1,
|
|
) {}
|
|
|
|
/** Returns (peakHz, magnitudeDbOverFloor) when a spike fires, else null. */
|
|
process(
|
|
centerHz: number,
|
|
sampleRateHz: number,
|
|
db: Float64Array,
|
|
nowMs: number,
|
|
): { frequency: number; magnitude: number } | null {
|
|
if (!this.floor || this.floor.length !== db.length) {
|
|
this.floor = Float64Array.from(db)
|
|
return null
|
|
}
|
|
let peak = 0
|
|
let peakBin = -1
|
|
for (let i = 0; i < db.length; i++) {
|
|
const dev = db[i] - this.floor[i]
|
|
if (dev > peak) {
|
|
peak = dev
|
|
peakBin = i
|
|
}
|
|
this.floor[i] += this.alpha * (db[i] - this.floor[i])
|
|
}
|
|
if (peakBin >= 0 && peak >= this.thresholdDb && nowMs - this.lastEmit >= this.throttleMs) {
|
|
this.lastEmit = nowMs
|
|
const binHz = sampleRateHz / 2 / db.length
|
|
const offset = (peakBin - db.length / 2) * binHz
|
|
return { frequency: (centerHz + offset) / 1e6, magnitude: peak }
|
|
}
|
|
return null
|
|
}
|
|
|
|
reset(): void {
|
|
this.floor = null
|
|
this.lastEmit = -Infinity
|
|
}
|
|
}
|
|
|
|
export const SWEEP_START_MHZ = 88
|
|
export const SWEEP_END_MHZ = 108
|