A Compose Multiplatform application demonstrating native MOD music playback using libopenmpt. Built with Kotlin Multiplatform (KMP), supporting Android, iOS, Desktop (JVM), and Web (WASM/JS).
- Native MOD Playback: Uses libopenmpt C library for authentic tracker music reproduction
- Prebuilt Binaries Included: All native libraries (libopenmpt) are prebuilt and included for all platforms
- Cross-Platform UI: Compose Multiplatform for consistent UI across all platforms
- Dependency Injection: Koin for platform-specific ModPlayer injection
- Reactive State Management: Kotlin Flows for real-time UI updates
- Full Playback Control: Play, pause, stop, and seek functionality
- Metadata Display: Shows module information (title, artist, format, etc.)
- Playback Settings: Speed and pitch control with presets
Add the library to your Kotlin Multiplatform project:
// In your commonMain dependencies
dependencies {
implementation("com.beyond-eye:libopenmpt-kmp:1.0.0")
}| Platform | Setup Required |
|---|---|
| Android | ✅ No action required - native libraries bundled in AAR |
| Desktop (JVM) | ✅ No action required - native libraries bundled |
| iOS | libopenmpt.xcframework |
| Wasm/JS | libopenmpt.js and libopenmpt.wasm |
📖 For detailed setup instructions, see Using as a Library and docs/README_library_consumers.md
| Platform | Status | Audio Backend |
|---|---|---|
| Android | ✅ Ready | Oboe + libopenmpt |
| Desktop (JVM) | ✅ Ready | JavaSound + libopenmpt |
| iOS | ✅ Ready | AudioUnit + libopenmpt |
| Web (WASM/JS) | ✅ Ready | Web Audio API + libopenmpt |
The demo app showcases all capabilities of the ModPlayer API:
- Sample File: Load a bundled sample MOD file from app resources
- File Picker: Load any tracker module from the file system (supports 60+ formats)
- Module title and artist
- Format type and tracker name
- Number of channels, patterns, instruments, and samples
- Total duration
- Play/Pause: Toggle playback with a single button
- Stop: Stop playback and reset to beginning
- Seek Bar: Drag to seek to any position with real-time position display
- Playback Info: Live display of current order, pattern, and row position
- Master Gain: Volume control from -10dB to +10dB with reset preset
- Auto-Loop: Toggle infinite repeat mode
- Speed Control: Adjust tempo from 0.25x to 2.0x with preset buttons (0.5x, 1.0x, 1.5x, 2.0x)
- Pitch Control: Adjust pitch from 0.25x to 2.0x with preset buttons (0.5x, 1.0x, 1.5x, 2.0x)
The ModPlayer interface (shared/src/commonMain/kotlin/com/beyondeye/openmpt/core/ModPlayer.kt) provides a platform-agnostic API for MOD music playback.
| Method | Description |
|---|---|
loadModule(data: ByteArray): Boolean |
Load a module from a byte array. Returns true on success. |
loadModuleSuspend(data: ByteArray): Boolean |
Suspend version of loadModule. Recommended for platforms requiring async initialization (e.g., wasmJS). |
loadModuleFromPath(path: String): Boolean |
Load a module from a file path. Not available on all platforms. |
release() |
Release all resources. Must be called when the player is no longer needed. |
| Method | Description |
|---|---|
play() |
Start or resume playback. |
pause() |
Pause playback. |
stop() |
Stop playback and reset position to the beginning. |
seek(positionSeconds: Double) |
Seek to a specific position in seconds. |
| Method | Description |
|---|---|
setRepeatCount(count: Int) |
Set repeat mode: -1 = infinite, 0 = no repeat, n = repeat n times. |
setMasterGain(gainMillibel: Int) |
Set master volume in millibels (0 = normal, negative = quieter, positive = louder). |
setStereoSeparation(percent: Int) |
Set stereo separation (0-200%, default 100%). |
setPlaybackSpeed(speed: Double) |
Set playback speed/tempo factor (0.25 to 2.0, 1.0 = normal). |
getPlaybackSpeed(): Double |
Get current playback speed. |
setPitch(pitch: Double) |
Set pitch factor (0.25 to 2.0, 1.0 = normal). |
getPitch(): Double |
Get current pitch factor. |
| Property | Type | Description |
|---|---|---|
playbackState |
PlaybackState |
Current state: Idle, Loading, Loaded, Playing, Paused, Stopped, or Error. |
isPlaying |
Boolean |
Whether the module is currently playing. |
positionSeconds |
Double |
Current playback position in seconds. |
durationSeconds |
Double |
Total duration of the module in seconds. |
| Method | Return Type | Description |
|---|---|---|
getMetadata() |
ModMetadata |
Get metadata (title, artist, format, tracker, channels, patterns, instruments, samples, duration). |
getCurrentOrder() |
Int |
Get current order position (-1 if no module loaded). |
getCurrentPattern() |
Int |
Get current pattern being played (-1 if no module loaded). |
getCurrentRow() |
Int |
Get current row in the pattern (-1 if no module loaded). |
getNumChannels() |
Int |
Get number of channels (0 if no module loaded). |
| Property | Type | Description |
|---|---|---|
playbackStateFlow |
StateFlow<PlaybackState> |
Flow of playback state changes for reactive UI updates. |
positionFlow |
StateFlow<Double> |
Flow of position updates in seconds, updated periodically during playback. |
┌─────────────────────────────────────────────────────┐
│ UI Layer (Compose Multiplatform) │
│ ├─ App.kt (common entry point) │
│ ├─ ModPlayerScreen │
│ └─ ModPlayerViewModel (Koin-injected) │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ Dependency Injection (Koin 4.1.1) │
│ ├─ appModule │
│ ├─ factory<ModPlayer> { createModPlayer() } │
│ └─ viewModel { ModPlayerViewModel(get()) } │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ Shared Module (KMP) │
│ ├─ ModPlayer interface (commonMain) │
│ ├─ createModPlayer() expect/actual factory │
│ └─ Platform implementations: │
│ ├─ AndroidModPlayer (JNI + Oboe) │
│ ├─ IosModPlayer (cinterop + AudioUnit) │
│ ├─ DesktopModPlayer (JNI + JavaSound) │
│ └─ WasmModPlayer (JS interop + Web Audio) │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ Native Layer (Android) │
│ ├─ mod_player_jni.cpp (JNI bridge) │
│ ├─ ModPlayerEngine.cpp (C++ engine) │
│ ├─ libopenmpt (MOD rendering) │
│ └─ Oboe (Audio output) │
└─────────────────────────────────────────────────────┘
app/
├── src/
│ ├── commonMain/kotlin/com/beyondeye/openmptdemo/
│ │ ├── App.kt # Main Compose UI
│ │ ├── ModPlayerViewModel.kt # Shared ViewModel
│ │ ├── di/
│ │ │ └── AppModule.kt # Koin DI module
│ │ └── ui/theme/
│ │ ├── Color.kt
│ │ ├── Theme.kt
│ │ └── Type.kt
│ ├── commonMain/composeResources/files/
│ │ └── sm64_mainmenuss.xm # Sample MOD file
│ ├── androidMain/kotlin/com/beyondeye/openmptdemo/
│ │ ├── MainActivity.kt # Android entry point
│ │ └── OpenMPTDemoApp.kt # Application class (Koin init)
│ ├── desktopMain/kotlin/com/beyondeye/openmptdemo/
│ │ └── main.kt # Desktop entry point
│ ├── wasmJsMain/kotlin/com/beyondeye/openmptdemo/
│ │ └── main.kt # WASM/JS entry point
│ └── iosMain/kotlin/com/beyondeye/openmptdemo/
│ └── MainViewController.kt # iOS entry point
shared/
├── src/
│ ├── commonMain/kotlin/com/beyondeye/openmpt/core/
│ │ ├── ModPlayer.kt # Platform-agnostic interface
│ │ ├── ModPlayerFactory.kt # expect fun createModPlayer()
│ │ ├── PlaybackState.kt
│ │ ├── ModMetadata.kt
│ │ └── ModPlayerException.kt
│ ├── androidMain/
│ │ ├── kotlin/.../AndroidModPlayer.kt
│ │ ├── kotlin/.../ModPlayerNative.kt
│ │ ├── kotlin/.../ModPlayerFactory.android.kt
│ │ ├── cpp/ # JNI native code
│ │ └── jniLibs/ # Prebuilt libopenmpt.so
│ ├── iosMain/kotlin/.../
│ ├── desktopMain/kotlin/.../
│ └── wasmJsMain/kotlin/.../
libopenmpt/ # Native library build module
- Android Studio Hedgehog or newer
- Android NDK (for Android builds)
- CMake 3.22.1 or newer
- JDK 11 or newer (use
/opt/android-studio/jbras specified in project rules)
Additional requirements for macOS Desktop builds:
- Xcode and Xcode Command Line Tools
- CMake 3.21+ (install via
brew install cmake) - See docs/README_macos_build.md for detailed instructions
Build libopenmpt first (Android):
./gradlew :libopenmpt:exportPrebuiltLibsDebugBuild Android app:
./gradlew :app:assembleDebugRun Desktop app:
./gradlew :app:runBuild Desktop native libraries for macOS (if not already built):
# Build libopenmpt.dylib
cd libopenmpt/src/main/cpp/macos
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release -j8
# Copy to resources
cp lib/libopenmpt.dylib ../../../../../../shared/src/desktopMain/resources/native/macos-arm64/
# Build JNI wrapper (from project root)
cd shared/src/desktopMain/cpp
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DLIBOPENMPT_INCLUDE_DIR=$(pwd)/../../../../libopenmpt/src/main/cpp \
-DLIBOPENMPT_LIBRARY=$(pwd)/../../resources/native/macos-arm64/libopenmpt.dylib
cmake --build . --config ReleaseBuild WASM/JS:
./gradlew :app:wasmJsBrowserDevelopmentRunBuild iOS libraries:
./gradlew :libopenmpt:buildIosInstall Android app:
./gradlew :app:installDebugThe project uses Koin 4.1.1 for multiplatform dependency injection:
// AppModule.kt
val appModule = module {
factory<ModPlayer> { createModPlayer() }
viewModel { ModPlayerViewModel(get()) }
}
// Usage in Composables
@Composable
fun ModPlayerScreen(
viewModel: ModPlayerViewModel = koinViewModel()
) { ... }Uses de.halfbit:logger (0.9) for cross-platform logging:
import de.halfbit.logger.d
import de.halfbit.logger.e
d("TAG") { "Debug message" }
e("TAG") { "Error: ${exception.message}" }Sample MOD files are loaded using Compose Multiplatform resources:
val bytes = Res.readBytes("files/sm64_mainmenuss.xm")
viewModel.loadModule(bytes)libopenmpt 0.8.3 supports a wide range of tracker formats:
- MOD (ProTracker, NoiseTracker, etc.)
- XM (FastTracker 2)
- IT (Impulse Tracker)
- S3M (Scream Tracker 3)
- And many more...
- see libopenmpt FAQ for full list
- Full native implementation with JNI
- Oboe for low-latency audio
- Complete playback control
- Full native implementation with JNI bridge
- libopenmpt for MOD rendering via JNI
- JavaSound
SourceDataLinefor 16-bit PCM audio output - Complete playback control with state management
- Full native implementation with Kotlin/Native cinterop
- libopenmpt compiled as XCFramework (arm64 device + arm64 simulator)
- AudioUnit (RemoteIO) for low-latency audio output
- Complete playback control with state management
- Note:
loadModuleFromPath()not implemented (useloadModule(ByteArray)instead) - Note: Background audio session not configured (add AVAudioSession setup if needed)
- Full implementation with libopenmpt compiled to WASM
- Web Audio API with ScriptProcessorNode for audio output
- Complete playback control and metadata support
- Note: ScriptProcessorNode is deprecated; future migration to AudioWorklet recommended
- Kotlin Multiplatform: 2.2.21
- Compose Multiplatform: 1.9.3
- Koin: 4.1.1
- Logger: 0.9 (de.halfbit:logger)
- libopenmpt: 0.8.3 (Native MOD rendering)
- Oboe: 1.8.0 (Android low-latency audio)
- Kotlinx Coroutines: 1.10.2
If you want to use the shared module as a dependency in your own Kotlin Multiplatform project, please note the following important requirements:
Prebuilt binaries are included for all platforms in the repository. The setup required depends on how you consume the shared module:
If you include the shared module as a local project dependency (like the app module in this repository):
| Platform | Native Library Status |
|---|---|
| Android | ✅ Bundled in AAR - no action needed |
| Desktop (JVM) | ✅ Bundled - no action needed |
| iOS | ✅ Automatically linked via cinterop - no action needed |
| Wasm/JS | libopenmpt.js and libopenmpt.wasm to app module |
If you consume the shared module as a published Maven artifact:
| Platform | Native Library Status |
|---|---|
| Android | ✅ Bundled in AAR - no action needed |
| Desktop (JVM) | ✅ Bundled - no action needed |
| iOS | |
| Wasm/JS | libopenmpt.js and libopenmpt.wasm |
Note: If using the shared module as a local/monorepo dependency, no iOS setup is required.
- Build or obtain
libopenmpt.xcframework - Copy it to
your-app/src/iosMain/libs/ - Add
linkerOptsin yourbuild.gradle.kts:iosArm64 { binaries.framework { linkerOpts("-L${projectDir}/src/iosMain/libs/libopenmpt.xcframework/ios-arm64", "-lopenmpt") } }
Note: Manual setup is required for Wasm/JS regardless of how you consume the shared module.
- The prebuilt
libopenmpt.jsandlibopenmpt.wasmfiles are located inshared/src/wasmJsMain/resources/ - Copy them to your app module's resources (e.g.,
your-app/src/wasmJsMain/resources/) - Call
LibOpenMpt.initializeLibOpenMpt()before using ModPlayer
📖 For detailed instructions, see docs/README_library_consumers.md
This project uses:
- libopenmpt (BSD license)
- Oboe (Apache 2.0)
- Sample MOD file from The Mod Archive
When extending platform support:
- Implement the
ModPlayerinterface in the platform-specific source set - Implement the
actual fun createModPlayer()factory function - Use the appropriate audio backend for the platform
- Test with the sample MOD file
- CMake not found: Install CMake from Android SDK Manager
- NDK errors: Ensure NDK is properly installed and configured
- Koin not found: Check that Koin dependencies are in the version catalog
- Native library loading fails: Ensure libopenmpt.so is built and exported
- No audio output: Check device audio settings
- Crashes on play: Check logcat for native crash details