Reviving a 15-year-old binaural beat app on modern Android
About 15 years ago I built an Android binaural beat generator by porting SBaGen (Jim Peters' legendary C program, GPLv2) to native Android via OpenSL ES on an ancient NDK. This summer I thought about how hard could it be to revive it on a modern stack — Kotlin, Jetpack Compose, GitLab CI — expecting the whole thing to be much easier now. It mostly was. But the interesting part was where it wasn't: the platform got more capable and more locked down at the same time.
But first some background if you don't know what binaural beats are. The brain has different states you can measure as different frequencies (EEG bands), associated with modes like sleep or wakefulness. That part isn't controversial. The idea of binaural beats is that you can induce one of those states by playing two slightly different frequencies, one in each ear — the difference between them being the target brain-state frequency. Whether that entrainment actually works is the more dubious, mumbo-jumbo-adjacent claim.
For me some of the frequencies appear to work which is good enough for me, and I also like drone music like Eliane Radigue, and the sounds of this app are to me similar.
Anyway, on to the technical discussion.
The premise
The original port targeted Android with android-ndk-r8b, GCC 4.6, an
arm-eabi toolchain, hand-linked crtbegin_dynamic.o, libgcc.a, and a
--dynamic-linker /system/bin/linker incantation. Looking at the old
mk-opensl.mak today is a good "how bad it used to be" artifact.
The 2026 hypothesis was simple: Kotlin, Compose, AGP and a CI runner should let me build a clean frontend and link the audio logic with far less ceremony. The goals:
- Build a binaural beat app on a modern stack.
- Support multiple audio "engines" — a simple built-in generator plus the classic SBaGen.
- Set up a GitLab CI pipeline that produces a downloadable APK.
- Run it on a real device (a Samsung Galaxy Z Fold).
The simple case got radically simpler
The first engine is pure Kotlin: an AudioTrack in stereo PCM-16 at
44.1 kHz. The left channel gets the base frequency, the right channel
gets base + beat. That frequency difference is the binaural beat — the
brain perceives a beat at the difference frequency. About 70 lines, no
native code.
This is worth dwelling on: the "simple" path that needed native C and OpenSL ES 15 years ago is now a trivial amount of Kotlin. The hypothesis held — for the simple case.
The UI is two sliders (base 100–500 Hz, beat 1–40 Hz) and a Play/Stop button. A tiny abstraction lets the app host several backends:
1interface AudioEngine {
2 val name: String
3 fun start(baseFreq: Float, beatFreq: Float)
4 fun stop()
5}
Integrating the real SBaGen — the hard part
I wanted the genuine SBaGen engine, not a reimplementation, because
SBaGen's .sbg files are a real little language: named tone-sets,
timelines, sequences, bells, pink noise, spin effects. This is where the
modern platform fought back.
Attempt A: bundle the binary and exec it
The old-school approach: compile sbagen.c with -DT_POSIX (file/stdout
output, no device audio), bundle the binary, and run it as a subprocess,
piping its raw PCM into an AudioTrack.
It compiled cleanly with one fix — -fgnu89-inline, because SBaGen uses
old-style inline semantics that modern Clang rejects by default. Since
Android only packages lib*.so files into the APK's native lib dir, I
renamed the executable to libsbagen.so via CMake. It built, packaged,
installed — and produced no sound.
The decisive diagnostic came from trying to run the binary directly over adb:
1/system/bin/sh: .../lib/arm64/libsbagen.so: inaccessible or not found
The file was present and executable. The problem is the W^X
(write-xor-execute) policy enforced since Android 10 (API 29): apps
cannot exec() writable files, anywhere. The "ship a binary and exec it"
trick that worked on old Android is simply dead on a modern targetSdk.
(An earlier run-as test appeared to work and sent me down a wrong
path briefly — a good reminder that run-as runs in a different security
context than the app itself.)
Attempt B: a JNI reimplementation (too simplistic)
I pivoted to JNI and wrote a small reimplementation of just the core
binaural algorithm. It produced sound and validated the JNI path — but it
wasn't actually SBaGen and couldn't parse the .sbg preset files. Not
good enough.
Attempt C: the real SBaGen via fork() + pipe
This is the winning design and the technical centerpiece. SBaGen's
main()~/~loop() is a monolith with global state, infinite while(1)
loops, and exit() calls everywhere. Refactoring 3,300 lines of legacy C
into a clean reentrant library would be large and fragile. So I didn't.
Instead:
- Compile the real, unmodified
sbagen.cwith-DT_POSIX -Dmain=sbagen_main -fgnu89-inline. The-Dmain=sbagen_maintrick renamesmainso it can be called from C. - A small JNI wrapper
fork()~s a child process. The child redirects its stdout to a pipe and calls ~sbagen_main(argc, argv)with-Q -O <preset>(quiet, raw PCM to stdout). The parent reads PCM from the pipe and hands it to Kotlin, which feedsAudioTrack. - Stopping just ~kill()~s the child and closes the pipe.
Why this is elegant: the child is a throwaway process. All of SBaGen's
exit() calls, global state, and run-forever behavior become non-issues
— I just kill the process to stop. No refactoring required. And fork()
is still fully allowed inside an app's own process; it's exec of a
writable file that's forbidden.
One crucial detail: I deliberately omitted SBaGen's -E flag (which
stops at the last tone-set). Without it, SBaGen generates as fast as it
can, but the OS pipe blocks when full and AudioTrack's blocking
write() paces consumption. Backpressure becomes the clock — no manual
real-time pacing needed.
This is what shipped: a 2026 phone running the genuine 1999-era SBaGen engine, unmodified.
Preset files
SBaGen's .sbg files are a small language — tone-sets like
ts: pink/40 150+6/15, timelines, nested sequences, bells, chakra chord
progressions. My old folder had ~150 across basics/, focus/,
contrib/ and jave/.
I bundled them as app assets (flattened with directory-prefix names) but
excluded presets that reference external -m mix files (.ogg=/.mp3=),
since I hadn't linked an OGG/MP3 decoder into the native lib. Net: 58
pure-tone presets shipped. When SBaGen is the active engine, a dropdown
lists the presets plus a "Custom (sliders)" option; picking a preset
copies the chosen asset to cacheDir because the native code needs a
real file path, not an asset stream.
Background ambient layer — design before code
I wanted background sounds (rain-ish, trains) and forced myself to design before implementing. Good instinct, because the right answer was not to route them through SBaGen.
SBaGen can mix an audio file into the binaural stream with -m, but
that needs a decoder linked into the native lib, doesn't loop by default,
and bakes the sample into the single tone stream. For ambient layers,
sample-accurate sync with the beat is meaningless — rain and trains are
atmospheric. So I split background sound into its own layer, fully
decoupled and mixed by the OS audio layer:
1MainActivity
2├── tone layer: AudioEngine (Simple | SBaGen) — unchanged
3└── background layer: BackgroundSource (Noise | Sample) — new, independent
1interface BackgroundSource {
2 val name: String
3 fun start(volume: Float)
4 fun setVolume(volume: Float) // live-adjustable
5 fun stop()
6}
NoiseSource— pink (Voss-McCartney) and brown (integrated white noise) generated in pure Kotlin via its ownAudioTrack.SampleSource— loops a bundled OGG (river.ogg,train.ogg) via plainMediaPlayerwithisLooping = true. I choseMediaPlayerover ExoPlayer to avoid a dependency for a one-file loop.
The payoff: an independent volume slider adjustable live, works identically under both tone engines, the OS decodes any format natively (no decoder in my native lib), and it's three small Kotlin files with zero native changes.
The deployment subplot
Getting it onto the phone was its own mini-saga. First adb and lsusb
showed no device at all — much fiddling with USB debugging and cable
quality, until I realized the phone was plugged into a different
machine. Then Play Protect blocked the unknown-developer APK ("More
details → Install anyway").
The elegant resolution was wireless ADB over Tailscale: the phone's
wireless-debugging endpoint was a 100.x.x.x Tailscale address. After
adb pair / adb connect, I could push every CI-built APK to the Z Fold
over the tailnet from anywhere, no cable. The modern hobbyist loop:
CI builds the APK → pull the artifact via glab → install over
Tailscale ADB.
F-Droid readiness and a licensing detective story
Once it worked I looked at F-Droid. The project was already well-positioned: GPLv2-compatible code, all-FOSS AndroidX/Compose deps, native lib compiled in-tree by CMake/NDK — no prebuilt binaries.
The one real risk was the bundled audio samples. F-Droid rejects NonCommercial and NoDerivatives Creative Commons variants — only CC0/BY/BY-SA count as "free."
river.oggwas easy: SBaGen's own docs declare the river sounds as CC BY-SA 1.0, © 2003–2004 Jim Peters.train.ogg(a Shinkansen recording) had unknown provenance. I hedged with product flavors: afullflavor (train included) and anfdroidflavor (river only), gated byBuildConfig.HAS_TRAIN.
Then I tracked the train down. It came from a radio aporee ::: maps field recording, which licenses each item individually. Via the archive.org mirror I confirmed it as "inside shinkhansen train" by Frank Schulte, CC BY-SA 3.0, recorded 2010-07-19 near Takasaki, Japan, for World Listening Day. CC BY-SA 3.0 is F-Droid-compatible — so I removed the flavor split entirely and folded the train back into a single build.
The lesson: "I'm not sure where this came from" is a genuine F-Droid blocker until resolved, and build flavors make a fine hedge until the facts come in.
Takeaways
- Then vs now: the OpenSL / NDK-r8b / hand-linked-crt nightmare versus a
few lines of Kotlin
AudioTrack. The simple case got radically simpler. - But embedding legacy native code didn't get easier — modern Android's W^X policy killed the old exec-a-binary trick. The platform got more capable and more locked down.
fork()+ pipe is a pragmatic escape hatch for wrapping gnarly,exit()-happy, global-state C without rewriting it. Let backpressure be the clock.- Separation of concerns in audio: let the OS mix independent layers rather than threading everything through one legacy mixer.
- Licensing matters: trace every bundled asset to a clean, documented license chain before claiming "free."
The code is on GitLab. It builds on SBaGen by Jim Peters (GPLv2); bundled samples are CC BY-SA (river by Jim Peters, train by Frank Schulte).