// 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 } 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 { 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 { // 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 { // 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