Low-latency raw PCM streaming audio player for Kotlin Multiplatform (Android, iOS, macOS).
Designed for feeding audio as it arrives from a TTS or realtime voice API (OpenAI, ElevenLabs, Cartesia, Google, ...): feed arbitrary-length PCM chunks, playback starts immediately and plays gaplessly.
One shared engine, two native UIs: the demo apps stream a jittery synthetic utterance while showing buffered audio and underrun recovery.
🔊 Watch the demo with sound – real captured playback: first Android streams with jitter off (gapless), then iOS streams over a simulated congested network and audibly recovers from underruns.
This is the Kotlin Multiplatform sibling of the
audio_stream_player Flutter
package and tracks its core behavior. The native engines are the same:
AudioTrack in MODE_STREAM on Android, AVAudioEngine +
AVAudioPlayerNode on Apple platforms.
Available on Maven Central. Add mavenCentral() to your repositories, then:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.adrianczuczka:audio-stream-player:0.1.0")
}
}
}With a version catalog:
# gradle/libs.versions.toml
[libraries]
audio-stream-player = { module = "com.adrianczuczka:audio-stream-player", version = "0.1.0" }- In a plain Android project the same coordinate resolves to the Android
variant through Gradle module metadata; no
-androidsuffix needed. kotlinx-coroutines-coreis anapidependency, soFlowandStateFloware available without declaring it yourself.
val player = AudioStreamPlayer(sampleRate = 24000)
player.play()
ttsResponse.collect { chunk -> player.feed(chunk) }
player.endOfStream() // suspends until the last sample has played
player.dispose()- Chunks may be any length – frame alignment across chunk boundaries is handled internally.
sampleRate/channels/formatdescribe the data you feed, not the device; resampling is handled natively.- Buffering is unbounded; use
bufferedDuration()for backpressure if the source can outrun playback indefinitely.
player.state // StateFlow<PlayerState>: IDLE, PLAYING, PAUSED
player.events // SharedFlow<AudioStreamPlayerEvent>: Underrun, ErrorAn underrun (buffer ran dry mid-stream) keeps the player in PLAYING;
playback resumes automatically when more data is fed.
By default the player configures the audio session for playback (category
playback, mode spokenAudio) and activates it on play(). Pass
configureAudioSession = false to manage the session yourself.
The GIF above is the checked-in demo, one app per platform sharing one engine:
demo-shared– common Kotlin driving the player: synthesizes a robot-speech utterance, feeds it in ~100ms chunks with network-like jitter, and reduces state, events, and buffer level into one UI state.demo-android– Jetpack Compose UI. Run with./gradlew :demo-android:installDebug.demo-ios– SwiftUI consuming the shared engine through a Kotlin/Native framework. Boot a simulator, then run./demo-ios/build-and-run.sh(no Xcode project needed).
| Platform | Backend | Targets | Minimum |
|---|---|---|---|
| Android | AudioTrack (MODE_STREAM) |
android |
API 23 |
| iOS | AVAudioEngine |
iosArm64, iosSimulatorArm64, iosX64 |
14.0 |
| macOS | AVAudioEngine |
macosArm64, macosX64 |
11.0 |