core_rtlsdr 0.2.0
core_rtlsdr: ^0.2.0 copied to clipboard
Testable radio engine for RTL-SDR on Android/Flutter: tuning, streaming, demodulation, gain, squelch, stereo/RDS, spectrum, recording, band scan and presets as ChangeNotifier controllers on top of the [...]
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.