core_rtlsdr

pub package pub points pub likes CI License: GPL v2 or later

Testable radio engine for RTL-SDR (RTL2832U dongles) on Android/Flutter, built on top of the driver_rtlsdr plugin (source).

driver_rtlsdr exposes the native core as raw FFI/USB primitives — tuning, streaming, gain, stats — and deliberately has no opinion on session logic or UI. core_rtlsdr is the layer in between: a set of ChangeNotifier controllers (tuning, streaming, demodulation, gain, squelch, stereo, RDS, spectrum, recording, band scan, presets) that turn those primitives into ready-to-use radio behavior — the same shape validated in the rtl-sdr mobile reference app, but decoupled from any one app, and unit-testable on a host with no dongle, no emulator, and no Android device at all.

Why this exists

Every app built directly on driver_rtlsdr (or on raw FFI) ends up reimplementing the same ~1000 lines of controller logic: a stats-polling timer with correct bytesPerSecond bookkeeping, a stereo-pilot-aware RDS poller, a band scanner that samples RF level fast enough to not miss a station, a presets store. That logic is genuinely reusable — it doesn't depend on dart:ffi, doesn't depend on Android, and shouldn't have to be re-tested by hand on a real dongle every time it changes. core_rtlsdr extracts it once, tests it against a fake, and lets every consumer (this package's own example/, and — the point of this package — a future widget_rtlsdr UI library) depend on the same tested implementation.

