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:

  1. Build a binaural beat app on a modern stack.
  2. Support multiple audio "engines" — a simple built-in generator plus the classic SBaGen.
  3. Set up a GitLab CI pipeline that produces a downloadable APK.
  4. 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.c with -DT_POSIX -Dmain=sbagen_main -fgnu89-inline. The -Dmain=sbagen_main trick renames main so 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 feeds AudioTrack.
  • 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 own AudioTrack.
  • SampleSource — loops a bundled OGG (river.ogg, train.ogg) via plain MediaPlayer with isLooping = true. I chose MediaPlayer over 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.ogg was 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: a full flavor (train included) and an fdroid flavor (river only), gated by BuildConfig.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).