Skip to content

Commit 37ce392

Browse files
committed
fix: address remaining Copilot review comments
- AndroidCodec2Encoder: VAD returns null instead of ByteArray(0) to prevent empty packet transmission; fix stale KDoc ref (Codec2Jni -> Codec2JNI) - AndroidAudioPlayer: fix mojibake encoding in comments - VoiceBurstViewModel: ENCODING_FAILED -> RECORDING_FAILED in recorder error path; fix 'broadcasted' log to 'sent to \'; fix mojibake in KDoc - VoiceBurstButton: remove reference to non-existent isVisible property - FeatureVoiceBurstAndroidModule: remove dead ref to FeatureAchievementsAndroidModule - Codec2Jni: replace duplicate JNI class with typealias to Codec2JNI
1 parent 6c9a121 commit 37ce392

File tree

6 files changed

+47
-78
lines changed

6 files changed

+47
-78
lines changed

feature/voiceburst/src/androidMain/kotlin/org/meshtastic/codec2/Codec2Jni.kt

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,38 +17,8 @@
1717
package org.meshtastic.codec2
1818

1919
/**
20-
* JNI wrapper for the Codec2 library.
21-
* This class is the interface between Kotlin/JVM and the C codec logic.
20+
* Backwards-compatible alias to the canonical Codec2 JNI wrapper used by
21+
* the voiceburst feature. The actual implementation lives in
22+
* [com.geeksville.mesh.voiceburst.Codec2JNI].
2223
*/
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-
}
24+
typealias Codec2Jni = com.geeksville.mesh.voiceburst.Codec2JNI

feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/audio/AndroidAudioPlayer.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ private const val TAG = "AndroidAudioPlayer"
3434
/**
3535
* Android implementation of [AudioPlayer].
3636
*
37-
* Fixes compared to previous versions:
38-
* - BUG: MODE_STATIC with bufferSize < minBufferSize → STATE_NO_STATIC_DATA (state=2) → silence.
37+
* Key implementation notes:
38+
* - BUG: MODE_STATIC with bufferSize < minBufferSize -> STATE_NO_STATIC_DATA (state=2) -> silence.
3939
* FIX: bufferSize = maxOf(minBufferSize, pcmBytes) ALWAYS, even in static mode.
4040
* - Using MODE_STREAM: simpler and avoids the STATE_NO_STATIC_DATA issue.
4141
* For 1 second at 8kHz (16000 bytes) MODE_STREAM is more than adequate.
42-
* - USAGE_MEDIA → main speaker (not earpiece).
42+
* - USAGE_MEDIA -> main speaker (not earpiece).
4343
* - [playingFilePath] StateFlow to sync play/stop icons in the UI.
4444
*/
4545
class AndroidAudioPlayer(
@@ -63,7 +63,7 @@ class AndroidAudioPlayer(
6363
}
6464

6565
if (pcmData.isEmpty()) {
66-
Logger.w(tag = TAG) { "PCM data is empty — skipping playback" }
66+
Logger.w(tag = TAG) { "PCM data is empty -- skipping playback" }
6767
onComplete()
6868
return
6969
}
@@ -80,7 +80,7 @@ class AndroidAudioPlayer(
8080
}
8181

8282
// CRITICAL: bufferSize must always be >= minBufferSize.
83-
// With MODE_STATIC, if bufferSize < minBufferSize → state=STATE_NO_STATIC_DATA=2 → silence.
83+
// With MODE_STATIC, if bufferSize < minBufferSize -> state=STATE_NO_STATIC_DATA=2 -> silence.
8484
// MODE_STREAM is used for simplicity and robustness.
8585
val pcmBytes = pcmData.size * Short.SIZE_BYTES
8686
val bufferSize = maxOf(minBufferSize, pcmBytes)

feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/codec/AndroidCodec2Encoder.kt

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,18 @@ private const val TAG = "AndroidCodec2Encoder"
2929
/**
3030
* Android implementation of [Codec2Encoder].
3131
*
32-
* When [Codec2Jni.isAvailable] = true, uses libcodec2 via JNI (real voice audio).
32+
* When [Codec2JNI.isAvailable] is true, uses libcodec2 via JNI (real voice audio).
3333
* Otherwise falls back to STUB mode (440Hz sine wave) for development/CI/builds without .so.
3434
*
3535
* Codec2 700B parameters:
3636
* - Sample rate input: 8000 Hz
3737
* - Frame: 40ms = 320 samples
3838
* - Bytes per frame: 4
39-
* - 1 second: 25 frames × 4 bytes = 100 bytes
39+
* - 1 second: 25 frames x 4 bytes = 100 bytes
4040
*
4141
* Preprocessing applied before encoding (JNI mode only):
4242
* 1. Amplitude normalization (brings to 70% of Short.MAX_VALUE)
43-
* 2. Simple VAD: if RMS < threshold, returns silence without encoding
43+
* 2. Simple VAD: if RMS < threshold, returns null without encoding
4444
*
4545
* JNI Lifecycle:
4646
* The Codec2 handle is created in the constructor and destroyed in [close()].
@@ -63,14 +63,14 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
6363
" bytesPerFrame=${Codec2JNI.getBytesPerFrame(Codec2JNI.MODE_700C)}"
6464
}
6565
} else {
66-
Logger.e(tag = TAG) { "Codec2JNI.create() returned 0 — falling back to stub mode" }
66+
Logger.e(tag = TAG) { "Codec2JNI.create() returned 0 -- falling back to stub mode" }
6767
codec2Handle = 0L
6868
isStub = true
6969
}
7070
} else {
7171
codec2Handle = 0L
7272
isStub = true
73-
Logger.w(tag = TAG) { "Codec2 JNI not available — stub mode (440Hz sine wave)" }
73+
Logger.w(tag = TAG) { "Codec2 JNI not available -- stub mode (440Hz sine wave)" }
7474
}
7575
}
7676

@@ -81,17 +81,17 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
8181
}
8282
}
8383

84-
// ─── encode ───────────────────────────────────────────────────────────────
84+
// --- encode -------------------------------------------------------------
8585

8686
/**
8787
* Encodes 16-bit mono 8000Hz PCM into Codec2 700B bytes.
8888
*
89-
* Accepts an array of any length — it is split into frames
89+
* Accepts an array of any length -- it is split into frames
9090
* of [SAMPLES_PER_FRAME] samples. The last incomplete frame is
9191
* padded with zeros (zero-padding).
9292
*
9393
* @param pcmData PCM samples from the microphone (8000 Hz, mono, signed 16-bit)
94-
* @return ByteArray with Codec2 bytes, null if input is empty
94+
* @return ByteArray with Codec2 bytes, null if input is empty or silence detected
9595
*/
9696
override fun encode(pcmData: ShortArray): ByteArray? {
9797
if (pcmData.isEmpty()) return null
@@ -110,11 +110,11 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
110110
// Preprocessing: normalization
111111
val normalized = normalize(pcmData)
112112

113-
// VAD: do not send silence
113+
// VAD: do not send silence -- return null so the ViewModel skips transmission
114114
val rms = computeRms(normalized)
115115
if (rms < SILENCE_RMS_THRESHOLD) {
116-
Logger.d(tag = TAG) { "VAD: silence detected (RMS=$rms) — skipping encode" }
117-
return ByteArray(0)
116+
Logger.d(tag = TAG) { "VAD: silence detected (RMS=$rms) -- skipping encode" }
117+
return null
118118
}
119119

120120
// Calculate needed frames (round up)
@@ -132,7 +132,6 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
132132
} else {
133133
ShortArray(samplesPerFrame).also {
134134
normalized.copyInto(it, 0, inStart, inEnd)
135-
// remaining already 0 by default
136135
}
137136
}
138137

@@ -147,13 +146,13 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
147146
}
148147

149148
Logger.d(tag = TAG) {
150-
"Encode JNI: ${pcmData.size} samples → ${output.size} bytes " +
151-
"($frameCount frames × $bytesPerFrame bytes)"
149+
"Encode JNI: ${pcmData.size} samples -> ${output.size} bytes " +
150+
"($frameCount frames x $bytesPerFrame bytes)"
152151
}
153152
return output
154153
}
155154

156-
// ─── decode ───────────────────────────────────────────────────────────────
155+
// --- decode -------------------------------------------------------------
157156