What this package provides

  • RtlSdrDriver: the seam. An interface covering everything a radio session needs from the native core, with plain-Dart models (RadioStats, RdsInfo) instead of raw FFI structs. NativeRtlSdrDriver is the real, Android-only implementation (a thin adapter over driver_rtlsdr's NativeBindings). Every controller below depends on this interface, never on FFI directly.
  • RadioController: tuning, streaming, demodulation mode (WFM/NFM/AM/USB/LSB), gain (auto/manual), squelch, stereo toggle, live stats (IQ throughput, RF/audio level, ring buffer overflow, PCM/I·Q recording bytes written). Owns spectrumController and rdsController and starts/stops them together with streaming.
  • SpectrumController: higher-rate polling (~25 fps default) of the captured band's spectrum — getSpectrumDb, ready to plot.
  • RdsController: PI/PTY/TP/TA/PS/RadioText decoding, only meaningful once the stereo pilot is locked and RDS is enabled.
  • RecordingController + defaultRecordingPath/defaultIqRecordingPath: records the demodulated PCM to a WAV file, and/or the raw interleaved I/Q (pre-demodulation, .cu8) to its own file — independent of each other, at a path you choose (ready-made timestamped paths under app-specific external storage are one call away), or straight into the public Downloads folder via startRecordingToDownloads/ startIqRecordingToDownloads (Android MediaStore, API 29+, with an automatic fallback to app-specific storage below that). shareRecording/ shareIqRecording hand the last completed recording to Android's native share sheet either way.
  • ScanController: sweeps a frequency range sampling RF level fast enough to catch a station in a ~70 ms step, mode/band agnostic (works for NFM/PMR scanning exactly like commercial WFM).
  • PresetsController + PresetsRepository (SharedPreferencesPresetsRepository, InMemoryPresetsRepository): save/recall a frequency+mode+gain combination, storage backend swappable via the repository interface.
  • UsbState/UsbChannel/DemodMode: re-exported from driver_rtlsdr so a consumer only ever needs to depend on this one package.

What this package deliberately does NOT provide

  • UI: zero widgets, same stance as driver_rtlsdr. example/ builds a plain Material UI directly against these controllers to prove they're sufficient on their own — a richer, reusable widget library (waterfall view, draggable spectrum tuner, themed panels) is exactly the job of a future widget_rtlsdr package (see below).
  • A foreground service: keeping the process alive in the background during streaming is a UX decision for each app. Use RadioController(driver, onStreamingStarted: ..., onStreamingStopped: ...) to hook your own in.
  • Where to save recordings: RecordingController.startRecording takes an absolute path; defaultRecordingPath is a convenience, not a requirement.

Installation

dependencies:
  core_rtlsdr: ^0.2.0

core_rtlsdr depends on driver_rtlsdr (pulled in transitively — no need to depend on it directly), which is Android-only. See "Android setup" below for the two things your app needs beyond pubspec.yaml.

Android setup

  1. minSdk = 26 in your app's android/app/build.gradle.kts — required by the Oboe/AAudio low-latency audio path driver_rtlsdr uses internally:

    android {
        defaultConfig {
            minSdk = maxOf(flutter.minSdkVersion, 26)
        }
    }
    
  2. USB auto-open intent filter, on your launcher <activity> in android/app/src/main/AndroidManifest.xml — this is what makes Android offer to open your app automatically when the dongle is plugged in (optional, but the whole point of a USB-OTG radio app):

    <activity ...>
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>
            <category android:name="android.intent.category.LAUNCHER"/>
        </intent-filter>
        <intent-filter>
            <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
        </intent-filter>
        <meta-data
            android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
            android:resource="@xml/device_filter" />
    </activity>
    

    @xml/device_filter (the VID/PID list of supported dongles) and the android.hardware.usb.host permission both come from driver_rtlsdr's own manifest, merged into your build automatically by Gradle's resource merger — there's nothing to copy. See example/android/app/src/main/AndroidManifest.xml in this repo for a working reference.

That's it — no native build step of your own, no CMake/NDK configuration: driver_rtlsdr's Gradle plugin handles compiling the vendored native core for you the first time you flutter pub get/build.

Usage

Every snippet below assumes:

import 'package:core_rtlsdr/core_rtlsdr.dart';

They build on each other — sections 1 and 2 are the minimum to get audio out of the speaker; everything else is additive and independent of each other (skip to whichever you need).

1. Detect the dongle and wait for it to be ready

The native driver isn't usable until Android has granted USB permission and driver_rtlsdr's Kotlin side has opened the device — that's the deviceReady event on `UsbState`.

final usbState = UsbState();
final usbChannel = UsbChannel(state: usbState);

// Call once, e.g. in initState()/on app start — covers the case where the
// dongle is already plugged in when your app opens (there's no ATTACHED
// intent to catch in that case).
await usbChannel.refreshConnectedDevices();

usbState.addListener(() {
  switch (usbState.status) {
    case UsbConnectionStatus.noDevice:
      print('No RTL-SDR dongle connected.');
    case UsbConnectionStatus.attached:
      usbChannel.requestPermission(); // a dongle showed up — ask for permission
    case UsbConnectionStatus.permissionRequested:
      print('Waiting for the permission dialog...');
    case UsbConnectionStatus.permissionDenied:
      print('User denied USB permission.');
    case UsbConnectionStatus.permissionGranted:
      print('Permission granted, native driver opening...');
    case UsbConnectionStatus.deviceReady:
      onDeviceReady(); // only now is it safe to construct NativeRtlSdrDriver()
  }
  if (usbState.lastError != null) print('USB error: ${usbState.lastError}');
});

// Don't forget, when the screen/app is done with it:
// usbChannel.dispose();

2. Tune and start streaming

late final RadioController radio;

void onDeviceReady() {
  radio = RadioController(NativeRtlSdrDriver());

  radio.setFrequencyHz(101500000); // 101.5 MHz
  radio.setDemodMode(DemodMode.wfm);
  radio.startStreaming();

  radio.addListener(() {
    print('${(radio.frequencyHz / 1e6).toStringAsFixed(1)} MHz  '
        'RF: ${radio.rfLevelDbfs.toStringAsFixed(1)} dBFS  '
        '${(radio.bytesPerSecond / 1e6).toStringAsFixed(2)} MB/s');
    if (radio.lastError != null) print('Radio error: ${radio.lastError}');
  });
}

// Call when leaving the screen / shutting the radio session down.
void dispose() {
  radio.dispose(); // stops streaming if still running
}

RadioController polls stats on a timer (every 500ms by default — statsInterval is a constructor parameter) and calls notifyListeners() on every tick — use it with provider's ChangeNotifierProvider, ListenableBuilder, or addListener directly, as above. See section 10 for the full provider composition.

3. Demodulation mode and squelch

// WFM (commercial FM, stereo+RDS capable), NFM (narrowband — PMR/ham/etc.),
// AM, or USB/LSB (single sideband — ham/shortwave). Switching mode while
// streaming restarts it automatically to apply immediately — no need to
// call stopStreaming()/startStreaming() yourself.
radio.setDemodMode(DemodMode.nfm);
// radio.setDemodMode(DemodMode.usb); // or .lsb — single sideband

// Squelch applies to NFM/AM/USB/LSB — WFM (commercial broadcast) doesn't use it.
if (radio.demodMode.supportsSquelch) {
  radio.setSquelchThresholdDb(-40); // dBFS
  print(radio.squelchOpen ? 'Signal present' : 'Squelched');
}

4. Gain: automatic or manual

// Fetch the gains this specific tuner supports (tenths of a dB) — call
// once after deviceReady; the list doesn't change at runtime.
radio.refreshGainList(); // populates radio.gainList

radio.setGainAuto(true); // AGC (the default)

// Or pick a manual gain from radio.gainList (e.g. the strongest available):
radio.setGainTenthDb(radio.gainList.last); // switches gainAuto off automatically
print('Gain: ${radio.gainTenthDb / 10} dB');

5. Stereo and RDS (WFM only)

radio.setStereoEnabled(true); // on by default

radio.addListener(() {
  if (radio.demodMode == DemodMode.wfm) {
    print(radio.stereoLocked ? 'Stereo (pilot locked)' : 'Mono');
  }
});

// RDS decoding is a separate controller, owned by RadioController and
// started/stopped automatically together with streaming.
radio.rdsController.setEnabled(true);
radio.rdsController.addListener(() {
  final info = radio.rdsController.info;
  if (info.syncLocked) {
    print('${info.programService} — ${info.radioText}'); // e.g. "BBC R1 — Now playing..."
  }
});

6. Spectrum data (for plotting)

// Also owned by RadioController, started/stopped with streaming. Polls
// faster than the main stats (~25fps by default) — kept as its own
// ChangeNotifier so a waterfall/spectrum widget can listen to just this
// and skip rebuilding the rest of a radio panel every frame.
radio.spectrumController.addListener(() {
  final List<double> bins = radio.spectrumController.bins; // dB; bin 0 = lower band edge
  // Feed `bins` to a chart/canvas — see
  // example/lib/widgets/spectrum_bars.dart for a minimal bar-chart
  // consumer, or build a waterfall view on top of it.
});

7. Recording to a WAV file

final recording = RecordingController(radio.driver); // same driver instance as radio

Future<void> startRecording() async {
  final path = await defaultRecordingPath(
    frequencyHz: radio.frequencyHz,
    mode: radio.demodMode,
  ); // .../Android/data/<pkg>/files/Recordings/rtlsdr_<timestamp>_<freq>MHz_<mode>.wav
  await recording.startRecording(path);
}

void stopRecording() => recording.stopRecording();

recording.addListener(() {
  if (recording.isRecording) {
    print('Recording to ${recording.currentFilePath}');
  } else if (recording.lastRecordingPath != null) {
    print('Saved ${recording.lastRecordingPath}');
  }
});

defaultRecordingPath is a convenience — pass any absolute path you like to startRecording instead (e.g. one resolved via your own path_provider/file_picker logic, or a user-chosen location).

8. Raw I/Q recording

// Same RecordingController as above — dumps the interleaved 8-bit unsigned
// I/Q exactly as the dongle sends it (the ".cu8" convention rtl_sdr/GNU
// Radio/gqrx use), tapped before decimation/demodulation. Independent of
// startRecording/stopRecording — both can run at once.
Future<void> startIqRecording() async {
  final path = await defaultIqRecordingPath(
    frequencyHz: radio.frequencyHz,
  ); // .../Android/data/<pkg>/files/Recordings/rtlsdr_iq_<timestamp>_<freq>MHz.cu8
  await recording.startIqRecording(path);
}

void stopIqRecording() => recording.stopIqRecording();

recording.addListener(() {
  if (recording.isIqRecording) {
    print('I/Q recording to ${recording.currentIqFilePath}');
  } else if (recording.lastIqRecordingPath != null) {
    print('I/Q saved ${recording.lastIqRecordingPath}');
  }
});

// Bytes written so far, polled the same way as the PCM recording:
radio.addListener(() => print('${radio.iqRecordingBytesWritten} bytes'));

9. Recording to Downloads, and sharing

// Same RecordingController, PCM or I/Q — records into the real, shared
// Downloads folder (Downloads/Recordings/...) via Android's MediaStore
// (API 29+) instead of app-specific storage, so the file shows up in the
// user's Files app / Downloads app without needing storage permissions.
// On Android below API 29 (no MediaStore.Downloads collection to insert
// into), this falls back to startRecording + defaultRecordingPath
// automatically — no error, no extra handling needed on your side.
await recording.startRecordingToDownloads(
  frequencyHz: radio.frequencyHz,
  mode: radio.demodMode,
);
await recording.startIqRecordingToDownloads(frequencyHz: radio.frequencyHz);

recording.stopRecording(); // or stopIqRecording() — same as before

// Share the last completed recording via Android's native share sheet.
// Works no matter which flow started it: a Downloads recording shares by
// its content:// URI directly, a plain-path recording is resolved to a
// shareable URI through a bundled FileProvider first.
await recording.shareRecording();
await recording.shareIqRecording();

RecordingController takes an optional downloadsChannel: constructor argument (defaults to DownloadsChannel(), re-exported from driver_rtlsdr) — swap in FakeDownloadsChannel from package:core_rtlsdr/testing.dart to unit-test any of the above without an Android device (see "Testing without hardware" below).

10. Band scanning

final scanner = ScanController(); // defaults: commercial FM band, 100kHz steps, -25dB threshold

scanner.addListener(() {
  print('Scanning: ${(scanner.progress * 100).toStringAsFixed(0)}%');
});

// Streaming must already be started (see section 2).
await scanner.startScan(radio);
// Or a custom range/mode, e.g. a PMR band in NFM:
// radio.setDemodMode(DemodMode.nfm);
// await scanner.startScan(radio, startHz: 446000000, endHz: 446200000, stepHz: 6250, thresholdDb: -30);

for (final hit in scanner.results) {
  print('${(hit.frequencyHz / 1e6).toStringAsFixed(3)} MHz '
      'at ${hit.rfLevelDbfs.toStringAsFixed(1)} dBFS');
}

scanner.applyHit(radio, scanner.results.first); // tune to a hit
scanner.stopScan(); // cancel mid-scan

11. Presets (save/recall frequency + mode + gain)

final presets = PresetsController(const SharedPreferencesPresetsRepository());
await presets.load();

await presets.add(Preset(
  name: 'Local NPR',
  frequencyHz: radio.frequencyHz,
  mode: radio.demodMode,
  gainAuto: radio.gainAuto,
  gainTenthDb: radio.gainTenthDb,
));

// Or straight from a scan hit:
scanner.saveHitAsPreset(presets, radio, scanner.results.first, 'Found station');

presets.applyTo(radio, presets.presets.first); // recall one
await presets.remove(presets.presets.first);

No persistence wanted (a quick prototype, or a test)? Use InMemoryPresetsRepository() instead — same PresetsController API, or implement PresetsRepository yourself against your own backend (a database, a server, ...).

12. Putting it all together with provider

This is exactly the shape example/lib/app.dart uses — a composition root that wires everything up once, with screens/widgets reading it via context.watch/context.read:

import 'package:core_rtlsdr/core_rtlsdr.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => UsbState()),
        ProxyProvider<UsbState, UsbChannel>(
          update: (_, usbState, previous) => previous ?? UsbChannel(state: usbState),
          dispose: (_, channel) => channel.dispose(),
        ),
        Provider<RtlSdrDriver>(
          create: (_) => NativeRtlSdrDriver(),
          dispose: (_, driver) => driver.dispose(),
        ),
        ChangeNotifierProvider(
          create: (context) => RadioController(context.read<RtlSdrDriver>()),
        ),
        ChangeNotifierProvider(
          create: (context) => RecordingController(context.read<RtlSdrDriver>()),
        ),
        ChangeNotifierProvider(create: (_) => ScanController()),
        ChangeNotifierProvider(
          create: (_) => PresetsController(const SharedPreferencesPresetsRepository())..load(),
        ),
      ],
      child: MaterialApp(home: const HomeScreen()),
    );
  }
}

