Files
qtalker---/frontend/src/lib/sdr.ts
Indiana 825f6aa510 fix: RTL-SDR bulk reads could hang forever with zero user feedback
Real bug report: user selects their RTL-SDR dongle in the WebUSB picker,
"nothing happens" — no error, no sweep, no visible change at all.

Root cause: WebUSB's transferIn/controlTransfer calls have no built-in
timeout. readSamples()'s bulk transferIn (called every sweep step, twice —
once to discard PLL-settle samples, once for real) had nothing bounding
it, so if the dongle doesn't actually stream data for any reason (this
init sequence has never been verified against real hardware), that
promise just never settles — indistinguishable from the page being frozen,
forever, with no way for the UI to ever surface an error.

Added withTimeout(), applied to: the bulk IQ read (4s — the one most
likely to actually hang during sweeping) and the whole open()/init
sequence as one unit (15s, since it's ~20 sequential unverified register
pokes). Also stopped silently falling back to default
interface/endpoint values when the device's USB descriptor doesn't expose
a bulk-IN endpoint as expected — now logs a warning so a real endpoint
mismatch is at least visible in DevTools instead of only surfacing as a
downstream hang.

4 new unit tests for withTimeout(). 306/306 frontend tests pass.
2026-07-25 18:38:59 +00:00

422 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
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<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.
*
* 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<void> {
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<void> {
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<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.
*
* 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<Uint8Array> {
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<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