A lightweight RTL-SDR receiver for Android — Kotlin + Jetpack Compose + Gradle.
This is a port of the rtlsdr-fx desktop app (Spring Boot + JavaFX) to a native Android app. The DSP is a faithful, line-by-line port of the desktop Java, so the spectrum, waterfall, and all four demodulation modes — WFM / NFM / AM / CW — behave identically.
It connects to an RTL-SDR dongle over the network via rtl_tcp, and also ships
with simulated sources so the app is fully usable with no hardware at all.
Copyright © 2026 Tauasa Timoteo · MIT License
- Live spectrum — power trace with a translucent fill, a slowly-decaying peak-hold trace, dB + frequency grids, and a dashed amber marker at the centre (the point that gets demodulated).
- Scrolling waterfall — new rows enter at the top, with five selectable palettes (Classic, Inferno, Ice, Green CRT, Grayscale) and a live preview.
- Audio playback —
WFMfor broadcast FM,NFMfor narrowband FM,AM, andCWfor Morse, with a volume slider. Output is resampled to a fixed 48 kHz regardless of the device sample rate. - CW (Morse) — a BFO turns a keyed carrier at band centre into an audible tone, followed by a narrow RBJ band-pass. Adjustable pitch (default 700 Hz) and filter width (default 300 Hz).
- Three sources —
RTL-TCP(real hardware over the network),Simulated(tones + noise), andSimulated (CW)(Morse, no hardware needed). - Survives rotation — the engine lives in a
ViewModel, so the stream and audio device aren't dropped on configuration change.
Open in Android Studio (Ladybug or newer) and hit Run, or from the CLI:
./gradlew assembleDebug # build the APK
./gradlew installDebug # build + install on a connected device
./gradlew test # run the JVM unit tests (DSP + CW)The APK lands in app/build/outputs/apk/debug/.
Requirements: JDK 17, Android SDK with API 35. Gradle itself is provided by the
wrapper (./gradlew), so no separate install is needed.
| Language | Kotlin 2.0.21 |
| UI | Jetpack Compose (Material 3) |
| Build | Gradle 8.10.2 + AGP 8.7.2 |
| min / target SDK | 24 (Android 7.0) / 35 |
The app starts on the Simulated source — just press Connect and the spectrum and waterfall come alive with two steady tones, a sweeping AM tone, and a noise floor.
- Set Source to
Simulated (CW)— this switches Mode toCWfor you. - Turn Audio on.
- Press Connect.
You'll hear CQ DE TAUASA K keyed at 18 WPM at a 700 Hz pitch, while the carrier
blinks at the centre of the waterfall. Open Settings (gear icon) to slide the
pitch and filter width and hear the tone move.
The CW simulator is paced to wall-clock time at the true sample rate, so the Morse timing is correct rather than merely plausible.
On the machine with the dongle plugged in:
rtl_tcp -a 0.0.0.0 # listen on all interfacesThen in the app set Source to RTL-TCP, enter that machine's IP and port
(default 1234), set Freq (MHz) and Rate, and press Connect. For
broadcast FM, tune to a strong local station (88–108 MHz), keep WFM, and turn
Audio on.
Both devices need to be on the same network. The app needs the
INTERNETpermission (already in the manifest) — nothing else.
Note: this talks to
rtl_tcpover TCP; it does not drive a dongle plugged directly into the phone's USB port. That would need the USB host API and a userspace librtlsdr, which is a much larger job.
The DSP and protocol layers are a direct port; the platform layers were swapped:
| Desktop (rtlsdr-fx) | Android (this app) |
|---|---|
SdrService (Spring @Service) |
SdrEngine — plain class owned by the ViewModel, no DI container |
SdrProperties + application.yml |
defaults in SdrEngine / SdrViewModel |
AudioPlayer via javax.sound.sampled |
AudioPlayer via android.media.AudioTrack |
SpectrumView / WaterfallView (JavaFX Canvas) |
SpectrumPlot / WaterfallPlot (Compose Canvas + Bitmap) |
MainController + AnimationTimer |
MainScreen + withFrameNanos render loop |
| JavaFX dialogs | Compose Dialog |
| Maven | Gradle (Kotlin DSL + version catalog) |
The "latest frame wins" trick carries over: the DSP thread drops frames into an
AtomicReference, and the render loop samples whatever is newest each display
frame, so a fast source can't flood the UI.
app/src/main/java/org/tauasa/apps/sdr/
├── MainActivity.kt single-activity host
├── dsp/ Fft, Window, Fir, decimators, Biquad,
│ SpectrumProcessor, Demodulator (WFM/NFM/AM/CW)
├── source/ SignalSource, RtlTcpSource,
│ SimulatedSource, SimulatedCwSource
├── rtl/ rtl_tcp opcodes + tuner types
├── audio/ AudioPlayer (AudioTrack)
├── engine/ SdrEngine, SpectrumFrame
└── ui/ MainScreen, SettingsDialog, PlotViews,
Palette, SdrViewModel, theme/
dsp/, source/, and rtl/ deliberately avoid Android APIs, which is why the
unit tests in app/src/test/ run on a plain JVM.
SignalSource (RtlTcpSource | SimulatedSource | SimulatedCwSource)
│ interleaved IQ blocks
▼
SdrEngine.onBlock
├─ audio path (every block): Demodulator → AudioPlayer (48 kHz out)
└─ display path (paced): SpectrumProcessor → SpectrumFrame → UI
Audio is demodulated on every block for continuity; only the display is paced, so dropped frames never cause audio gaps.
Inside the Demodulator: front-end low-pass + decimate to a ~240 kHz IF → the
mode-specific stage (FM phase discriminator, AM envelope detector, or the CW BFO
mix) → audio low-pass + decimate → 75 µs de-emphasis (WFM only) → linear resample
to exactly 48 kHz → narrow band-pass (CW only).
./gradlew testCovers the FFT (bin placement, fftshift, power-of-two guard), AM recovery of a 1 kHz modulating tone, and CW: that a carrier at band centre beats down to the configured pitch (600/700/800 Hz), stays finite, and that the CW simulator produces a keyed tone at the pitch.
The Kotlin DSP was checked against the Java original and matches to the digit — a centre carrier at pitches 600/700/800 Hz demodulates to 598.9/699.9/800.8 Hz in both, and the simulated Morse lands at ~703 Hz with an identical 0.689 peak.