// Anywhere deeper in the widget tree:
class TuneButton extends StatelessWidget {
  const TuneButton({super.key});

  @override
  Widget build(BuildContext context) {
    final radio = context.watch<RadioController>(); // rebuilds on every stats tick
    return FilledButton(
      onPressed: () => context.read<RadioController>().startStreaming(),
      child: Text(radio.isStreaming ? 'Streaming' : 'Start'),
    );
  }
}

See example/lib/ for the full, working version of this — a USB status card plus a tuner card covering every section above, all wired through provider — that you can flutter run on a real device.

Testing without hardware

Every controller depends on RtlSdrDriver, never on FFI directly, so package:core_rtlsdr/testing.dart exports FakeRtlSdrDriver — a pure-Dart, in-memory implementation you can poke directly in a test:

import 'package:core_rtlsdr/core_rtlsdr.dart';
import 'package:core_rtlsdr/testing.dart';

test('scan finds a hit above the threshold', () async {
  final driver = FakeRtlSdrDriver(
    signalLevelForFrequency: (hz) => hz == 101500000 ? -10.0 : -90.0,
  );
  final radio = RadioController(driver)..startStreaming();
  final scanner = ScanController(settleDelay: Duration.zero, sampleGap: Duration.zero);

  await scanner.startScan(radio, startHz: 101000000, endHz: 102000000, stepHz: 100000);

  expect(scanner.results.map((h) => h.frequencyHz), contains(101500000));
});

