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 + } +}