
State-of-the-art audio toolkit: playback, recording, DSP effects, HLS streaming, background media controls, pluggable transcription and Compose UI components driven by a single coherent API.
A state-of-the-art audio library for Kotlin Multiplatform / Compose Multiplatform. Playback, recording, effects, streaming, background controls, and pluggable transcription, with one coherent API across Android, iOS, macOS native, Desktop JVM, and WebAssembly.
The KMP/CMP audio space is a patchwork of half-finished solo libraries. Some do playback, some do recording, none cover the full stack with parity across targets. soundscape is the one library that ships a single coherent surface across every CMP target.
MediaRecorder + getUserMedia and returns a blob URL. AAC/Opus where the platform encoder is natively available.commonMain for unit tests, with real platform offload in v0.2:
android.media.audiofx (Equalizer + PresetReverb + DynamicsProcessing limiter on API 28+ + LoudnessEnhancer).AVAudioEngine + AVAudioUnitEQ + AVAudioUnitReverb (file-source playback; URL streams await MTAudioProcessingTap in v0.3).MediaElementAudioSourceNode → BiquadFilterNode chain → DynamicsCompressorNode → ConvolverNode reverb → GainNode).commonMain, ExoPlayer HLS/DASH on Android, HLS native on Apple, hls.js on WASM.MediaSession + lockscreen, iOS/macOS native MPNowPlayingInfoCenter + MPRemoteCommandCenter, navigator.mediaSession on WASM. Desktop JVM (Linux MPRIS / Windows SMTC / macOS NowPlaying via JNA) is a structural shell in v0.2; full method dispatch lands in v0.3. macOS users should target macosArm64 natively for production NowPlaying coverage.Transcriber SPI. Native SFSpeechRecognizer / SpeechRecognizer / Web Speech in v0.2; whisper.cpp adapter ships as a separate soundscape-transcription-whisper artifact in v0.3 (native binary distribution).Waveform, Scrubber, PlayerControls, EqualizerView, LevelMeter, RecorderButton driven by the player/recorder Flows.| Module | Android | iOS | macOS native | Desktop JVM | WASM |
|---|---|---|---|---|---|
soundscape-core |
✅ | ✅ | ✅ | ✅ | ✅ |
soundscape-player |
✅ | ✅ | ✅ | ✅ | ✅ |
soundscape-recorder |
✅ | ✅ | ✅ | ✅ | ✅ MediaRecorder |
soundscape-effects (offload) |
✅ audiofx | ✅ AVAudioEngine (file sources) | ✅ AVAudioEngine | ✅ pure-Kotlin DSP | ✅ Web Audio |
soundscape-streaming |
✅ HLS+DASH | ✅ HLS | 🚧 v0.3 | ✅ HLS | ✅ HLS |
soundscape-background |
✅ MediaSession | ✅ NowPlaying | ✅ NowPlaying | 🚧 v0.3 (use macosArm64 instead) | ✅ MediaSession API |
soundscape-transcription |
✅ SpeechRecognizer | ✅ SFSpeechRecognizer | ✅ SFSpeechRecognizer | 🚧 v0.3 via -whisper
|
✅ Web Speech (Chromium) |
soundscape-ui-compose |
✅ | ✅ | ✅ | ✅ | ✅ |
In v0.3:
soundscape-transcription-whisper (whisper.cpp via whisper-jni, Desktop JVM)soundscape-desktop-ffmpeg (AAC/ALAC/Opus/etc on Desktop via JavaCV's GPL FFmpeg bundle - GPL license applies)Deferred to v0.3.x point release (CHANGELOG.md has the rationale):
MTAudioProcessingTap (design doc captured; needs MediaToolbox cinterop work).# gradle/libs.versions.toml
[versions]
soundscape = "0.4.0"
[libraries]
soundscape-bom = { module = "io.github.nadeemiqbal:soundscape-bom", version.ref = "soundscape" }
soundscape-core = { module = "io.github.nadeemiqbal:soundscape-core" }
soundscape-player = { module = "io.github.nadeemiqbal:soundscape-player" }
soundscape-recorder = { module = "io.github.nadeemiqbal:soundscape-recorder" }
soundscape-effects = { module = "io.github.nadeemiqbal:soundscape-effects" }
soundscape-streaming = { module = "io.github.nadeemiqbal:soundscape-streaming" }
soundscape-background = { module = "io.github.nadeemiqbal:soundscape-background" }
soundscape-transcription = { module = "io.github.nadeemiqbal:soundscape-transcription" }
soundscape-ui-compose = { module = "io.github.nadeemiqbal:soundscape-ui-compose" }// build.gradle.kts
commonMain.dependencies {
implementation(project.dependencies.platform(libs.soundscape.bom))
implementation(libs.soundscape.player)
implementation(libs.soundscape.ui.compose)
}import io.github.nadeemiqbal.soundscape.core.AudioSource
import io.github.nadeemiqbal.soundscape.player.MediaItem
import io.github.nadeemiqbal.soundscape.player.SoundscapePlayer
val player = SoundscapePlayer.create()
player.setQueue(
items = listOf(
MediaItem(id = "1", source = AudioSource.Url("https://example.com/track-a.mp3")),
MediaItem(id = "2", source = AudioSource.Url("https://example.com/track-b.mp3")),
),
)
player.play() // fire-and-forget; observe via player.state / positionTransport methods (setQueue, play, pause, stop, seekTo, skipToNext, skipToPrev) are intentionally non-suspend so the WASM backend can call HTMLAudioElement.play() synchronously from a user-gesture event handler. The browser autoplay policy rejects deferred calls. Wire them directly into Compose onClick lambdas without scope.launch {}.
val recorder = Recorder.create()
val session = recorder.start(RecordingConfig.preset(RecordingConfig.Quality.Voice, "/path/to/out.wav"))
recorder.levels.collect { rms -> println("level=$rms") }
val result = recorder.stop()
// result.outputPath is a filesystem path on Android/Apple/Desktop, or a blob URL on WASM.val chain = EffectsChain()
.equalizer(EffectsChain.FlatEq10Band.map { it.copy(gainDb = 3f) })
.reverb(ReverbPreset.Hall, wetDryMix = 0.4f)
.compressor(thresholdDb = -12f, ratio = 4f)
.gain(db = -3f)
player.setEffectsChainHandle(chain)
// Android: native audiofx attached to the ExoPlayer audio session.
// Apple: AVAudioEngine path activates for AudioSource.File items.
// Web: Web Audio graph wired between the audio element and destination.
// Desktop: pure-Kotlin DSP inserted in the PCM pipeline.val bg = BackgroundController.create()
bg.bind(player)
bg.setMetadata(Metadata(title = "Track A", artist = "Artist", durationMs = 240_000))
bg.transportActions.collect { action -> /* react to lockscreen / media-key events */ }// In your Application.onCreate
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AndroidContextHolder.install(this)
}
}Add to your manifest if you use the Recorder:
<uses-permission android:name="android.permission.RECORD_AUDIO" />samples/unified/ is a single Compose Multiplatform app that runs on every target with seven tabs covering every module (Player, Recorder, Effects, Streaming, Background, Transcribe, Visualizer).samples/platforms/ ships separate native-idiomatic shells for Android, Desktop, Web, and iOS that demonstrate the platform-only OS integrations (MediaSession on Android, NowPlaying on iOS/macOS, etc).Run the desktop sample:
./gradlew :samples:unified:desktopApp:runEach module publishes its own artifact and depends only on :core plus the modules it logically needs. Use the BOM to pin every artifact at one version.
ui-compose -> player, recorder, effects -+
background -> player --------------------+
streaming -> player --------------------+-> core
transcription --------------------------+
player -> effects (for backend offload bridges) ----+
Flow-driven throughout. StateFlow for state, Flow for streams (position, levels, transcripts), sealed SoundscapeException for errors. Transport methods (play, pause, seekTo, setQueue, skip*) are non-suspend so WASM can call them inside the same JS turn as the user gesture; everything else that does real I/O (Recorder.start, Transcriber.transcribe) remains suspend.
MTAudioProcessingTap, Desktop OS controls (Linux MPRIS / Windows SMTC / macOS NowPlaying-via-JVM), soundscape-transcription-whisper (whisper.cpp via JNI), soundscape-desktop-ffmpeg (AAC/ALAC on Desktop), lower-latency Android engine (AAudio).PRs welcome. See CONTRIBUTING.md.
Apache 2.0. See LICENSE.
A state-of-the-art audio library for Kotlin Multiplatform / Compose Multiplatform. Playback, recording, effects, streaming, background controls, and pluggable transcription, with one coherent API across Android, iOS, macOS native, Desktop JVM, and WebAssembly.
The KMP/CMP audio space is a patchwork of half-finished solo libraries. Some do playback, some do recording, none cover the full stack with parity across targets. soundscape is the one library that ships a single coherent surface across every CMP target.
MediaRecorder + getUserMedia and returns a blob URL. AAC/Opus where the platform encoder is natively available.commonMain for unit tests, with real platform offload in v0.2:
android.media.audiofx (Equalizer + PresetReverb + DynamicsProcessing limiter on API 28+ + LoudnessEnhancer).AVAudioEngine + AVAudioUnitEQ + AVAudioUnitReverb (file-source playback; URL streams await MTAudioProcessingTap in v0.3).MediaElementAudioSourceNode → BiquadFilterNode chain → DynamicsCompressorNode → ConvolverNode reverb → GainNode).commonMain, ExoPlayer HLS/DASH on Android, HLS native on Apple, hls.js on WASM.MediaSession + lockscreen, iOS/macOS native MPNowPlayingInfoCenter + MPRemoteCommandCenter, navigator.mediaSession on WASM. Desktop JVM (Linux MPRIS / Windows SMTC / macOS NowPlaying via JNA) is a structural shell in v0.2; full method dispatch lands in v0.3. macOS users should target macosArm64 natively for production NowPlaying coverage.Transcriber SPI. Native SFSpeechRecognizer / SpeechRecognizer / Web Speech in v0.2; whisper.cpp adapter ships as a separate soundscape-transcription-whisper artifact in v0.3 (native binary distribution).Waveform, Scrubber, PlayerControls, EqualizerView, LevelMeter, RecorderButton driven by the player/recorder Flows.| Module | Android | iOS | macOS native | Desktop JVM | WASM |
|---|---|---|---|---|---|
soundscape-core |
✅ | ✅ | ✅ | ✅ | ✅ |
soundscape-player |
✅ | ✅ | ✅ | ✅ | ✅ |
soundscape-recorder |
✅ | ✅ | ✅ | ✅ | ✅ MediaRecorder |
soundscape-effects (offload) |
✅ audiofx | ✅ AVAudioEngine (file sources) | ✅ AVAudioEngine | ✅ pure-Kotlin DSP | ✅ Web Audio |
soundscape-streaming |
✅ HLS+DASH | ✅ HLS | 🚧 v0.3 | ✅ HLS | ✅ HLS |
soundscape-background |
✅ MediaSession | ✅ NowPlaying | ✅ NowPlaying | 🚧 v0.3 (use macosArm64 instead) | ✅ MediaSession API |
soundscape-transcription |
✅ SpeechRecognizer | ✅ SFSpeechRecognizer | ✅ SFSpeechRecognizer | 🚧 v0.3 via -whisper
|
✅ Web Speech (Chromium) |
soundscape-ui-compose |
✅ | ✅ | ✅ | ✅ | ✅ |
In v0.3:
soundscape-transcription-whisper (whisper.cpp via whisper-jni, Desktop JVM)soundscape-desktop-ffmpeg (AAC/ALAC/Opus/etc on Desktop via JavaCV's GPL FFmpeg bundle - GPL license applies)Deferred to v0.3.x point release (CHANGELOG.md has the rationale):
MTAudioProcessingTap (design doc captured; needs MediaToolbox cinterop work).# gradle/libs.versions.toml
[versions]
soundscape = "0.4.0"
[libraries]
soundscape-bom = { module = "io.github.nadeemiqbal:soundscape-bom", version.ref = "soundscape" }
soundscape-core = { module = "io.github.nadeemiqbal:soundscape-core" }
soundscape-player = { module = "io.github.nadeemiqbal:soundscape-player" }
soundscape-recorder = { module = "io.github.nadeemiqbal:soundscape-recorder" }
soundscape-effects = { module = "io.github.nadeemiqbal:soundscape-effects" }
soundscape-streaming = { module = "io.github.nadeemiqbal:soundscape-streaming" }
soundscape-background = { module = "io.github.nadeemiqbal:soundscape-background" }
soundscape-transcription = { module = "io.github.nadeemiqbal:soundscape-transcription" }
soundscape-ui-compose = { module = "io.github.nadeemiqbal:soundscape-ui-compose" }// build.gradle.kts
commonMain.dependencies {
implementation(project.dependencies.platform(libs.soundscape.bom))
implementation(libs.soundscape.player)
implementation(libs.soundscape.ui.compose)
}import io.github.nadeemiqbal.soundscape.core.AudioSource
import io.github.nadeemiqbal.soundscape.player.MediaItem
import io.github.nadeemiqbal.soundscape.player.SoundscapePlayer
val player = SoundscapePlayer.create()
player.setQueue(
items = listOf(
MediaItem(id = "1", source = AudioSource.Url("https://example.com/track-a.mp3")),
MediaItem(id = "2", source = AudioSource.Url("https://example.com/track-b.mp3")),
),
)
player.play() // fire-and-forget; observe via player.state / positionTransport methods (setQueue, play, pause, stop, seekTo, skipToNext, skipToPrev) are intentionally non-suspend so the WASM backend can call HTMLAudioElement.play() synchronously from a user-gesture event handler. The browser autoplay policy rejects deferred calls. Wire them directly into Compose onClick lambdas without scope.launch {}.
val recorder = Recorder.create()
val session = recorder.start(RecordingConfig.preset(RecordingConfig.Quality.Voice, "/path/to/out.wav"))
recorder.levels.collect { rms -> println("level=$rms") }
val result = recorder.stop()
// result.outputPath is a filesystem path on Android/Apple/Desktop, or a blob URL on WASM.val chain = EffectsChain()
.equalizer(EffectsChain.FlatEq10Band.map { it.copy(gainDb = 3f) })
.reverb(ReverbPreset.Hall, wetDryMix = 0.4f)
.compressor(thresholdDb = -12f, ratio = 4f)
.gain(db = -3f)
player.setEffectsChainHandle(chain)
// Android: native audiofx attached to the ExoPlayer audio session.
// Apple: AVAudioEngine path activates for AudioSource.File items.
// Web: Web Audio graph wired between the audio element and destination.
// Desktop: pure-Kotlin DSP inserted in the PCM pipeline.val bg = BackgroundController.create()
bg.bind(player)
bg.setMetadata(Metadata(title = "Track A", artist = "Artist", durationMs = 240_000))
bg.transportActions.collect { action -> /* react to lockscreen / media-key events */ }// In your Application.onCreate
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AndroidContextHolder.install(this)
}
}Add to your manifest if you use the Recorder:
<uses-permission android:name="android.permission.RECORD_AUDIO" />samples/unified/ is a single Compose Multiplatform app that runs on every target with seven tabs covering every module (Player, Recorder, Effects, Streaming, Background, Transcribe, Visualizer).samples/platforms/ ships separate native-idiomatic shells for Android, Desktop, Web, and iOS that demonstrate the platform-only OS integrations (MediaSession on Android, NowPlaying on iOS/macOS, etc).Run the desktop sample:
./gradlew :samples:unified:desktopApp:runEach module publishes its own artifact and depends only on :core plus the modules it logically needs. Use the BOM to pin every artifact at one version.
ui-compose -> player, recorder, effects -+
background -> player --------------------+
streaming -> player --------------------+-> core
transcription --------------------------+
player -> effects (for backend offload bridges) ----+
Flow-driven throughout. StateFlow for state, Flow for streams (position, levels, transcripts), sealed SoundscapeException for errors. Transport methods (play, pause, seekTo, setQueue, skip*) are non-suspend so WASM can call them inside the same JS turn as the user gesture; everything else that does real I/O (Recorder.start, Transcriber.transcribe) remains suspend.
MTAudioProcessingTap, Desktop OS controls (Linux MPRIS / Windows SMTC / macOS NowPlaying-via-JVM), soundscape-transcription-whisper (whisper.cpp via JNI), soundscape-desktop-ffmpeg (AAC/ALAC on Desktop), lower-latency Android engine (AAudio).PRs welcome. See CONTRIBUTING.md.
Apache 2.0. See LICENSE.