I build an app called Afterfade on my own. You record seven five-second videos over the course of a day, and the app turns them into a single lo-fi track. It ships on both Android and iOS, built with Kotlin Multiplatform (KMP) and Compose Multiplatform.
Music generation used to be Python running on AWS. The app extracted audio from the videos, uploaded it, and got a finished track back. There is no server now. Generation happens entirely on the device — about 10 seconds instead of a minute, zero cost per track, works in airplane mode, and nothing you record leaves your phone.
Why is in an earlier post; this one is about what I actually did. The part that mattered most: the tests I had written for Android ran on iOS as they were.
The setup I had, and what bothered me about it
The app pulled audio out of the videos, uploaded it to AWS, and Python on Lambda built a track and sent it back.
Afterfade is free and ad-supported. On Lambda in Tokyo, a track costs roughly $0.0023 — about $670/month at 300,000 tracks. The absolute numbers are not the problem; I did not want a setup where cost scales with usage.
Moving on-device turns one implementation into two
While generation lives on a server, there is exactly one implementation. Move it onto the device and it becomes two — one for Android, one for iOS. And this app is supposed to be deterministic.
This is where KMP paid off. Music generation touches no OS API, so I write it once in Kotlin and it runs on both. The ported engine is 2,236 lines in commonMain, importing only kotlin.math. FFT, filters, and resampling are hand-rolled — zero third-party dependencies. That DSP layer is now public as afterfade-dsp.
And because the destination language was Kotlin, the tests came along as they were. The 52 tests I had written for the JVM ran on the iOS simulator after nothing more than moving them to the shared source set.
Only two points on the path to a finished track touch the OS.
The MP4 decoder and the filesystem are the only pieces borrowed from the OS. About 86% of the music path is shared Kotlin.
Do both platforms really produce the same track?
I ran those 52 tests on the iOS simulator and 51 passed. One accompaniment did not match its expected value. The cause was this line, which converts a MIDI note number to a frequency.
fun midiToHz(midi: Int): Double = 440.0 * 2.0.pow((midi - 69) / 12.0)
pow is not required by its specification to return a correctly rounded result. The JVM and Apple's libm disagree on the last bit for some inputs. There are only twelve semitone ratios, so I dropped pow and replaced it with twelve correctly rounded constants. This looks like an audio-only problem, but it happens anywhere you compare floating-point results across platforms.
Had I written this twice, in Kotlin and in Swift, I would have had to build the verification tool before I could check anything.
Which workloads can lose their server?
The bill was not the only thing that went away — so did API versioning, timeouts, retries, cold-start workarounds, and the code that guaranteed uploaded audio was deleted.
My rule: the work has to be pure computation. The input is already on the device, the output follows from that input, and there is no large asset like a trained model to ship. If all three hold, there is not much reason to keep it on a server.
Since the day I deleted the server, I have not thought about the bill once. The setup I picked to share the UI turned out to help most outside it.
Afterfade is being built for RevenueCat Shipaton 2026.


Top comments (0)