core_rtlsdr
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.NativeRtlSdrDriveris the real, Android-only implementation (a thin adapter overdriver_rtlsdr'sNativeBindings). 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). OwnsspectrumControllerandrdsControllerand 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 viastartRecordingToDownloads/startIqRecordingToDownloads(AndroidMediaStore, API 29+, with an automatic fallback to app-specific storage below that).shareRecording/shareIqRecordinghand 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 fromdriver_rtlsdrso 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 futurewidget_rtlsdrpackage (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.startRecordingtakes an absolute path;defaultRecordingPathis 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
-
minSdk = 26in your app'sandroid/app/build.gradle.kts— required by the Oboe/AAudio low-latency audio pathdriver_rtlsdruses internally:android { defaultConfig { minSdk = maxOf(flutter.minSdkVersion, 26) } } -
USB auto-open intent filter, on your launcher
<activity>inandroid/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 theandroid.hardware.usb.hostpermission both come fromdriver_rtlsdr's own manifest, merged into your build automatically by Gradle's resource merger — there's nothing to copy. Seeexample/android/app/src/main/AndroidManifest.xmlin 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:
- Depend only on
core_rtlsdr(never ondriver_rtlsdrordart:ffidirectly) — every native concept it needs (frequency, stats, RDS, spectrum bins, demod mode) already has a plain-Dart shape here. - Take the controllers it needs as constructor parameters (
RadioController,SpectrumController,ScanController, ...) rather than constructing them — that's what lets an app compose them withprovider/riverpod/ whatever it already uses, exactly likeexample/lib/app.dartdoes. - Test against
FakeRtlSdrDriver(package:core_rtlsdr/testing.dart), so its own CI never needs an emulator either — reserve on-deviceintegration_testfor the one thing that actually requires a device: confirming widgets render correctly against a live-ish stream of updates. - Look at
example/lib/widgets/in this package for the data each section needs (e.g.SpectrumBarsshows whatSpectrumController.binslooks like to consume) — deliberately built with plainContainers/Sliders/ListTiles, leaving the actual design system, waterfall/canvas rendering, and theming towidget_rtlsdr.
Tests
test/— pure Dart/Flutter unit tests, run on the host (no Android or dongle needed): every controller againstFakeRtlSdrDriver,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; confirmsNativeRtlSdrDriverreacheslibnative_rtlsdr.soand 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.mdfor 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 (neverdart:ffi, neverdriver_rtlsdrdirectly) to test radio logic or UI built on top of this package without an Android device, an emulator, or a dongle.