
Automates Maven Central releases through a dedicated Gradle publishing build, creating signed upload bundles, handling Sonatype/GPG secrets and customizable POM license metadata.
kpal gives you a cross-platform Kotlin API for device features, then hardens that API with manual QA apps and a device simulator. Use the QA apps when behavior must be proven on real Android and iOS hardware, and use the simulator for cheap integration tests that run quickly in coding-agent feedback loops without a real device or human intervention.
Use the device module for production code:
dependencies:
- ./deviceUse the simulator module from tests or test-only tools:
dependencies:
- ./simulatorPublished artifacts use this group:
implementation("io.github.leon-jakob-schneider.kpal:device:<version>")
testImplementation("io.github.leon-jakob-schneider.kpal:simulator:<version>")Create a platform device and request an audio engine:
import app.miso.audio.AudioSessionConfig
import app.miso.device.DeviceConfig
import app.miso.device.DeviceImpl
import kotlinx.coroutines.runBlocking
val device = DeviceImpl(
platformContext = platformContext,
config = DeviceConfig(
audio = AudioSessionConfig(
sampleRate = 24_000,
ioBufferFrames = 1_024,
preferSpeaker = true,
voiceProcessing = true,
),
),
)
val request = device.audio.requestEngine()
val engine = request.engine ?: return
engine.useDuplex { duplex ->
runBlocking {
val inputChunk = duplex.takeNextInputPcm16()
duplex.playPcm16(inputChunk)
}
}useDuplex calls its block only after the platform audio graph is ready. AudioDuplex functions are suspending and cancellation-aware, so bridge into a coroutine from the block before reading, writing, or restarting. takeNextInputPcm16() suspends until a PCM16 input chunk is available and throws if the duplex session fails; it does not use null to represent an empty queue.
On Android, pass an Android Context as platformContext. On iOS and JVM desktop, platformContext can stay null.
Pass an AudioSessionObserver when you need route, level, byte-count, or error updates:
import app.miso.audio.AudioError
import app.miso.audio.AudioSessionObserver
import app.miso.audio.AudioSessionState
import app.miso.device.DeviceImpl
val observer = object : AudioSessionObserver {
override fun onStateChanged(state: AudioSessionState) {
println("running=${state.isRunning} level=${state.inputLevel}")
println("route=${state.route}")
}
override fun onError(error: AudioError) {
println(error.message)
}
}
val device = DeviceImpl(
platformContext = platformContext,
audioObserver = observer,
)Use the shared tone generator for simple playback checks:
import app.miso.audio.Pcm16ToneGenerator
val tone = Pcm16ToneGenerator.sine(
frequencyHz = 440.0,
durationMillis = 1_500,
sampleRate = 24_000,
)Use DeviceSimulator when the code under test should exercise the same Device surface without opening a real microphone or speaker:
import app.miso.audio.Pcm16ToneGenerator
import app.miso.simulator.DeviceSimulator
import kotlinx.coroutines.runBlocking
val device = DeviceSimulator()
val input = Pcm16ToneGenerator.sine(durationMillis = 200)
device.setAudioInputPcm16(input)
val engine = device.audio.requestEngine().engine ?: error("No audio engine")
engine.useDuplex { duplex ->
runBlocking {
val captured = duplex.takeNextInputPcm16()
check(captured.contentEquals(input))
duplex.playPcm16(captured)
}
}
val output = device.drainAudioOutputPcm16()
check(output.contentEquals(input))Useful simulator controls:
setAudioInputPcm16(bytes): replace pending simulated microphone input.appendAudioInputPcm16(bytes): queue more simulated microphone input.takeNextAudioOutputPcm16(): read the next speaker output chunk.audioOutputPcm16(): snapshot all captured speaker output.drainAudioOutputPcm16(): read and clear speaker output.clearAudioInput() / clearAudioOutput(): reset simulator buffers.Build the app:
./amper build -m device-qa-android-app -p androidRun it on a connected Android device or emulator:
adb devices
./amper run -m device-qa-android-app -p android -d <device-id>Use the app to start capture, play a 440 Hz tone, play captured audio, and inspect route, input level, captured bytes, and played bytes.
Build the app:
./amper build -m device-qa-ios-app -p iosSimulatorArm64Run it on a simulator or connected iOS device:
xcrun simctl list devices
./amper run -m device-qa-ios-app -p iosSimulatorArm64 -d <device-id>For real-device QA, open device-qa-ios-app/module.xcodeproj in Xcode and run the app on an iPhone.
Use the iOS QA suite to validate built-in speaker playback, built-in mic loopback, recording coverage, AirPods playback, and AirPods mic loopback. Export the report at the end of a manual run when you need evidence for a device-specific change.
List modules:
./amper show modulesBuild all modules:
./amper buildBuild the shared device library:
./amper build -m deviceBuild the JVM desktop device library:
./amper build -m device -p jvmBuild the simulator:
./amper build -m simulatorkpal gives you a cross-platform Kotlin API for device features, then hardens that API with manual QA apps and a device simulator. Use the QA apps when behavior must be proven on real Android and iOS hardware, and use the simulator for cheap integration tests that run quickly in coding-agent feedback loops without a real device or human intervention.
Use the device module for production code:
dependencies:
- ./deviceUse the simulator module from tests or test-only tools:
dependencies:
- ./simulatorPublished artifacts use this group:
implementation("io.github.leon-jakob-schneider.kpal:device:<version>")
testImplementation("io.github.leon-jakob-schneider.kpal:simulator:<version>")Create a platform device and request an audio engine:
import app.miso.audio.AudioSessionConfig
import app.miso.device.DeviceConfig
import app.miso.device.DeviceImpl
import kotlinx.coroutines.runBlocking
val device = DeviceImpl(
platformContext = platformContext,
config = DeviceConfig(
audio = AudioSessionConfig(
sampleRate = 24_000,
ioBufferFrames = 1_024,
preferSpeaker = true,
voiceProcessing = true,
),
),
)
val request = device.audio.requestEngine()
val engine = request.engine ?: return
engine.useDuplex { duplex ->
runBlocking {
val inputChunk = duplex.takeNextInputPcm16()
duplex.playPcm16(inputChunk)
}
}useDuplex calls its block only after the platform audio graph is ready. AudioDuplex functions are suspending and cancellation-aware, so bridge into a coroutine from the block before reading, writing, or restarting. takeNextInputPcm16() suspends until a PCM16 input chunk is available and throws if the duplex session fails; it does not use null to represent an empty queue.
On Android, pass an Android Context as platformContext. On iOS and JVM desktop, platformContext can stay null.
Pass an AudioSessionObserver when you need route, level, byte-count, or error updates:
import app.miso.audio.AudioError
import app.miso.audio.AudioSessionObserver
import app.miso.audio.AudioSessionState
import app.miso.device.DeviceImpl
val observer = object : AudioSessionObserver {
override fun onStateChanged(state: AudioSessionState) {
println("running=${state.isRunning} level=${state.inputLevel}")
println("route=${state.route}")
}
override fun onError(error: AudioError) {
println(error.message)
}
}
val device = DeviceImpl(
platformContext = platformContext,
audioObserver = observer,
)Use the shared tone generator for simple playback checks:
import app.miso.audio.Pcm16ToneGenerator
val tone = Pcm16ToneGenerator.sine(
frequencyHz = 440.0,
durationMillis = 1_500,
sampleRate = 24_000,
)Use DeviceSimulator when the code under test should exercise the same Device surface without opening a real microphone or speaker:
import app.miso.audio.Pcm16ToneGenerator
import app.miso.simulator.DeviceSimulator
import kotlinx.coroutines.runBlocking
val device = DeviceSimulator()
val input = Pcm16ToneGenerator.sine(durationMillis = 200)
device.setAudioInputPcm16(input)
val engine = device.audio.requestEngine().engine ?: error("No audio engine")
engine.useDuplex { duplex ->
runBlocking {
val captured = duplex.takeNextInputPcm16()
check(captured.contentEquals(input))
duplex.playPcm16(captured)
}
}
val output = device.drainAudioOutputPcm16()
check(output.contentEquals(input))Useful simulator controls:
setAudioInputPcm16(bytes): replace pending simulated microphone input.appendAudioInputPcm16(bytes): queue more simulated microphone input.takeNextAudioOutputPcm16(): read the next speaker output chunk.audioOutputPcm16(): snapshot all captured speaker output.drainAudioOutputPcm16(): read and clear speaker output.clearAudioInput() / clearAudioOutput(): reset simulator buffers.Build the app:
./amper build -m device-qa-android-app -p androidRun it on a connected Android device or emulator:
adb devices
./amper run -m device-qa-android-app -p android -d <device-id>Use the app to start capture, play a 440 Hz tone, play captured audio, and inspect route, input level, captured bytes, and played bytes.
Build the app:
./amper build -m device-qa-ios-app -p iosSimulatorArm64Run it on a simulator or connected iOS device:
xcrun simctl list devices
./amper run -m device-qa-ios-app -p iosSimulatorArm64 -d <device-id>For real-device QA, open device-qa-ios-app/module.xcodeproj in Xcode and run the app on an iPhone.
Use the iOS QA suite to validate built-in speaker playback, built-in mic loopback, recording coverage, AirPods playback, and AirPods mic loopback. Export the report at the end of a manual run when you need evidence for a device-specific change.
List modules:
./amper show modulesBuild all modules:
./amper buildBuild the shared device library:
./amper build -m deviceBuild the JVM desktop device library:
./amper build -m device -p jvmBuild the simulator:
./amper build -m simulator