Skip to content

Commit 5d0c18e

Browse files
committed
feat(voiceburst): Voice Burst - voice messages with Codec2 compression
- Codec2 JNI integration for Android (arm64-v8a, x86_64) - AudioRecorder/AudioPlayer interfaces - VoiceBurstViewModel and UI button - GPL-3.0 license headers - All comments in English
1 parent d0e3b68 commit 5d0c18e

22 files changed

Lines changed: 2298 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
import java.io.File
19+
20+
plugins {
21+
alias(libs.plugins.meshtastic.kmp.feature)
22+
alias(libs.plugins.meshtastic.kotlinx.serialization)
23+
}
24+
25+
// ─── Codec2 JNI detection ─────────────────────────────────────────
26+
val codec2SoArm64 = File(projectDir, "src/androidMain/jniLibs/arm64-v8a/libcodec2.so")
27+
val codec2JniArm64 = File(projectDir, "src/androidMain/jniLibs/arm64-v8a/libcodec2_jni.so")
28+
val codec2Available = codec2SoArm64.exists() && codec2JniArm64.exists()
29+
30+
if (codec2Available) {
31+
logger.lifecycle(":feature:voiceburst — libcodec2.so + libcodec2_jni.so trovate")
32+
} else {
33+
logger.lifecycle(":feature:voiceburst — .so assenti → stub mode (esegui scripts/build_codec2.sh)")
34+
}
35+
36+
kotlin {
37+
jvm()
38+
39+
@Suppress("UnstableApiUsage")
40+
android {
41+
namespace = "org.meshtastic.feature.voiceburst"
42+
androidResources.enable = false
43+
withHostTest { isIncludeAndroidResources = true }
44+
}
45+
46+
sourceSets {
47+
commonMain.dependencies {
48+
implementation(projects.core.common)
49+
implementation(projects.core.data)
50+
implementation(projects.core.datastore)
51+
implementation(projects.core.model)
52+
implementation(projects.core.navigation)
53+
implementation(projects.core.proto)
54+
implementation(projects.core.repository)
55+
implementation(projects.core.service)
56+
implementation(projects.core.ui)
57+
implementation(projects.core.di)
58+
59+
implementation(libs.kotlinx.collections.immutable)
60+
}
61+
62+
androidUnitTest.dependencies {
63+
implementation(libs.junit)
64+
implementation(libs.robolectric)
65+
implementation(libs.turbine)
66+
implementation(libs.kotlinx.coroutines.test)
67+
implementation(libs.androidx.test.ext.junit)
68+
}
69+
70+
commonTest.dependencies {
71+
implementation(project(":core:testing"))
72+
}
73+
}
74+
}
75+
76+
// ─── NESSUN externalNativeBuild ───────────────────────────────────
77+
// Le .so prebuilt in jniLibs/ vengono incluse automaticamente da AGP.
78+
// Il JNI wrapper è compilato da scripts/build_codec2.sh.
Binary file not shown.
Binary file not shown.
1.4 MB
Binary file not shown.
Binary file not shown.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2026 Chris7X
3+
*
4+
* This library is free software; you can redistribute it and/or
5+
* modify it under the terms of the GNU Lesser General Public
6+
* License as published by the Free Software Foundation; either
7+
* version 2.1 of the License, or (at your option) any later version.
8+
*
9+
* This library is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
* Lesser General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Lesser General Public
15+
* License along with this library; if not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.geeksville.mesh.voiceburst
19+
20+
import android.util.Log
21+
22+
/**
23+
* JNI binding to a prebuilt libcodec2 library.
24+
* Both shared objects (libcodec2.so + libcodec2_jni.so) must be present in jniLibs/.
25+
*/
26+
internal object Codec2JNI {
27+
28+
private const val TAG = "Codec2JNI"
29+
private var loaded = false
30+
31+
fun ensureLoaded() {
32+
if (!loaded) {
33+
try {
34+
System.loadLibrary("codec2")
35+
Log.i(TAG, "libcodec2.so loaded OK")
36+
} catch (e: UnsatisfiedLinkError) {
37+
Log.e(TAG, "Failed to load libcodec2.so: ${e.message}")
38+
return
39+
}
40+
try {
41+
System.loadLibrary("codec2_jni")
42+
Log.i(TAG, "libcodec2_jni.so loaded OK — JNI active")
43+
loaded = true
44+
} catch (e: UnsatisfiedLinkError) {
45+
Log.e(TAG, "Failed to load libcodec2_jni.so: ${e.message}")
46+
// loaded remains false -> fallback to stub
47+
}
48+
}
49+
}
50+
51+
val isAvailable: Boolean
52+
get() = loaded
53+
54+
// Codec2 operating modes
55+
const val MODE_3200 = 0
56+
const val MODE_2400 = 1
57+
const val MODE_1600 = 2
58+
const val MODE_1400 = 3
59+
const val MODE_1300 = 4
60+
const val MODE_1200 = 5
61+
const val MODE_700C = 8
62+
const val MODE_450 = 10
63+
64+
@JvmStatic external fun getSamplesPerFrame(mode: Int): Int
65+
@JvmStatic external fun getBytesPerFrame(mode: Int): Int
66+
@JvmStatic external fun create(mode: Int): Long
67+
@JvmStatic external fun encode(ptr: Long, pcm: ShortArray): ByteArray
68+
@JvmStatic external fun decode(ptr: Long, frame: ByteArray): ShortArray
69+
@JvmStatic external fun destroy(ptr: Long)
70+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) 2026 Chris7X
3+
*
4+
* This library is free software; you can redistribute it and/or
5+
* modify it under the terms of the GNU Lesser General Public
6+
* License as published by the Free Software Foundation; either
7+
* version 2.1 of the License, or (at your option) any later version.
8+
*
9+
* This library is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
* Lesser General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Lesser General Public
15+
* License along with this library; if not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.codec2
18+
19+
/**
20+
* JNI wrapper for the Codec2 library.
21+
* This class is the interface between Kotlin/JVM and the C codec logic.
22+
*/
23+
class Codec2Jni {
24+
25+
/**
26+
* Encodes 16-bit mono PCM audio (8kHz) into Codec2 compressed frames.
27+
* @param pcm Input audio data (ShortArray)
28+
* @return Compressed byte array or null on error
29+
*/
30+
external fun encode(pcm: ShortArray): ByteArray?
31+
32+
/**
33+
* Decodes Codec2 compressed frames back into 16-bit mono PCM audio (8kHz).
34+
* @param compressed Compressed audio data (ByteArray)
35+
* @return Decoded ShortArray or null on error
36+
*/
37+
external fun decode(compressed: ByteArray): ShortArray?
38+
39+
/**
40+
* Gets the current Codec2 mode (e.g., 3200, 2400, etc.).
41+
*/
42+
external fun getMode(): Int
43+
44+
companion object {
45+
init {
46+
try {
47+
System.loadLibrary("codec2_jni")
48+
} catch (e: UnsatisfiedLinkError) {
49+
// Logger not available in this core-module, using println
50+
println("Critical: Could not load codec2_jni library")
51+
}
52+
}
53+
}
54+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright (c) 2026 Chris7X
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.voiceburst.audio
18+
19+
import android.media.AudioAttributes
20+
import android.media.AudioFormat
21+
import android.media.AudioManager
22+
import android.media.AudioTrack
23+
import co.touchlab.kermit.Logger
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.Job
27+
import kotlinx.coroutines.flow.MutableStateFlow
28+
import kotlinx.coroutines.flow.StateFlow
29+
import kotlinx.coroutines.flow.asStateFlow
30+
import kotlinx.coroutines.launch
31+
32+
private const val TAG = "AndroidAudioPlayer"
33+
34+
/**
35+
* Android implementation of [AudioPlayer].
36+
*
37+
* Fixes compared to previous versions:
38+
* - BUG: MODE_STATIC with bufferSize < minBufferSize → STATE_NO_STATIC_DATA (state=2) → silence.
39+
* FIX: bufferSize = maxOf(minBufferSize, pcmBytes) ALWAYS, even in static mode.
40+
* - Using MODE_STREAM: simpler and avoids the STATE_NO_STATIC_DATA issue.
41+
* For 1 second at 8kHz (16000 bytes) MODE_STREAM is more than adequate.
42+
* - USAGE_MEDIA → main speaker (not earpiece).
43+
* - [playingFilePath] StateFlow to sync play/stop icons in the UI.
44+
*/
45+
class AndroidAudioPlayer(
46+
private val scope: CoroutineScope,
47+
) : AudioPlayer {
48+
49+
private var audioTrack: AudioTrack? = null
50+
private var playingJob: Job? = null
51+
52+
private val _playingFilePath = MutableStateFlow<String?>(null)
53+
override val playingFilePath: StateFlow<String?> = _playingFilePath.asStateFlow()
54+
55+
override val isPlaying: Boolean
56+
get() = audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING
57+
58+
override fun play(pcmData: ShortArray, filePath: String, onComplete: () -> Unit) {
59+
// If already playing, stop before starting a new track
60+
if (isPlaying) {
61+
Logger.d(tag = TAG) { "Stopping previous track before starting new one" }
62+
stopInternal()
63+
}
64+
65+
if (pcmData.isEmpty()) {
66+
Logger.w(tag = TAG) { "PCM data is empty — skipping playback" }
67+
onComplete()
68+
return
69+
}
70+
71+
val sampleRate = SAMPLE_RATE_HZ
72+
val channelConfig = AudioFormat.CHANNEL_OUT_MONO
73+
val audioEncoding = AudioFormat.ENCODING_PCM_16BIT
74+
75+
val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioEncoding)
76+
if (minBufferSize <= 0) {
77+
Logger.e(tag = TAG) { "getMinBufferSize error: $minBufferSize" }
78+
onComplete()
79+
return
80+
}
81+
82+
// CRITICAL: bufferSize must always be >= minBufferSize.
83+
// With MODE_STATIC, if bufferSize < minBufferSize → state=STATE_NO_STATIC_DATA=2 → silence.
84+
// MODE_STREAM is used for simplicity and robustness.
85+
val pcmBytes = pcmData.size * Short.SIZE_BYTES
86+
val bufferSize = maxOf(minBufferSize, pcmBytes)
87+
88+
val attrs = AudioAttributes.Builder()
89+
.setUsage(AudioAttributes.USAGE_MEDIA)
90+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
91+
.build()
92+
93+
val format = AudioFormat.Builder()
94+
.setSampleRate(sampleRate)
95+
.setEncoding(audioEncoding)
96+
.setChannelMask(channelConfig)
97+
.build()
98+
99+
val track = try {
100+
AudioTrack(attrs, format, bufferSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
101+
} catch (e: Exception) {
102+
Logger.e(e, tag = TAG) { "Failed to create AudioTrack" }
103+
onComplete()
104+
return
105+
}
106+
107+
if (track.state != AudioTrack.STATE_INITIALIZED) {
108+
Logger.e(tag = TAG) { "AudioTrack not initialized: state=${track.state} (expected ${AudioTrack.STATE_INITIALIZED})" }
109+
track.release()
110+
onComplete()
111+
return
112+
}
113+
114+
audioTrack = track
115+
_playingFilePath.value = filePath.ifEmpty { null }
116+
117+
playingJob = scope.launch(Dispatchers.IO) {
118+
try {
119+
// MODE_STREAM: call play() FIRST, then write() for streaming
120+
track.play()
121+
Logger.d(tag = TAG) { "Playback started: ${pcmData.size} samples @ ${sampleRate}Hz" }
122+
123+
val written = track.write(pcmData, 0, pcmData.size)
124+
if (written < 0) {
125+
Logger.e(tag = TAG) { "write() error: $written" }
126+
} else {
127+
Logger.d(tag = TAG) { "Write complete: $written samples" }
128+
// Wait for the DAC to drain all samples in the buffer
129+
val drainMs = written.toLong() * 1000L / sampleRate + DRAIN_GUARD_MS
130+
kotlinx.coroutines.delay(drainMs)
131+
}
132+
} catch (e: Exception) {
133+
Logger.e(e, tag = TAG) { "Playback error" }
134+
} finally {
135+
releaseTrack(track)
136+
_playingFilePath.value = null
137+
scope.launch(Dispatchers.Main) { onComplete() }
138+
}
139+
}
140+
}
141+
142+
override fun stop() {
143+
if (!isPlaying && playingJob?.isActive != true) return
144+
Logger.d(tag = TAG) { "Stopping playback" }
145+
stopInternal()
146+
}
147+
148+
private fun stopInternal() {
149+
playingJob?.cancel()
150+
playingJob = null
151+
audioTrack?.let { releaseTrack(it) }
152+
_playingFilePath.value = null
153+
}
154+
155+
private fun releaseTrack(track: AudioTrack) {
156+
try { track.stop() } catch (_: Exception) {}
157+
try { track.flush() } catch (_: Exception) {}
158+
track.release()
159+
if (audioTrack === track) audioTrack = null
160+
}
161+
162+
companion object {
163+
private const val SAMPLE_RATE_HZ = 8000
164+
private const val DRAIN_GUARD_MS = 150L // extra margin for DAC drain
165+
}
166+
}

0 commit comments

Comments
 (0)