mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Merge da94d6985d into 3322257cfd
This commit is contained in:
commit
e45ee0d160
22 changed files with 2270 additions and 0 deletions
81
feature/voiceburst/build.gradle.kts
Normal file
81
feature/voiceburst/build.gradle.kts
Normal 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.
|
||||
Binary file not shown.
Binary file not shown.
BIN
feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2.so
Normal file
BIN
feature/voiceburst/src/androidMain/jniLibs/x86_64/libcodec2.so
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue