This commit is contained in:
Chris7X 2026-04-20 07:28:34 -05:00 committed by GitHub
commit e45ee0d160
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2270 additions and 0 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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.

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String?>(null)
override val playingFilePath: StateFlow<String?> = _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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences> =
PreferenceDataStoreFactory.create(
scope = scope,
produceFile = { context.preferencesDataStoreFile("voice_burst") },
)
@Single(createdAtStart = true)
fun provideVoiceBurstRepository(
radioController: RadioController,
@Named("VoiceBurstDataStore") dataStore: DataStore<Preferences>,
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
* <filesDir>/voice_bursts/<uuid>.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<Preferences>,
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<Boolean> =
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<VoiceBurstPayload>(replay = 0, extraBufferCapacity = 8)
override val incomingBursts: Flow<VoiceBurstPayload> = _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 <voiceBurstsDir>/<uuid>.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")
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String?>
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean>
/**
* 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 "<channel>!<nodeId>" (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<VoiceBurstPayload>
/**
* 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?
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}
}
}

View file

@ -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>(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/<uuid>.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
}
}