Native core & Dart bindings reference
A component-by-component map of how driver_rtlsdr is built: every
native C/C++ file under android/src/main/cpp/, every Dart file
under lib/src/, and the two Kotlin files that bridge Android's USB
permission APIs to the native core. See README.md for
how to use the package — this document is about how it works internally.
What this document covers
Three layers, in the order data actually flows through them:
-
Kotlin (
android/src/main/kotlin/) — the only layer that can talk toandroid.hardware.usb.UsbManager. Detects the dongle, requests USB permission, and hands a raw file descriptor to the native side over JNI. Everything else (tuning, streaming, stats, recording) never touches Kotlin again. -
Native core (
android/src/main/cpp/, C11 + a little C++17 for Oboe) — opens the dongle with that fd, runs two threads (USB read, DSP), and exposes a smallextern "C"API (rtlsdr_shim.h) that both JNI and Dart FFI call directly into the same loaded.so. -
Dart (
lib/src/) — calls that same API directly viadart:ffi(no method channel round-trip for the hot path: tuning, streaming control, telemetry polling). The USB permission lifecycle is the one thing that still goes through aMethodChannel/EventChannel, because only Kotlin can grant it.
Every native module below links to its Dart correspondent where one exists (some, like the ring buffer or the FIR decimator, are pure implementation detail with nothing exposed across the FFI boundary).
Signal pipeline
USB dongle (RTL2832U)
│
│ Kotlin: UsbManager, permission dialog, UsbDeviceConnection
▼
DriverRtlsdrPlugin.kt ──JNI (RegisterNatives)──► jni_bridge.cpp
│
▼
rtlsdr_shim.c: shim_open_with_fd()
libusb_wrap_sys_device + rtlsdr_open_fd
│
┌─────────────────┴─────────────────┐
▼ │
usb_thread (rtlsdr_read_async) │
│ iq_callback() │
▼ │
iq_ring (dsp/ring_buffer.c) ── SPSC ──► dsp_thread_main()
│
┌──────────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
dsp/fir_decimator (IQ stage) dsp/spectrum_fft dsp/iq_writer
(channel-band decimation) (raw pre-decim FFT, (raw .cu8 tap,
│ waterfall/spectrum) demod-independent)
▼
demod dispatch on demod_mode_t (rtlsdr_shim.c):
dsp/demod_fm (WFM/NFM) · dsp/demod_am (AM) · dsp/demod_ssb (USB/LSB)
│
(WFM only) dsp/fm_stereo_pilot ──► dsp/rds_decoder
│
squelch (NFM/AM/USB/LSB) + dsp/fir_decimator (audio stage)
+ dsp/deemphasis (WFM L/R, post-split)
│
▼
pcm_ring ──► audio/audio_sink_oboe.cpp (Oboe) ──► speaker
│
▼
dsp/wav_writer (demodulated-PCM recording tap)
Dart, same process, same loaded .so, direct FFI (no channel round-trip):
native_library.dart (dlopen) → native_bindings.dart (function table)
│
shim_types.dart (ShimStats / ShimRdsInfo structs)
demod_mode.dart (DemodMode enum ↔ demod_mode_t)
usb_state.dart + usb_channel.dart — MethodChannel/EventChannel to Kotlin,
separate from the FFI path above (only for USB permission lifecycle).
Component map
| Native (C/C++) | Dart correspondent | What it's for |
|---|---|---|
rtlsdr_shim.h/.c | native_bindings.dart, shim_types.dart | Whole public API + DSP thread orchestration |
dsp/ring_buffer.* | — | SPSC byte ring buffer (USB → DSP thread) |
dsp/fir_decimator.* | — | Two-stage decimation (IQ stage, audio stage) |
dsp/demod_fm.* | demod_mode.dart (wfm/nfm) | Quadrature discriminator |
dsp/demod_am.* | demod_mode.dart (am) | Envelope detector + DC blocker |
dsp/demod_ssb.* | demod_mode.dart (usb/lsb) | Phasing-method SSB demod |
dsp/fm_stereo_pilot.* | ShimStats.stereoLocked/pilotLevel | 19kHz pilot PLL, harmonics for demux/RDS |
dsp/rds_decoder.* | ShimRdsInfo | 57kHz RDS/RBDS decode |
dsp/deemphasis.* | — | WFM L/R de-emphasis (50/75µs) |
dsp/spectrum_fft.* | shimGetSpectrumDb | KissFFT over raw IQ, for waterfall |
dsp/wav_writer.* | shimStart/StopRecording | Streaming WAV writer (demodulated PCM) |
dsp/iq_writer.* | shimStart/StopIqRecording | Raw .cu8 I/Q writer |
audio/audio_sink* | — | Oboe (AAudio) playback |
jni_bridge.cpp | — (Kotlin's external fun) | USB-fd handoff, JNI only |
| — | native_library.dart | dlopen the shared library |
| — | usb_state.dart / usb_channel.dart | USB permission lifecycle (channel, not FFI) |
Native core (C/C++)
rtlsdr_shim.h / rtlsdr_shim.c C
android/src/main/cpp/rtlsdr_shim.h, rtlsdr_shim.c
The whole public API of the native library, and the orchestrator that ties every other
module together. Every function is extern "C" so it can be called both by
Dart FFI (direct dlsym, no name mangling) and by jni_bridge.cpp.
Only shim_open_with_fd() is JNI-only — it needs a USB file descriptor that
only Kotlin's UsbManager can obtain.
Lifecycle: shim_open_with_fd wraps the fd with
libusb_wrap_sys_device + rtlsdr_open_fd (a patched librtlsdr entry
point — see vendor — that skips libusb enumeration, which would need
root on Android), allocates the two ring buffers, and sets defaults (100.0MHz, 1.024Msps,
AGC on, WFM). shim_start_streaming spawns two pthreads:
usb_thread_main— blocks inrtlsdr_read_async, copies raw bytes intoiq_ringas fast as possible, nothing else (must never be slow).dsp_thread_main— the actual pipeline: reads raw I/Q off the ring, converts the 8-bit unsigned offset-binary samples to-1..1floats, taps the raw stream for spectrum and I/Q recording, runs the IQ-stage decimator, computes RF level + squelch, dispatches to the mode's demodulator (FM/AM/SSB), runs stereo/RDS when in WFM, decimates to audio rate, applies de-emphasis (WFM), converts to PCM16, writes topcm_ringand taps it for WAV recording.
Decimation targets are mode-dependent: WFM decimates the IQ stage to ~256kHz (wide enough
for ±75kHz deviation + the 38/57kHz subcarriers); NFM/AM/USB/LSB all decimate to ~32kHz
(narrow channel). The audio stage further decimates to a fixed 32kHz output rate.
demod_mode is only read once, at the top of dsp_thread_main —
switching modes requires stopping and restarting streaming — but
stereo/RDS/squelch/gain are all read live from atomics every block, no restart needed.
State is a single global shim_state_t g_state (mutexes for anything with a
heap pointer that can be swapped concurrently — recording writers, RDS/spectrum snapshots
— atomics for scalar telemetry). This is deliberate: there is exactly one RTL-SDR dongle
per process, so a singleton avoids threading a context pointer through every FFI call.
| Group | Functions |
|---|---|
| Lifecycle | shim_open_with_fd, shim_close, shim_is_open |
| Tuning | shim_set/get_frequency_hz, shim_set/get_sample_rate_hz |
| Gain | shim_set_gain_mode, shim_set_gain_tenth_db, shim_get_gain_list |
| Demod/squelch | shim_set/get_demod_mode, shim_set_squelch_threshold_db |
| Stereo/RDS (WFM) | shim_set_stereo_enabled, shim_set_rds_enabled, shim_get_rds_info |
| Streaming | shim_start/stop_streaming, shim_is_streaming |
| Telemetry | shim_get_stats → shim_stats_t |
| Recording (PCM) | shim_start/stop_recording, shim_is_recording |
| Recording (raw I/Q) | shim_start/stop_iq_recording, shim_is_iq_recording |
| Spectrum | shim_get_spectrum_db |
shim_stats_t/shim_rds_info_t).dsp/ring_buffer.h / .c C
android/src/main/cpp/dsp/ring_buffer.h, ring_buffer.c
Lock-free single-producer/single-consumer byte ring buffer. Capacity must be a power of 2
(masking instead of modulo). Uses monotonically increasing head/tail
counters rather than values already wrapped to capacity — wraparound of the
counters themselves is fine because only their difference is ever used.
Exactly two instances exist: iq_ring (4MiB, ≈1s of margin at 2.048Msps) between
usb_thread_main and dsp_thread_main, and pcm_ring
(256KiB) between dsp_thread_main and Oboe's audio callback. Writes that don't
fit are dropped and counted (ring_overflow_count in shim_stats_t)
rather than blocking — a producer/consumer both running on time-sensitive threads must
never stall waiting on each other.
Not exposed across FFI — purely an implementation detail of the pipeline's plumbing.
dsp/fir_decimator.h / .c C
android/src/main/cpp/dsp/fir_decimator.h, fir_decimator.c
Generic Hamming-windowed low-pass FIR, usable in both complex mode (I/Q, anti-aliasing
before reducing the sample rate) and real mode (post-demodulation audio, in_q/
out_q = NULL). Keeps a circular history buffer and only computes
the tap dot-product at decimation instants — cost is O(num_taps) per
output sample, not per input sample.
Used twice per streaming session: once for the IQ stage (63 taps, cutoff scaled to the decimation factor) and once for the audio stage (same tap count, different cutoff). In WFM the audio stage runs in "complex mode" too, but as two independent parallel real filters for L and R rather than an actual I/Q rotation.
Not exposed across FFI.
dsp/demod_fm.h / .c C
android/src/main/cpp/dsp/demod_fm.h, demod_fm.c
Shared quadrature discriminator for WFM and NFM — the math is identical, only
max_deviation_hz (75kHz WFM, 5kHz NFM) and de-emphasis differ.
Conjugate-product discriminator: d[n] = s[n]·conj(s[n-1]), phase via
atan2f, scaled so maximum deviation maps to ±1.0 audio amplitude. In WFM the
de-emphasis argument is passed as 0 — the raw MPX has to survive intact (pilot
+ subcarriers) until after the stereo demux; actual de-emphasis runs afterward, per channel,
via dsp/deemphasis.
dsp/demod_am.h / .c C
android/src/main/cpp/dsp/demod_am.h, demod_am.c
Envelope detector (sqrt(i²+q²)) followed by a one-pole DC blocker (coefficient
0.995, cuts well below 100Hz at the ~32kHz audio rates used here) to remove the large offset
the carrier leaves behind. The smallest demodulator in the codebase — 36 lines.
am value.dsp/demod_ssb.h / .c C
android/src/main/cpp/dsp/demod_ssb.h, demod_ssb.c
SSB demodulation via the phasing method. Given the complex baseband
s[n] = i[n] + j·q[n] (same convention as demod_fm's
discriminator), simply taking i[n] as audio would fold together whatever sits
at +f and -f — a real signal on the wrong side of the tuned
frequency would leak straight through. Instead:
audio_usb[n] = i[n-d] - hilbert(q)[n] // keeps +f, rejects -f
audio_lsb[n] = i[n-d] + hilbert(q)[n] // keeps -f, rejects +f
hilbert(q) is a 63-tap windowed linear-phase FIR approximation of the ideal
Hilbert transform (odd taps only, h[k] = 2/(πk), Hamming-windowed — same
window formula as fir_decimator, for consistency); d
is that filter's group delay (31 samples), applied to i[n] via a plain circular
delay buffer so both branches line up in time before combining.
tool/native_tests/test_demod_ssb.c feeds single-tone complex baseband signals
on each side of DC and checks that the matching sideband recovers the tone while the other
rejects it (>50dB image rejection measured; run it with plain cc, no Android
toolchain needed — see the file's header comment for the exact command).
dsp/fm_stereo_pilot.h / .c C
android/src/main/cpp/dsp/fm_stereo_pilot.h, fm_stereo_pilot.cSecond-order PLL locked to the 19kHz stereo pilot tone, running directly on the raw MPX (no dedicated bandpass — the PLL's own narrow loop acts as a tracking filter, the same technique open-source decoders like SoftFM use). Generates the 2nd and 3rd harmonics (38kHz for the L−R demux, 57kHz for RDS downconversion) via double/triple-angle identities from the NCO's own phase, so no second or third oscillator is needed.
ShimStats.stereoLocked/pilotLevel in shim_types.dart, toggled via shimSetStereoEnabled in native_bindings.dart.dsp/rds_decoder.h / .c C
android/src/main/cpp/dsp/rds_decoder.h, rds_decoder.cFull RDS/RBDS decode chain: coherent downconversion of the 57kHz subcarrier (reusing the stereo pilot's PLL) → decimation → Gardner symbol-clock recovery at the 2375Hz biphase chip rate → BPSK Costas loop for residual phase → biphase combining + differential decoding → 26-bit block sync (CRC/offset words) → group parsing (PI/PTY/TP/TA/PS from group 0A, RadioText from group 2A/2B).
The CRC polynomial, offset-word/syndrome values, and the 26-row parity check matrix were checked byte-for-byte against redsea (an actively-maintained open-source reference, MIT-licensed) rather than trusted from memory. The DSP front-end (Gardner, Costas, biphase combining) is an original, simpler implementation.
ShimRdsInfo in shim_types.dart, toggled via shimSetRdsEnabled/read via shimGetRdsInfo in native_bindings.dart.dsp/deemphasis.h / .c C
android/src/main/cpp/dsp/deemphasis.h, deemphasis.cOne-pole de-emphasis shelf filter (50µs Europe/world, 75µs US/Korea). Extracted as its own module because WFM stereo needs it applied twice — once per L/R channel, after the pilot/L−R split and audio decimation — rather than once over the whole MPX before the split (which would smear the 19/38/57kHz content the demux and RDS decoder both still need intact).
Not exposed across FFI — an internal WFM-only detail.
dsp/spectrum_fft.h / .c C
android/src/main/cpp/dsp/spectrum_fft.h, spectrum_fft.c
Complex FFT (via the vendored KissFFT) over a Hann-windowed block of
raw, pre-decimation I/Q — a view of the whole captured band, not the demodulator's
narrow channel. Output is already fftshift-reordered (bin 0 = lower edge of the
band, last bin = upper edge, DC in the middle) so it's ready to draw directly onto a
waterfall/spectrum widget. Throttled to ~20Hz in dsp_thread_main regardless of
how often the DSP loop itself runs.
shimGetSpectrumDb in native_bindings.dart.dsp/wav_writer.h / .c C
android/src/main/cpp/dsp/wav_writer.h, wav_writer.c
Incremental PCM16 WAV writer — writes a placeholder 44-byte RIFF/data header up front (the
final size is only known when recording stops), then streams samples as they arrive without
buffering the whole recording in memory. wav_writer_close seeks back and fixes
up the two size fields.
Tapped directly in dsp_thread_main, right before the PCM is written to
pcm_ring — records exactly what's about to reach the speaker, mono or stereo
depending on the active demod_mode.
shimStart/StopRecording/shimIsRecording in native_bindings.dart, progress via ShimStats.recordingBytesWritten.dsp/iq_writer.h / .c C
android/src/main/cpp/dsp/iq_writer.h, iq_writer.c
The simplest writer in the codebase: no header, just a straight fwrite of the
interleaved 8-bit unsigned I/Q bytes exactly as the dongle sends them. This is the same
.cu8 convention rtl_sdr/GNU Radio/gqrx use for raw captures, so a
recording from this driver opens directly in those tools.
Tapped in dsp_thread_main before the IQ-stage decimator — a faithful,
demod_mode-independent capture of what the dongle actually sent, useful for
signals this driver doesn't demodulate (yet). Runs on its own lock/state pair
(iq_record_lock/iq_record_writer), independent of
wav_writer's — both taps can be active at the same time.
shimStart/StopIqRecording/shimIsIqRecording in native_bindings.dart, progress via ShimStats.iqRecordingBytesWritten.audio/audio_sink.h, audio/audio_sink_oboe.cpp C / C++
android/src/main/cpp/audio/audio_sink.h, audio_sink_oboe.cpp
audio_sink.h is a deliberately plain C boundary between the DSP thread (C,
rtlsdr_shim.c) and Oboe (C++, needs C++17). Keeping it C-only avoids pulling
dsp/ring_buffer.h's _Atomic C11 syntax into a .cpp
translation unit, which doesn't mix cleanly with C++17.
audio_sink_oboe.cpp implements it against Oboe/AAudio: a pull-based callback
(audio_pull_cb_t) that Oboe's high-priority audio thread calls whenever it
needs more frames — rtlsdr_shim.c supplies pcm_pull_callback,
which just drains pcm_ring. An underrun (the callback returns fewer frames
than requested) zero-fills the rest of the buffer, careful to compute the fill offset in
samples (frames × channel count), not bytes.
Not exposed across FFI — audio just plays; there's nothing for Dart to control here beyond
the channel count/sample rate implied by the active demod_mode.
jni_bridge.cpp C++
android/src/main/cpp/jni_bridge.cpp
The only JNI surface in the whole native library — it exists purely because USB
permission/opening can only happen from Kotlin. Registers three native methods
(nativeOpenWithFd/nativeClose/nativeIsOpen) against
DriverRtlsdrPlugin via RegisterNatives in JNI_OnLoad,
rather than relying on the static name-mangling convention
(Java_com_..._DriverRtlsdrPlugin_nativeXxx) — the underscore in
driver_rtlsdr mangles to _1, easy to get wrong by hand.
Everything past the initial fd handoff (tuning, streaming, stats, recording) is called by
Dart directly via FFI into rtlsdr_shim.h's functions — this file has nothing
to do with any of that.
external fun nativeOpenWithFd/nativeClose/nativeIsOpen in DriverRtlsdrPlugin.kt (not Dart — this bridge is Kotlin-only).vendor/ — libusb, librtlsdr, KissFFT C
android/src/main/cpp/vendor/Three vendored third-party dependencies, built from source (portable C, no prebuilt per-ABI binaries — which is exactly why building an extra ABI for CI testing, like the x86_64 opt-in used by the integration test emulator, is straightforward):
- libusb (LGPL-2.1) — only the pieces this project actually needs
(
core.c,descriptor.c,hotplug.c,io.c,sync.c, the Linux/posix backend files), same source list librtlsdr's own Android build uses. - librtlsdr (GPLv2-or-later) — the tuner/demod-agnostic RTL2832U driver
itself, patched (
rtlsdr_open_fd) to open a dongle from a file descriptor Android'sUsbDeviceConnectionalready granted, instead of enumerating USB devices itself (which would require root on Android). - KissFFT (BSD-3-Clause) — the FFT engine behind dsp/spectrum_fft.
See tool/setup_native_deps.sh for how these are fetched/patched, and
LICENSE for the full license breakdown (this driver links GPLv2 librtlsdr, which
requires any app using it to be GPL-distributed too).
Dart bindings
native_library.dart Dart
lib/src/native_library.dart
Opens libnative_rtlsdr.so via dart:ffi's
DynamicLibrary.open — the exact same shared library Kotlin loads with
System.loadLibrary("native_rtlsdr"). Because both sides run in the same
Android process and reference the same soname, the system linker resolves them to the
same loaded instance — which is why rtlsdr_shim.c's global
g_state is effectively shared between the JNI-only USB open (Kotlin) and the
FFI-only control surface (Dart), with no IPC involved.
Throws UnsupportedError if opened on a non-Android host — this package is
Android-only.
native_bindings.dart Dart
lib/src/native_bindings.dart
Hand-written FFI bindings (not ffigen — the API is small and stable enough
that the extra tooling isn't worth it) for every function in
rtlsdr_shim.h except shim_open_with_fd, which
deliberately has no Dart binding: only Kotlin has a valid USB file descriptor with
permission already granted (see usb_channel.dart). Each entry
is a static final function pointer resolved once via
DynamicLibrary.lookupFunction, grouped into the same sections as the C header
(lifecycle, tuning, gain, demod/squelch, stereo, RDS, streaming, telemetry, PCM recording,
raw I/Q recording, spectrum).
shim_types.dart Dart
lib/src/shim_types.dart
Two ffi.Struct subclasses that must mirror shim_stats_t and
shim_rds_info_t from rtlsdr_shim.h byte-for-byte — same field
order and types, including whatever implicit alignment padding a C compiler would insert
(dart:ffi resolves that the same way, as long as the order matches).
test/shim_types_test.dart guards this with a hand-computed
sizeOf check against each struct — not a full offset check (dart:ffi
doesn't expose a public offsetOf), but it catches the most common mistake:
adding a field on one side and forgetting the other. ShimStats is currently
48 bytes (grew from 40 when iqRecordingBytesWritten was added); ShimRdsInfo
is 100 bytes.
demod_mode.dart Dart
lib/src/demod_mode.dart
DemodMode enum mirroring demod_mode_t — the numeric
nativeValue of each variant (wfm=0, nfm=1,
am=2, usb=3, lsb=4) is passed directly to
shimSetDemodMode via FFI, so it must stay in sync with the C enum by hand.
Also carries two convenience getters used by consuming UIs:
supportsSquelch (everything except WFM) and supportsStereoAndRds
(WFM only).
usb_state.dart Dart
lib/src/usb_state.dart
UsbState is a plain ChangeNotifier holding the observable
connection status (UsbConnectionStatus: noDevice →
attached → permissionRequested →
permissionGranted/permissionDenied → deviceReady)
plus the currently-connected UsbDeviceInfo and any last error message. It has
no native/channel code of its own — every mutator (deviceAttached,
permissionGranted, etc.) is called by usb_channel.dart
in response to Kotlin events, keeping the observable state and the transport that feeds it
cleanly separated.
usb_channel.dart Dart
lib/src/usb_channel.dart
The one part of this package that does not use FFI — bridges to Kotlin via a
MethodChannel (driver_rtlsdr, for commands:
getConnectedDevice, requestPermission) and an
EventChannel (driver_rtlsdr/events, for
attached/detached/permissionGranted/
permissionDenied/deviceReady/error events), because
only Kotlin's UsbAttachReceiver/UsbManager can grant USB
permission. Every incoming event is translated into a call on UsbState.
Kotlin bridge
Included for completeness — this is the layer that makes the native/Dart split above possible at all (only Kotlin can grant USB permission), even though it isn't C or Dart itself.
DriverRtlsdrPlugin.kt Kotlin
android/src/main/kotlin/com/rtlsdrmobile/driver_rtlsdr/DriverRtlsdrPlugin.kt
The plugin's Flutter entry point (FlutterPlugin,
MethodCallHandler, EventChannel.StreamHandler). Detects the
dongle via UsbManager.deviceList, requests permission through the classic
PendingIntent pattern, and — once granted — opens a
UsbDeviceConnection and hands its file descriptor to
nativeOpenWithFd (declared as external fun, resolved by
jni_bridge.cpp's RegisterNatives call). From that
point on this class is out of the picture — tuning/streaming/stats/recording all go
straight from Dart to the native library over FFI.
Deliberately does not manage a foreground service — unlike the original
rtl-sdr mobile app this driver was extracted from, keeping the process alive
during background streaming is left as a UX decision for each consuming app (see
README.md's "What this package deliberately does NOT provide").
UsbAttachReceiver.kt Kotlin
android/src/main/kotlin/com/rtlsdrmobile/driver_rtlsdr/UsbAttachReceiver.kt
A small BroadcastReceiver that listens for
ACTION_USB_DEVICE_ATTACHED/DETACHED and the result of the
permission dialog DriverRtlsdrPlugin triggers, and forwards each as a plain
callback on its Listener interface
(onDeviceAttached/onDeviceDetached/onPermissionResult)
— DriverRtlsdrPlugin implements that interface and turns each callback into an
EventChannel event for Dart.