A focused example of building a tone generator in a Compose Multiplatform project targeting Android, iOS, and Desktop (JVM).
The project is intentionally small. It is not trying to be a synthesizer framework, an audio production engine, or a polished product UI. The goal is to make the essential mechanics visible: generate samples, keep oscillator phase, convert normalized waveforms into the sample format each platform expects, and feed those samples into the native audio output API.
The same Compose UI drives three platform audio implementations:
- Android uses
AudioTrack - iOS uses
AVAudioEnginewith anAVAudioSourceNode - Desktop JVM uses
SourceDataLine
The app demonstrates single-tone generation, two-tone mixing, musical note frequencies, DTMF tones, and several colored noise generators. The UI is deliberately plain so the audio path remains the subject.
This project is meant to accompany a longer article. The README gives enough context to understand the code, while the article will go deeper into the tradeoffs, simplifications, and production alternatives.
Clone the repository and open it in IntelliJ IDEA or Android Studio. The project is a standard Kotlin Multiplatform project targeting Android, iOS, and JVM desktop.
To run the desktop version:
./gradlew :composeApp:runTo build the Android debug APK:
./gradlew :composeApp:assembleDebugTo run the iOS version, open the iosApp directory in Xcode or use the iOS run configuration created by the Kotlin Multiplatform tooling.
To run the current test suite:
./gradlew :composeApp:allTestsTo run the broader verification task:
./gradlew :composeApp:checkOne practical note: this app produces sound. Start with a modest system volume, especially when experimenting with noise.
A sine wave as samples:
The core tone is generated one sample at a time:
sin(phase) * volumeEach generated sample is a normalized value in the range -1.0..1.0. The phase advances by:
2.0 * PI * frequency / sampleRateThat phase accumulator is the heart of the tone generator. The app keeps separate phase accumulators for tone 1 and tone 2 so the two oscillators can run independently.
Mixing and clipping:
When two tones are enabled, their samples are added together and constrained to -1.0..1.0 before being written to the audio device. This is the simplest useful mixing model. It is not a mastering chain, and it does not attempt gain staging beyond basic clipping protection.
A short amplitude envelope:
Starting and stopping audio abruptly can produce clicks because the waveform jumps discontinuously. Each platform implementation applies a short 10 ms attack/release ramp. That is enough to demonstrate the idea without turning the sample into an envelope-generator tutorial.
The important point is not the exact 10 ms value. The point is that clicks usually come from abrupt amplitude discontinuities, and a short ramp smooths the transition.
DTMF tones:
DTMF keypad tones are generated by playing two sine waves at the same time: one row frequency and one column frequency. The table in App.kt contains the standard 12-key frequencies:
DtmfButtonInfo("5", "JKL", 770f, 1336f)The DTMF screen sets both tone volumes to a known value when a key is pressed. That makes the keypad deterministic rather than inheriting whatever volume was left by another screen.
Musical notes:
The note list is generated from MIDI note numbers 21 through 108, the standard piano range from A0 to C8. Frequencies are computed from A4 = 440 Hz:
440.0 * 2.0.pow((midi - 69).toDouble() / 12.0)The common tests cover the note range, selected reference pitches, interval naming, and DTMF frequency table.
Colored noise:
The noise screen includes white, pink, brown, blue, violet, green, and gray noise. These are compact educational implementations rather than definitive reference implementations for measurement work. They are useful for showing how sample generation can be stateful, especially for filters such as pink, brown, green, and gray noise.
The platform files intentionally look similar. That is a teaching choice.
In a larger codebase, I would normally consider moving the oscillator and noise generation into commonMain and leaving only native audio output in the platform source sets. That would reduce duplication. In this tutorial, the duplication makes the three platform implementations easier to compare side by side.
The same basic loop appears in all three backends:
- Read the current frequency, volume, second-tone, and noise settings.
- Compute phase increments from frequency and sample rate.
- Generate a buffer of normalized samples.
- Apply the attack/release envelope.
- Convert or copy the samples into the platform audio buffer.
- Write the buffer to the native audio output API.
Android uses AudioTrack in streaming mode:
AudioTrack.Builder()
.setTransferMode(AudioTrack.MODE_STREAM)
.build()Samples are generated into a reusable ShortArray because Android is using 16-bit mono PCM. Each normalized sample is scaled by Short.MAX_VALUE before being written.
Stopping is intentionally simple. The generator thread is asked to ramp down, stop() waits briefly for the release ramp to be written, and then AudioTrack.stop() is called. The code deliberately does not call flush() at that point because flushing can discard the buffer containing the release ramp.
A production audio engine might track playback head position, use marker callbacks, or implement a more explicit drain strategy. Those are valid approaches, but they would distract from the core mechanics this sample is trying to show.
iOS uses AVAudioEngine with an AVAudioSourceNode. The source node's render callback fills audio buffers as the engine asks for them.
iOS uses 32-bit floating-point samples, so the implementation generates into a reusable FloatArray and then copies those samples into AVAudio's native buffer:
memcpy(audioBuffer.mData, floatBuffer.refTo(0), (framesToWrite * 4).toULong())The reusable buffer is a deliberate compromise. It keeps the iOS code parallel with Android's ShortArray and JVM's ByteArray, and it avoids allocating a new Kotlin array on every render callback. If AVAudio requests a larger frame count than previously seen, the buffer grows. A production implementation might preallocate a known maximum or write directly into mData.
Stopping also uses a small tutorial-friendly simplification: stop() sets the envelope target to silence and schedules a pause slightly after the 10 ms release ramp. A more precise production approach would have the render path signal when the envelope actually reaches silence, then let a control thread pause the engine.
The desktop implementation uses Java Sound's SourceDataLine.
Samples are packed into a reusable ByteArray as signed 16-bit little-endian PCM:
byteBuffer[i * 2] = (sample.toInt() and 0xFF).toByte()
byteBuffer[i * 2 + 1] = (sample.toInt() ushr 8).toByte()Like Android, the JVM player waits briefly for the release ramp during stop(). This keeps the code easy to follow. A production desktop audio engine would probably avoid blocking the UI/control thread by using an audio-thread command queue or a stop-completion callback.
This repository makes a few choices that are meant to serve the tutorial rather than represent the only reasonable production design.
The DSP code is duplicated across platform backends.
That is intentional. It makes Android, iOS, and JVM easier to compare directly. A production library would probably move the oscillator, noise generation, and envelope code into commonMain, then test that shared generator thoroughly.
The stop paths are simple.
Android and JVM briefly wait for their generator threads to write the release ramp. iOS uses a short delayed pause. These are simple and visible. More robust audio engines often use command queues, callbacks, playback-head tracking, or explicit state machines.
Errors are handled minimally.
The JVM implementation prints audio-device failures rather than introducing a UI error state or logging abstraction. A production app should surface those failures to the user or telemetry/logging layer. Here, the player API stays focused on tone generation.
The UI is intentionally plain.
The purpose of the UI is to exercise the audio generator, not to demonstrate a design system. The controls are enough to make the audio behavior observable: frequency sliders, volume sliders, note selection, noise selection, and a DTMF keypad.
App.kt keeps related tutorial code together.
The note data, DTMF table, and screens could be split into smaller files. For now they stay together so readers can scan the whole app without jumping through a larger structure. If this grows, the first likely extraction would be Notes.kt and Dtmf.kt.
composeApp/src/commonMain/kotlin/com/roywatson/cmp_tone_generator/
App.kt # Compose UI, note math, DTMF table, navigation, shared app state
TonePlayerInterface.kt # Common TonePlayer contract and expect factory
composeApp/src/androidMain/kotlin/com/roywatson/cmp_tone_generator/
MainActivity.kt # Android entry point
TonePlayer.Android.kt # AudioTrack streaming implementation
composeApp/src/iosMain/kotlin/com/roywatson/cmp_tone_generator/
MainViewController.kt # iOS Compose entry point
TonePlayer.Ios.kt # AVAudioEngine / AVAudioSourceNode implementation
composeApp/src/jvmMain/kotlin/com/roywatson/cmp_tone_generator/
main.kt # Desktop entry point
TonePlayer.Jvm.kt # SourceDataLine implementation
composeApp/src/commonTest/kotlin/com/roywatson/cmp_tone_generator/
ComposeAppCommonTest.kt # Note math, interval naming, and DTMF table tests
| Language | Kotlin 2.3.21 |
| UI framework | Compose Multiplatform 1.10.3 |
| Material | Material 3 1.10.0-alpha05 |
| Android Gradle Plugin | 8.11.2 |
| Android SDK | compile 36, min 24, target 36 |
| Coroutines | 1.10.2 |
| JVM target | 11 |
Before Roy Watson wrote a line of Android code, he was programming the propellant management system for NASA's Delta launch vehicles, building radiation detection software for Los Alamos and Sandia National Laboratories, and developing embedded vision systems for IBM's autonomous vehicle research. After 30+ years as a systems developer, he approaches Android differently than most.
Over the past 16 years he has specialized in Android, staying current with every major evolution of the platform: Kotlin, Jetpack Compose, and now Compose Multiplatform (KMP) for iOS, Desktop, and Web. Recent clients include the New York Public Library, Auddia, Bechtel, and Goodyear. When performance work requires dropping into C/C++, SIMD intrinsics, or custom memory management, he does not hand it off.
His personal published apps have accumulated 70,000+ Android downloads (4.5 star) and 207,000+ iOS downloads (4.6 star). He studied Physics and Mathematics at Purdue University and is a member of Mensa International.
This project is part of a series of public examples demonstrating current Android and Compose Multiplatform architecture and patterns. The series begins with Android Custom Theme and Compose Multiplatform Custom Theme, which build a complete custom theming system from first principles. Subsequent projects introduce Dependency Injection with Koin, DataStore Preferences, and HTTP networking with ktor. This project adds a compact audio example: generating tones, notes, DTMF signals, and noise across Android, iOS, and Desktop from a shared Compose Multiplatform UI.
Questions, corrections, or suggestions are welcome. Reach out through roywatson.app or directly at rwatson@roywatson.com.
Available for contract and full-time engagements: roywatson.app
roywatson.app | linkedin.com/in/roywatson3 | github.com/roywatson