A few more, showing common patterns:

// Simulate a native call failing and assert your error-handling path.
test('setFrequencyHz surfaces a native failure via lastError', () {
  final driver = FakeRtlSdrDriver()..failNextCall = true;
  final radio = RadioController(driver);

  radio.setFrequencyHz(101500000);

  expect(radio.lastError, isNotNull);
  expect(radio.frequencyHz, isNot(101500000)); // unchanged
});

// Drive RDS without a real broadcast — just set the fake's rdsInfo directly.
test('RdsController surfaces the program service once synced', () {
  final driver = FakeRtlSdrDriver()
    ..rdsInfo = const RdsInfo(
      syncLocked: true,
      piCode: 0x1234,
      pty: 10,
      tp: true,
      ta: false,
      programService: 'BBC R1',
      radioText: 'Now playing something',
      generation: 1,
    );
  final rds = RdsController(driver)..refresh();

  expect(rds.info.programService, 'BBC R1');
});

// Test a widget/controller that needs presets, with no real storage.
test('PresetsController works against InMemoryPresetsRepository', () async {
  final presets = PresetsController(InMemoryPresetsRepository());
  await presets.add(const Preset(
    name: 'Test station',
    frequencyHz: 101500000,
    mode: DemodMode.wfm,
    gainAuto: true,
    gainTenthDb: 0,
  ));

  expect(presets.presets, hasLength(1));
});