158157
/**
159158
* Decodes Codec2 700B bytes into 16-bit mono 8000Hz PCM samples.
@@ -178,7 +177,7 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
178177
if (codec2Data.size % bytesPerFrame != 0) {
179178
Logger.w(tag = TAG) {
180179
"Decode: input size (${codec2Data.size}) not a multiple of " +
181-
"bytesPerFrame ($bytesPerFrame) — truncating to complete frame"
180+
"bytesPerFrame ($bytesPerFrame) -- truncating to complete frame"
182181
}
183182
}
184183

@@ -203,24 +202,23 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
203202
}
204203

205204
Logger.d(tag = TAG) {
206-
"Decode JNI: ${codec2Data.size} bytes → ${output.size} samples " +
207-
"($frameCount frames × $samplesPerFrame samples)"
205+
"Decode JNI: ${codec2Data.size} bytes -> ${output.size} samples " +
206+
"($frameCount frames x $samplesPerFrame samples)"
208207
}
209208
return output
210209
}
211210

212-
// ─── Preprocessing helpers ────────────────────────────────────────────────
211+
// --- Preprocessing helpers ----------------------------------------------
213212

214213
/**
215-
* Normalizes the signal amplitude to [TARGET_AMPLITUDE] × Short.MAX_VALUE.
214+
* Normalizes the signal amplitude to [TARGET_AMPLITUDE] x Short.MAX_VALUE.
216215
* Prevents clipping and improves Codec2 quality on low-volume voices.
217216
*/
218217
private fun normalize(pcm: ShortArray): ShortArray {
219218
val maxAmp = pcm.maxOfOrNull { abs(it.toInt()) }?.toFloat() ?: return pcm
220219
if (maxAmp < 1f) return pcm // absolute silence
221220

222221
val gain = (TARGET_AMPLITUDE * Short.MAX_VALUE) / maxAmp
223-
// Limit maximum gain to 10x to avoid excessive noise amplification
224222
val clampedGain = minOf(gain, MAX_GAIN)
225223

226224
return ShortArray(pcm.size) { i ->
@@ -238,12 +236,12 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
238236
return sqrt(sumSquares / pcm.size)
239237
}
240238

241-
// ─── Stub (fallback when JNI is not available) ─────────────────────────
239+
// --- Stub (fallback when JNI is not available) --------------------------
242240

243241
private fun encodeStub(pcmData: ShortArray): ByteArray {
244242
val frameCount = (pcmData.size + SAMPLES_PER_FRAME - 1) / SAMPLES_PER_FRAME
245243
Logger.w(tag = TAG) {
246-
"Codec2 STUB encode: ${pcmData.size} samples → ${frameCount * BYTES_PER_FRAME} bytes (zeros)"
244+
"Codec2 STUB encode: ${pcmData.size} samples -> ${frameCount * BYTES_PER_FRAME} bytes (zeros)"
247245
}
248246
return ByteArray(frameCount * BYTES_PER_FRAME) { 0x00 }
249247
}
@@ -253,10 +251,10 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
253251
val totalSamples = frameCount * SAMPLES_PER_FRAME
254252

255253
Logger.w(tag = TAG) {
256-
"Codec2 STUB decode: ${codec2Data.size} bytes → $totalSamples samples (440Hz sine wave)"
254+
"Codec2 STUB decode: ${codec2Data.size} bytes -> $totalSamples samples (440Hz sine wave)"
257255
}
258256

259-
// Generate 440Hz sine wave (A4) — audible and recognizable
257+
// Generate 440Hz sine wave (A4) -- audible and recognizable
260258
val sampleRate = 8000.0
261259
val frequency = 440.0
262260
val amplitude = Short.MAX_VALUE * 0.3 // 30% volume
@@ -277,12 +275,12 @@ class AndroidCodec2Encoder : Codec2Encoder, AutoCloseable {
277275
/** Target amplitude for normalization (70% of Short.MAX_VALUE). */
278276
private const val TARGET_AMPLITUDE = 0.70f
279277

280-
/** Maximum gain applied by normalization (10×). */
278+
/** Maximum gain applied by normalization (10x). */
281279
private const val MAX_GAIN = 10.0f
282280

283281
/**
284282
* RMS threshold below which the frame is considered silence (simple VAD).
285-
* 200.0 on the 0-32767 scale is approximately -44 dBFS — normal voice is 2000-8000.
283+
* 200.0 on the 0-32767 scale is approximately -44 dBFS -- normal voice is 2000-8000.
286284
*/
287285
private const val SILENCE_RMS_THRESHOLD = 200.0
288286
}

feature/voiceburst/src/androidMain/kotlin/org/meshtastic/feature/voiceburst/di/FeatureVoiceBurstAndroidModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import org.meshtastic.feature.voiceburst.repository.VoiceBurstRepository
4343
/**
4444
* Koin module for the Voice Burst feature module.
4545
*
46-
* Follows the same pattern as [FeatureAchievementsAndroidModule]:
46+
* Follows the standard Android feature-module pattern:
4747
* - Context and Android-only APIs remain in androidMain
4848
* - commonMain has no direct Android dependencies
4949
*/

feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstButton.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ import org.jetbrains.compose.resources.stringResource
5454
/**
5555
* PTT (Push-To-Talk) button for Voice Burst.
5656
*
57-
* Visible only if [VoiceBurstViewModel.isVisible] == true (feature flag enabled).
58-
* Disabled during encoding/sending/rate limit.
57+
* Render this composable only when Voice Burst is available; callers should not render
58+
* it for [VoiceBurstState.Unsupported].
59+
* Disabled during non-interactive processing states such as encoding and sending.
5960
*
6061
* Visual states:
6162
* Idle -> Mic icon, normal color

feature/voiceburst/src/commonMain/kotlin/org/meshtastic/feature/voiceburst/ui/VoiceBurstViewModel.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ private const val TAG = "VoiceBurstViewModel"
4444
* ViewModel handling the lifecycle and orchestration of Voice Burst messaging.
4545
*
4646
* Full pipeline:
47-
* MIC → [AudioRecorder] → PCM → [Codec2Encoder.encode] → bytes → [VoiceBurstRepository.sendBurst]
48-
* RADIO → [VoiceBurstRepository.incomingBursts] → bytes → [Codec2Encoder.decode] → PCM → [AudioPlayer]
47+
* MIC -> [AudioRecorder] -> PCM -> [Codec2Encoder.encode] -> bytes -> [VoiceBurstRepository.sendBurst]
48+
* RADIO -> [VoiceBurstRepository.incomingBursts] -> bytes -> [Codec2Encoder.decode] -> PCM -> [AudioPlayer]
4949
*
5050
* Rate limiting is enforced: minimum [RATE_LIMIT_MS] between consecutive bursts.
5151
*
@@ -103,7 +103,7 @@ class VoiceBurstViewModel(
103103
.launchIn(viewModelScope)
104104
}
105105

106-
// ─── Receiver-side logic ────────────────────────────────────────────────
106+
// --- Receiver-side logic ------------------------------------------------
107107

108108
private fun onBurstReceived(payload: VoiceBurstPayload) {
109109
Logger.i(tag = TAG) {
@@ -114,7 +114,7 @@ class VoiceBurstViewModel(
114114

115115
val pcmData = encoder.decode(payload.audioData)
116116
if (pcmData == null || pcmData.isEmpty()) {
117-
Logger.e(tag = TAG) { "Decoding failed — no PCM samples to play" }
117+
Logger.e(tag = TAG) { "Decoding failed -- no PCM samples to play" }
118118
_state.update { VoiceBurstState.Idle }
119119
return
120120
}
@@ -128,7 +128,7 @@ class VoiceBurstViewModel(
128128
}
129129
}
130130

131-
// ─── Sender-side (PTT) recording ──────────────────────────────────────
131+
// --- Sender-side (PTT) recording ----------------------------------------
132132

133133
/**
134134
* Initiates microphone recording if the state machine is [Idle].
@@ -183,7 +183,7 @@ class VoiceBurstViewModel(
183183
uiTimerJob?.cancel()
184184
uiTimerJob = null
185185
Logger.e(tag = TAG) { "Hardware recording error: ${error.message}" }
186-
_state.update { VoiceBurstState.Error(VoiceBurstError.ENCODING_FAILED) }
186+
_state.update { VoiceBurstState.Error(VoiceBurstError.RECORDING_FAILED) }
187187
},
188188
maxDurationMs = MAX_DURATION_MS,
189189
)
@@ -201,7 +201,7 @@ class VoiceBurstViewModel(
201201
audioRecorder.stopRecording()
202202
}
203203

204-
// ─── Encoding and Dispatch ──────────────────────────────────────────────
204+
// --- Encoding and Dispatch ----------------------------------------------
205205

206206
internal fun onRecordingComplete(pcmData: ShortArray, durationMs: Int) {
207207
_state.update { VoiceBurstState.Encoding }
@@ -215,9 +215,9 @@ class VoiceBurstViewModel(
215215
}
216216

217217
if (encoder.isStub) {
218-
Logger.w(tag = TAG) { "Running with Codec2 stub — transmission will not be intelligible" }
218+
Logger.w(tag = TAG) { "Running with Codec2 stub -- transmission will not be intelligible" }
219219
} else {
220-
Logger.i(tag = TAG) { "Enc JNI Success: ${pcmData.size} samples → ${audioBytes.size} bytes" }
220+
Logger.i(tag = TAG) { "Enc JNI Success: ${pcmData.size} samples -> ${audioBytes.size} bytes" }
221221
}
222222

223223
val payload = VoiceBurstPayload(

0 commit comments

Comments
 (0)