// 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's transferIn/controlTransfer calls have no built-in timeout — if a * dongle doesn't respond as expected (wrong endpoint, a mis-poked register * during the unverified init sequence, anything), the returned promise just * never settles. Without this, that hang is silent and indistinguishable * from "nothing happened" — no error, no UI change, forever. Every * USB call below that could plausibly stall on real (mis-)behaving * hardware is wrapped in this. */ export function withTimeout(promise: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error(`${label} timed out after ${ms}ms — the dongle went quiet`)), ms, ) promise.then( (v) => { clearTimeout(timer) resolve(v) }, (err) => { clearTimeout(timer) reject(err) }, ) }) } /** 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. * * The whole sequence is time-boxed: it's ~20 sequential raw USB control * transfers against an unverified register-poke sequence on real * hardware, and any one of them stalling (device confused, wrong * endpoint, anything) would otherwise hang this promise forever with no * way for a caller to ever know — see withTimeout's comment. */ async open(sampleRateHz = 2_048_000): Promise { const dev = this.device if (!dev) throw new Error('no device selected') await withTimeout(this._openSequence(dev, sampleRateHz), 15_000, 'RTL-SDR open/init sequence') } private async _openSequence(dev: USBDevice, sampleRateHz: number): Promise { 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 } else { // Silently falling back to the standard interface-0/endpoint-0x81 // defaults used to mean a real endpoint mismatch could go completely // unnoticed until the bulk read hangs during sweeping — surface it // immediately instead, even though the defaults are the correct // values for a standard RTL2832U and may well still work. console.warn( '[sdr] could not read this device’s USB descriptor for its bulk-IN endpoint; ' + `falling back to the standard interface ${this.interfaceNumber} / ` + `endpoint 0x${this.endpointIn.toString(16)}. If sweeping never produces data, ` + 'this device likely uses a non-standard endpoint layout.', ) } // 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. * * This is the single most important place in this file to time-box: a * bulk `transferIn` has no built-in timeout at all, and if the dongle * isn't actually streaming (wrong endpoint, tuner not really locked * despite the init sequence "succeeding", anything), this call is where * the whole sweep silently hangs forever — no error, no data, no visible * change on the page. That exact symptom is why this wrapper exists. */ async readSamples(bytes: number): Promise { const dev = this.device if (!dev?.opened) throw new Error('device not open') const res = await withTimeout( dev.transferIn(this.endpointIn, bytes), 4_000, 'bulk IQ read', ) 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