diff --git a/feature/voiceburst/build.gradle.kts b/feature/voiceburst/build.gradle.kts
new file mode 100644
index 0000000000..bf07655cae
--- /dev/null
+++ b/feature/voiceburst/build.gradle.kts
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import java.io.File
+
+plugins {
+ alias(libs.plugins.meshtastic.kmp.feature)
+ alias(libs.plugins.meshtastic.kotlinx.serialization)
+}
+
+// --- Codec2 JNI detection ---------------------------------------------------
+val codec2SoArm64 = File(projectDir, "src/androidMain/jniLibs/arm64-v8a/libcodec2.so")
+val codec2JniArm64 = File(projectDir, "src/androidMain/jniLibs/arm64-v8a/libcodec2_jni.so")
+val codec2SoX86_64 = File(projectDir, "src/androidMain/jniLibs/x86_64/libcodec2.so")
+val codec2JniX86_64 = File(projectDir, "src/androidMain/jniLibs/x86_64/libcodec2_jni.so")
+val codec2Available = (codec2SoArm64.exists() && codec2JniArm64.exists()) ||
+ (codec2SoX86_64.exists() && codec2JniX86_64.exists())
+
+if (codec2Available) {
+ logger.lifecycle(":feature:voiceburst -- libcodec2.so + libcodec2_jni.so found")
+} else {
+ logger.lifecycle(":feature:voiceburst -- .so not found -> stub mode (run scripts/build_codec2.sh)")
+}
+
+kotlin {
+ jvm()
+
+ @Suppress("UnstableApiUsage")
+ android {
+ namespace = "org.meshtastic.feature.voiceburst"
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.data)
+ implementation(projects.core.datastore)
+ implementation(projects.core.model)
+ implementation(projects.core.navigation)
+ implementation(projects.core.proto)
+ implementation(projects.core.repository)
+ implementation(projects.core.service)
+ implementation(projects.core.ui)
+ implementation(projects.core.di)
+
+ implementation(libs.kotlinx.collections.immutable)
+ }
+
+ androidUnitTest.dependencies {
+ implementation(libs.junit)
+ implementation(libs.robolectric)
+ implementation(libs.turbine)
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.androidx.test.ext.junit)
+ }
+
+ commonTest.dependencies {
+ implementation(project(":core:testing"))
+ }
+ }
+}
+
+// No externalNativeBuild block needed.
+// Prebuilt .so files in jniLibs/ are packaged automatically by AGP.
+// The JNI wrapper is compiled separately via scripts/build_codec2.sh.
diff --git a/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2.so b/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2.so
new file mode 100644
index 0000000000..57c83581d2
Binary files /dev/null and b/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2.so differ
diff --git a/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2_jni.so b/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2_jni.so
new file mode 100644
index 0000000000..02db0ddeba
Binary files /dev/null and b/feature/voiceburst/src/androidMain/jniLibs/arm64-v8a/libcodec2_jni.so differ
diff --git a/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2.so b/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2.so
new file mode 100644
index 0000000000..02bea33195
Binary files /dev/null and b/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2.so differ
diff --git a/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2_jni.so b/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2_jni.so
new file mode 100644
index 0000000000..5a24d0ede8
Binary files /dev/null and b/feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2_jni.so differ
diff --git a/feature/voiceburst/src/androidMain/kotlin/com/geeksville/mesh/voiceburst/Codec2JNI.kt b/feature/voiceburst/src/androidMain/kotlin/com/geeksville/mesh/voiceburst/Codec2JNI.kt
new file mode 100644
index 0000000000..5ce3c0680b
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/com/geeksville/mesh/voiceburst/Codec2JNI.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see .
+ */
+
+package com.geeksville.mesh.voiceburst
+
+import android.util.Log
+
+/**
+ * JNI binding to a prebuilt libcodec2 library.
+ * Both shared objects (libcodec2.so + libcodec2_jni.so) must be present in jniLibs/.
+ */
+internal object Codec2JNI {
+
+ private const val TAG = "Codec2JNI"
+ private var loaded = false
+
+ fun ensureLoaded() {
+ if (!loaded) {
+ try {
+ System.loadLibrary("codec2")
+ Log.i(TAG, "libcodec2.so loaded OK")
+ } catch (e: UnsatisfiedLinkError) {
+ Log.e(TAG, "Failed to load libcodec2.so: ${e.message}")
+ return
+ }
+ try {
+ System.loadLibrary("codec2_jni")
+ Log.i(TAG, "libcodec2_jni.so loaded OK — JNI active")
+ loaded = true
+ } catch (e: UnsatisfiedLinkError) {
+ Log.e(TAG, "Failed to load libcodec2_jni.so: ${e.message}")
+ // loaded remains false -> fallback to stub
+ }
+ }
+ }
+
+ val isAvailable: Boolean
+ get() = loaded
+
+ // Codec2 operating modes
+ const val MODE_3200 = 0
+ const val MODE_2400 = 1
+ const val MODE_1600 = 2
+ const val MODE_1400 = 3
+ const val MODE_1300 = 4
+ const val MODE_1200 = 5
+ const val MODE_700C = 8
+ const val MODE_450 = 10
+
+ @JvmStatic external fun getSamplesPerFrame(mode: Int): Int
+ @JvmStatic external fun getBytesPerFrame(mode: Int): Int
+ @JvmStatic external fun create(mode: Int): Long
+ @JvmStatic external fun encode(ptr: Long, pcm: ShortArray): ByteArray
+ @JvmStatic external fun decode(ptr: Long, frame: ByteArray): ShortArray
+ @JvmStatic external fun destroy(ptr: Long)
+}
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/codec2/Codec2Jni.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/codec2/Codec2Jni.kt
new file mode 100644
index 0000000000..36e4da8069
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/codec2/Codec2Jni.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see .
+ */
+package org.meshtastic.codec2
+
+/**
+ * Backwards-compatible alias to the canonical Codec2 JNI wrapper used by
+ * the voiceburst feature. The actual implementation lives in
+ * [com.geeksville.mesh.voiceburst.Codec2JNI].
+ */
+typealias Codec2Jni = com.geeksville.mesh.voiceburst.Codec2JNI
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioPlayer.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioPlayer.kt
new file mode 100644
index 0000000000..8cf374e028
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioPlayer.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.audio
+
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioTrack
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+private const val TAG = "AndroidAudioPlayer"
+
+/**
+ * Android implementation of [AudioPlayer].
+ *
+ * Key implementation notes:
+ * - BUG: MODE_STATIC with bufferSize < minBufferSize -> STATE_NO_STATIC_DATA (state=2) -> silence.
+ * FIX: bufferSize = maxOf(minBufferSize, pcmBytes) ALWAYS, even in static mode.
+ * - Using MODE_STREAM: simpler and avoids the STATE_NO_STATIC_DATA issue.
+ * For 1 second at 8kHz (16000 bytes) MODE_STREAM is more than adequate.
+ * - USAGE_MEDIA -> main speaker (not earpiece).
+ * - [playingFilePath] StateFlow to sync play/stop icons in the UI.
+ */
+class AndroidAudioPlayer(
+ private val scope: CoroutineScope,
+) : AudioPlayer {
+
+ private var audioTrack: AudioTrack? = null
+ private var playingJob: Job? = null
+
+ private val _playingFilePath = MutableStateFlow(null)
+ override val playingFilePath: StateFlow = _playingFilePath.asStateFlow()
+
+ override val isPlaying: Boolean
+ get() = audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING
+
+ override fun play(pcmData: ShortArray, filePath: String, onComplete: () -> Unit) {
+ // If already playing, stop before starting a new track
+ if (isPlaying) {
+ Logger.d(tag = TAG) { "Stopping previous track before starting new one" }
+ stopInternal()
+ }
+
+ if (pcmData.isEmpty()) {
+ Logger.w(tag = TAG) { "PCM data is empty -- skipping playback" }
+ onComplete()
+ return
+ }
+
+ val sampleRate = SAMPLE_RATE_HZ
+ val channelConfig = AudioFormat.CHANNEL_OUT_MONO
+ val audioEncoding = AudioFormat.ENCODING_PCM_16BIT
+
+ val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioEncoding)
+ if (minBufferSize <= 0) {
+ Logger.e(tag = TAG) { "getMinBufferSize error: $minBufferSize" }
+ onComplete()
+ return
+ }
+
+ // CRITICAL: bufferSize must always be >= minBufferSize.
+ // With MODE_STATIC, if bufferSize < minBufferSize -> state=STATE_NO_STATIC_DATA=2 -> silence.
+ // MODE_STREAM is used for simplicity and robustness.
+ val pcmBytes = pcmData.size * Short.SIZE_BYTES
+ val bufferSize = maxOf(minBufferSize, pcmBytes)
+
+ val attrs = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+
+ val format = AudioFormat.Builder()
+ .setSampleRate(sampleRate)
+ .setEncoding(audioEncoding)
+ .setChannelMask(channelConfig)
+ .build()
+
+ val track = try {
+ AudioTrack(attrs, format, bufferSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Failed to create AudioTrack" }
+ onComplete()
+ return
+ }
+
+ if (track.state != AudioTrack.STATE_INITIALIZED) {
+ Logger.e(tag = TAG) { "AudioTrack not initialized: state=${track.state} (expected ${AudioTrack.STATE_INITIALIZED})" }
+ track.release()
+ onComplete()
+ return
+ }
+
+ audioTrack = track
+ _playingFilePath.value = filePath.ifEmpty { null }
+
+ playingJob = scope.launch(Dispatchers.IO) {
+ try {
+ // MODE_STREAM: call play() FIRST, then write() for streaming
+ track.play()
+ Logger.d(tag = TAG) { "Playback started: ${pcmData.size} samples @ ${sampleRate}Hz" }
+
+ val written = track.write(pcmData, 0, pcmData.size)
+ if (written < 0) {
+ Logger.e(tag = TAG) { "write() error: $written" }
+ } else {
+ Logger.d(tag = TAG) { "Write complete: $written samples" }
+ // Wait for the DAC to drain all samples in the buffer
+ val drainMs = written.toLong() * 1000L / sampleRate + DRAIN_GUARD_MS
+ kotlinx.coroutines.delay(drainMs)
+ }
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Playback error" }
+ } finally {
+ releaseTrack(track)
+ _playingFilePath.value = null
+ scope.launch(Dispatchers.Main) { onComplete() }
+ }
+ }
+ }
+
+ override fun stop() {
+ if (!isPlaying && playingJob?.isActive != true) return
+ Logger.d(tag = TAG) { "Stopping playback" }
+ stopInternal()
+ }
+
+ private fun stopInternal() {
+ playingJob?.cancel()
+ playingJob = null
+ audioTrack?.let { releaseTrack(it) }
+ _playingFilePath.value = null
+ }
+
+ private fun releaseTrack(track: AudioTrack) {
+ try { track.stop() } catch (_: Exception) {}
+ try { track.flush() } catch (_: Exception) {}
+ track.release()
+ if (audioTrack === track) audioTrack = null
+ }
+
+ companion object {
+ private const val SAMPLE_RATE_HZ = 8000
+ private const val DRAIN_GUARD_MS = 150L // extra margin for DAC drain
+ }
+}
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioRecorder.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioRecorder.kt
new file mode 100644
index 0000000000..d53f930743
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioRecorder.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.audio
+
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+private const val TAG = "AndroidAudioRecorder"
+
+/**
+ * Android implementation of [AudioRecorder] based on [AudioRecord].
+ *
+ * Fixed parameters for Codec2 700B:
+ * - Source: MIC
+ * - Rate: 8000 Hz
+ * - Channel: CHANNEL_IN_MONO
+ * - Encoding: PCM_16BIT
+ *
+ * PREREQUISITE: the caller must have obtained android.permission.RECORD_AUDIO
+ * before invoking [startRecording].
+ *
+ * Stop behaviour: [stopRecording] sets a volatile flag that causes the read
+ * loop to exit gracefully, then [onComplete] is called with the data collected
+ * so far. The coroutine is NOT cancelled — cancellation would prevent onComplete
+ * from being called.
+ */
+class AndroidAudioRecorder(
+ private val scope: CoroutineScope,
+) : AudioRecorder {
+
+ private var audioRecord: AudioRecord? = null
+ private var recordingJob: Job? = null
+
+ // Volatile flag: true while we want the read loop to keep running.
+ @Volatile private var keepRecording = false
+
+ override val isRecording: Boolean
+ get() = audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING
+
+ override fun startRecording(
+ onComplete: (pcmData: ShortArray, durationMs: Int) -> Unit,
+ onError: (Throwable) -> Unit,
+ maxDurationMs: Int,
+ ) {
+ if (isRecording) {
+ Logger.w(tag = TAG) { "startRecording called while already recording — ignored" }
+ return
+ }
+
+ val sampleRate = 8000
+ val channelConfig = AudioFormat.CHANNEL_IN_MONO
+ val audioFormat = AudioFormat.ENCODING_PCM_16BIT
+
+ val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
+ if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) {
+ onError(IllegalStateException("AudioRecord not supported on this device"))
+ return
+ }
+
+ // Buffer sized for maxDurationMs + 20% margin
+ val totalSamples = (sampleRate * maxDurationMs / 1000.0 * 1.2).toInt()
+ val bufferSize = maxOf(minBufferSize, totalSamples * 2 /* bytes per short */)
+
+ try {
+ @Suppress("MissingPermission") // Permission verified by the caller
+ audioRecord = AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ sampleRate,
+ channelConfig,
+ audioFormat,
+ bufferSize,
+ )
+ } catch (e: SecurityException) {
+ onError(e)
+ return
+ }
+
+ val record = audioRecord ?: run {
+ onError(IllegalStateException("AudioRecord not initialized"))
+ return
+ }
+
+ if (record.state != AudioRecord.STATE_INITIALIZED) {
+ onError(IllegalStateException("AudioRecord initialization failed"))
+ record.release()
+ audioRecord = null
+ return
+ }
+
+ keepRecording = true
+
+ recordingJob = scope.launch(Dispatchers.IO) {
+ val maxSamples = sampleRate * maxDurationMs / 1000
+ val pcmBuffer = ShortArray(maxSamples)
+ var samplesRead = 0
+ val startTime = System.currentTimeMillis()
+
+ try {
+ record.startRecording()
+ Logger.d(tag = TAG) { "Recording started (max ${maxDurationMs}ms, ${sampleRate}Hz mono PCM16)" }
+
+ // Read loop: exits when keepRecording is false OR buffer is full.
+ while (keepRecording && samplesRead < pcmBuffer.size) {
+ val chunkSize = minOf(minBufferSize / 2, pcmBuffer.size - samplesRead)
+ val read = record.read(pcmBuffer, samplesRead, chunkSize)
+ if (read < 0) {
+ Logger.e(tag = TAG) { "AudioRecord.read error: $read" }
+ break
+ }
+ samplesRead += read
+ }
+
+ val durationMs = (System.currentTimeMillis() - startTime)
+ .toInt().coerceAtMost(maxDurationMs)
+
+ Logger.d(tag = TAG) { "Recording complete: $samplesRead samples, ${durationMs}ms" }
+
+ // Always call onComplete — even on early stop — so the ViewModel
+ // can encode and send whatever was recorded.
+ onComplete(pcmBuffer.copyOf(samplesRead), durationMs)
+
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Error during recording" }
+ onError(e)
+ } finally {
+ runCatching { record.stop() } // guard against IllegalStateException on early-stop
+ record.release()
+ audioRecord = null
+ keepRecording = false
+ }
+ }
+ }
+
+ override fun stopRecording() {
+ if (!keepRecording) return
+ Logger.d(tag = TAG) { "Early stop requested — draining remaining samples" }
+ // Signal the read loop to exit. The job itself is NOT cancelled so that
+ // onComplete is still called with the data collected up to this point.
+ keepRecording = false
+ // Stop AudioRecord so the next record.read() returns immediately.
+ audioRecord?.stop()
+ }
+}
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2Encoder.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2Encoder.kt
new file mode 100644
index 0000000000..c1d9c8c28c
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2Encoder.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.codec
+
+import com.geeksville.mesh.voiceburst.Codec2JNI
+
+import co.touchlab.kermit.Logger
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+private const val TAG = "AndroidCodec2Encoder"
+
+/**
+ * Android implementation of [Codec2Encoder].
+ *
+ * When [Codec2JNI.isAvailable] is true, uses libcodec2 via JNI (real voice audio).
+ * Otherwise falls back to STUB mode (440Hz sine wave) for development/CI/builds without .so.
+ *
+ * Codec2 700B parameters:
+ * - Sample rate input: 8000 Hz
+ * - Frame: 40ms = 320 samples
+ * - Bytes per frame: 4
+ * - 1 second: 25 frames x 4 bytes = 100 bytes
+ *
+ * Preprocessing applied before encoding (JNI mode only):
+ * 1. Amplitude normalization (brings to 70% of Short.MAX_VALUE)
+ * 2. Simple VAD: if RMS < threshold, returns null without encoding
+ *
+ * JNI Lifecycle:
+ * The Codec2 handle is created in the constructor and destroyed in [close()].
+ * Ensure to use [use { }] or call [close()] explicitly.
+ */
+class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
+
+ private val codec2Handle: Long
+ override val isStub: Boolean
+
+ init {
+ Codec2JNI.ensureLoaded()
+ if (Codec2JNI.isAvailable) {
+ val handle = Codec2JNI.create(Codec2JNI.MODE_700C)
+ if (handle != 0L) {
+ codec2Handle = handle
+ isStub = false
+ Logger.i(tag = TAG) {
+ "Codec2 JNI OK: samplesPerFrame=${Codec2JNI.getSamplesPerFrame(Codec2JNI.MODE_700C)}" +
+ " bytesPerFrame=${Codec2JNI.getBytesPerFrame(Codec2JNI.MODE_700C)}"
+ }
+ } else {
+ Logger.e(tag = TAG) { "Codec2JNI.create() returned 0 -- falling back to stub mode" }
+ codec2Handle = 0L
+ isStub = true
+ }
+ } else {
+ codec2Handle = 0L
+ isStub = true
+ Logger.w(tag = TAG) { "Codec2 JNI not available -- stub mode (440Hz sine wave)" }
+ }
+ }
+
+ override fun close() {
+ if (codec2Handle != 0L) {
+ Codec2JNI.destroy(codec2Handle)
+ Logger.d(tag = TAG) { "Codec2 handle released" }
+ }
+ }
+
+ // --- encode -------------------------------------------------------------
+
+ /**
+ * Encodes 16-bit mono 8000Hz PCM into Codec2 700B bytes.
+ *
+ * Accepts an array of any length -- it is split into frames
+ * of [SAMPLES_PER_FRAME] samples. The last incomplete frame is
+ * padded with zeros (zero-padding).
+ *
+ * @param pcmData PCM samples from the microphone (8000 Hz, mono, signed 16-bit)
+ * @return ByteArray with Codec2 bytes, null if input is empty or silence detected
+ */
+ override fun encode(pcmData: ShortArray): ByteArray? {
+ if (pcmData.isEmpty()) return null
+
+ return if (!isStub && codec2Handle != 0L) {
+ encodeJni(pcmData)
+ } else {
+ encodeStub(pcmData)
+ }
+ }
+
+ private fun encodeJni(pcmData: ShortArray): ByteArray? {
+ val samplesPerFrame = Codec2JNI.getSamplesPerFrame(Codec2JNI.MODE_700C)
+ val bytesPerFrame = Codec2JNI.getBytesPerFrame(Codec2JNI.MODE_700C)
+
+ // Preprocessing: normalization
+ val normalized = normalize(pcmData)
+
+ // VAD: do not send silence -- return null so the ViewModel skips transmission
+ val rms = computeRms(normalized)
+ if (rms < SILENCE_RMS_THRESHOLD) {
+ Logger.d(tag = TAG) { "VAD: silence detected (RMS=$rms) -- skipping encode" }
+ return null
+ }
+
+ // Calculate needed frames (round up)
+ val frameCount = (normalized.size + samplesPerFrame - 1) / samplesPerFrame
+ val output = ByteArray(frameCount * bytesPerFrame)
+ var outOffset = 0
+
+ for (frameIdx in 0 until frameCount) {
+ val inStart = frameIdx * samplesPerFrame
+ val inEnd = minOf(inStart + samplesPerFrame, normalized.size)
+
+ // Extract frame (with zero-padding if incomplete)
+ val frame = if (inEnd - inStart == samplesPerFrame) {
+ normalized.copyOfRange(inStart, inEnd)
+ } else {
+ ShortArray(samplesPerFrame).also {
+ normalized.copyInto(it, 0, inStart, inEnd)
+ }
+ }
+
+ val encoded = Codec2JNI.encode(codec2Handle, frame)
+ if (encoded == null || encoded.size != bytesPerFrame) {
+ Logger.e(tag = TAG) { "Encode failed at frame $frameIdx" }
+ return null
+ }
+
+ encoded.copyInto(output, outOffset)
+ outOffset += bytesPerFrame
+ }
+
+ Logger.d(tag = TAG) {
+ "Encode JNI: ${pcmData.size} samples -> ${output.size} bytes " +
+ "($frameCount frames x $bytesPerFrame bytes)"
+ }
+ return output
+ }
+
+ // --- decode -------------------------------------------------------------
+
+ /**
+ * Decodes Codec2 700B bytes into 16-bit mono 8000Hz PCM samples.
+ *
+ * @param codec2Data ByteArray of Codec2 bytes (multiple of bytesPerFrame)
+ * @return ShortArray of PCM samples, null if input is empty/invalid
+ */
+ override fun decode(codec2Data: ByteArray): ShortArray? {
+ if (codec2Data.isEmpty()) return null
+
+ return if (!isStub && codec2Handle != 0L) {
+ decodeJni(codec2Data)
+ } else {
+ decodeStub(codec2Data)
+ }
+ }
+
+ private fun decodeJni(codec2Data: ByteArray): ShortArray? {
+ val samplesPerFrame = Codec2JNI.getSamplesPerFrame(Codec2JNI.MODE_700C)
+ val bytesPerFrame = Codec2JNI.getBytesPerFrame(Codec2JNI.MODE_700C)
+
+ if (codec2Data.size % bytesPerFrame != 0) {
+ Logger.w(tag = TAG) {
+ "Decode: input size (${codec2Data.size}) not a multiple of " +
+ "bytesPerFrame ($bytesPerFrame) -- truncating to complete frame"
+ }
+ }
+
+ val frameCount = codec2Data.size / bytesPerFrame
+ if (frameCount == 0) return null
+
+ val output = ShortArray(frameCount * samplesPerFrame)
+ var outOffset = 0
+
+ for (frameIdx in 0 until frameCount) {
+ val inStart = frameIdx * bytesPerFrame
+ val frame = codec2Data.copyOfRange(inStart, inStart + bytesPerFrame)
+
+ val decoded = Codec2JNI.decode(codec2Handle, frame)
+ if (decoded == null || decoded.size != samplesPerFrame) {
+ Logger.e(tag = TAG) { "Decode failed at frame $frameIdx" }
+ return null
+ }
+
+ decoded.copyInto(output, outOffset)
+ outOffset += samplesPerFrame
+ }
+
+ Logger.d(tag = TAG) {
+ "Decode JNI: ${codec2Data.size} bytes -> ${output.size} samples " +
+ "($frameCount frames x $samplesPerFrame samples)"
+ }
+ return output
+ }
+
+ // --- Preprocessing helpers ----------------------------------------------
+
+ /**
+ * Normalizes the signal amplitude to [TARGET_AMPLITUDE] x Short.MAX_VALUE.
+ * Prevents clipping and improves Codec2 quality on low-volume voices.
+ */
+ private fun normalize(pcm: ShortArray): ShortArray {
+ val maxAmp = pcm.maxOfOrNull { abs(it.toInt()) }?.toFloat() ?: return pcm
+ if (maxAmp < 1f) return pcm // absolute silence
+
+ val gain = (TARGET_AMPLITUDE * Short.MAX_VALUE) / maxAmp
+ val clampedGain = minOf(gain, MAX_GAIN)
+
+ return ShortArray(pcm.size) { i ->
+ (pcm[i] * clampedGain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
+ }
+ }
+
+ /**
+ * Computes the Root Mean Square of the signal.
+ * Used for simple VAD: RMS < [SILENCE_RMS_THRESHOLD] = silence.
+ */
+ private fun computeRms(pcm: ShortArray): Double {
+ if (pcm.isEmpty()) return 0.0
+ val sumSquares = pcm.fold(0.0) { acc, s -> acc + (s.toDouble() * s.toDouble()) }
+ return sqrt(sumSquares / pcm.size)
+ }
+
+ // --- Stub (fallback when JNI is not available) --------------------------
+
+ private fun encodeStub(pcmData: ShortArray): ByteArray {
+ val frameCount = (pcmData.size + SAMPLES_PER_FRAME - 1) / SAMPLES_PER_FRAME
+ Logger.w(tag = TAG) {
+ "Codec2 STUB encode: ${pcmData.size} samples -> ${frameCount * BYTES_PER_FRAME} bytes (zeros)"
+ }
+ return ByteArray(frameCount * BYTES_PER_FRAME) { 0x00 }
+ }
+
+ private fun decodeStub(codec2Data: ByteArray): ShortArray {
+ val frameCount = maxOf(1, codec2Data.size / BYTES_PER_FRAME)
+ val totalSamples = frameCount * SAMPLES_PER_FRAME
+
+ Logger.w(tag = TAG) {
+ "Codec2 STUB decode: ${codec2Data.size} bytes -> $totalSamples samples (440Hz sine wave)"
+ }
+
+ // Generate 440Hz sine wave (A4) -- audible and recognizable
+ val sampleRate = 8000.0
+ val frequency = 440.0
+ val amplitude = Short.MAX_VALUE * 0.3 // 30% volume
+
+ return ShortArray(totalSamples) { i ->
+ val angle = 2.0 * PI * frequency * i / sampleRate
+ (sin(angle) * amplitude).toInt().toShort()
+ }
+ }
+
+ companion object {
+ /** Codec2 700B: 320 samples per frame (40ms @ 8000 Hz). */
+ const val SAMPLES_PER_FRAME = 320
+
+ /** Codec2 700B: 4 bytes per frame (700 bps rounded). */
+ const val BYTES_PER_FRAME = 4
+
+ /** Target amplitude for normalization (70% of Short.MAX_VALUE). */
+ private const val TARGET_AMPLITUDE = 0.70f
+
+ /** Maximum gain applied by normalization (10x). */
+ private const val MAX_GAIN = 10.0f
+
+ /**
+ * RMS threshold below which the frame is considered silence (simple VAD).
+ * 200.0 on the 0-32767 scale is approximately -44 dBFS -- normal voice is 2000-8000.
+ */
+ private const val SILENCE_RMS_THRESHOLD = 200.0
+ }
+}
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstAndroidModule.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstAndroidModule.kt
new file mode 100644
index 0000000000..921af30cdf
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstAndroidModule.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.feature.voiceburst.audio.AndroidAudioPlayer
+import org.meshtastic.feature.voiceburst.audio.AudioPlayer
+import org.meshtastic.feature.voiceburst.audio.AndroidAudioRecorder
+import org.meshtastic.feature.voiceburst.audio.AudioRecorder
+import org.meshtastic.feature.voiceburst.codec.AndroidCodec2Encoder
+import org.meshtastic.feature.voiceburst.codec.Codec2Encoder
+import org.meshtastic.feature.voiceburst.repository.AndroidVoiceBurstRepository
+import org.meshtastic.feature.voiceburst.repository.VoiceBurstRepository
+
+/**
+ * Koin module for the Voice Burst feature module.
+ *
+ * Follows the standard Android feature-module pattern:
+ * - Context and Android-only APIs remain in androidMain
+ * - commonMain has no direct Android dependencies
+ */
+@Module
+class FeatureVoiceBurstAndroidModule {
+
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ @Single
+ @Named("VoiceBurstDataStore")
+ fun provideVoiceBurstDataStore(context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("voice_burst") },
+ )
+
+ @Single(createdAtStart = true)
+ fun provideVoiceBurstRepository(
+ radioController: RadioController,
+ @Named("VoiceBurstDataStore") dataStore: DataStore,
+ packetRepository: PacketRepository,
+ nodeRepository: NodeRepository,
+ serviceRepository: ServiceRepository,
+ context: Context,
+ ): VoiceBurstRepository = AndroidVoiceBurstRepository(
+ radioController = radioController,
+ dataStore = dataStore,
+ packetRepository = packetRepository,
+ nodeRepository = nodeRepository,
+ serviceRepository = serviceRepository,
+ context = context,
+ scope = scope,
+ )
+
+ @Single
+ fun provideCodec2Encoder(): Codec2Encoder = AndroidCodec2Encoder()
+
+ @Single
+ fun provideAudioRecorder(): AudioRecorder = AndroidAudioRecorder(scope = scope)
+
+ @Single
+ fun provideAudioPlayer(): AudioPlayer = AndroidAudioPlayer(scope = scope)
+}
diff --git a/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/repository/AndroidVoiceBurstRepository.kt b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/repository/AndroidVoiceBurstRepository.kt
new file mode 100644
index 0000000000..8fd7c336e8
--- /dev/null
+++ b/feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/repository/AndroidVoiceBurstRepository.kt
@@ -0,0 +1,247 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.repository
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.feature.voiceburst.model.VoiceBurstPayload
+import org.meshtastic.proto.PortNum
+import java.io.File
+
+private const val TAG = "AndroidVoiceBurstRepository"
+
+/**
+ * Android implementation of [VoiceBurstRepository].
+ *
+ * Audio persistence: each burst (sent or received) is stored as
+ * /voice_bursts/.c2, where uuid is the Room-generated
+ * primary key returned by [PacketRepository.savePacket].
+ * [PacketEntity.toMessage] reconstructs the path deterministically
+ * as "voice_bursts/$uuid.c2" — no extra DB column needed.
+ */
+class AndroidVoiceBurstRepository(
+ private val radioController: RadioController,
+ private val dataStore: DataStore,
+ private val packetRepository: PacketRepository,
+ private val nodeRepository: NodeRepository,
+ private val serviceRepository: ServiceRepository,
+ private val context: Context,
+ private val scope: kotlinx.coroutines.CoroutineScope,
+) : VoiceBurstRepository {
+
+ private val voiceBurstsDir: File by lazy {
+ File(context.filesDir, "voice_bursts").also { it.mkdirs() }
+ }
+
+ // ─── Feature flag ─────────────────────────────────────────────────────────
+
+ private val featureEnabledFlow = dataStore.data
+ .map { prefs -> prefs[KEY_FEATURE_ENABLED] ?: false } // Default OFF (experimental opt-in)
+
+ override val isFeatureEnabled: StateFlow =
+ featureEnabledFlow.stateIn(
+ scope = scope,
+ started = SharingStarted.Eagerly,
+ initialValue = false,
+ )
+
+ override suspend fun setFeatureEnabled(enabled: Boolean) {
+ dataStore.edit { prefs -> prefs[KEY_FEATURE_ENABLED] = enabled }
+ Logger.i(tag = TAG) { "Voice Burst feature: ${if (enabled) "enabled" else "disabled"}" }
+ }
+
+ // ─── Send ──────────────────────────────────────────────────────────────────
+
+ override suspend fun sendBurst(payload: VoiceBurstPayload, contactKey: String): Boolean {
+ val channelDigit = contactKey.firstOrNull()?.digitToIntOrNull()
+ val destNodeId = if (channelDigit != null) contactKey.substring(1) else contactKey
+
+ return try {
+ val ourNode = nodeRepository.ourNodeInfo.value
+ val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
+ val myNodeNum = ourNode?.num ?: 0
+
+ val packet = DataPacket(
+ to = destNodeId,
+ bytes = payload.encode().toByteString(),
+ dataType = VoiceBurstPayload.PORT_NUM,
+ from = fromId,
+ channel = channelDigit ?: DataPacket.PKC_CHANNEL_INDEX,
+ wantAck = true,
+ status = MessageStatus.ENROUTE,
+ )
+
+ // Step 1: persist to DB — returns the Room uuid used as audio filename.
+ val uuid = packetRepository.savePacket(
+ myNodeNum = myNodeNum,
+ contactKey = contactKey,
+ packet = packet,
+ receivedTime = nowMillis,
+ read = true,
+ )
+
+ // Step 2: save audio BEFORE sending so replay works immediately even if radio fails.
+ saveAudioFile(uuid, payload.audioData)
+ Logger.d(tag = TAG) { "Sender audio saved: voice_bursts/$uuid.c2" }
+
+ // Step 3: hand the packet to the radio.
+ radioController.sendMessage(packet)
+ Logger.i(tag = TAG) { "Burst sent to $destNodeId: ${payload.audioData.size} bytes, uuid=$uuid" }
+
+ true
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Error sending burst to $destNodeId (contactKey=$contactKey)" }
+ false
+ }
+ }
+
+ // ─── Receive ───────────────────────────────────────────────────────────────
+
+ private val _incomingBursts = MutableSharedFlow(replay = 0, extraBufferCapacity = 8)
+ override val incomingBursts: Flow = _incomingBursts
+
+ init {
+ serviceRepository.meshPacketFlow
+ .filter { it.decoded?.portnum == PortNum.PRIVATE_APP }
+ .onEach { packet -> processIncomingBurst(packet) }
+ .launchIn(scope)
+ }
+
+ private suspend fun processIncomingBurst(packet: org.meshtastic.proto.MeshPacket) {
+ val decoded = packet.decoded ?: return
+ val payloadBytes = decoded.payload.toByteArray()
+
+ val payload = VoiceBurstPayload.decode(payloadBytes)
+ if (payload == null) {
+ Logger.w(tag = TAG) { "Invalid payload from ${packet.from} (${payloadBytes.size} bytes)" }
+ return
+ }
+
+ Logger.i(tag = TAG) { "Burst received from ${packet.from}: ${payload.durationMs}ms, ${payload.audioData.size} bytes" }
+
+ val ourNode = nodeRepository.ourNodeInfo.value
+ val myNodeNum = ourNode?.num ?: 0
+ val fromId = DataPacket.nodeNumToDefaultId(packet.from)
+ val toId = if (packet.to < 0 || packet.to == DataPacket.NODENUM_BROADCAST) {
+ DataPacket.ID_BROADCAST
+ } else {
+ DataPacket.nodeNumToDefaultId(packet.to)
+ }
+
+ val channelIndex = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel
+ val contactKey = "${channelIndex}${fromId}"
+
+ val dataPacket = DataPacket(
+ to = toId,
+ bytes = payloadBytes.toByteString(),
+ dataType = VoiceBurstPayload.PORT_NUM,
+ from = fromId,
+ time = nowMillis,
+ id = packet.id,
+ status = MessageStatus.RECEIVED,
+ channel = channelIndex,
+ wantAck = false,
+ snr = packet.rx_snr,
+ rssi = packet.rx_rssi,
+ )
+
+ try {
+ // Deduplicate: ignore packets we have already processed.
+ if (packetRepository.findPacketsWithId(packet.id).isNotEmpty()) {
+ Logger.d(tag = TAG) { "Duplicate burst ignored: packetId=${packet.id}" }
+ return
+ }
+
+ // Save to DB — uuid is the Room primary key used as audio filename.
+ val uuid = packetRepository.savePacket(
+ myNodeNum = myNodeNum,
+ contactKey = contactKey,
+ packet = dataPacket,
+ receivedTime = nowMillis,
+ read = false,
+ )
+
+ // Save audio to disk — filename matches what PacketEntity.toMessage() builds.
+ saveAudioFile(uuid, payload.audioData)
+ Logger.i(tag = TAG) { "Burst saved: contactKey=$contactKey file=voice_bursts/$uuid.c2" }
+
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Error saving burst from ${packet.from}" }
+ }
+
+ // Emit for immediate autoplay on arrival.
+ _incomingBursts.tryEmit(payload.copy(senderNodeId = fromId))
+ }
+
+ // ─── Audio file I/O ────────────────────────────────────────────────────────
+
+ /**
+ * Saves Codec2 bytes to /.c2.
+ * The filename must match the path built by PacketEntity.toMessage():
+ * audioFilePath = "voice_bursts/$uuid.c2"
+ */
+ private fun saveAudioFile(uuid: Long, audioData: ByteArray) {
+ try {
+ val file = File(voiceBurstsDir, "$uuid.c2")
+ file.writeBytes(audioData)
+ Logger.d(tag = TAG) { "Audio saved: ${file.absolutePath} (${audioData.size} bytes)" }
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Error writing audio file for uuid=$uuid" }
+ }
+ }
+
+ /**
+ * Reads Codec2 bytes from disk given a relative path.
+ * Called by VoiceBurstViewModel to replay a saved voice message.
+ *
+ * @param relativePath e.g. "voice_bursts/12345678.c2"
+ */
+ override fun readAudioFile(relativePath: String): ByteArray? {
+ return try {
+ val file = File(context.filesDir, relativePath)
+ if (file.exists()) file.readBytes() else null
+ } catch (e: Exception) {
+ Logger.e(e, tag = TAG) { "Error reading audio file: $relativePath" }
+ null
+ }
+ }
+
+ companion object {
+ private val KEY_FEATURE_ENABLED = booleanPreferencesKey("voice_burst_feature_enabled")
+ }
+}
diff --git a/feature/voiceburst/src/androidUnitTest/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2EncoderTest.kt b/feature/voiceburst/src/androidUnitTest/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2EncoderTest.kt
new file mode 100644
index 0000000000..dd6061dd30
--- /dev/null
+++ b/feature/voiceburst/src/androidUnitTest/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2EncoderTest.kt
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.codec
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+import com.geeksville.mesh.voiceburst.Codec2JNI
+
+/**
+ * Unit tests for [AndroidCodec2Encoder].
+ *
+ * In CI/JVM environments (without libcodec2.so) all tests run against the STUB.
+ * When JNI is available (device/emulator), [Codec2JNI.isAvailable] = true and
+ * the tests verify the real codec.
+ *
+ * Tests are structured to pass in both modes:
+ * - Stub: verifies sizes and structural properties
+ * - Real JNI: also verifies audio quality (minimum SNR)
+ */
+class AndroidCodec2EncoderTest {
+
+ // ─── Stub mode tests (always executed) ────────────────────────────────────
+
+ @Test
+ fun `encode returns non-null for valid PCM input`() {
+ val encoder = AndroidCodec2Encoder()
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val encoded = encoder.encode(pcm)
+ assertNotNull("encode() must not return null for valid input", encoded)
+ }
+
+ @Test
+ fun `encode returns null for empty input`() {
+ val encoder = AndroidCodec2Encoder()
+ val result = encoder.encode(ShortArray(0))
+ assertEquals("encode() must return null for empty input", null, result)
+ }
+
+ @Test
+ fun `encode output size is within codec2 700B budget`() {
+ val encoder = AndroidCodec2Encoder()
+ // 1 second @ 8000 Hz = 8000 samples
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val encoded = encoder.encode(pcm)!!
+
+ // Codec2 700B: max 100 bytes per 1 second (25 frames × 4 bytes)
+ // Accepting up to 110 bytes to allow for frame rounding tolerance
+ assertTrue(
+ "Payload too large for LoRa: ${encoded.size} bytes > 110 (MVP budget limit)",
+ encoded.size <= 110,
+ )
+ assertTrue(
+ "Payload unexpectedly small: ${encoded.size} bytes",
+ encoded.size >= 4,
+ )
+ }
+
+ @Test
+ fun `decode returns non-null for valid codec2 input`() {
+ val encoder = AndroidCodec2Encoder()
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val encoded = encoder.encode(pcm)!!
+ val decoded = encoder.decode(encoded)
+ assertNotNull("decode() must not return null for valid input", decoded)
+ }
+
+ @Test
+ fun `decode returns null for empty input`() {
+ val encoder = AndroidCodec2Encoder()
+ val result = encoder.decode(ByteArray(0))
+ assertEquals("decode() must return null for empty input", null, result)
+ }
+
+ @Test
+ fun `encode then decode roundtrip preserves length`() {
+ val encoder = AndroidCodec2Encoder()
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val encoded = encoder.encode(pcm)!!
+ val decoded = encoder.decode(encoded)!!
+
+ // Decoded length may differ slightly due to frame padding,
+ // but must be close to the original length
+ val ratio = decoded.size.toDouble() / pcm.size.toDouble()
+ assertTrue(
+ "Decoded length (${decoded.size}) too far from original (${pcm.size}). Ratio: $ratio",
+ ratio in 0.8..1.2,
+ )
+ }
+
+ @Test
+ fun `VoiceBurstPayload encodes and decodes correctly`() {
+ val encoder = AndroidCodec2Encoder()
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val codec2Bytes = encoder.encode(pcm)!!
+
+ // Simulate the complete payload serialization cycle
+ val payload = org.meshtastic.feature.voiceburst.model.VoiceBurstPayload(
+ version = 1,
+ codecMode = 0,
+ durationMs = 1000,
+ audioData = codec2Bytes,
+ )
+ val wireBytes = payload.encode()
+ val decodedPayload = org.meshtastic.feature.voiceburst.model.VoiceBurstPayload.decode(wireBytes)
+
+ assertNotNull("VoiceBurstPayload.decode() must not return null", decodedPayload)
+ assertEquals("version", payload.version, decodedPayload!!.version)
+ assertEquals("codecMode", payload.codecMode, decodedPayload.codecMode)
+ assertEquals("durationMs", payload.durationMs, decodedPayload.durationMs)
+ assertArrayEquals("audioData", payload.audioData, decodedPayload.audioData)
+ }
+
+ @Test
+ fun `payload size fits in single LoRa packet`() {
+ val encoder = AndroidCodec2Encoder()
+ val pcm = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val codec2Bytes = encoder.encode(pcm)!!
+
+ val payload = org.meshtastic.feature.voiceburst.model.VoiceBurstPayload(
+ version = 1,
+ codecMode = 0,
+ durationMs = 1000,
+ audioData = codec2Bytes,
+ )
+ val wireBytes = payload.encode()
+
+ // LoRa max MTU ~233 bytes. With mesh overhead: safe budget = 200 bytes.
+ assertTrue(
+ "Payload ${wireBytes.size} bytes exceeds LoRa budget (200 bytes)",
+ wireBytes.size <= 200,
+ )
+ }
+
+ // ─── JNI mode tests (executed only if Codec2Jni.isAvailable) ─────────────
+
+ @Test
+ fun `JNI roundtrip SNR above minimum threshold`() {
+ Codec2JNI.ensureLoaded()
+ if (!Codec2JNI.isAvailable) {
+ // Skip gracefully in stub mode
+ println("[SKIP] Codec2JNI not available — SNR test skipped (stub mode)")
+ return
+ }
+
+ val encoder = AndroidCodec2Encoder()
+ val original = generateSineWave(freq = 440f, durationSec = 1.0f, sampleRate = 8000)
+ val encoded = encoder.encode(original)!!
+ val decoded = encoder.decode(encoded)!!
+
+ // Approximate SNR measurement on the reconstructed signal
+ val snrDb = computeSnrDb(original, decoded)
+ println("SNR Codec2 700B roundtrip: $snrDb dB")
+
+ // Codec2 700B at 440Hz sinusoidal: we expect at least 5 dB SNR
+ // (low threshold — Codec2 700B is a voice codec, not hi-fi)
+ assertTrue(
+ "SNR too low for Codec2 700B: $snrDb dB (minimum expected: 5 dB)",
+ snrDb >= 5.0,
+ )
+ }
+
+ @Test
+ fun `JNI handle lifecycle create and destroy`() {
+ Codec2JNI.ensureLoaded()
+ if (!Codec2JNI.isAvailable) {
+ println("[SKIP] Codec2JNI not available")
+ return
+ }
+ val handle = Codec2JNI.create(Codec2JNI.MODE_700C)
+ assertTrue("Handle must be != 0", handle != 0L)
+ assertEquals("samplesPerFrame must be 320 for 700B", 320, Codec2JNI.getSamplesPerFrame(Codec2JNI.MODE_700C))
+ assertTrue("bytesPerFrame must be > 0", Codec2JNI.getBytesPerFrame(Codec2JNI.MODE_700C) > 0)
+ Codec2JNI.destroy(handle)
+ // If we reach this point without a crash, the lifecycle is correct
+ }
+
+ // ─── Helpers ──────────────────────────────────────────────────────────────
+
+ /**
+ * Generates a 16-bit PCM sine wave as a test signal.
+ * Amplitude at 70% of Short.MAX_VALUE to simulate normalized voice input.
+ */
+ private fun generateSineWave(freq: Float, durationSec: Float, sampleRate: Int): ShortArray {
+ val numSamples = (sampleRate * durationSec).toInt()
+ val amplitude = Short.MAX_VALUE * 0.7
+ return ShortArray(numSamples) { i ->
+ val angle = 2.0 * PI * freq * i / sampleRate
+ (sin(angle) * amplitude).toInt().toShort()
+ }
+ }
+
+ /**
+ * Computes the approximate Signal-to-Noise Ratio between two signals.
+ * Signals must have similar lengths — truncates to the minimum.
+ */
+ private fun computeSnrDb(original: ShortArray, decoded: ShortArray): Double {
+ val len = minOf(original.size, decoded.size)
+ if (len == 0) return Double.NEGATIVE_INFINITY
+
+ var signalPower = 0.0
+ var noisePower = 0.0
+
+ for (i in 0 until len) {
+ val s = original[i].toDouble()
+ val d = decoded[i].toDouble()
+ signalPower += s * s
+ noisePower += (s - d) * (s - d)
+ }
+
+ if (noisePower < 1e-10) return Double.POSITIVE_INFINITY // perfect decode
+ return 10.0 * kotlin.math.log10(signalPower / noisePower)
+ }
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioPlayer.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioPlayer.kt
new file mode 100644
index 0000000000..74a0671f66
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioPlayer.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.audio
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface AudioPlayer {
+
+ /**
+ * Plays the provided PCM buffer.
+ * @param pcmData PCM 16-bit mono 8000 Hz
+ * @param filePath logical path of the file being played (used by the UI to know which bubble is active)
+ * @param onComplete invoked on natural completion or after stop
+ */
+ fun play(pcmData: ShortArray, filePath: String = "", onComplete: () -> Unit = {})
+
+ /** Stops the current playback. */
+ fun stop()
+
+ /** True if audio is currently playing. */
+ val isPlaying: Boolean
+
+ /**
+ * Path of the file currently being played, null if none.
+ * Allows the UI to know which bubble to display as "playing".
+ * Emits null on completion/stop.
+ */
+ val playingFilePath: StateFlow
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioRecorder.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioRecorder.kt
new file mode 100644
index 0000000000..4d44354d75
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/audio/AudioRecorder.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.audio
+
+/**
+ * Platform-agnostic interface for audio recording.
+ *
+ * The Android implementation ([AndroidAudioRecorder]) uses [android.media.AudioRecord]
+ * with optimal parameters for Codec2:
+ * - sample rate: 8000 Hz
+ * - encoding: PCM 16-bit
+ * - channel: CHANNEL_IN_MONO
+ * - maximum duration: 1000ms (MVP)
+ *
+ * Requires the android.permission.RECORD_AUDIO permission.
+ * The UI must verify the permission before calling [startRecording].
+ */
+interface AudioRecorder {
+
+ /**
+ * Starts recording.
+ *
+ * @param onComplete callback invoked on completion with PCM data and the effective duration.
+ * Invoked on the caller's thread via coroutine.
+ * @param onError callback invoked in case of a recording error.
+ * @param maxDurationMs maximum duration in milliseconds (default: 1000ms MVP).
+ */
+ fun startRecording(
+ onComplete: (pcmData: ShortArray, durationMs: Int) -> Unit,
+ onError: (Throwable) -> Unit,
+ maxDurationMs: Int = 1000,
+ )
+
+ /**
+ * Stops the recording early.
+ * If no recording is in progress, this is a no-op.
+ * On completion, [onComplete] is still called with the data collected so far.
+ */
+ fun stopRecording()
+
+ /** True if a recording is currently in progress. */
+ val isRecording: Boolean
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/codec/Codec2Encoder.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/codec/Codec2Encoder.kt
new file mode 100644
index 0000000000..b626ec13fc
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/codec/Codec2Encoder.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.codec
+
+/**
+ * Platform-agnostic interface for Codec2 encoding/decoding.
+ *
+ * The Android implementation ([AndroidCodec2Encoder]) uses JNI + libcodec2.
+ * If the library is unavailable ([isStub]=true), it falls back to stub mode
+ * (440Hz sine wave) to allow development and CI without the .so file.
+ *
+ * Implements [AutoCloseable]: call [close()] (or use `use {}`) to
+ * release the JNI state when the codec is no longer needed.
+ */
+interface Codec2Encoder : AutoCloseable {
+
+ /**
+ * Encodes a 16-bit mono 8kHz PCM buffer into Codec2 700B bytes.
+ *
+ * @param pcmData PCM short array (16-bit, mono, 8000 Hz)
+ * @return compressed Codec2 bytes, or null in case of error
+ *
+ * Expected dimensions:
+ * input: 8000 samples/s × 1s = 8000 shorts = 16000 bytes PCM
+ * output: ~88 bytes Codec2 700B per 1 second
+ */
+ fun encode(pcmData: ShortArray): ByteArray?
+
+ /**
+ * Decodes Codec2 700B bytes into 16-bit mono 8kHz PCM.
+ *
+ * @param codec2Data compressed bytes
+ * @return PCM short array, or null in case of error
+ */
+ fun decode(codec2Data: ByteArray): ShortArray?
+
+ /**
+ * Indicates whether this implementation is functional (library available)
+ * or a stub.
+ */
+ val isStub: Boolean
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstModule.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstModule.kt
new file mode 100644
index 0000000000..c0cf77e3dc
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstModule.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.di
+
+import org.koin.core.annotation.ComponentScan
+import org.koin.core.annotation.Module
+
+/**
+ * Koin commonMain module for the Voice Burst feature.
+ *
+ * @ComponentScan scans the package and auto-registers via KSP:
+ * - VoiceBurstViewModel (@KoinViewModel with @InjectedParam destNodeId)
+ *
+ * Android-only dependencies (AudioRecorder, Codec2Encoder, DataStore, Repository)
+ * are registered in [FeatureVoiceBurstAndroidModule] (androidMain).
+ */
+@Module
+@ComponentScan("org.meshtastic.feature.voiceburst")
+class FeatureVoiceBurstModule
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstPayload.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstPayload.kt
new file mode 100644
index 0000000000..bbe633ca7e
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstPayload.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.model
+
+/**
+ * Payload of a Voice Burst ready for transmission or just received.
+ *
+ * Target MVP sizes:
+ * - audioData: ~88 bytes (Codec2 700B, 1 second at 700 bps)
+ * - overhead metadata: ~12 bytes
+ * - total: < 120 bytes → fits in a single MeshPacket (max ~240 bytes)
+ *
+ * PortNum: PRIVATE_APP = 256 (provisional — open question in the PRD)
+ * TODO: define official proto or request a registered portnum upstream.
+ *
+ * MVP serialization: raw bytes prefixed with a minimal fixed-length header:
+ * [1 byte version=1][1 byte codecMode][2 bytes durationMs][N bytes audioData]
+ * This avoids an additional protobuf dependency in the module for MVP.
+ */
+data class VoiceBurstPayload(
+ /**
+ * Version of the payload format.
+ * Increment if the format changes, to allow graceful degradation.
+ */
+ val version: Byte = 1,
+
+ /**
+ * Codec mode used for encoding.
+ * 0 = Codec2 700B (only supported value in MVP)
+ * TODO: map to Codec2Mode enum when available.
+ */
+ val codecMode: Byte = 0,
+
+ /**
+ * Actual duration of the recorded audio, in milliseconds.
+ * MVP: always ≤ 1000ms.
+ */
+ val durationMs: Short,
+
+ /**
+ * Audio bytes compressed with Codec2.
+ * MVP: ~88 bytes per 1 second at 700B.
+ */
+ val audioData: ByteArray,
+
+ /**
+ * Sender node ID (used on the receiver side for display).
+ * Populated by the receiver with the from field of the DataPacket.
+ */
+ val senderNodeId: String = "",
+) {
+
+ /**
+ * Serializes the payload into a ByteArray to insert into [DataPacket.bytes].
+ * Format: [version:1][codecMode:1][durationMs:2 BE][audioData:N]
+ */
+ fun encode(): ByteArray {
+ val buf = ByteArray(4 + audioData.size)
+ buf[0] = version
+ buf[1] = codecMode
+ buf[2] = ((durationMs.toInt() shr 8) and 0xFF).toByte()
+ buf[3] = (durationMs.toInt() and 0xFF).toByte()
+ audioData.copyInto(buf, destinationOffset = 4)
+ return buf
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is VoiceBurstPayload) return false
+ return version == other.version &&
+ codecMode == other.codecMode &&
+ durationMs == other.durationMs &&
+ audioData.contentEquals(other.audioData) &&
+ senderNodeId == other.senderNodeId
+ }
+
+ override fun hashCode(): Int {
+ var result = version.toInt()
+ result = 31 * result + codecMode.toInt()
+ result = 31 * result + durationMs.toInt()
+ result = 31 * result + audioData.contentHashCode()
+ result = 31 * result + senderNodeId.hashCode()
+ return result
+ }
+
+ companion object {
+ /** Provisional PortNum for MVP. PRIVATE_APP = 256. */
+ const val PORT_NUM = 256
+
+ /** Maximum duration supported in MVP (1 second). */
+ const val MAX_DURATION_MS = 1000
+
+ /**
+ * Deserializes a payload received from a [DataPacket].
+ * Returns null if the format is unrecognizable or the version is not supported.
+ */
+ fun decode(bytes: ByteArray, senderNodeId: String = ""): VoiceBurstPayload? {
+ if (bytes.size < 5) return null // minimum: 4-byte header + 1 byte audio
+ val version = bytes[0]
+ if (version != 1.toByte()) return null // unsupported version
+ val codecMode = bytes[1]
+ val durationMs = (((bytes[2].toInt() and 0xFF) shl 8) or (bytes[3].toInt() and 0xFF)).toShort()
+ val audioData = bytes.copyOfRange(4, bytes.size)
+ return VoiceBurstPayload(
+ version = version,
+ codecMode = codecMode,
+ durationMs = durationMs,
+ audioData = audioData,
+ senderNodeId = senderNodeId,
+ )
+ }
+ }
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstState.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstState.kt
new file mode 100644
index 0000000000..c5636c7676
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/model/VoiceBurstState.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.model
+
+/**
+ * States of the lifecycle of a Voice Burst.
+ *
+ * Valid transitions:
+ * Idle → Recording → Encoding → Sending → Sent
+ * Any state → Error
+ * Any state → Unsupported (if incompatible preset detected)
+ */
+sealed class VoiceBurstState {
+ /** Ready to record. No operation in progress. */
+ data object Idle : VoiceBurstState()
+
+ /**
+ * Audio recording in progress.
+ * @param elapsedMs milliseconds elapsed since the start of recording.
+ */
+ data class Recording(val elapsedMs: Long = 0L) : VoiceBurstState()
+
+ /** Codec2 encoding in progress (fast operation, typically < 50ms). */
+ data object Encoding : VoiceBurstState()
+
+ /**
+ * Packet queued for sending via RadioController.
+ * Enters this state if the node is temporarily disconnected.
+ */
+ data object Queued : VoiceBurstState()
+
+ /** Packet delivered to the node via BLE. Waiting for ACK (optional). */
+ data object Sending : VoiceBurstState()
+
+ /** Send completed successfully. */
+ data object Sent : VoiceBurstState()
+
+ /** Burst received from remote. Ready for playback. */
+ data class Received(val payload: VoiceBurstPayload) : VoiceBurstState()
+
+ /**
+ * Error during the burst lifecycle.
+ * @param reason cause of the error.
+ */
+ data class Error(val reason: VoiceBurstError) : VoiceBurstState()
+
+ /**
+ * Feature not available in the current context.
+ * Shown when: slow sub-1GHz preset, feature flag disabled,
+ * or recipient does not support the portnum.
+ */
+ data class Unsupported(val reason: String) : VoiceBurstState()
+}
+
+/** Error causes for [VoiceBurstState.Error]. */
+enum class VoiceBurstError {
+ /** Microphone permission denied by the user. */
+ MICROPHONE_PERMISSION_DENIED,
+
+ /** Error during audio recording. */
+ RECORDING_FAILED,
+
+ /** Codec2 encoding failed (stub or library not available). */
+ ENCODING_FAILED,
+
+ /** Destination node not reachable. */
+ SEND_FAILED,
+
+ /** Rate limit: too many bursts in a short time. Wait at least 30s. */
+ RATE_LIMITED,
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/repository/VoiceBurstRepository.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/repository/VoiceBurstRepository.kt
new file mode 100644
index 0000000000..2b7e782598
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/repository/VoiceBurstRepository.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.repository
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.feature.voiceburst.model.VoiceBurstPayload
+
+/**
+ * Platform-agnostic interface for sending and receiving Voice Bursts.
+ *
+ * The Android implementation ([AndroidVoiceBurstRepository]) uses [RadioController]
+ * to send [DataPacket] with dataType = [VoiceBurstPayload.PORT_NUM].
+ */
+interface VoiceBurstRepository {
+
+ /**
+ * Feature flag: Voice Burst experimental enabled by user.
+ * Default: false. Readable as StateFlow for UI reactivity.
+ */
+ val isFeatureEnabled: StateFlow
+
+ /**
+ * Enable or disable the Voice Burst feature.
+ * Persists in DataStore.
+ */
+ suspend fun setFeatureEnabled(enabled: Boolean)
+
+ /**
+ * Sends a [VoiceBurstPayload] to the recipient node via BLE/RadioController
+ * and saves it in the local DB to show it in the chat.
+ *
+ * @param payload the already encoded payload
+ * @param contactKey contact key in the format "!" (e.g. "0!42424243", "8!42424243")
+ * @return true if the packet was delivered to RadioController, false otherwise
+ */
+ suspend fun sendBurst(payload: VoiceBurstPayload, contactKey: String): Boolean
+
+ /**
+ * Flow of bursts received from other nodes.
+ * Emits every time a DataPacket with PORT_NUM = 256 arrives and
+ * the payload is decodable.
+ */
+ val incomingBursts: Flow
+
+ /**
+ * Reads Codec2 bytes from disk given the relative path saved in [Message.audioFilePath].
+ * Used to play a previously received/sent voice message.
+ *
+ * @param relativePath path relative to filesDir, e.g. "voice_bursts/12345678.c2"
+ * @return ByteArray with Codec2 bytes, or null if the file doesn't exist or I/O error
+ */
+ fun readAudioFile(relativePath: String): ByteArray?
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstButton.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstButton.kt
new file mode 100644
index 0000000000..8440f951f3
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstButton.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.voiceburst.ui
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.Mic
+import androidx.compose.material.icons.rounded.MicOff
+import androidx.compose.material.icons.rounded.StopCircle
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.unit.dp
+import org.meshtastic.feature.voiceburst.model.VoiceBurstState
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.voice_burst_record
+import org.meshtastic.core.resources.voice_burst_recording
+import org.meshtastic.core.resources.voice_burst_encoding
+import org.meshtastic.core.resources.voice_burst_sending
+import org.meshtastic.core.resources.voice_burst_sent
+import org.meshtastic.core.resources.voice_burst_error
+import org.meshtastic.core.resources.voice_burst_received
+import org.meshtastic.core.resources.voice_burst_unsupported
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * PTT (Push-To-Talk) button for Voice Burst.
+ *
+ * Render this composable only when Voice Burst is available; callers should not render
+ * it for [VoiceBurstState.Unsupported].
+ * Disabled during non-interactive processing states such as encoding and sending.
+ *
+ * Visual states:
+ * Idle -> Mic icon, normal color
+ * Recording -> Pulsing Mic icon (scale animation), error/red color
+ * Encoding -> Mic icon, secondary color, disabled
+ * Sending -> Mic icon, secondary color, disabled
+ * Sent -> Mic icon, primary color (short feedback)
+ * Error -> MicOff icon, error color
+ * Unsupported -> hidden (caller should not render the composable)
+ *
+ * @param state Current state machine state
+ * @param onClick Callback when the user presses the button
+ * @param modifier Optional modifier
+ */
+@Composable
+fun VoiceBurstButton(
+ state: VoiceBurstState,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // Enabled in Idle (start), Recording (stop), Sent (start immediately), and Error (reset)
+ val isEnabled = state is VoiceBurstState.Idle
+ || state is VoiceBurstState.Recording
+ || state is VoiceBurstState.Sent
+ || state is VoiceBurstState.Error
+ val isRecording = state is VoiceBurstState.Recording
+ val isError = state is VoiceBurstState.Error
+
+ val tint by animateColorAsState(
+ targetValue = when (state) {
+ is VoiceBurstState.Recording -> MaterialTheme.colorScheme.error
+ is VoiceBurstState.Sent -> MaterialTheme.colorScheme.primary
+ is VoiceBurstState.Error -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ label = "voiceBurstTint",
+ )
+
+ // Pulsation during recording
+ val infiniteTransition = rememberInfiniteTransition(label = "recordingPulse")
+ val scale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = if (isRecording) 1.2f else 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 400),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "recordingScale",
+ )
+
+ IconButton(
+ onClick = onClick,
+ enabled = isEnabled,
+ modifier = modifier,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ // Progress ring during recording: shows fraction of the max duration
+ if (isRecording) {
+ val progress = ((state as VoiceBurstState.Recording).elapsedMs / 1000f)
+ .coerceIn(0f, 1f)
+ CircularProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(36.dp),
+ color = MaterialTheme.colorScheme.error,
+ strokeWidth = 2.5.dp,
+ trackColor = MaterialTheme.colorScheme.error.copy(alpha = 0.2f),
+ )
+ }
+ Icon(
+ imageVector = when {
+ isRecording -> Icons.Rounded.StopCircle
+ state is VoiceBurstState.Sent -> Icons.Rounded.CheckCircle
+ isError -> Icons.Rounded.MicOff
+ else -> Icons.Rounded.Mic
+ },
+ contentDescription = when (state) {
+ is VoiceBurstState.Idle -> stringResource(Res.string.voice_burst_record)
+ is VoiceBurstState.Recording -> stringResource(Res.string.voice_burst_recording, (state.elapsedMs / 100) / 10f)
+ is VoiceBurstState.Encoding -> stringResource(Res.string.voice_burst_encoding)
+ is VoiceBurstState.Sending,
+ is VoiceBurstState.Queued -> stringResource(Res.string.voice_burst_sending)
+ is VoiceBurstState.Sent -> stringResource(Res.string.voice_burst_sent)
+ is VoiceBurstState.Error -> stringResource(Res.string.voice_burst_error)
+ is VoiceBurstState.Received -> stringResource(Res.string.voice_burst_received)
+ is VoiceBurstState.Unsupported -> stringResource(Res.string.voice_burst_unsupported)
+ },
+ tint = tint,
+ modifier = Modifier.size(24.dp).scale(scale),
+ )
+ }
+ }
+}
diff --git a/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstViewModel.kt b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstViewModel.kt
new file mode 100644
index 0000000000..501619548f
--- /dev/null
+++ b/feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstViewModel.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2026 Chris7X
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+package org.meshtastic.feature.voiceburst.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import org.koin.core.annotation.InjectedParam
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.feature.voiceburst.audio.AudioPlayer
+import org.meshtastic.feature.voiceburst.audio.AudioRecorder
+import org.meshtastic.feature.voiceburst.codec.Codec2Encoder
+import org.meshtastic.feature.voiceburst.model.VoiceBurstError
+import org.meshtastic.feature.voiceburst.model.VoiceBurstPayload
+import org.meshtastic.feature.voiceburst.model.VoiceBurstState
+import org.meshtastic.feature.voiceburst.repository.VoiceBurstRepository
+
+private const val TAG = "VoiceBurstViewModel"
+
+/**
+ * ViewModel handling the lifecycle and orchestration of Voice Burst messaging.
+ *
+ * Full pipeline:
+ * MIC -> [AudioRecorder] -> PCM -> [Codec2Encoder.encode] -> bytes -> [VoiceBurstRepository.sendBurst]
+ * RADIO -> [VoiceBurstRepository.incomingBursts] -> bytes -> [Codec2Encoder.decode] -> PCM -> [AudioPlayer]
+ *
+ * Rate limiting is enforced: minimum [RATE_LIMIT_MS] between consecutive bursts.
+ *
+ * @param repository Manages feature flags, sending, and receiving bursts.
+ * @param encoder Codec2 encoding/decoding engine (may be a sine-wave stub).
+ * @param audioPlayer Plays the decoded PCM audio.
+ * @param audioRecorder Records audio from the microphone (8kHz mono PCM16).
+ * @param destNodeId Target hex ID for the conversation (e.g., "0!42424243").
+ */
+@KoinViewModel
+class VoiceBurstViewModel(
+ private val repository: VoiceBurstRepository,
+ private val encoder: Codec2Encoder,
+ private val audioPlayer: AudioPlayer,
+ private val audioRecorder: AudioRecorder,
+ @InjectedParam private val destNodeId: String,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(VoiceBurstState.Idle)
+ val state = _state.asStateFlow()
+
+ /**
+ * Observable state of the Voice Burst feature flag from DataStore.
+ */
+ val isFeatureEnabled = repository.isFeatureEnabled.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = false,
+ )
+
+ val incomingBursts = repository.incomingBursts
+
+ /**
+ * Path of the audio file currently being played.
+ * Observed by the UI to display the correct Play/Stop icon.
+ * Null when no audio is active.
+ */
+ val playingFilePath = audioPlayer.playingFilePath
+
+ private val resolvedNodeId: String = run {
+ val channelDigit = destNodeId.firstOrNull()?.digitToIntOrNull()
+ if (channelDigit != null) destNodeId.substring(1) else destNodeId
+ }
+
+ /** UI timer Job: updates elapsedMs every 100ms during manual recording. */
+ private var uiTimerJob: Job? = null
+
+ private var lastSentTimestamp = 0L
+
+ init {
+ // Listen for incoming radio bursts and trigger automatic playback.
+ repository.incomingBursts
+ .onEach { payload -> onBurstReceived(payload) }
+ .catch { e -> Logger.w(tag = TAG) { "Incoming bursts flow error: ${e.message}" } }
+ .launchIn(viewModelScope)
+ }
+
+ // --- Receiver-side logic ------------------------------------------------
+
+ private fun onBurstReceived(payload: VoiceBurstPayload) {
+ Logger.i(tag = TAG) {
+ "Burst received from ${payload.senderNodeId}: " +
+ "${payload.durationMs}ms, ${payload.audioData.size} bytes"
+ }
+ _state.update { VoiceBurstState.Received(payload) }
+
+ val pcmData = encoder.decode(payload.audioData)
+ if (pcmData == null || pcmData.isEmpty()) {
+ Logger.e(tag = TAG) { "Decoding failed -- no PCM samples to play" }
+ _state.update { VoiceBurstState.Idle }
+ return
+ }
+
+ Logger.d(tag = TAG) { "Starting playback: ${pcmData.size} samples @ ${SAMPLE_RATE_HZ}Hz" }
+ // Empty filePath indicates autoplay (not triggered by a specific UI bubble).
+ audioPlayer.play(pcmData, filePath = "") {
+ if (_state.value is VoiceBurstState.Received) {
+ _state.update { VoiceBurstState.Idle }
+ }
+ }
+ }
+
+ // --- Sender-side (PTT) recording ----------------------------------------
+
+ /**
+ * Initiates microphone recording if the state machine is [Idle].
+ * Enforces the [RATE_LIMIT_MS] guard before starting.
+ *
+ * Note: Permissions (RECORD_AUDIO) must be verified by the UI before calling.
+ */
+ fun startRecording() {
+ if (_state.value !is VoiceBurstState.Idle) return
+
+ // Rate limit check
+ val now = Clock.System.now().toEpochMilliseconds()
+ val remaining = RATE_LIMIT_MS - (now - lastSentTimestamp)
+ if (remaining > 0) {
+ Logger.w(tag = TAG) { "Rate limit active: waiting ${remaining / 1000}s" }
+ _state.update { VoiceBurstState.Error(VoiceBurstError.RATE_LIMITED) }
+ viewModelScope.launch {
+ delay(remaining)
+ if (_state.value is VoiceBurstState.Error) {
+ _state.update { VoiceBurstState.Idle }
+ }
+ }
+ return
+ }
+
+ Logger.d(tag = TAG) { "Starting PTT recording for $resolvedNodeId (dest=$destNodeId)" }
+ _state.update { VoiceBurstState.Recording(elapsedMs = 0L) }
+
+ // Start UI timer: updates the elapsed time for the PTT progress indicator.
+ val startTime = Clock.System.now().toEpochMilliseconds()
+ uiTimerJob = viewModelScope.launch {
+ while (_state.value is VoiceBurstState.Recording) {
+ delay(TIMER_TICK_MS)
+ val elapsed = Clock.System.now().toEpochMilliseconds() - startTime
+ _state.update {
+ if (it is VoiceBurstState.Recording)
+ VoiceBurstState.Recording(elapsedMs = minOf(elapsed, MAX_DURATION_MS.toLong()))
+ else it
+ }
+ }
+ }
+
+ // Engage the hardware audio recorder.
+ audioRecorder.startRecording(
+ onComplete = { pcmData, durationMs ->
+ uiTimerJob?.cancel()
+ uiTimerJob = null
+ Logger.d(tag = TAG) { "Recording finished: ${pcmData.size} samples, ${durationMs}ms" }
+ onRecordingComplete(pcmData, durationMs)
+ },
+ onError = { error ->
+ uiTimerJob?.cancel()
+ uiTimerJob = null
+ Logger.e(tag = TAG) { "Hardware recording error: ${error.message}" }
+ _state.update { VoiceBurstState.Error(VoiceBurstError.RECORDING_FAILED) }
+ },
+ maxDurationMs = MAX_DURATION_MS,
+ )
+ }
+
+ /**
+ * Mandates the recorder to stop recording immediately.
+ * The recorder will then trigger the completion callback with the partial PCM data.
+ */
+ fun stopRecording() {
+ if (_state.value !is VoiceBurstState.Recording) return
+ Logger.d(tag = TAG) { "Manual recording stop triggered" }
+ uiTimerJob?.cancel()
+ uiTimerJob = null
+ audioRecorder.stopRecording()
+ }
+
+ // --- Encoding and Dispatch ----------------------------------------------
+
+ internal fun onRecordingComplete(pcmData: ShortArray, durationMs: Int) {
+ _state.update { VoiceBurstState.Encoding }
+
+ viewModelScope.launch {
+ val audioBytes = encoder.encode(pcmData)
+ if (audioBytes == null) {
+ Logger.e(tag = TAG) { "Codec2 encoding failed (check JNI)" }
+ _state.update { VoiceBurstState.Error(VoiceBurstError.ENCODING_FAILED) }
+ return@launch
+ }
+
+ if (encoder.isStub) {
+ Logger.w(tag = TAG) { "Running with Codec2 stub -- transmission will not be intelligible" }
+ } else {
+ Logger.i(tag = TAG) { "Enc JNI Success: ${pcmData.size} samples -> ${audioBytes.size} bytes" }
+ }
+
+ val payload = VoiceBurstPayload(
+ durationMs = durationMs.toShort(),
+ audioData = audioBytes,
+ )
+
+ _state.update { VoiceBurstState.Sending }
+ val success = repository.sendBurst(payload, destNodeId)
+
+ if (success) {
+ lastSentTimestamp = Clock.System.now().toEpochMilliseconds()
+ Logger.i(tag = TAG) { "Voice Burst sent to $destNodeId: ${audioBytes.size} bytes, ${durationMs}ms" }
+ _state.update { VoiceBurstState.Sent }
+ delay(SENT_DISPLAY_MS)
+ _state.update { VoiceBurstState.Idle }
+ } else {
+ Logger.e(tag = TAG) { "Failed to send burst to $destNodeId" }
+ _state.update { VoiceBurstState.Error(VoiceBurstError.SEND_FAILED) }
+ }
+ }
+ }
+
+ /**
+ * Resets the internal state machine back to Idle.
+ */
+ fun reset() {
+ _state.update { VoiceBurstState.Idle }
+ }
+
+ /**
+ * Plays a previously recorded voice message from the local storage.
+ * Invoked when tapping a Voice Burst capsule in the message list.
+ *
+ * @param relativePath Disk path relative to the app's files directory.
+ * Format: "voice_bursts/.c2"
+ */
+ fun playBurst(relativePath: String) {
+ if (audioPlayer.isPlaying) {
+ val wasPlayingThis = audioPlayer.playingFilePath.value == relativePath
+ audioPlayer.stop()
+ // Second tap on the same bubble = stop only.
+ if (wasPlayingThis) return
+ }
+ viewModelScope.launch {
+ val codec2Bytes = repository.readAudioFile(relativePath)
+ if (codec2Bytes == null || codec2Bytes.isEmpty()) {
+ Logger.e(tag = TAG) { "Audio file missing: $relativePath" }
+ return@launch
+ }
+ val pcmData = encoder.decode(codec2Bytes)
+ if (pcmData == null || pcmData.isEmpty()) {
+ Logger.e(tag = TAG) { "Failed to decode audio file: $relativePath" }
+ return@launch
+ }
+ Logger.d(tag = TAG) { "Streaming from file: $relativePath (${pcmData.size} samples)" }
+ audioPlayer.play(pcmData, filePath = relativePath)
+ }
+ }
+
+ companion object {
+ const val RATE_LIMIT_MS = 30_000L
+ const val MAX_DURATION_MS = 1000
+ const val SAMPLE_RATE_HZ = 8000
+ private const val SENT_DISPLAY_MS = 1500L
+ private const val TIMER_TICK_MS = 100L
+ }
+}