// Test Downloads-folder recording + sharing without an Android device —
// FakeDownloadsChannel tracks every call so you can assert on it directly.
test('startRecordingToDownloads uses the downloads channel', () async {
  final driver = FakeRtlSdrDriver();
  final downloads = FakeDownloadsChannel();
  final recording = RecordingController(driver, downloadsChannel: downloads);

  await recording.startRecordingToDownloads(
    frequencyHz: 101500000,
    mode: DemodMode.wfm,
  );

  expect(recording.isRecording, isTrue);
  expect(downloads.openCalls, hasLength(1));
  expect(downloads.openCalls.single.mimeType, 'audio/wav');
});

// failNextOpen simulates the legacy-Android fallback (pre-API 29, no
// MediaStore.Downloads collection) — startRecordingToDownloads falls back
// to app-specific storage automatically instead of surfacing an error.
test('startRecordingToDownloads falls back below API 29', () async {
  final driver = FakeRtlSdrDriver();
  final downloads = FakeDownloadsChannel()..failNextOpen = true;
  final recording = RecordingController(driver, downloadsChannel: downloads);

  await recording.startRecordingToDownloads(
    frequencyHz: 101500000,
    mode: DemodMode.wfm,
  );

  expect(recording.isRecording, isTrue); // still recording, just not via MediaStore
  expect(driver.recordingPath, isNotNull); // fell back to startRecording(path)
});

