Part 12 of From the Issue Tracker, postmortems of GopherTrunk bugs that fought back. Part 11 ended with a probe line that named its bug in one hex code. This part’s bug needed three numbers — and once you learn to read them together, they form a signature you will recognize on sight for the rest of your SDR career.
TL;DR: The Airspy R2 failed to open with
set sample rate ... protocol error(#454): the driver’s vendor-request opcode table was systematically shifted against libairspy’s enum, so “set sample rate” went out on the wire as “set LNA gain” — with the wrong transfer direction on top. Fixing the table opened the device… which then decoded nothing. The reporter’s offline capture measured EVM 22.7%, phase imbalance +78.1°, and image rejection 3.3 dB — the textbook signature of a real-sampling stream misread as interleaved I/Q. The Airspy streams bare ADC samples at twice the IQ rate; complex conversion is the host’s job, and libairspy does it so internally you can forget it exists. The fix is a host-side converter — DC blocker, Fs/4 translation, half-band Hilbert pair, decimate-by-two — that took the measured phase error from +78.1° to −0.0007°.
Cheat sheet
| Issues | #270 (R2 support), #454 (open fails) |
| Symptom 1 | set sample type ... device disconnected, then set sample rate ... protocol error — the R2 never opens |
| Cause 1 | vendor-request opcode table systematically shifted against libairspy’s airspy_commands enum, plus a wrong transfer direction — fixed via PR #380 |
| Symptom 2 | device opens, tunes, streams — and decodes nothing; RTL-SDR locks on the same antenna |
| Diagnostic triplet | gain +0.001 dB, phase +78.136°, image rejection 3.3 dB (EVM 22.7%) |
| Cause 2 | the R2 streams real ADC samples at 2× the IQ rate; the driver paired adjacent samples as I/Q — the host-side complex conversion was missing |
| Fix | DC blocker → Fs/4 translation → half-band Hilbert pair → decimate-by-2; phase −0.0007°, image rejection ~70.8 dB |
In this post
- Prologue: an opcode that didn’t exist — the Windows “device disconnected” era and a firmware update that fixed nothing.
- Bug one: the opcode table that was almost right — a systematically shifted enum and a backwards transfer direction.
- Bug two: opens fine, decodes nothing — the offline capture and the triplet that reads as a sentence.
- The real cause: the conversion nobody remembers exists — real-sampling receivers and the hidden host-side job.
- The fix: a Hilbert converter on the host — four stages, why each is there, and the numbers after.
- What we keep — signatures, real-vs-complex, offline captures, and shifted-table bug class.
Prologue: an opcode that didn’t exist
The R2’s troubles predate #454. When preliminary Airspy support first landed (#270), a contributor with an R2 on the bench found it detected but unusable on Windows:
level=ERROR msg="open device failed" driver=airspy index=0
err="airspy: set sample type: winusb: WinUsb_ControlTransfer OUT: usb: device disconnected"
That “set sample type” command is a preview of everything that follows: the driver
was sending opcode 11 for a SET_SAMPLE_TYPE operation that does not exist in
the Airspy firmware’s command set at all. The device’s response to the nonsense
request surfaced as a disconnect. An interim change (#358)
deferred the call out of the open path — symptom relief, not diagnosis.
Another user then did everything right from the hardware side: updated the R2 to
firmware NOS v1.0.0-rc10, confirmed with airspy_info that it advertises its two
native rates (10 and 2.5 MSPS), and retested — and still hit
set sample rate failed ... err="protocol error", with the R2 dropping out of the
pool and the daemon falling back to its RTL-SDRs. (They also tried the R2’s native
10 MS/s, which config validation rejected — sdr.sample_rate must be between
225 kHz and 3.2 MHz — a cap tracked separately in
#550.) The same
contributor said plainly what the accumulating evidence showed — “there are a
number of incorrect primitives in the existing Airspy driver” — and put up
PR #380 with the corrected
wire protocol, smoke-tested on a real R2. Root-causing #454 confirmed it
wholesale.
Bug one: the opcode table that was almost right
The R2’s first failure mode was refusing to open at all, with protocol error
(EPROTO) on the sample-rate command. The cause was a driver opcode table that had
drifted systematically against libairspy’s airspy_commands enum — not one wrong
entry but a whole shifted block, the fingerprint of an off-by-a-few transcription
from the reference source:
| Command | Should be | Was sent as | Which the firmware reads as |
|---|---|---|---|
SET_SAMPLERATE |
12 | 14 | SET_LNA_GAIN |
SET_FREQ |
13 | 12 | (shifted down) |
| gain / AGC family | 14–18 | 19–23 | wrong or nonexistent ops |
SET_RF_BIAS |
20 | 24 | nonexistent |
GET_SAMPLERATES |
25 | 13 | SET_FREQ |
SET_SAMPLE_TYPE |
(doesn’t exist) | 11 | nonsense — the prologue’s “disconnect” |
Two of those rows combine to produce the exact reported symptom. GET_SAMPLERATES
went out as SET_FREQ, so the supported-rate table never loaded. Then
SET_SAMPLERATE went out as opcode 14 (SET_LNA_GAIN) — and direction compounded
it: the real SET_SAMPLERATE is a vendor-IN request carrying the rate in
wIndex and reading one status byte back, and sending it as a data-less
vendor-OUT gets NAK’d by the firmware, which surfaces as EPROTO: protocol error.
A systematically shifted table is worth recognizing as a class: every entry is
plausible in isolation, some commands even appear to work, and the device does
something — just never what you asked.
The correction was correspondingly wholesale, not a one-line patch: opcodes
renumbered to match libairspy’s enum, SetSampleRate switched to vendor-IN with
the rate in wIndex, the bogus SET_SAMPLE_TYPE removed, bias-tee reimplemented
as the GPIO write it actually is, a receiver reset plus transient-error retry
added to the open path, and serial matching taught the AIRSPY SN: prefix the
firmware reports (so a configured bare serial still matches). Opt-in on-hardware
tests (make test-airspy-real) now exercise the corrected wire protocol against
a real device.
Bug two: opens fine, decodes nothing
With the table fixed, the R2 opened, tuned, and streamed. And decoded nothing — on a control channel (Mt Anakie, 420.0875 MHz) that locked immediately with an RTL-SDR on the same antenna and feed. The reporter did exactly the right thing: took the live pipeline out of the equation with a 10-second offline capture and replayed it with diagnostics on — two commands that turned “it doesn’t work” into a portable, replayable fixture:
gophertrunk capture -serial "AIRSPY SN:..." -freq 420087500 \
-sample-rate 2500000 -seconds 10 -out airspy-420087500.cfile
gophertrunk replay -in airspy-420087500.cfile -format f32 \
-sample-rate 2500000 -protocol p25p1 -demod c4fm -diag
did NOT lock the control channel
FSW hits = 0
demod (c4fm): EVM = 22.7% SNR ≈ 7.4 dB
raw IQ imbalance: gain = +0.001 dB phase = +78.136° image_rejection = 3.3 dB
Read as a sentence, that triplet is unambiguous. Gain imbalance is essentially zero — both “channels” have identical statistics. Phase imbalance is not the half-degree of an imperfect quadrature mixer but seventy-eight degrees — the two channels are nearly the same signal. And 3.3 dB of image rejection means the image is essentially not suppressed at all. Hardware doesn’t fail like this. A quadrature front end with a real analog defect gives you a few degrees and maybe 20 dB. Two channels that are almost copies of each other with no image suppression means the “I” and “Q” were never in quadrature to begin with — they are adjacent samples of one real signal.
The real cause: the conversion nobody remembers exists
The Airspy R2 and Mini are real-sampling receivers. The firmware streams bare ADC
samples — unpacked little-endian uint16, 12 bits used, DC centered at 2048 — at
twice the configured IQ rate. Turning that into complex baseband is a host-side
job, and libairspy performs it inside its own sample callbacks, so every
application built on it receives complex samples without ever seeing the real
stream. Port the USB protocol without porting the converter and you inherit exactly
this bug.
GopherTrunk’s driver was pairing adjacent real samples as (I, Q). Two neighboring
samples of an oversampled signal are highly correlated, and the measured phase
imbalance of such a pairing lands at arcsin(correlation) — near 90° for strong
correlation. The +78.1° wasn’t noise; it was the correlation coefficient of the
input wearing a trench coat. That’s why the number names the bug: no other defect
produces near-zero gain error, a phase error pushing 90°, and no image rejection
simultaneously. The triplet now has an entry of its own in
signal signatures.
The fix: a Hilbert converter on the host
The driver now runs the real stream through a proper real-to-complex converter:
- Leaky DC blocker — removes the ADC’s 2048 offset without a hard notch.
- Fs/4 translation — shifts the band of interest to center the spectrum.
- Half-band Hilbert pair — a symmetric FIR on one rail and a matched pure delay on the other, producing genuine quadrature.
- Decimate by two — down to the configured IQ rate.
Each stage earns its place. A real stream at twice the IQ rate carries its unique spectrum between 0 Hz and the IQ rate, with the tuner parking the wanted band at a quarter of the ADC rate — so the Fs/4 translation is what slides that band down to 0 Hz, and it’s also what makes the next stage cheap: once the band is centered, rejecting the negative-frequency image is exactly the job a Hilbert pair does, and a half-band FIR design does it with nearly half its coefficients identically zero. The Q rail’s matched pure delay keeps the two rails time-aligned with the FIR’s group delay — skip that and you have rebuilt a miniature of the original bug, two rails that don’t correspond to the same instant. And the final decimate-by-two is information-preserving, not lossy: 2×-rate real samples and 1×-rate complex samples carry the same bandwidth, which is why the output lands exactly on the configured IQ rate.
The converter is stateful across USB packets — the FIR history and translation phase carry over each buffer boundary, so packet edges introduce no discontinuities — and it was written from DSP first principles rather than vendored, because libairspy’s own converter carries an Airspy-ecosystem-only license. On a synthetic real tone the results bracket the story neatly:
| Metric | Before | After |
|---|---|---|
| Phase imbalance | +78.136° | −0.0007° |
| Image rejection | 3.3 dB | ~70.8 dB |
Unit tests gate phase below 1° and image rejection above 40 dB so the converter
can’t quietly regress. The reporter rebuilt and confirmed both fixes on
hardware: the R2 now opens reliably, runs with dc_avoid active
(lo_offset_hz=625000), and locks the control channel — matching the RTL-SDR on
the same feed. That confirmation closed #454,
and with it the original support issue #270,
whose two adjacent loose ends are tracked on their own: the config cap that
blocks the R2’s native 10 MS/s
(#550), and the
10-vs-2.5 MS/s in-channel SNR deficit on this same R2 — which became its own
investigation, told in
Part 5.
What we keep
- EVM tells you it’s broken; the imbalance triplet tells you what broke. 22.7% EVM says “unusable.” Zero gain error + ~78° phase + ~3 dB image rejection says “real stream read as I/Q” — a one-look diagnosis catalogued in signal signatures.
- Know whether your device samples real or complex before reading one byte of its stream. It’s the single most consequential fact in a driver bring-up, and the reference library may be hiding the conversion from you. Rates double, DC moves, and pairing rules change; see Airspy rate selection for how the 2× real rate interacts with the usable IQ rates.
- An offline capture converts an argument into a measurement. “Decodes on RTL, not on Airspy, same antenna” became a pinned, replayable fixture with numbers attached — the pipeline-vs-source question answered in one replay.
- Systematically shifted tables are a bug class. When one opcode is wrong, check the whole table against the reference enum — and check each request’s direction, because a right opcode with the wrong direction still NAKs.
Next: Part 13 closes the driver cluster with a network SDR: a SoapyRemote server that segfaulted on connect, three confident root causes that were all wrong, and a handshake with one more phase than anyone implemented.
FAQ
How can a wrongly shifted opcode table half-work? Because most wrong opcodes still hit some valid command. The device detected, enumerated, and even reported a gain list — while “set sample rate” was actually setting LNA gain and “get sample rates” was actually setting frequency. The device always did something; it just never did what was asked. That’s what makes the class dangerous: partial success reads as “nearly working” instead of “not talking the protocol at all.”
What does “real-sampling receiver” mean, and how is it different from an RTL-SDR?
An RTL-SDR delivers complex (I/Q) baseband: two channels in quadrature, produced
by its own hardware chain. The Airspy R2/Mini instead stream the bare output of
one ADC — real samples, 12 bits in a little-endian uint16, DC offset at 2048 —
at twice the configured IQ rate, and expect the host to construct the complex
signal. libairspy does that construction internally, which is exactly why it’s
easy to port the USB protocol and forget the conversion exists.
Why 78° and not exactly 90°?
The measured “phase imbalance” of the mis-paired stream is arcsin of the
correlation between adjacent samples. Neighboring samples of an oversampled real
signal are highly correlated but not perfectly so — the residual decorrelation
comes from the signal’s actual bandwidth — so the angle lands near, but not at,
90°. Anything in that neighborhood, combined with ~0 dB gain error and no image
rejection, means the two “channels” are one real signal read twice.
Why write the converter from scratch instead of using libairspy’s? License. libairspy’s converter carries an Airspy-ecosystem-only license, and GopherTrunk only ports permissively-licensed reference code. The chain — leaky DC blocker, Fs/4 translation, half-band Hilbert pair, decimate-by-two — is standard DSP, implementable from first principles and verifiable by measurement (phase −0.0007°, image rejection ~70.8 dB on a synthetic tone).
Does the Airspy Mini need the same treatment? Yes. The R2 and Mini share the real-sampling architecture, so both stream real ADC samples at twice the IQ rate and both go through the same host-side converter. How the 2× real rate interacts with each model’s usable IQ rates is covered in Airspy rate selection.
Series navigation
Part 12 of 22 · ← Part 11: Detected but Not Present — One Hex Code from a Fix That Already Existed · Next → Part 13: The SoapyRemote Handshake — Three Wrong Root Causes and a Server That Says Nothing First