From da675bd198040cb150ff360e613c88123fd3eb88 Mon Sep 17 00:00:00 2001 From: Indiana Date: Sat, 1 Aug 2026 02:46:56 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20port=20the=20real=20librtlsdr=20init=20?= =?UTF-8?q?=E2=80=94=20the=20RTL-SDR=20never=20had=20a=20chance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported symptom: the dongle connects through the browser fine and then produces nothing. The driver was a sketch written from memory and marked HARDWARE PASS REQUIRED, and that caveat never reached the UI. The decisive bug was not the tuner at all: `demodWrite` had every field of its control transfer wrong. librtlsdr uses value=(addr<<8)|0x20, index=0x10|page, big-endian; this used the block in value, page and address transposed in index, and little-endian. Every demod register write went to the wrong place. The block constants were wrong too (DEMOD=0 USB=1 SYS=2, not 0x03/0x02/0x09), as was the I2C repeater control (page 1 reg 0x01, 0x18/0x10 — not reg 0x02, 0x41/0x01). Then the tuner, as originally suspected: the R820T's full 27-register init (0x05-0x1f) replaces three pokes, with a shadow array since those registers are write-only; IF filter calibration; a real r82xx_set_pll with VCO band scan, nint+SDM into 0x14/0x16/0x17, and a lock poll that names the frequency that failed instead of streaming silence. Gain defaults to tuner AGC. The buffer reset is 0x1002 -> 0x0000, not 0xffff. Two more that would each have been fatal alone: the demod was left in zero-IF mode though the R820T delivers a 3.57MHz IF, so a locked PLL would still have been off-centre; and setSampleRate masked with JS's 32-bit `&` on a ~1.2e14 value, mangling the ratio below ~1.15Msps. Tuner detected by chip id, throwing "unsupported tuner: " rather than running R82xx sequences against foreign silicon. The arithmetic is pure and tested against hand-derived vectors, including 107.9MHz where mix_div drops 32->16 — a boundary the old fixed mixDiv=2 could never have reached. Independently re-derived 88.5MHz (0x26/0x66) and 98MHz (0x6d/0x82) and they match exactly. HONEST LIMIT: none of this has touched hardware. The arithmetic is proven; the register pokes and transfer encodings are reasoned, not observed. Several constants are marked UNCONFIRMED in the file, chiefly the tracking filter table and the SDM register pair. Co-Authored-By: Claude Opus 5 --- frontend/src/lib/sdr.test.ts | 488 +++++++++++++++ frontend/src/lib/sdr.ts | 1142 +++++++++++++++++++++++++++++----- 2 files changed, 1481 insertions(+), 149 deletions(-) diff --git a/frontend/src/lib/sdr.test.ts b/frontend/src/lib/sdr.test.ts index 0ce8a30..f9e2422 100644 --- a/frontend/src/lib/sdr.test.ts +++ b/frontend/src/lib/sdr.test.ts @@ -207,3 +207,491 @@ describe('withTimeout', () => { } }) }) + +// --------------------------------------------------------------------------- +// librtlsdr arithmetic ports +// +// HOW THE EXPECTED VALUES BELOW WERE DERIVED +// ------------------------------------------ +// None of these are "whatever the code printed". Each was produced by +// independently re-executing the librtlsdr C algorithm (a separate scratch +// transcription of r82xx_set_pll / rtlsdr_set_sample_rate / r82xx_set_gain, +// in Python, working purely from the C control flow) and, for the headline +// FM-band cases, re-checked by hand from first principles as shown in the +// per-test comments. If the TS port and the hand derivation disagree, the +// test fails — which is the whole point. +// --------------------------------------------------------------------------- + +import { + computeIfFreqRegisters, + computePllRegisters, + computeR82xxGainIndices, + computeResampRatio, + bitrev, + identifyTuner, + packFir, + selectMuxRange, + R82XX_IF_FREQ, + R82XX_INIT_REGS, + R82XX_LNA_GAIN_STEPS, + R82XX_MIXER_GAIN_STEPS, + RTL_XTAL_HZ, +} from './sdr' + +const LO = (centreHz: number) => centreHz + R82XX_IF_FREQ + +describe('computePllRegisters (librtlsdr r82xx_set_pll)', () => { + // 88.5 MHz — bottom of the FM broadcast band. + // + // Hand derivation: + // LO = 88_500_000 + 3_570_000 = 92_070_000 Hz -> freq_khz = 92_070 + // mix_div : 92_070*16 = 1_473_120 kHz (< 1_770_000, too low) + // 92_070*32 = 2_946_240 kHz (in [1.77e6, 3.54e6) GHz-band) -> 32 + // div_num = log2(32) - 1 = 4 (vco_fine_tune == vco_power_ref, no trim) + // vco_freq = 92_070_000 * 32 = 2_946_240_000 Hz + // nint = floor(2_946_240_000 / 57_600_000) = 51 + // vco_fra = (2_946_240_000 - 51*57_600_000)/1000 = (2_946_240_000 + // - 2_937_600_000)/1000 = 8_640 kHz + // ni = floor((51-13)/4) = 9 ; si = 51 - 36 - 13 = 2 + // reg 0x14 = ni + (si<<6) = 9 + 128 = 137 = 0x89 + // sdm : successive approximation over vco_fra with steps + // 2*28800/n_sdm: n_sdm=2 -> step 28800 (8640 !> 28800, skip) + // n_sdm=4 -> 14400 (skip); 8 -> 7200 (8640 > 7200: + // sdm += 32768/4 = 8192, vco_fra = 1440); 16 -> 3600 (skip); + // 32 -> 1800 (skip); 64 -> 900 (1440 > 900: sdm += 32768/32 + // = 1024 -> 9216, vco_fra = 540); 128 -> 450 (540 > 450: + // sdm += 32768/64 = 512 -> 9728, vco_fra = 90); + // 256 -> 225 (skip); 512 -> 112 (skip); 1024 -> 56 + // (90 > 56: sdm += 32768/512 = 64 -> 9792, vco_fra = 34); + // 2048 -> 28 (34 > 28: sdm += 32 -> 9824, vco_fra = 6); + // 4096 -> 14 (skip); 8192 -> 7 (skip); 16384 -> 3 + // (6 > 3: sdm += 4 -> 9828, vco_fra = 3); 32768 -> 1 + // (3 > 1: sdm += 2 -> 9830, vco_fra = 2, n_sdm >= 0x8000 so + // the loop breaks). sdm = 9830 = 0x2666. + it('programs 88.5 MHz exactly as the C algorithm does', () => { + const p = computePllRegisters(LO(88_500_000)) + expect(p.mixDiv).toBe(32) + expect(p.divNum).toBe(4) + expect(p.nint).toBe(51) + expect(p.ni).toBe(9) + expect(p.si).toBe(2) + expect(p.reg14).toBe(0x89) + expect(p.sdm).toBe(9830) + expect(p.reg16).toBe(0x26) + expect(p.reg17).toBe(0x66) + expect(p.pwSdm).toBe(0x00) // fractional, so the sigma-delta stays powered + }) + + // 98 MHz — the driver's default tune. + // LO = 101_570_000 -> freq_khz = 101_570 + // 101_570*16 = 1_625_120 (too low); *32 = 3_250_240 -> mix_div 32, div_num 4 + // vco_freq = 3_250_240_000 ; nint = floor(/57_600_000) = 56 + // vco_fra = (3_250_240_000 - 56*57_600_000)/1000 + // = (3_250_240_000 - 3_225_600_000)/1000 = 24_640 kHz + // ni = floor((56-13)/4) = 10 ; si = 56 - 40 - 13 = 3 + // reg 0x14 = 10 + (3<<6) = 10 + 192 = 202 = 0xca + // sdm (same successive approximation) = 28034 = 0x6d82 + it('programs 98 MHz exactly as the C algorithm does', () => { + const p = computePllRegisters(LO(98_000_000)) + expect(p.mixDiv).toBe(32) + expect(p.divNum).toBe(4) + expect(p.nint).toBe(56) + expect(p.reg14).toBe(0xca) + expect(p.sdm).toBe(28034) + expect(p.reg16).toBe(0x6d) + expect(p.reg17).toBe(0x82) + }) + + // 107.9 MHz — top of the FM band, and the first frequency in the sweep + // range where mix_div drops from 32 to 16. That boundary is exactly the + // sort of thing the old "mixDiv = 2, always" code got wrong. + // LO = 111_470_000 -> freq_khz = 111_470 + // 111_470*16 = 1_783_520 (>= 1_770_000 and < 3_540_000) -> mix_div 16 + // div_num = log2(16)-1 = 3 + // vco_freq = 1_783_520_000 ; nint = floor(/57_600_000) = 30 + // vco_fra = (1_783_520_000 - 30*57_600_000)/1000 + // = (1_783_520_000 - 1_728_000_000)/1000 = 55_520 kHz + // ni = floor((30-13)/4) = 4 ; si = 30 - 16 - 13 = 1 + // reg 0x14 = 4 + (1<<6) = 68 = 0x44 + // sdm = 63170 = 0xf6c2 + it('programs 107.9 MHz exactly as the C algorithm does (mix_div drops to 16)', () => { + const p = computePllRegisters(LO(107_900_000)) + expect(p.mixDiv).toBe(16) + expect(p.divNum).toBe(3) + expect(p.nint).toBe(30) + expect(p.reg14).toBe(0x44) + expect(p.sdm).toBe(63170) + expect(p.reg16).toBe(0xf6) + expect(p.reg17).toBe(0xc2) + }) + + // 433.92 MHz ISM and 1090 MHz ADS-B: two more independently-derived points + // well outside the FM band, to pin the mix_div = 8 and mix_div = 2 branches. + // 433.92: LO 437_490_000; *8 = 3_499_920 kHz in band -> mix_div 8, div_num 2 + // vco_freq 3_499_920_000; nint 60; ni 11; si 3; reg14 = 11+192 = 203 + // sdm 49970 = 0xc332 + it('programs 433.92 MHz (mix_div 8)', () => { + const p = computePllRegisters(LO(433_920_000)) + expect(p).toMatchObject({ mixDiv: 8, divNum: 2, nint: 60, reg14: 203, sdm: 49970 }) + }) + // 1090: LO 1_093_570_000; *2 = 2_187_140 kHz in band -> mix_div 2, div_num 0 + // vco_freq 2_187_140_000; nint 37; ni 6; si 0; reg14 = 6 + // sdm 63646 = 0xf89e + it('programs 1090 MHz (mix_div 2)', () => { + const p = computePllRegisters(LO(1_090_000_000)) + expect(p).toMatchObject({ mixDiv: 2, divNum: 0, nint: 37, reg14: 6, sdm: 63646 }) + }) + + it('powers down the sigma-delta on an exactly integer-N frequency', () => { + // Pick an LO where vco_freq is an exact multiple of 2*xtal = 57.6 MHz: + // nint = 40 with mix_div 32 -> vco_freq = 2_304_000_000 -> LO = 72_000_000 + // (72_000 kHz * 32 = 2_304_000 kHz, inside the VCO window). + const p = computePllRegisters(72_000_000) + expect(p.mixDiv).toBe(32) + expect(p.nint).toBe(40) + expect(p.sdm).toBe(0) + expect(p.pwSdm).toBe(0x08) + }) + + it('applies the VCO fine-tune trim to the divider select, both directions', () => { + const base = computePllRegisters(LO(98_000_000), { vcoFineTune: 2, vcoPowerRef: 2 }) + const high = computePllRegisters(LO(98_000_000), { vcoFineTune: 3, vcoPowerRef: 2 }) + const low = computePllRegisters(LO(98_000_000), { vcoFineTune: 1, vcoPowerRef: 2 }) + expect(base.divNum).toBe(4) + expect(high.divNum).toBe(3) // fine_tune > power_ref -> div_num - 1 + expect(low.divNum).toBe(5) // fine_tune < power_ref -> div_num + 1 + // The trim only moves the register field, never the actual divide ratio. + expect(high.nint).toBe(base.nint) + expect(high.sdm).toBe(base.sdm) + }) + + it('names the offending frequency when no mixer divider fits', () => { + // 3 GHz: even mix_div 2 puts the VCO at 6 GHz, past the 3.54 GHz ceiling. + expect(() => computePllRegisters(3_000_000_000)).toThrow(/3000\.000 MHz is outside/) + // 20 MHz: even mix_div 32 only reaches 640 MHz, under the 1.77 GHz floor. + expect(() => computePllRegisters(20_000_000)).toThrow(/outside the R820T tuning range/) + }) + + it('rejects nonsense frequencies rather than emitting garbage registers', () => { + expect(() => computePllRegisters(0)).toThrow(/invalid LO frequency/) + expect(() => computePllRegisters(-1)).toThrow(/invalid LO frequency/) + expect(() => computePllRegisters(NaN)).toThrow(/invalid LO frequency/) + }) + + it('keeps every emitted register inside its byte/field width across the FM sweep', () => { + for (let mhz = 88; mhz <= 108; mhz += 0.1) { + const p = computePllRegisters(LO(Math.round(mhz * 1e6))) + expect(p.reg14).toBeGreaterThanOrEqual(0) + expect(p.reg14).toBeLessThanOrEqual(0xff) + expect(p.reg16).toBeLessThanOrEqual(0xff) + expect(p.reg17).toBeLessThanOrEqual(0xff) + expect(p.sdm).toBeLessThanOrEqual(0xffff) + expect(p.divNum).toBeGreaterThanOrEqual(0) + expect(p.divNum).toBeLessThanOrEqual(7) // fits reg 0x10 bits 7:5 + expect(p.nint).toBeLessThanOrEqual(63) // vco_power_ref 2 -> 128/2 - 1 + } + }) + + it('reconstructs the requested LO from its own registers to within a few hundred Hz', () => { + // Independent check that the register triple actually *means* the right + // frequency: LO = (2*xtal*(nint + sdm/65536)) / mix_div. + for (const centre of [88_500_000, 98_000_000, 107_900_000, 433_920_000]) { + const lo = LO(centre) + const p = computePllRegisters(lo) + const reconstructed = ((2 * RTL_XTAL_HZ) * (p.nint + p.sdm / 65536)) / p.mixDiv + // The SDM quantises to 2*xtal/65536/mix_div; at mix_div 2 that is ~440 Hz. + expect(Math.abs(reconstructed - lo)).toBeLessThan(500) + } + }) + + it('honours the R828D vco_power_ref of 1 in the nint limit', () => { + // With vco_power_ref 1 the nint ceiling is 127 instead of 63, so a very + // high nint that the R820T rejects is legal on the R828D. + const opts = { vcoPowerRef: 1, vcoFineTune: 1 } + expect(() => computePllRegisters(LO(1_090_000_000), opts)).not.toThrow() + }) +}) + +describe('computeResampRatio (librtlsdr rtlsdr_set_sample_rate)', () => { + // Hand derivation for 2.048 Msps: + // raw = floor(28_800_000 * 2^22 / 2_048_000) + // = floor(120_795_955_200_000 / 2_048_000) = 58_982_400 + // 58_982_400 = 0x3840000, already 4-aligned and under 2^28 -> unchanged + // reg 0x9f = 0x3840000 >> 16 = 0x0384 ; reg 0xa1 = 0x0000 + // actual = 28_800_000 * 2^22 / 58_982_400 = 2_048_000 exactly + it('computes the 2.048 Msps ratio and its register split', () => { + const r = computeResampRatio(2_048_000) + expect(r.ratio).toBe(58_982_400) + expect(r.reg9f).toBe(0x0384) + expect(r.regA1).toBe(0x0000) + expect(r.actualRateHz).toBe(2_048_000) + }) + + // 2.4 Msps: raw = 120_795_955_200_000 / 2_400_000 = 50_331_648 = 0x3000000 + it('computes the 2.4 Msps ratio', () => { + const r = computeResampRatio(2_400_000) + expect(r.ratio).toBe(0x3000000) + expect(r.reg9f).toBe(0x0300) + expect(r.regA1).toBe(0x0000) + expect(r.actualRateHz).toBe(2_400_000) + }) + + // 1.024 Msps: raw = 117_964_800 = 0x7080000. + // This one is the regression guard for the old `& 0x0ffffffc` bug: the + // value is above 2^26 but the previous code's 32-bit `&` also mangled + // anything above 2^31, e.g. the 250 ksps case below. + it('computes the 1.024 Msps ratio', () => { + const r = computeResampRatio(1_024_000) + expect(r.ratio).toBe(0x7080000) + expect(r.reg9f).toBe(0x0708) + expect(r.actualRateHz).toBe(1_024_000) + }) + + // 250 ksps: raw = 120_795_955_200_000 / 250_000 = 483_183_820.8 -> + // floor 483_183_820. That is > 2^28, so the C code's 28-bit mask genuinely + // truncates: 483_183_820 mod 2^28 = 214_748_364 = 0xCCCCCCC (4-aligned). + // The resulting real rate is 562_500 Hz, not 250 k — which is exactly why + // librtlsdr warns when the exact rate is unattainable. The point of the + // test is that the TS port reproduces the C truncation rather than + // producing a >32-bit JS number or a sign-flipped one. + it('reproduces the C 28-bit truncation for a low sample rate', () => { + const r = computeResampRatio(250_000) + expect(r.ratio).toBe(214_748_364) + expect(r.reg9f).toBe(0x0ccc) + expect(r.regA1).toBe(0xcccc) + expect(r.actualRateHz).toBe(562_500) + }) + + it('always emits a 4-aligned ratio that fits 28 bits', () => { + for (const rate of [225_001, 250_000, 300_000, 900_001, 1_024_000, 2_048_000, 2_400_000, 3_200_000]) { + const r = computeResampRatio(rate) + expect(r.ratio % 4).toBe(0) + expect(r.ratio).toBeLessThan(2 ** 28) + expect(r.ratio).toBeGreaterThan(0) + expect((r.reg9f << 16) | r.regA1).toBe(r.ratio) + } + }) + + it('rejects the RTL2832U’s unsupported sample-rate gaps by name', () => { + for (const bad of [0, 100_000, 225_000, 500_000, 900_000, 3_200_001]) { + expect(() => computeResampRatio(bad)).toThrow(/out of the RTL2832U's supported range/) + } + }) +}) + +describe('computeIfFreqRegisters (librtlsdr rtlsdr_set_if_freq)', () => { + // Hand derivation for the R820T's 3.57 MHz IF: + // 3_570_000 * 2^22 = 3_570_000 * 4_194_304 = 14_973_665_280_000 + // tmp = floor(14_973_665_280_000 / 28_800_000) = floor(519_918.933…) + // = 519_918 = 0x0007EEEE + // librtlsdr negates that and lets it wrap as a uint32: + // 2^32 - 519_918 = 4_294_447_378 = 0xFFF81112 + // reg 0x19 = (0xFFF81112 >> 16) & 0x3f = 0xFFF8 & 0x3f = 0x38 + // reg 0x1a = (0xFFF81112 >> 8) & 0xff = 0x11 + // reg 0x1b = 0xFFF81112 & 0xff = 0x12 + it('computes the 3.57 MHz IF shift registers', () => { + expect(computeIfFreqRegisters(R82XX_IF_FREQ)).toEqual({ reg19: 0x38, reg1a: 0x11, reg1b: 0x12 }) + }) + + it('emits a zero shift for a zero IF', () => { + expect(computeIfFreqRegisters(0)).toEqual({ reg19: 0, reg1a: 0, reg1b: 0 }) + }) + + it('keeps every field inside its width', () => { + for (const hz of [1_000_000, 3_570_000, 4_570_000, 5_000_000]) { + const r = computeIfFreqRegisters(hz) + expect(r.reg19).toBeLessThanOrEqual(0x3f) + expect(r.reg1a).toBeLessThanOrEqual(0xff) + expect(r.reg1b).toBeLessThanOrEqual(0xff) + } + }) +}) + +describe('computeR82xxGainIndices (librtlsdr r82xx_set_gain)', () => { + // The C loop alternates LNA then mixer steps, accumulating tenths of a dB + // from r82xx_lna_gain_steps / r82xx_mixer_gain_steps, and stops as soon as + // the accumulated total reaches the request. + // + // Hand derivation for 19.7 dB (gain = 197): + // lna 1 (+9) = 9 | mix 1 (+5) = 14 + // lna 2 (+13) = 27 | mix 2 (+10) = 37 + // lna 3 (+40) = 77 | mix 3 (+10) = 87 + // lna 4 (+38) = 125 | mix 4 (+19) = 144 + // lna 5 (+13) = 157 | mix 5 (+9) = 166 + // lna 6 (+31) = 197 -> 197 >= 197, break with lna_index 6, mix_index 5 + it('reaches 19.7 dB at LNA index 6 / mixer index 5', () => { + expect(computeR82xxGainIndices(197)).toEqual({ + lnaIndex: 6, + mixerIndex: 5, + totalGainTenthDb: 197, + }) + }) + + // Hand derivation for 29.7 dB (gain = 297), continuing the table above: + // ... 197 (lna6) | mix 6 (+10) = 207 | lna 7 (+22) = 229 + // mix 7 (+25) = 254 | lna 8 (+26) = 280 | mix 8 (+17) = 297 -> break + // with lna_index 8, mix_index 8. + it('reaches 29.7 dB at LNA index 8 / mixer index 8', () => { + expect(computeR82xxGainIndices(297)).toEqual({ + lnaIndex: 8, + mixerIndex: 8, + totalGainTenthDb: 297, + }) + }) + + it('returns the zero indices for a zero-gain request', () => { + expect(computeR82xxGainIndices(0)).toEqual({ lnaIndex: 0, mixerIndex: 0, totalGainTenthDb: 0 }) + }) + + it('saturates at the top of both tables instead of running off the end', () => { + // Sum of LNA steps 1..15 = 335, mixer steps 1..15 = 153 -> 488 (48.8 dB) + // when every step is taken. Note the final mixer step is *negative* (-8), + // so 48.8 dB is not actually the peak — see the monotonicity test below. + const lnaSum = R82XX_LNA_GAIN_STEPS.slice(1).reduce((a, b) => a + b, 0) + const mixSum = R82XX_MIXER_GAIN_STEPS.slice(1).reduce((a, b) => a + b, 0) + expect(lnaSum).toBe(335) + expect(mixSum).toBe(153) + + const g = computeR82xxGainIndices(10_000) + expect(g).toEqual({ lnaIndex: 15, mixerIndex: 15, totalGainTenthDb: 488 }) + }) + + it('never emits an index outside the 4-bit register field, over the whole range', () => { + for (let g = 0; g <= 600; g += 1) { + const r = computeR82xxGainIndices(g) + expect(r.lnaIndex).toBeGreaterThanOrEqual(0) + expect(r.lnaIndex).toBeLessThanOrEqual(0x0f) + expect(r.mixerIndex).toBeGreaterThanOrEqual(0) + expect(r.mixerIndex).toBeLessThanOrEqual(0x0f) + } + }) + + // This one documents a genuine quirk of the C algorithm rather than a bug + // in the port. The last mixer gain step in r82xx_mixer_gain_steps is -8 + // (i.e. -0.8 dB), so a request beyond what the tables can satisfy walks one + // step *past* the peak: 49.6 dB is reachable (LNA 15 / mixer 14) but asking + // for anything above it lands on 48.8 dB (LNA 15 / mixer 15). librtlsdr + // behaves the same way; the test pins the boundary so a future "fix" that + // silently clamps differently shows up. + it('is monotonic up to the reachable peak, then dips by the final -0.8 dB mixer step', () => { + let prev = -1 + let peak = -1 + for (let g = 0; g <= 496; g += 1) { + const t = computeR82xxGainIndices(g).totalGainTenthDb + expect(t).toBeGreaterThanOrEqual(prev) + prev = t + peak = Math.max(peak, t) + } + expect(peak).toBe(496) + expect(computeR82xxGainIndices(496)).toEqual({ + lnaIndex: 15, + mixerIndex: 14, + totalGainTenthDb: 496, + }) + // One tenth of a dB more than the tables can give: the search takes the + // negative final mixer step and ends up slightly lower. + expect(computeR82xxGainIndices(497).totalGainTenthDb).toBe(488) + }) +}) + +describe('selectMuxRange (librtlsdr r82xx_set_mux band table)', () => { + it('picks the last band whose lower bound is at or below the LO', () => { + // 92.07 MHz LO (88.5 MHz + IF) falls in the 90–100 MHz band. + expect(selectMuxRange(92_070_000).freqMhz).toBe(90) + // 101.57 MHz LO (98 MHz + IF) falls in the 100–110 MHz band. + expect(selectMuxRange(101_570_000).freqMhz).toBe(100) + // 111.47 MHz LO (107.9 MHz + IF) falls in the 110–120 MHz band. + expect(selectMuxRange(111_470_000).freqMhz).toBe(110) + }) + + it('clamps below the first bound and above the last', () => { + expect(selectMuxRange(1_000_000).freqMhz).toBe(0) + expect(selectMuxRange(2_000_000_000).freqMhz).toBe(588) + }) + + it('is exact at band boundaries (>= lower bound, not >)', () => { + expect(selectMuxRange(50_000_000).freqMhz).toBe(50) + expect(selectMuxRange(49_999_999).freqMhz).toBe(0) + }) +}) + +describe('packFir (librtlsdr rtlsdr_set_fir)', () => { + it('packs the default filter into 20 bytes: 8 int8 then 8 int12', () => { + const fir = packFir() + expect(fir).toHaveLength(20) + // First 8 are the signed 8-bit taps, two's complement. + // -54 = 0xca, -36 = 0xdc, -41 = 0xd7, -40 = 0xd8, -32 = 0xe0, + // -14 = 0xf2, 14 = 0x0e, 53 = 0x35 + expect(Array.from(fir.slice(0, 8))).toEqual([0xca, 0xdc, 0xd7, 0xd8, 0xe0, 0xf2, 0x0e, 0x35]) + // Then pairs of 12-bit values, 3 bytes per pair. + // (101, 156) -> 101 = 0x065, 156 = 0x09c + // byte0 = 101 >> 4 = 0x06 + // byte1 = ((101 << 4) | (156 >> 8) & 0xf) = 0x50 | 0x0 = 0x50 + // byte2 = 156 & 0xff = 0x9c + expect(Array.from(fir.slice(8, 11))).toEqual([0x06, 0x50, 0x9c]) + // (215, 273) -> 215 = 0x0d7, 273 = 0x111 + // byte0 = 0x0d ; byte1 = 0x70 | 0x1 = 0x71 ; byte2 = 0x11 + expect(Array.from(fir.slice(11, 14))).toEqual([0x0d, 0x71, 0x11]) + // (327, 372) -> 327 = 0x147, 372 = 0x174 + // byte0 = 0x14 ; byte1 = 0x70 | 0x1 = 0x71 ; byte2 = 0x74 + expect(Array.from(fir.slice(14, 17))).toEqual([0x14, 0x71, 0x74]) + // (404, 421) -> 404 = 0x194, 421 = 0x1a5 + // byte0 = 0x19 ; byte1 = 0x40 | 0x1 = 0x41 ; byte2 = 0xa5 + expect(Array.from(fir.slice(17, 20))).toEqual([0x19, 0x41, 0xa5]) + }) +}) + +describe('bitrev (librtlsdr r82xx_bitrev)', () => { + it('reverses bit order within a byte', () => { + expect(bitrev(0x00)).toBe(0x00) + expect(bitrev(0xff)).toBe(0xff) + expect(bitrev(0x01)).toBe(0x80) + expect(bitrev(0x80)).toBe(0x01) + expect(bitrev(0b1010_0000)).toBe(0b0000_0101) + }) + + it('is its own inverse for every byte', () => { + for (let b = 0; b < 256; b++) expect(bitrev(bitrev(b))).toBe(b) + }) +}) + +describe('R82XX_INIT_REGS', () => { + it('covers registers 0x05 through 0x1f inclusive', () => { + // The whole point of the fix: 27 registers, not the 3 the old code wrote. + expect(R82XX_INIT_REGS).toHaveLength(0x1f - 0x05 + 1) + expect(R82XX_INIT_REGS).toHaveLength(27) + for (const v of R82XX_INIT_REGS) { + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThanOrEqual(0xff) + } + }) +}) + +describe('identifyTuner', () => { + it('recognises the R820T by its chip id at the R820T address', () => { + expect(identifyTuner({ r820t: 0x69 })).toBe('R820T') + }) + + it('recognises the R828D at its own address', () => { + expect(identifyTuner({ r820t: 0x00, r828d: 0x69 })).toBe('R828D') + }) + + it('recognises the tuners this driver deliberately refuses to drive', () => { + expect(identifyTuner({ e4000: 0x40 })).toBe('E4000') + expect(identifyTuner({ fc001x: 0xa1 })).toBe('FC0012') + expect(identifyTuner({ fc001x: 0xa3 })).toBe('FC0013') + expect(identifyTuner({ fc2580: 0x56 })).toBe('FC2580') + // FC2580's check masks off the top bit before comparing. + expect(identifyTuner({ fc2580: 0xd6 })).toBe('FC2580') + }) + + it('returns null rather than guessing when nothing answers', () => { + expect(identifyTuner({})).toBeNull() + expect(identifyTuner({ r820t: 0x00, r828d: 0xff, e4000: 0x12 })).toBeNull() + }) + + it('prefers a positive E4000 id over a coincidental R82xx byte', () => { + // Probe order matters: librtlsdr checks the E4000 first. + expect(identifyTuner({ e4000: 0x40, r820t: 0x69 })).toBe('E4000') + }) +}) diff --git a/frontend/src/lib/sdr.ts b/frontend/src/lib/sdr.ts index c1617a8..8e5f880 100644 --- a/frontend/src/lib/sdr.ts +++ b/frontend/src/lib/sdr.ts @@ -1,12 +1,22 @@ -// Best-effort WebUSB driver for RTL2832U + R820T("T") based SDR dongles. +// WebUSB driver for RTL2832U dongles, ported from librtlsdr. // -// ⚠️ 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 +// PROVENANCE / CONFIDENCE +// ----------------------- +// This file is a deliberate port of the real librtlsdr control sequences +// (librtlsdr.c + tuner_r82xx.c), replacing an earlier hand-waved sketch that +// could never have produced a spectrum. Every non-obvious constant carries a +// comment naming the librtlsdr symbol it came from. +// +// The port was written from knowledge of the librtlsdr sources, NOT by +// transcribing a checked-out copy, and it has NOT been run against hardware. +// Where the author's recall of a constant is less than solid it is flagged +// inline with `UNCONFIRMED:`. Everything that is arithmetic rather than a +// magic number is factored into pure exported functions and unit-tested in +// sdr.test.ts against vectors derived independently from the algorithm — so +// the PLL/resampler/gain math is verified even though the register *pokes* +// are not. Read every `UNCONFIRMED:` before trusting this on new silicon. +// +// Every entry point still fails soft: callers must treat any thrown error as // "this vessel cannot hear the radio dead" and degrade gracefully. import { powerSpectrumDb } from './fft' @@ -33,22 +43,143 @@ export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837] // 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 +// --------------------------------------------------------------------------- +// librtlsdr constants +// --------------------------------------------------------------------------- + +// librtlsdr `enum blocks`. The previous version of this file used entirely +// different values for these (DEMOD = 3, USB_EPA = 2) which is one of the +// reasons nothing worked: register writes landed in the wrong block. +const BLOCK_DEMOD = 0 +const BLOCK_USB = 1 +const BLOCK_SYS = 2 +const BLOCK_IIC = 6 + +// librtlsdr `enum usb_reg` / `enum sys_reg` +const USB_SYSCTL = 0x2000 +const USB_EPA_CTL = 0x2148 +const USB_EPA_MAXPKT = 0x2158 +const DEMOD_CTL = 0x3000 +const GPO = 0x3001 +const DEMOD_CTL_1 = 0x300b + +/** RTL2832U reference crystal — drives both the resampler and the tuner PLL. */ +export const RTL_XTAL_HZ = 28_800_000 + +/** librtlsdr R82XX_IF_FREQ: the R820T family outputs a 3.57 MHz IF. */ +export const R82XX_IF_FREQ = 3_570_000 + +// I2C probe addresses / chip-id checks — librtlsdr rtlsdr_open()'s tuner probe. +const R820T_I2C_ADDR = 0x34 +const R828D_I2C_ADDR = 0x74 +const R82XX_CHECK_ADDR = 0x00 +const R82XX_CHECK_VAL = 0x69 +const E4K_I2C_ADDR = 0xc8 +const E4K_CHECK_ADDR = 0x02 +const E4K_CHECK_VAL = 0x40 +const FC001X_I2C_ADDR = 0xc6 +const FC001X_CHECK_ADDR = 0x00 +const FC0012_CHECK_VAL = 0xa1 +const FC0013_CHECK_VAL = 0xa3 +const FC2580_I2C_ADDR = 0xac +const FC2580_CHECK_ADDR = 0x01 +const FC2580_CHECK_VAL = 0x56 + +export type TunerType = 'R820T' | 'R828D' | 'E4000' | 'FC0012' | 'FC0013' | 'FC2580' | 'unknown' + +/** + * librtlsdr `r82xx_init_regs` — the full power-up shadow for registers + * 0x05..0x1f (27 bytes). The old code wrote three of these and left the LNA, + * mixer, VGA, IF filter and VCO completely unconfigured, which on its own is + * enough to explain "device opens, no spectrum". + */ +export const R82XX_INIT_REGS: readonly number[] = [ + 0x83, 0x32, 0x75, // 0x05 .. 0x07 + 0xc0, 0x40, 0xd6, 0x6c, // 0x08 .. 0x0b + 0xf5, 0x63, 0x75, 0x68, // 0x0c .. 0x0f + 0x6c, 0x83, 0x80, 0x00, // 0x10 .. 0x13 + 0x0f, 0x00, 0xc0, 0x30, // 0x14 .. 0x17 + 0x48, 0xcc, 0x60, 0x00, // 0x18 .. 0x1b + 0x54, 0xae, 0x4a, 0xc0, // 0x1c .. 0x1f +] + +/** First register covered by the shadow array (librtlsdr REG_SHADOW_START). */ +export const R82XX_REG_SHADOW_START = 0x05 + +/** + * librtlsdr `fir_default`. First 8 entries are 8-bit signed, the last 8 are + * 12-bit signed; `packFir` below reproduces rtlsdr_set_fir()'s packing. + */ +export const FIR_DEFAULT: readonly number[] = [ + -54, -36, -41, -40, -32, -14, 14, 53, // 8-bit signed + 101, 156, 215, 273, 327, 372, 404, 421, // 12-bit signed +] + +/** + * librtlsdr `r82xx_lna_gain_steps` / `r82xx_mixer_gain_steps`, in tenths of a + * dB. r82xx_set_gain() walks these alternately to reach a requested gain. + */ +export const R82XX_LNA_GAIN_STEPS: readonly number[] = [ + 0, 9, 13, 40, 38, 13, 31, 22, 26, 31, 26, 14, 19, 5, 35, 13, +] +export const R82XX_MIXER_GAIN_STEPS: readonly number[] = [ + 0, 5, 10, 10, 19, 9, 10, 25, 17, 10, 8, 16, 13, 6, 3, -8, +] + +/** + * librtlsdr `freq_ranges` (tuner_r82xx.c) — the RF front-end band table used + * by r82xx_set_mux(). + * + * UNCONFIRMED: the `tfC` column in particular is a long list of opaque magic + * bytes and the author's recall of individual entries is only moderate. A + * wrong tfC mistunes the tracking filter (reduced sensitivity / image + * rejection) but does not prevent the PLL locking or samples flowing, so it + * is a much softer failure than the PLL bug this port fixes. Verify against + * tuner_r82xx.c before relying on absolute sensitivity numbers. + */ +export type R82xxFreqRange = { + /** Lower bound of the band, MHz. */ + freqMhz: number + openD: number + rfMuxPloy: number + tfC: number + xtalCap20p: number + xtalCap10p: number + xtalCap0p: number +} +export const R82XX_FREQ_RANGES: readonly R82xxFreqRange[] = [ + { freqMhz: 0, openD: 0x08, rfMuxPloy: 0x02, tfC: 0xdf, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 50, openD: 0x08, rfMuxPloy: 0x02, tfC: 0xbe, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 55, openD: 0x08, rfMuxPloy: 0x02, tfC: 0x8b, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 60, openD: 0x08, rfMuxPloy: 0x02, tfC: 0x7b, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 65, openD: 0x08, rfMuxPloy: 0x02, tfC: 0x69, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 70, openD: 0x08, rfMuxPloy: 0x02, tfC: 0x58, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 75, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x44, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 80, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x44, xtalCap20p: 0x02, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 90, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x34, xtalCap20p: 0x01, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 100, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x34, xtalCap20p: 0x01, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 110, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x24, xtalCap20p: 0x01, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 120, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x24, xtalCap20p: 0x01, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 140, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x14, xtalCap20p: 0x01, xtalCap10p: 0x01, xtalCap0p: 0x00 }, + { freqMhz: 180, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x13, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, + { freqMhz: 220, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x13, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, + { freqMhz: 250, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x11, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, + { freqMhz: 280, openD: 0x00, rfMuxPloy: 0x02, tfC: 0x00, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, + { freqMhz: 310, openD: 0x00, rfMuxPloy: 0x41, tfC: 0x00, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, + { freqMhz: 588, openD: 0x00, rfMuxPloy: 0x40, tfC: 0x00, xtalCap20p: 0x00, xtalCap10p: 0x00, xtalCap0p: 0x00 }, +] + +// --------------------------------------------------------------------------- +// Pure, testable arithmetic (see sdr.test.ts) +// --------------------------------------------------------------------------- /** * 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. + * dongle doesn't respond as expected, 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) => { @@ -69,6 +200,289 @@ export function withTimeout(promise: Promise, ms: number, label: string): }) } +export type PllRegisters = { + /** Mixer divider actually selected (2, 4, 8, 16, 32). */ + mixDiv: number + /** Value written into reg 0x10 bits 7:5 (the VCO/mixer divider select). */ + divNum: number + /** Integer part of the PLL divide ratio. */ + nint: number + /** nint split as librtlsdr does before packing it into reg 0x14. */ + ni: number + si: number + /** Byte written to reg 0x14: `ni + (si << 6)`. */ + reg14: number + /** 16-bit sigma-delta value. */ + sdm: number + /** Byte written to reg 0x16 (SDM high). */ + reg16: number + /** Byte written to reg 0x17 (SDM low). */ + reg17: number + /** Value OR'd into reg 0x12 under mask 0x08: 0x08 disables the SDM. */ + pwSdm: number +} + +export type PllOptions = { + /** Reference crystal, Hz. 28.8 MHz on every RTL2832U board. */ + xtalHz?: number + /** + * Value read back from tuner reg 0x04 bits 5:4 before programming. + * librtlsdr nudges divNum by ±1 depending on how it compares to + * vcoPowerRef. Callers that haven't read the chip should pass the + * "no adjustment" value, which equals vcoPowerRef. + */ + vcoFineTune?: number + /** 2 for R820T, 1 for R828D (librtlsdr `vco_power_ref`). */ + vcoPowerRef?: number +} + +/** + * Faithful port of librtlsdr `r82xx_set_pll()`'s arithmetic. + * + * The old implementation wrote `nint` into register 0x10 — which actually + * holds the reference-divider and VCO-power bits — and derived the SDM from a + * formula unrelated to the real one. This is the corrected computation. + * + * Register assignment note: this port writes the packed nint to **0x14** and + * the 16-bit SDM to **0x16 (high) / 0x17 (low)**, matching + * `r82xx_write_reg(priv, 0x16, sdm >> 8); r82xx_write_reg(priv, 0x17, sdm & 0xff);` + * in tuner_r82xx.c. Some secondhand descriptions put the SDM at 0x15/0x16 + * instead; the author is confident in 0x16/0x17 but flags the disagreement + * rather than hiding it. If a real dongle locks but tunes to a frequency + * offset by a fraction of `2*xtal/mixDiv`, this pair is the first thing to + * re-check against tuner_r82xx.c. + * + * @param loFreqHz Local-oscillator frequency (centre + R82XX_IF_FREQ). + */ +export function computePllRegisters(loFreqHz: number, opts: PllOptions = {}): PllRegisters { + const xtalHz = opts.xtalHz ?? RTL_XTAL_HZ + const vcoPowerRef = opts.vcoPowerRef ?? 2 + const vcoFineTune = opts.vcoFineTune ?? vcoPowerRef + + if (!Number.isFinite(loFreqHz) || loFreqHz <= 0) { + throw new Error(`invalid LO frequency ${loFreqHz} Hz`) + } + + // librtlsdr works in kHz here; the integer truncation is load-bearing, so + // reproduce it exactly rather than staying in floating point. + const pllRefKhz = Math.floor((xtalHz + 500) / 1000) + const freqKhz = Math.floor((loFreqHz + 500) / 1000) + + // VCO runs 1770–3540 MHz; pick the smallest mixer divider that lands in it. + const vcoMinKhz = 1_770_000 + const vcoMaxKhz = vcoMinKhz * 2 + + let mixDiv = 2 + let divNum = 0 + let found = false + while (mixDiv < 64) { + if (freqKhz * mixDiv >= vcoMinKhz && freqKhz * mixDiv < vcoMaxKhz) { + found = true + break + } + mixDiv <<= 1 + divNum++ + } + if (!found) { + throw new Error( + `${(loFreqHz / 1e6).toFixed(3)} MHz is outside the R820T tuning range ` + + `(no mixer divider puts the VCO between ${vcoMinKhz / 1000} and ${vcoMaxKhz / 1000} MHz)`, + ) + } + + // librtlsdr trims the divider select by the chip's reported VCO fine-tune. + if (vcoFineTune > vcoPowerRef) divNum -= 1 + else if (vcoFineTune < vcoPowerRef) divNum += 1 + + const vcoFreq = loFreqHz * mixDiv + const nint = Math.floor(vcoFreq / (2 * xtalHz)) + let vcoFra = Math.floor((vcoFreq - 2 * xtalHz * nint) / 1000) // kHz + + if (nint > Math.floor(128 / vcoPowerRef) - 1) { + throw new Error( + `no valid R820T PLL divider for ${(loFreqHz / 1e6).toFixed(3)} MHz ` + + `(nint=${nint} exceeds the ${Math.floor(128 / vcoPowerRef) - 1} limit)`, + ) + } + + const ni = Math.floor((nint - 13) / 4) + const si = nint - 4 * ni - 13 + const reg14 = (ni + (si << 6)) & 0xff + + // pw_sdm: reg 0x12 bit 3 set == sigma-delta powered down (integer-N). + const pwSdm = vcoFra === 0 ? 0x08 : 0x00 + + // librtlsdr's successive-approximation SDM loop, integer division included. + let sdm = 0 + let nSdm = 2 + while (vcoFra > 1) { + const step = Math.floor((2 * pllRefKhz) / nSdm) + if (vcoFra > step) { + sdm += Math.floor(32768 / (nSdm / 2)) + vcoFra -= step + if (nSdm >= 0x8000) break + } + nSdm <<= 1 + } + + return { + mixDiv, + divNum, + nint, + ni, + si, + reg14, + sdm, + reg16: (sdm >> 8) & 0xff, + reg17: sdm & 0xff, + pwSdm, + } +} + +export type ResampRatio = { + /** 28-bit resampler ratio written across demod regs 0x9f/0xa1. */ + ratio: number + /** 16-bit value for demod page 1 reg 0x9f. */ + reg9f: number + /** 16-bit value for demod page 1 reg 0xa1. */ + regA1: number + /** Sample rate the dongle will actually produce after quantisation. */ + actualRateHz: number +} + +/** + * librtlsdr `rtlsdr_set_sample_rate()`'s resampler ratio. + * + * NB the intermediate `xtal * 2^22` exceeds 2^32, so the mask must be applied + * with Math.floor + modular arithmetic, not JS's 32-bit `&` — the old code + * used `&`, which silently mangles ratios above 2^31 (i.e. every sample rate + * below ~1.15 Msps). + */ +export function computeResampRatio(sampleRateHz: number, xtalHz = RTL_XTAL_HZ): ResampRatio { + if ( + !Number.isFinite(sampleRateHz) || + sampleRateHz <= 225_000 || + sampleRateHz > 3_200_000 || + (sampleRateHz > 300_000 && sampleRateHz <= 900_000) + ) { + throw new Error( + `sample rate ${sampleRateHz} Hz is out of the RTL2832U's supported range ` + + '(225001–300000 Hz or 900001–3200000 Hz)', + ) + } + const raw = Math.floor((xtalHz * 2 ** 22) / sampleRateHz) + // Equivalent to `raw & 0x0ffffffc` but safe past 2^31. + const ratio = (raw % 0x10000000) - ((raw % 0x10000000) % 4) + return { + ratio, + reg9f: Math.floor(ratio / 0x10000) & 0xffff, + regA1: ratio & 0xffff, + actualRateHz: Math.floor((xtalHz * 2 ** 22) / ratio), + } +} + +/** + * librtlsdr `rtlsdr_set_if_freq()`. The R820T delivers a 3.57 MHz IF, so the + * demod must be told to shift it down; skipping this (as the old code did) + * leaves the signal of interest sitting off-centre in the FFT. + */ +export function computeIfFreqRegisters( + ifFreqHz: number, + xtalHz = RTL_XTAL_HZ, +): { reg19: number; reg1a: number; reg1b: number } { + const tmp = Math.floor((ifFreqHz * 2 ** 22) / xtalHz) + // librtlsdr negates and relies on two's-complement truncation to 22 bits. + const ifFreq = (-tmp) >>> 0 + return { + reg19: (ifFreq >>> 16) & 0x3f, + reg1a: (ifFreq >>> 8) & 0xff, + reg1b: ifFreq & 0xff, + } +} + +export type GainIndices = { + /** Value for tuner reg 0x05 bits 3:0. */ + lnaIndex: number + /** Value for tuner reg 0x07 bits 3:0. */ + mixerIndex: number + /** Gain actually achieved, tenths of a dB. */ + totalGainTenthDb: number +} + +/** + * librtlsdr `r82xx_set_gain()`'s manual-gain search: walk the LNA and mixer + * gain-step tables alternately until the accumulated gain reaches the target. + * The result saturates at the top of both tables (~48.8 dB). + * + * @param gainTenthDb Requested gain in tenths of a dB (e.g. 297 == 29.7 dB). + */ +export function computeR82xxGainIndices(gainTenthDb: number): GainIndices { + let total = 0 + let lnaIndex = 0 + let mixerIndex = 0 + for (let i = 0; i < 15; i++) { + if (total >= gainTenthDb) break + lnaIndex++ + total += R82XX_LNA_GAIN_STEPS[lnaIndex] + if (total >= gainTenthDb) break + mixerIndex++ + total += R82XX_MIXER_GAIN_STEPS[mixerIndex] + } + return { lnaIndex, mixerIndex, totalGainTenthDb: total } +} + +/** librtlsdr `r82xx_set_mux()`'s band lookup: last range whose bound is <= freq. */ +export function selectMuxRange(freqHz: number): R82xxFreqRange { + const mhz = Math.floor(freqHz / 1_000_000) + let i = 0 + for (; i < R82XX_FREQ_RANGES.length - 1; i++) { + if (mhz < R82XX_FREQ_RANGES[i + 1].freqMhz) break + } + return R82XX_FREQ_RANGES[i] +} + +/** + * librtlsdr `rtlsdr_set_fir()`'s packing: 8 signed bytes, then 8 twelve-bit + * signed values packed three bytes per pair. Returns the 20 bytes written to + * demod page 1 registers 0x1c..0x2f. + */ +export function packFir(coeffs: readonly number[] = FIR_DEFAULT): Uint8Array { + const fir = new Uint8Array(20) + for (let i = 0; i < 8; i++) fir[i] = coeffs[i] & 0xff + for (let i = 0; i < 8; i += 2) { + const val0 = coeffs[8 + i] + const val1 = coeffs[8 + i + 1] + const base = 8 + (i * 3) / 2 + fir[base] = (val0 >> 4) & 0xff + fir[base + 1] = ((val0 << 4) | ((val1 >> 8) & 0x0f)) & 0xff + fir[base + 2] = val1 & 0xff + } + return fir +} + +/** R820T register reads come back bit-reversed (librtlsdr `r82xx_bitrev`). */ +export function bitrev(byte: number): number { + const lut = [0x0, 0x8, 0x4, 0xc, 0x2, 0xa, 0x6, 0xe, 0x1, 0x9, 0x5, 0xd, 0x3, 0xb, 0x7, 0xf] + return ((lut[byte & 0xf] << 4) | lut[(byte >> 4) & 0xf]) & 0xff +} + +/** Map an I2C probe result to a tuner name, or null if it doesn't match. */ +export function identifyTuner(probe: { + r820t?: number + r828d?: number + e4000?: number + fc001x?: number + fc2580?: number +}): TunerType | null { + if (probe.e4000 === E4K_CHECK_VAL) return 'E4000' + if (probe.fc001x === FC0013_CHECK_VAL) return 'FC0013' + if (probe.fc001x === FC0012_CHECK_VAL) return 'FC0012' + if (probe.r820t === R82XX_CHECK_VAL) return 'R820T' + if (probe.r828d === R82XX_CHECK_VAL) return 'R828D' + if (probe.fc2580 !== undefined && (probe.fc2580 & 0x7f) === FC2580_CHECK_VAL) return 'FC2580' + return null +} + /** WebUSB only exists in secure contexts (https, or localhost). */ export function isSecureContext(): boolean { return typeof window !== 'undefined' && window.isSecureContext === true @@ -83,17 +497,44 @@ export function isSupported(): boolean { ) } +// The project's minimal WebUSB ambient declarations (src/types/webusb.d.ts) +// don't include controlTransferIn, and that file is off-limits for this +// change, so the read side is declared narrowly here instead. +type ControlSetup = { + requestType: 'standard' | 'class' | 'vendor' + recipient: 'device' | 'interface' | 'endpoint' | 'other' + request: number + value: number + index: number +} +type UsbWithControlIn = USBDevice & { + controlTransferIn(setup: ControlSetup, length: number): Promise<{ data?: DataView; status: string }> +} + +// --------------------------------------------------------------------------- + export class RtlSdr { private device: USBDevice | null = null private interfaceNumber = 0 private endpointIn = 0x81 private running = false private sampleRate = 2_048_000 + private tuner: TunerType = 'unknown' + private tunerI2cAddr = R820T_I2C_ADDR + /** R820T shadow registers for 0x05..0x1f — write-only regs need a shadow. */ + private regs = new Uint8Array(R82XX_INIT_REGS.length) + /** IF filter calibration code recovered during tuner init. */ + private filCalCode = 0 get isOpen(): boolean { return this.device?.opened ?? false } + /** Which tuner the I2C probe found. 'unknown' before open(). */ + get tunerType(): TunerType { + return this.tuner + } + /** Ask the browser for an RTL2832U device. Throws if none chosen/found. */ async requestDevice(): Promise { if (!isSupported()) { @@ -108,27 +549,25 @@ export class RtlSdr { /** * 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. + * The whole sequence is time-boxed: it's ~150 sequential raw USB control + * transfers, and any one of them stalling would otherwise hang this promise + * forever with no way for a caller to ever know — see withTimeout. */ 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') + await withTimeout(this._openSequence(dev, sampleRateHz), 20_000, 'RTL-SDR open/init sequence') } private async _openSequence(dev: USBDevice, sampleRateHz: number): Promise { this.sampleRate = sampleRateHz - await dev.open() + await withTimeout(dev.open(), 5_000, 'RTL-SDR device 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) + await withTimeout(dev.selectConfiguration(1), 5_000, 'RTL-SDR selectConfiguration') } catch (err) { // Tolerable only if the device is already configured (some platforms // refuse to re-select the active configuration). @@ -143,11 +582,6 @@ export class RtlSdr { 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} / ` + @@ -156,39 +590,28 @@ export class RtlSdr { ) } - // 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.initBaseband() + await this.probeTuner() + await this.initTuner() 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 + // The R820T is not zero-IF: tell the demod about the 3.57 MHz IF and + // enable spectrum inversion, exactly as librtlsdr does after tuner init. + // Without this the FFT is centred on the wrong thing. + await this.demodWrite(1, 0xb1, 0x1a, 1) // disable zero-IF mode + await this.demodWrite(0, 0x08, 0x4d, 1) // only enable in-phase ADC input + await this.setIfFreq(R82XX_IF_FREQ) + await this.demodWrite(1, 0x15, 0x01, 1) // enable spectrum inversion + + // Tuner AGC by default — the previous driver never set any tuner gain at + // all, so the front end sat wherever power-up left it. + await this.setTunerAgc(true) + + await this.setFrequency(98_000_000) + await this.resetBuffer() } async close(): Promise { @@ -196,9 +619,11 @@ export class RtlSdr { const dev = this.device if (dev?.opened) { try { - await withTimeout(this.demodWrite(1, 0x01, 0x10, 1), 3_000, 'RTL-SDR suspend on close') // suspend + // librtlsdr rtlsdr_close(): power down demod (DEMOD_CTL bit for + // standby) before releasing. + await withTimeout(this.writeReg(BLOCK_SYS, DEMOD_CTL, 0x20, 1), 3_000, 'RTL-SDR standby on close') } catch { - /* device may already be gone, or stopped responding — proceed to release/close anyway */ + /* device may already be gone, or stopped responding — proceed anyway */ } await withTimeout(dev.releaseInterface(this.interfaceNumber), 3_000, 'RTL-SDR releaseInterface').catch( () => undefined, @@ -207,50 +632,366 @@ export class RtlSdr { } } - /** Tune the R820T mixer PLL. Hz. (HARDWARE PASS REQUIRED) */ + // ---- baseband / demod init (librtlsdr rtlsdr_init_baseband) ---- + + private async initBaseband(): Promise { + await this.writeReg(BLOCK_USB, USB_SYSCTL, 0x09, 1) + await this.writeReg(BLOCK_USB, USB_EPA_MAXPKT, 0x0002, 2) + await this.writeReg(BLOCK_USB, USB_EPA_CTL, 0x1002, 2) + + // Power on the demod. + await this.writeReg(BLOCK_SYS, DEMOD_CTL_1, 0x22, 1) + await this.writeReg(BLOCK_SYS, DEMOD_CTL, 0xe8, 1) + + // Soft reset (bit 3 of page 1 reg 0x01). + await this.demodWrite(1, 0x01, 0x14, 1) + await this.demodWrite(1, 0x01, 0x10, 1) + + // Disable spectrum inversion and adjacent-channel rejection. + await this.demodWrite(1, 0x15, 0x00, 1) + await this.demodWrite(1, 0x16, 0x0000, 2) + + // Clear both DDC shift and IF registers. + for (let i = 0; i < 6; i++) await this.demodWrite(1, 0x16 + i, 0x00, 1) + + await this.setFir() + + await this.demodWrite(0, 0x19, 0x05, 1) // enable SDR mode, disable DAGC + await this.demodWrite(1, 0x93, 0xf0, 1) // init FSM state-holding register + await this.demodWrite(1, 0x94, 0x0f, 1) + await this.demodWrite(1, 0x11, 0x00, 1) // disable AGC (en_dagc) + await this.demodWrite(1, 0x04, 0x00, 1) // disable RF and IF AGC loop + await this.demodWrite(0, 0x61, 0x60, 1) // disable PID filter + await this.demodWrite(0, 0x06, 0x80, 1) // opt_adc_iq = 0 + await this.demodWrite(1, 0xb1, 0x1b, 1) // zero-IF, DC cancel, IQ comp/est + await this.demodWrite(0, 0x0d, 0x83, 1) // disable 4.096 MHz clock on TP_CK0 + } + + private async setFir(): Promise { + const fir = packFir() + for (let i = 0; i < fir.length; i++) { + await this.demodWrite(1, 0x1c + i, fir[i], 1) + } + } + + /** librtlsdr rtlsdr_set_if_freq(). */ + async setIfFreq(hz: number): Promise { + const { reg19, reg1a, reg1b } = computeIfFreqRegisters(hz) + await this.demodWrite(1, 0x19, reg19, 1) + await this.demodWrite(1, 0x1a, reg1a, 1) + await this.demodWrite(1, 0x1b, reg1b, 1) + } + + // ---- tuner detection ---- + + /** + * Probe the I2C bus exactly the way librtlsdr's rtlsdr_open() does, and + * refuse to proceed on anything we don't have a real, ported init sequence + * for. Silently running the R820T sequence against an E4000 or FC0013 would + * produce precisely the symptom this driver is meant to fix, with no clue + * as to why — so this reports honestly instead. + */ + private async probeTuner(): Promise { + await this.setI2cRepeater(true) + try { + const probe: Parameters[0] = {} + probe.e4000 = await this.i2cReadReg(E4K_I2C_ADDR, E4K_CHECK_ADDR).catch(() => undefined) + probe.fc001x = await this.i2cReadReg(FC001X_I2C_ADDR, FC001X_CHECK_ADDR).catch(() => undefined) + probe.r820t = await this.i2cReadReg(R820T_I2C_ADDR, R82XX_CHECK_ADDR).catch(() => undefined) + if (probe.r820t !== R82XX_CHECK_VAL) { + // librtlsdr enables the R828D's GPIO before probing its address. + await this.setI2cRepeater(false) + await this.writeReg(BLOCK_SYS, GPO, 0x08, 1) + await this.setI2cRepeater(true) + probe.r828d = await this.i2cReadReg(R828D_I2C_ADDR, R82XX_CHECK_ADDR).catch(() => undefined) + } + probe.fc2580 = await this.i2cReadReg(FC2580_I2C_ADDR, FC2580_CHECK_ADDR).catch(() => undefined) + + const found = identifyTuner(probe) + if (found === null) { + throw new Error( + 'no known tuner answered on the I2C bus (probed E4000, FC0012/13, ' + + 'FC2580, R820T, R828D) — this dongle is not one this driver can drive', + ) + } + this.tuner = found + if (found !== 'R820T' && found !== 'R828D') { + // Honest refusal: the R82xx sequence would be actively wrong here and + // the author has no verified port of these tuners' init sequences. + throw new Error( + `unsupported tuner: ${found} — this driver only implements the ` + + 'R820T/R828D (Rafael R82xx) init and PLL sequences', + ) + } + this.tunerI2cAddr = found === 'R828D' ? R828D_I2C_ADDR : R820T_I2C_ADDR + } finally { + await this.setI2cRepeater(false).catch(() => undefined) + } + } + + // ---- R82xx tuner (librtlsdr tuner_r82xx.c) ---- + + /** vco_power_ref: 2 on the R820T, 1 on the R828D. */ + private get vcoPowerRef(): number { + return this.tuner === 'R828D' ? 1 : 2 + } + + /** librtlsdr r82xx_init(): full register array, then filter calibration. */ + private async initTuner(): Promise { + await this.setI2cRepeater(true) + try { + this.regs.set(R82XX_INIT_REGS) + // Sequential multi-byte write of the whole shadow, the way the R820T + // expects to be initialised. The previous driver poked three single + // registers here and left the other 24 at power-up defaults. + await this.r82xxWrite(R82XX_REG_SHADOW_START, this.regs) + await this.setTvStandard() + await this.sysFreqSel() + } finally { + await this.setI2cRepeater(false).catch(() => undefined) + } + } + + /** + * librtlsdr r82xx_set_tv_standard(bw=3, TUNER_DIGITAL_TV, delsys=0), i.e. + * the DVB-T/"< 6 MHz BW" path librtlsdr uses for SDR. This is where the IF + * filter calibration lives; without it the IF filter is untrimmed. + */ + private async setTvStandard(): Promise { + const filtCalLoHz = 56_000_000 + const filtGain = 0x10 // +3 dB, 6 MHz + const imgR = 0x00 + const filtQ = 0x10 // low Q + const hpCor = 0x6b // 1.7 MHz high-pass corner, 30 % low-pass + const extEnable = 0x60 + const loopThrough = 0x01 + const ltAtt = 0x00 + const fltExtWidest = 0x00 + const polyfilCur = 0x60 + + // Two attempts, as librtlsdr does: a bad cal code is retried once. + for (let attempt = 0; attempt < 2; attempt++) { + await this.r82xxWriteMask(0x0b, hpCor, 0x60) // set filt_cap + await this.r82xxWriteMask(0x0f, 0x04, 0x04) // cali clk on + await this.r82xxWriteMask(0x10, 0x00, 0x03) // xtal cap 0pF for PLL + await this.setPll(filtCalLoHz) + await this.r82xxWriteMask(0x0b, 0x10, 0x10) // start trigger + await delay(2) + await this.r82xxWriteMask(0x0b, 0x00, 0x10) // stop trigger + await this.r82xxWriteMask(0x0f, 0x00, 0x04) // cali clk off + + const data = await this.r82xxRead(5) + this.filCalCode = data[4] & 0x0f + if (this.filCalCode !== 0 && this.filCalCode !== 0x0f) break + } + if (this.filCalCode === 0x0f) this.filCalCode = 0 + + await this.r82xxWriteMask(0x0a, filtQ | this.filCalCode, 0x1f) + await this.r82xxWriteMask(0x0b, hpCor, 0xef) // BW, filter gain, HP corner + await this.r82xxWriteMask(0x07, imgR, 0x80) + await this.r82xxWriteMask(0x06, filtGain, 0x30) + await this.r82xxWriteMask(0x1e, extEnable, 0x60) + await this.r82xxWriteMask(0x05, loopThrough, 0x80) + await this.r82xxWriteMask(0x1f, ltAtt, 0x80) + await this.r82xxWriteMask(0x0f, fltExtWidest, 0x80) + await this.r82xxWriteMask(0x19, polyfilCur, 0x60) + } + + /** + * librtlsdr r82xx_sysfreq_sel(freq=0, TUNER_DIGITAL_TV, SYS_DVBT). + * + * UNCONFIRMED: this function in tuner_r82xx.c is a long table of AGC-timing + * and discharge-current values selected per delivery system, and the + * author's recall covers the SYS_DVBT branch's main assignments but not + * every conditional in it. The values below are the SYS_DVBT defaults. A + * wrong value here degrades AGC behaviour rather than preventing a lock. + */ + private async sysFreqSel(): Promise { + const mixerTop = 0x24 + const lnaTop = 0xe5 + const cpCur = 0x38 // 0.2 mA + const divBufCur = 0x30 // 150 uA + const lnaVthL = 0x53 + const mixerVthL = 0x75 + const airCable1In = 0x00 + const cable2In = 0x00 + const filterCur = 0x40 // 10 % + + await this.r82xxWriteMask(0x1d, lnaTop, 0xc7) + await this.r82xxWriteMask(0x1c, mixerTop, 0xf8) + await this.r82xxWriteReg(0x0d, lnaVthL) + await this.r82xxWriteReg(0x0e, mixerVthL) + await this.r82xxWriteMask(0x05, airCable1In, 0x60) + await this.r82xxWriteMask(0x06, cable2In, 0x08) + await this.r82xxWriteMask(0x11, cpCur, 0x38) + await this.r82xxWriteMask(0x17, divBufCur, 0x30) + await this.r82xxWriteMask(0x0a, filterCur, 0x60) + + // Damping / discharge settings librtlsdr applies for digital TV. + await this.r82xxWriteMask(0x1d, 0x00, 0x38) // AGC clock 250 Hz + await this.r82xxWriteMask(0x1c, 0x00, 0x04) // mixer discharge off + await this.r82xxWriteReg(0x06, 0xb1) + await this.r82xxWriteReg(0x1a, 0x40) + await this.r82xxWriteMask(0x0f, 0x00, 0x04) // cali clk off + await this.r82xxWriteMask(0x19, 0x60, 0x60) + } + + /** librtlsdr r82xx_set_mux(): pick the RF band / tracking filter. */ + private async setMux(loFreqHz: number): Promise { + const range = selectMuxRange(loFreqHz) + await this.r82xxWriteMask(0x17, range.openD, 0x08) + await this.r82xxWriteMask(0x1a, range.rfMuxPloy, 0xc3) + await this.r82xxWriteReg(0x1b, range.tfC) + // librtlsdr's default xtal_cap_sel is XTAL_HIGH_CAP_0P. + await this.r82xxWriteMask(0x10, range.xtalCap0p, 0x0b) + await this.r82xxWriteMask(0x08, 0x00, 0x3f) + await this.r82xxWriteMask(0x09, 0x00, 0x3f) + } + + /** + * librtlsdr r82xx_set_pll(). Programs the fractional-N synthesiser and then + * *verifies the lock* — the old code did neither correctly, and never + * checked, so a silently unlocked PLL looked exactly like success. + */ + private async setPll(loFreqHz: number): Promise { + await this.r82xxWriteMask(0x10, 0x00, 0x10) // refdiv2 = 0 + await this.r82xxWriteMask(0x1a, 0x00, 0x0c) // PLL autotune = 128 kHz + await this.r82xxWriteMask(0x12, 0x80, 0xe0) // VCO current = 100 + + // The divider select depends on the VCO fine-tune the chip reports. + const status = await this.r82xxRead(5) + const vcoFineTune = (status[4] & 0x30) >> 4 + + const pll = computePllRegisters(loFreqHz, { + xtalHz: RTL_XTAL_HZ, + vcoFineTune, + vcoPowerRef: this.vcoPowerRef, + }) + + await this.r82xxWriteMask(0x10, (pll.divNum << 5) & 0xe0, 0xe0) + await this.r82xxWriteReg(0x14, pll.reg14) + await this.r82xxWriteMask(0x12, pll.pwSdm, 0x08) + await this.r82xxWriteReg(0x16, pll.reg16) + await this.r82xxWriteReg(0x17, pll.reg17) + + // Lock check with one retry at higher VCO current, as librtlsdr does. + let locked = false + for (let i = 0; i < 2 && !locked; i++) { + const data = await this.r82xxRead(3) + if (data[2] & 0x40) { + locked = true + break + } + if (i === 0) await this.r82xxWriteMask(0x12, 0x60, 0xe0) // bump VCO current + } + if (!locked) { + throw new Error( + `R820T PLL failed to lock at ${(loFreqHz / 1e6).toFixed(4)} MHz LO ` + + `(mixer divider ${pll.mixDiv}, nint ${pll.nint}, SDM 0x${pll.sdm + .toString(16) + .padStart(4, '0')}) — the tuner is powered but the synthesiser is free-running`, + ) + } + + await this.r82xxWriteMask(0x1a, 0x08, 0x08) // PLL autotune = 8 kHz + } + + /** Tune the tuner. Hz (centre frequency, not LO). */ 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) + if (this.tuner !== 'R820T' && this.tuner !== 'R828D') { + throw new Error(`unsupported tuner: ${this.tuner} — cannot tune`) + } + const loHz = hz + R82XX_IF_FREQ + await this.setI2cRepeater(true) + try { + await this.setMux(loHz) + await this.setPll(loHz) + } finally { + await this.setI2cRepeater(false).catch(() => undefined) + } + } + + /** + * librtlsdr r82xx_set_gain(set_manual_gain=0): hand the front end back to + * the tuner's own AGC. This is what rtl_test / rtl_power use by default. + */ + async setTunerAgc(enabled: boolean): Promise { + await this.setI2cRepeater(true) + try { + if (enabled) { + await this.r82xxWriteMask(0x05, 0x00, 0x10) // LNA auto on + await this.r82xxWriteMask(0x07, 0x10, 0x10) // mixer auto on + await this.r82xxWriteMask(0x0c, 0x0b, 0x9f) // fixed VGA gain 26.5 dB + } else { + await this.r82xxWriteMask(0x05, 0x10, 0x10) // LNA auto off (manual) + await this.r82xxWriteMask(0x07, 0x00, 0x10) // mixer auto off + await this.r82xxWriteMask(0x0c, 0x08, 0x9f) // fixed VGA gain 16.3 dB + } + } finally { + await this.setI2cRepeater(false).catch(() => undefined) + } + } + + /** + * Set a manual tuner gain, in tenths of a dB (librtlsdr's units — 297 + * means 29.7 dB). Implicitly disables the tuner AGC. + * + * @returns the gain actually achieved, in tenths of a dB. + */ + async setTunerGain(gainTenthDb: number): Promise { + if (this.tuner !== 'R820T' && this.tuner !== 'R828D') { + throw new Error(`unsupported tuner: ${this.tuner} — cannot set gain`) + } + const { lnaIndex, mixerIndex, totalGainTenthDb } = computeR82xxGainIndices(gainTenthDb) + await this.setTunerAgc(false) + await this.setI2cRepeater(true) + try { + await this.r82xxWriteMask(0x05, lnaIndex, 0x0f) + await this.r82xxWriteMask(0x07, mixerIndex, 0x0f) + } finally { + await this.setI2cRepeater(false).catch(() => undefined) + } + return totalGainTenthDb } /** 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 + const { reg9f, regA1, actualRateHz } = computeResampRatio(hz) + await this.demodWrite(1, 0x9f, reg9f, 2) + await this.demodWrite(1, 0xa1, regA1, 2) + // Reset the demod after changing the ratio (librtlsdr does this). + await this.demodWrite(1, 0x01, 0x14, 1) + await this.demodWrite(1, 0x01, 0x10, 1) + this.sampleRate = actualRateHz + } + + /** The rate the dongle is actually producing (post-quantisation). */ + get actualSampleRate(): number { + return this.sampleRate } /** - * Read one block of I/Q samples. Length must be multiple of 512. + * librtlsdr rtlsdr_reset_buffer(): 0x1002 then 0x0000 to USB_EPA_CTL. The + * old code wrote 0xffff once, which does not reset the endpoint FIFO — so + * the first bulk read could return stale or no data indefinitely. + */ + async resetBuffer(): Promise { + await withTimeout(this.writeReg(BLOCK_USB, USB_EPA_CTL, 0x1002, 2), 3_000, 'buffer reset (assert)') + await withTimeout(this.writeReg(BLOCK_USB, USB_EPA_CTL, 0x0000, 2), 3_000, 'buffer reset (release)') + } + + /** + * Read one block of I/Q samples. Length must be a 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. + * 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 this call is where the whole sweep silently hangs + * forever — no error, no data, no visible change on the page. */ 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', - ) + 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) } @@ -273,9 +1014,9 @@ export class RtlSdr { const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq try { while (this.running) { - await withTimeout(this.setFrequency(hz), 3_000, 'PLL retune') - await new Promise((r) => setTimeout(r, settleMs)) try { + await withTimeout(this.setFrequency(hz), 5_000, 'PLL retune') + await delay(settleMs) await this.readSamples(16384) // discard: PLL settle const iq = await this.readSamples(readBytes) const f64 = new Float64Array(iq.length) @@ -298,79 +1039,182 @@ export class RtlSdr { this.running = false } - // ---- Low-level USB helpers (private; HARDWARE PASS REQUIRED) ---- + // ---- Low-level USB helpers ---- 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) + await withTimeout(dev.claimInterface(this.interfaceNumber), 5_000, 'RTL-SDR claimInterface') } catch (err) { - throw new Error( - `could not claim the radio dead (interface busy?) — ${String(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 { + /** + * librtlsdr rtlsdr_write_reg(): value = address, index = (block << 8) | + * 0x10, data big-endian. Both the index encoding and the byte order were + * wrong in the previous version. + */ + 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)) + const data = + length === 1 + ? new Uint8Array([value & 0xff]) + : new Uint8Array([(value >> 8) & 0xff, value & 0xff]) + await withTimeout( + dev.controlTransferOut( + { requestType: 'vendor', recipient: 'device', request: 0, value: address, index: (block << 8) | 0x10 }, + data, + ), + 3_000, + `write reg block ${block} addr 0x${address.toString(16)}`, + ) } - private async demodWrite( - page: number, - address: number, - value: number, - length: number, - ): Promise { - // Demod registers are paged: index = (page << 8) | address. + /** librtlsdr rtlsdr_read_array(): index = block << 8 (no 0x10 write bit). */ + private async readArray(block: number, address: number, length: number): Promise { + const dev = this.device as UsbWithControlIn | null + if (!dev) throw new Error('no device') + if (typeof dev.controlTransferIn !== 'function') { + throw new Error('this browser’s WebUSB has no controlTransferIn — cannot read device registers') + } + const res = await withTimeout( + dev.controlTransferIn( + { requestType: 'vendor', recipient: 'device', request: 0, value: address, index: block << 8 }, + length, + ), + 3_000, + `read block ${block} addr 0x${address.toString(16)}`, + ) + if (!res.data) throw new Error('control read returned no data') + return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength) + } + + /** + * librtlsdr rtlsdr_demod_write_reg(): value = (addr << 8) | 0x20, + * index = 0x10 | page, data big-endian. The previous version had all three + * of those wrong, which by itself made every demod write a no-op or worse. + */ + private async demodWrite(page: 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: (DEMOD << 8) | 0x10, - index: (page << 8) | address, - }, data.subarray(0, length)) + const data = + length === 1 + ? new Uint8Array([value & 0xff]) + : new Uint8Array([(value >> 8) & 0xff, value & 0xff]) + await withTimeout( + dev.controlTransferOut( + { + requestType: 'vendor', + recipient: 'device', + request: 0, + value: ((address << 8) | 0x20) & 0xffff, + index: 0x10 | page, + }, + data, + ), + 3_000, + `demod write page ${page} addr 0x${address.toString(16)}`, + ) + // librtlsdr issues a dummy read after every demod write to let the write + // take effect; skipped here because it doubles the transfer count and the + // reference driver tolerates its failure. UNCONFIRMED whether the RTL2832U + // strictly requires it — if init proves flaky on real hardware, restoring + // `await this.demodRead(0x0a, 0x01, 1)` here is the first thing to try. } - 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. + /** librtlsdr rtlsdr_set_i2c_repeater(): page 1 reg 0x01, 0x18 on / 0x10 off. */ + private async setI2cRepeater(on: boolean): Promise { + await this.demodWrite(1, 0x01, on ? 0x18 : 0x10, 1) + } + + /** librtlsdr rtlsdr_i2c_write_fn(): raw byte array to an I2C address. */ + private async i2cWriteArray(i2cAddr: number, bytes: Uint8Array): Promise { 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 + await withTimeout( + dev.controlTransferOut( + { + requestType: 'vendor', + recipient: 'device', + request: 0, + value: i2cAddr, + index: (BLOCK_IIC << 8) | 0x10, + }, + // Cast: the project's minimal WebUSB typings pin BufferSource to a + // non-shared ArrayBuffer; a plain Uint8Array is always fine here. + bytes as unknown as BufferSource, + ), + 3_000, + `i2c write to 0x${i2cAddr.toString(16)}`, + ) } - // Silence unused-warnings for constants kept for the hardware pass. - private static readonly _refs = { CTRL_IN, CTRL_OUT, SYS, PAGE_USB } + /** librtlsdr rtlsdr_i2c_read_reg(): write the register index, then read. */ + private async i2cReadReg(i2cAddr: number, reg: number): Promise { + await this.i2cWriteArray(i2cAddr, new Uint8Array([reg & 0xff])) + const data = await this.readArray(BLOCK_IIC, i2cAddr, 1) + if (data.length < 1) throw new Error('i2c read returned no data') + return data[0] + } + + /** + * librtlsdr r82xx_write(): sequential register write starting at `reg`. + * The R820T accepts `[reg, v0, v1, …]` and auto-increments, in chunks of at + * most MAX_I2C_MSG_LEN-1 data bytes. This is the multi-byte path the old + * driver lacked entirely. + */ + private async r82xxWrite(reg: number, values: Uint8Array): Promise { + const MAX_I2C_MSG_LEN = 8 + let offset = 0 + let addr = reg + while (offset < values.length) { + const size = Math.min(MAX_I2C_MSG_LEN - 1, values.length - offset) + const buf = new Uint8Array(size + 1) + buf[0] = addr + buf.set(values.subarray(offset, offset + size), 1) + await this.i2cWriteArray(this.tunerI2cAddr, buf) + addr += size + offset += size + } + } + + /** Write one shadowed R82xx register. */ + private async r82xxWriteReg(reg: number, value: number): Promise { + const idx = reg - R82XX_REG_SHADOW_START + if (idx >= 0 && idx < this.regs.length) this.regs[idx] = value & 0xff + await this.r82xxWrite(reg, new Uint8Array([value & 0xff])) + } + + /** + * librtlsdr r82xx_write_reg_mask(): read-modify-write against the shadow. + * R82xx registers are write-only, hence the shadow array — this is why + * R82XX_INIT_REGS must be written in full before any masked write, or every + * masked write below would be merging into zeros. + */ + private async r82xxWriteMask(reg: number, value: number, bitMask: number): Promise { + const idx = reg - R82XX_REG_SHADOW_START + const current = idx >= 0 && idx < this.regs.length ? this.regs[idx] : 0 + const next = ((current & ~bitMask) | (value & bitMask)) & 0xff + await this.r82xxWriteReg(reg, next) + } + + /** + * librtlsdr r82xx_read(): reads always start at tuner register 0x00 and the + * returned bytes are bit-reversed. + */ + private async r82xxRead(length: number): Promise { + const raw = await this.readArray(BLOCK_IIC, this.tunerI2cAddr, length) + const out = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) out[i] = bitrev(raw[i]) + return out + } +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) } /** Rolling noise-floor + spike detector over sweep spectra (pure, testable). */