FakeRtlSdrDriver's state (rfLevelDbfs, rdsInfo, spectrumDb, gainList, failNextCall, ...) is all plain mutable fields — poke whatever the scenario needs directly, no mocking framework required. FakeDownloadsChannel (also from testing.dart) is the same idea for startRecordingToDownloads/shareRecording, tracking openCalls/ finishedFds/shareCalls instead of touching a real MediaStore.

This is the same reason driver_rtlsdr only unit-tests FFI struct layout on the host and leaves everything else to on-device integration_test — except here, because the actual radio logic lives above the FFI seam instead of being entangled with it, that logic gets full unit test coverage without ever touching a device. See test/ in this package (60+ tests) for the full suite, and example/integration_test/ for the on-device check that NativeRtlSdrDriver actually reaches libnative_rtlsdr.so.

Building widget_rtlsdr on top of this package

This package exists to make that next package straightforward. A UI layer built on core_rtlsdr should:

  1. Depend only on core_rtlsdr (never on driver_rtlsdr or dart:ffi directly) — every native concept it needs (frequency, stats, RDS, spectrum bins, demod mode) already has a plain-Dart shape here.
  2. Take the controllers it needs as constructor parameters (RadioController, SpectrumController, ScanController, ...) rather than constructing them — that's what lets an app compose them with provider/riverpod/ whatever it already uses, exactly like example/lib/app.dart does.
  3. Test against FakeRtlSdrDriver (package:core_rtlsdr/testing.dart), so its own CI never needs an emulator either — reserve on-device integration_test for the one thing that actually requires a device: confirming widgets render correctly against a live-ish stream of updates.
  4. Look at example/lib/widgets/ in this package for the data each section needs (e.g. SpectrumBars shows what SpectrumController.bins looks like to consume) — deliberately built with plain Containers/ Sliders/ListTiles, leaving the actual design system, waterfall/canvas rendering, and theming to widget_rtlsdr.

Tests

  • test/ — pure Dart/Flutter unit tests, run on the host (no Android or dongle needed): every controller against FakeRtlSdrDriver, Preset/SharedPreferencesPresetsRepository. Run with: flutter test.
  • example/test/ — a widget test of the example app's initial (no-device) state.
  • example/integration_test/ — runs on a real Android device/emulator; confirms NativeRtlSdrDriver reaches libnative_rtlsdr.so and a real FFI call round-trips, without needing a dongle physically connected. Run with: cd example && flutter test integration_test.
  • Validation against real hardware: inherited from driver_rtlsdr — see that package's README and ../../app/flutter/rtl-sdr mobile/docs/how-it-was-built.md for the results of validating the underlying native core against a real RTL2838U dongle. This package's own controller logic (stats math, scan stepping, RDS string decoding, preset round-tripping) is covered by the unit tests above and doesn't require re-validation on hardware when it changes — only the native core does.

License

GPLv2, or (at your option) any later version — see LICENSE. This package depends on driver_rtlsdr, which links librtlsdr (GPLv2), requiring that any software using it be distributed under the GPL — hence the same choice here.

Contributing

Contributions are welcome! See CONTRIBUTING.md for how to set up your environment, coding conventions, and the PR process.

Libraries

core_rtlsdr
Testable radio engine for RTL-SDR on Android/Flutter.
testing
Test doubles for core_rtlsdr — import this (never dart:ffi, never driver_rtlsdr directly) to test radio logic or UI built on top of this package without an Android device, an emulator, or a dongle.