Files
qtalker---/frontend/src/lib/sdr.ts
Indiana 0756e677b9 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
2026-07-21 02:28:14 +00:00

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