
WhatsApp/Telegram-style voice messaging UI primitives: hold-to-record with slide-to-lock and slide-to-cancel gestures, live amplitude waveform, seekable playback bubble; audio capture kept BYO.
WhatsApp / Telegram-style voice messaging primitives for Compose Multiplatform. A hold-to-record mic with slide-to-lock and slide-to-cancel gestures, plus a playback bubble with waveform and scrubber. One library, every CMP target — audio capture stays BYO.
Every chat app eventually needs the WhatsApp / Telegram voice interaction — and every team rebuilds it from scratch. The gesture choreography is the awkward part: long-press to start, slide up past a threshold to lock, slide left past another threshold to cancel, release with different outcomes per phase, plus the live amplitude waveform and the receive-side playback bubble with seekable scrubber.
voice-message ships that whole flow as a clean Compose Multiplatform primitive. Audio capture
(microphone, encoding, file output) stays BYO so the library remains pure Compose and works
identically on every CMP target — you wire MediaRecorder / AVAudioRecorder / Web Audio /
JavaSound into the state callbacks once and the UX behaves the same everywhere.
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
voice-message = { module = "io.github.nadeemiqbal:voice-message", version = "0.3.0" }
# Optional: drop-in mic capture so you do not have to wire MediaRecorder / AVAudioRecorder /
# JavaSound / browser MediaRecorder yourself. Pair with rememberAudioBoundVoiceRecorderState.
voice-message-audio = { module = "io.github.nadeemiqbal:voice-message-audio", version = "0.3.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.voice.message)
}
}
}val recorderState = rememberVoiceRecorderState(
onStart = { /* open MediaRecorder / AVAudioRecorder / Web Audio worklet */ },
onCancel = { /* close + delete the in-progress file */ },
onSend = { duration, samples ->
// close the recorder, encode, ship the file
viewModel.sendVoiceMessage(file, duration, samples)
},
)
// In your chat input row:
VoiceRecorderInput(
state = recorderState,
idlePlaceholder = {
// your text field goes here — visible while not recording
ChatTextField(modifier = Modifier.weight(1f))
},
)
// Feed live amplitudes from your audio source while recording:
LaunchedEffect(Unit) {
audioCapture.amplitudes.collect { recorderState.pushAmplitude(it) }
}VoiceMessageBubble(
samples = message.amplitudes, // List<Float> 0f..1f
duration = message.duration,
isPlaying = playerState.isPlaying(message.id),
progress = playerState.progress(message.id),
onPlayPauseToggle = { playerState.toggle(message.id) },
onSeek = { fraction -> playerState.seek(message.id, fraction) },
role = if (message.isMine) VoiceMessageRole.Sender else VoiceMessageRole.Receiver,
)Programmatic control over the recorder
recorderState.start() // begin recording
recorderState.pushAmplitude(0.6f) // feed mic peaks while recording
recorderState.forceCancel() // discard (e.g. on incoming call)
recorderState.sendFromLock() // tap the Send button while locked
recorderState.cancelFromLock() // tap Cancel while locked
recorderState.phase // VoicePhase.Idle | RecordingHeld | ...
recorderState.elapsed // current Duration
recorderState.capturedSamples // List<Float> pushed so farTuning thresholds
VoiceRecorderInput(
state = recorderState,
lockThresholdDp = 100.dp, // longer slide-up to lock
cancelThresholdDp = 60.dp, // shorter slide-left to cancel
)
rememberVoiceRecorderState(
minDuration = 800.milliseconds, // tighter tap-vs-hold threshold
maxDuration = 10.minutes, // longer recordings allowed
onSend = { _, _ -> },
)Custom colours
VoiceRecorderInput(
state = recorderState,
colors = VoiceMessageDefaults.recorderColors().copy(
micActiveColor = Color(0xFF25D366), // WhatsApp green
sendButtonColor = Color(0xFF25D366),
),
)The library never opens a microphone or writes a file. Wire your platform audio in three places:
val recorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(file)
}
val state = rememberVoiceRecorderState(
onStart = { recorder.prepare(); recorder.start() },
onCancel = { recorder.stop(); recorder.release(); file.delete() },
onSend = { duration, _ -> recorder.stop(); recorder.release(); upload(file, duration) },
)
// Poll the recorder for amplitude:
LaunchedEffect(state.phase) {
while (state.phase == VoicePhase.RecordingHeld ||
state.phase == VoicePhase.RecordingLocked ||
state.phase == VoicePhase.Cancelling) {
state.pushAmplitude(recorder.maxAmplitude / 32768f)
delay(50)
}
}Sketch — see your favourite Kotlin/Native audio stack for the full glue. The point is that the library's API is the same on every platform; only the actual audio plumbing differs.
Same pattern — open the capture API on onStart, push amplitudes into pushAmplitude, close
on onCancel / onSend.
long-press
Idle ──────────────► RecordingHeld
│ │ │
│ │ └─ release (elapsed >= minDuration)
│ │ └► onSend(duration, samples)
│ │
│ ├─ slide-up past lockThreshold ► RecordingLocked
│ │ ├─ Send tapped ► onSend(…)
│ │ └─ Cancel tapped ► onCancel()
│ │
│ └─ slide-left past cancelThreshold ► Cancelling
│ ├─ slide back ► RecordingHeld
│ └─ release ► onCancel()
│
└─ release (elapsed < minDuration) ► Idle (silent, no callback)
└─ maxDuration reached ► onSend(…)
└─ forceCancel() ► onCancel()
Every transition + every edge case is covered by 31 pure-logic test cases in
VoiceMessageLogicTest and 11 UI wiring tests in VoiceMessageUiTest.
lockThresholdDp / cancelThresholdDp — drag distances for the lock and cancel gestures.minDuration / maxDuration — tap-vs-hold threshold and recording length cap.colors — full VoiceRecorderColors / VoiceMessageBubbleColors overrides.barCount — bar count on VoiceWaveform and VoiceMessageBubble. Lower = chunkier bars.role — Sender / Receiver flips the bubble's default tinting.idlePlaceholder — the composable slot shown to the left of the mic when not recording.| VoiceMessage | Hand-rolled per platform | Material 3 | |
|---|---|---|---|
| Hold-to-record + slide gestures | ✅ FSM-driven | ❌ | |
| Slide-to-lock hands-free | ✅ | ❌ | |
| Slide-to-cancel | ✅ | ❌ | |
| Live amplitude waveform | ✅ | ❌ | |
| Playback bubble with scrubber | ✅ | ❌ | |
| Multiplatform | ✅ A/iOS/Desktop/Web | ❌ four separate impls | |
| Audio-stack agnostic | ✅ BYO | n/a | n/a |
voice-message-audio with expect/actual
audio adapters that close the BYO gap: Android MediaRecorder, iOS AVAudioRecorder, Desktop
javax.sound.sampled, Web MediaRecorder API. Permission helpers and system-interrupt
observers included.onTap callback) so consumers can toggle into text-only mode on tap.voice-message-audio companion artifact: drop-in mic capture for Android, iOS, Desktop, Web.
Use rememberAudioBoundVoiceRecorderState { audio, samples -> ... } and skip BYO entirely.MicButton is now at one stable slot
across phase changes, so its pointer-input modifier survives state.start() recomposition).VoicePhase.Sent removed (was a stuck terminal phase); phase resets to Idle synchronously
after onSend.semantics, contentDescription, Role.Button, LiveRegionMode.Polite
on the timer.rememberVoiceHaptics() with per-platform actuals.See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.
WhatsApp / Telegram-style voice messaging primitives for Compose Multiplatform. A hold-to-record mic with slide-to-lock and slide-to-cancel gestures, plus a playback bubble with waveform and scrubber. One library, every CMP target — audio capture stays BYO.
Every chat app eventually needs the WhatsApp / Telegram voice interaction — and every team rebuilds it from scratch. The gesture choreography is the awkward part: long-press to start, slide up past a threshold to lock, slide left past another threshold to cancel, release with different outcomes per phase, plus the live amplitude waveform and the receive-side playback bubble with seekable scrubber.
voice-message ships that whole flow as a clean Compose Multiplatform primitive. Audio capture
(microphone, encoding, file output) stays BYO so the library remains pure Compose and works
identically on every CMP target — you wire MediaRecorder / AVAudioRecorder / Web Audio /
JavaSound into the state callbacks once and the UX behaves the same everywhere.
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
voice-message = { module = "io.github.nadeemiqbal:voice-message", version = "0.3.0" }
# Optional: drop-in mic capture so you do not have to wire MediaRecorder / AVAudioRecorder /
# JavaSound / browser MediaRecorder yourself. Pair with rememberAudioBoundVoiceRecorderState.
voice-message-audio = { module = "io.github.nadeemiqbal:voice-message-audio", version = "0.3.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.voice.message)
}
}
}val recorderState = rememberVoiceRecorderState(
onStart = { /* open MediaRecorder / AVAudioRecorder / Web Audio worklet */ },
onCancel = { /* close + delete the in-progress file */ },
onSend = { duration, samples ->
// close the recorder, encode, ship the file
viewModel.sendVoiceMessage(file, duration, samples)
},
)
// In your chat input row:
VoiceRecorderInput(
state = recorderState,
idlePlaceholder = {
// your text field goes here — visible while not recording
ChatTextField(modifier = Modifier.weight(1f))
},
)
// Feed live amplitudes from your audio source while recording:
LaunchedEffect(Unit) {
audioCapture.amplitudes.collect { recorderState.pushAmplitude(it) }
}VoiceMessageBubble(
samples = message.amplitudes, // List<Float> 0f..1f
duration = message.duration,
isPlaying = playerState.isPlaying(message.id),
progress = playerState.progress(message.id),
onPlayPauseToggle = { playerState.toggle(message.id) },
onSeek = { fraction -> playerState.seek(message.id, fraction) },
role = if (message.isMine) VoiceMessageRole.Sender else VoiceMessageRole.Receiver,
)Programmatic control over the recorder
recorderState.start() // begin recording
recorderState.pushAmplitude(0.6f) // feed mic peaks while recording
recorderState.forceCancel() // discard (e.g. on incoming call)
recorderState.sendFromLock() // tap the Send button while locked
recorderState.cancelFromLock() // tap Cancel while locked
recorderState.phase // VoicePhase.Idle | RecordingHeld | ...
recorderState.elapsed // current Duration
recorderState.capturedSamples // List<Float> pushed so farTuning thresholds
VoiceRecorderInput(
state = recorderState,
lockThresholdDp = 100.dp, // longer slide-up to lock
cancelThresholdDp = 60.dp, // shorter slide-left to cancel
)
rememberVoiceRecorderState(
minDuration = 800.milliseconds, // tighter tap-vs-hold threshold
maxDuration = 10.minutes, // longer recordings allowed
onSend = { _, _ -> },
)Custom colours
VoiceRecorderInput(
state = recorderState,
colors = VoiceMessageDefaults.recorderColors().copy(
micActiveColor = Color(0xFF25D366), // WhatsApp green
sendButtonColor = Color(0xFF25D366),
),
)The library never opens a microphone or writes a file. Wire your platform audio in three places:
val recorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(file)
}
val state = rememberVoiceRecorderState(
onStart = { recorder.prepare(); recorder.start() },
onCancel = { recorder.stop(); recorder.release(); file.delete() },
onSend = { duration, _ -> recorder.stop(); recorder.release(); upload(file, duration) },
)
// Poll the recorder for amplitude:
LaunchedEffect(state.phase) {
while (state.phase == VoicePhase.RecordingHeld ||
state.phase == VoicePhase.RecordingLocked ||
state.phase == VoicePhase.Cancelling) {
state.pushAmplitude(recorder.maxAmplitude / 32768f)
delay(50)
}
}Sketch — see your favourite Kotlin/Native audio stack for the full glue. The point is that the library's API is the same on every platform; only the actual audio plumbing differs.
Same pattern — open the capture API on onStart, push amplitudes into pushAmplitude, close
on onCancel / onSend.
long-press
Idle ──────────────► RecordingHeld
│ │ │
│ │ └─ release (elapsed >= minDuration)
│ │ └► onSend(duration, samples)
│ │
│ ├─ slide-up past lockThreshold ► RecordingLocked
│ │ ├─ Send tapped ► onSend(…)
│ │ └─ Cancel tapped ► onCancel()
│ │
│ └─ slide-left past cancelThreshold ► Cancelling
│ ├─ slide back ► RecordingHeld
│ └─ release ► onCancel()
│
└─ release (elapsed < minDuration) ► Idle (silent, no callback)
└─ maxDuration reached ► onSend(…)
└─ forceCancel() ► onCancel()
Every transition + every edge case is covered by 31 pure-logic test cases in
VoiceMessageLogicTest and 11 UI wiring tests in VoiceMessageUiTest.
lockThresholdDp / cancelThresholdDp — drag distances for the lock and cancel gestures.minDuration / maxDuration — tap-vs-hold threshold and recording length cap.colors — full VoiceRecorderColors / VoiceMessageBubbleColors overrides.barCount — bar count on VoiceWaveform and VoiceMessageBubble. Lower = chunkier bars.role — Sender / Receiver flips the bubble's default tinting.idlePlaceholder — the composable slot shown to the left of the mic when not recording.| VoiceMessage | Hand-rolled per platform | Material 3 | |
|---|---|---|---|
| Hold-to-record + slide gestures | ✅ FSM-driven | ❌ | |
| Slide-to-lock hands-free | ✅ | ❌ | |
| Slide-to-cancel | ✅ | ❌ | |
| Live amplitude waveform | ✅ | ❌ | |
| Playback bubble with scrubber | ✅ | ❌ | |
| Multiplatform | ✅ A/iOS/Desktop/Web | ❌ four separate impls | |
| Audio-stack agnostic | ✅ BYO | n/a | n/a |
voice-message-audio with expect/actual
audio adapters that close the BYO gap: Android MediaRecorder, iOS AVAudioRecorder, Desktop
javax.sound.sampled, Web MediaRecorder API. Permission helpers and system-interrupt
observers included.onTap callback) so consumers can toggle into text-only mode on tap.voice-message-audio companion artifact: drop-in mic capture for Android, iOS, Desktop, Web.
Use rememberAudioBoundVoiceRecorderState { audio, samples -> ... } and skip BYO entirely.MicButton is now at one stable slot
across phase changes, so its pointer-input modifier survives state.start() recomposition).VoicePhase.Sent removed (was a stuck terminal phase); phase resets to Idle synchronously
after onSend.semantics, contentDescription, Role.Button, LiveRegionMode.Polite
on the timer.rememberVoiceHaptics() with per-platform actuals.See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.