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 to android.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 small extern "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 via dart: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 a MethodChannel/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 correspondentWhat it's for
rtlsdr_shim.h/.cnative_bindings.dart, shim_types.dartWhole 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/pilotLevel19kHz pilot PLL, harmonics for demux/RDS
dsp/rds_decoder.*ShimRdsInfo57kHz RDS/RBDS decode
dsp/deemphasis.*WFM L/R de-emphasis (50/75µs)
dsp/spectrum_fft.*shimGetSpectrumDbKissFFT over raw IQ, for waterfall
dsp/wav_writer.*shimStart/StopRecordingStreaming WAV writer (demodulated PCM)
dsp/iq_writer.*shimStart/StopIqRecordingRaw .cu8 I/Q writer
audio/audio_sink*Oboe (AAudio) playback
jni_bridge.cpp— (Kotlin's external fun)USB-fd handoff, JNI only
native_library.dartdlopen the shared library
usb_state.dart / usb_channel.dartUSB 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 in rtlsdr_read_async, copies raw bytes into iq_ring as 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..1 floats, 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 to pcm_ring and 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_mainswitching 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.

GroupFunctions
Lifecycleshim_open_with_fd, shim_close, shim_is_open
Tuningshim_set/get_frequency_hz, shim_set/get_sample_rate_hz
Gainshim_set_gain_mode, shim_set_gain_tenth_db, shim_get_gain_list
Demod/squelchshim_set/get_demod_mode, shim_set_squelch_threshold_db
Stereo/RDS (WFM)shim_set_stereo_enabled, shim_set_rds_enabled, shim_get_rds_info
Streamingshim_start/stop_streaming, shim_is_streaming
Telemetryshim_get_statsshim_stats_t
Recording (PCM)shim_start/stop_recording, shim_is_recording
Recording (raw I/Q)shim_start/stop_iq_recording, shim_is_iq_recording
Spectrumshim_get_spectrum_db
Maps to → native_bindings.dart (every function above), shim_types.dart (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.

Maps to → demod_mode.dart's wfm/nfm values.

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.

Maps to → demod_mode.dart's 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.

Verified against a synthetic signal, not real hardware. 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).
Maps to → demod_mode.dart's usb/lsb values.

dsp/fm_stereo_pilot.h / .c C

android/src/main/cpp/dsp/fm_stereo_pilot.h, fm_stereo_pilot.c

Second-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.

Not validated on real hardware (no dongle in the development environment). Loop gains (~8Hz bandwidth) and lock amplitude thresholds are starting-point estimates — may need tuning against a real dongle, especially given the crystal frequency offset common in cheap RTL-SDR dongles.
Maps to → 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.c

Full 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.

Not validated on real hardware — the highest-risk module in the project. Known simplifications: 2-point linear interpolation for Gardner (vs. an independent interpolation at −sps/2); block sync acquisition anchored only on offset A instead of searching all 5 offsets in parallel; biphase-pair ambiguity resolved by a fallback heuristic rather than analytically; no burst error correction (a group with any CRC error is simply discarded); RDS character set not mapped (PS/RadioText stored as raw bytes — correct for plain-ASCII stations, not the extended RDS table).
Maps to → 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.c

One-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.

Maps to → 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.

Maps to → 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.

Maps to → 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.

Maps to → Kotlin's 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's UsbDeviceConnection already 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: noDeviceattachedpermissionRequestedpermissionGranted/permissionDenieddeviceReady) 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.