Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fc3a08b
Add interoperable LXMF voice messages
torlando-tech Aug 5, 2026
ab4ccad
Harden voice attachment loading
torlando-tech Aug 5, 2026
a46b8ae
Harden voice message retries and payload handling
torlando-tech Aug 5, 2026
d6d31c9
Redesign voice message recording flow
torlando-tech Aug 5, 2026
3476a51
Fix voice composer lifecycle and accessibility
torlando-tech Aug 5, 2026
4b06d86
Preserve message drafts on persistence failure
torlando-tech Aug 5, 2026
eb98e52
Harden voice draft lifecycle
torlando-tech Aug 5, 2026
7979322
Close voice composer lifecycle races
torlando-tech Aug 5, 2026
1fad55b
Improve voice playback presentation
torlando-tech Aug 6, 2026
b5acf80
Derive voice waveforms from decoded audio
torlando-tech Aug 6, 2026
5135d50
Compact voice message bubbles
torlando-tech Aug 6, 2026
4992a17
Pin LXST voice recorder revision
torlando-tech Aug 6, 2026
3b810dc
Advance LXST recorder revision
torlando-tech Aug 6, 2026
f38850d
Pin serialized LXST recorder publication
torlando-tech Aug 6, 2026
b5cd448
Advance LXST recorder test revision
torlando-tech Aug 6, 2026
4918d87
Satisfy attachment persistence quality checks
torlando-tech Aug 6, 2026
e10360e
Close voice message review findings
torlando-tech Aug 6, 2026
73464ad
Serialize microphone and metadata admission
torlando-tech Aug 6, 2026
1f88bac
Tokenize microphone admission ownership
torlando-tech Aug 6, 2026
10fc50a
Use LXST recorder release
torlando-tech Aug 6, 2026
fb179c1
Merge remote-tracking branch 'github/main' into feature/voice-messages
torlando-tech Aug 6, 2026
3f0c9c2
Close voice message review findings
torlando-tech Aug 6, 2026
b90e253
test: prevent announce scheduler hangs
torlando-tech Aug 6, 2026
b470f61
fix: always release voice recording lease
torlando-tech Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/src/debug/java/network/columba/app/test/TestController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import network.columba.app.rns.api.RnsTelemetry
import network.columba.app.rns.api.util.LxmfFields
import network.columba.app.repository.InterfaceRepository
import network.columba.app.service.InterfaceConfigManager
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest

/**
* Debug-only test surface for the columba phone harness.
Expand Down Expand Up @@ -112,6 +115,7 @@ object TestController {
"rx_msg source=stream from=${msg.sourceHash.toHex()} " +
"id=${msg.messageHash} content=${escape(msg.content)}",
)
logAudioField(msg)
}
}
deliveryJob = scope.launch {
Expand Down Expand Up @@ -161,6 +165,34 @@ object TestController {
Log.i(LOGCAT_TAG, "controller_ready")
}

private fun logAudioField(message: ReceivedMessage) {
val fields = message.fieldsJson?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return
val audio = fields.optJSONArray(LxmfFields.FIELD_AUDIO.toString()) ?: return
if (audio.length() != 2) return
val mode = audio.optInt(0, -1)
val payload = decodeInlineHex(audio.opt(1)) ?: return
val sha256 = MessageDigest.getInstance("SHA-256").digest(payload).toHex()
Log.i(
LOGCAT_TAG,
"rx_audio id=${message.messageHash} mode=$mode bytes=${payload.size} sha256=$sha256",
)
}

private fun decodeInlineHex(value: Any?): ByteArray? {
val hex =
when (value) {
is String -> value
is JSONArray -> value.optString(1, "")
else -> return null
}
if (hex.length % 2 != 0 || !hex.matches(Regex("[0-9a-fA-F]*"))) return null
return runCatching {
ByteArray(hex.length / 2) { index ->
hex.substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}.getOrNull()
}

fun handleGetDest(context: Context) {
ensureInit(context)
scope.launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package network.columba.app.audio

import android.content.Context
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.pow
import kotlin.math.sqrt

internal fun interface AudioWaveformReader {
suspend fun read(bytes: ByteArray, durationMs: Int): List<Float>?
}

internal class AndroidPcmWaveformReader(
context: Context,
) : AudioWaveformReader {
private val cacheDir = context.applicationContext.cacheDir

override suspend fun read(bytes: ByteArray, durationMs: Int): List<Float>? {
if (durationMs !in 1..MAX_DURATION_MS) return null
val file = File.createTempFile("voice_waveform_", ".ogg", cacheDir)
return try {
file.writeBytes(bytes)
decode(file, durationMs)
} finally {
file.delete()
}
}

// MediaCodec's input/output state machine is intentionally fail-closed at each buffer boundary.
@Suppress("NestedBlockDepth", "ReturnCount")
private suspend fun decode(file: File, durationMs: Int): List<Float>? {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val trackIndex =
(0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
} ?: return null
extractor.selectTrack(trackIndex)
val inputFormat = extractor.getTrackFormat(trackIndex)
val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return null
decoder = MediaCodec.createDecoderByType(mime)
decoder.configure(inputFormat, null, null, 0)
decoder.start()

val accumulator = PcmWaveformAccumulator(durationMs = durationMs, barCount = WAVEFORM_BARS)
val bufferInfo = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idleIterations = 0
var outputFormat = inputFormat

while (!outputEnded && idleIterations < MAX_IDLE_ITERATIONS) {
currentCoroutineContext().ensureActive()
var madeProgress = false
if (!inputEnded) {
val inputIndex = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIndex >= 0) {
val inputBuffer = decoder.getInputBuffer(inputIndex) ?: return null
val sampleSize = extractor.readSampleData(inputBuffer, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputEnded = true
} else {
decoder.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
extractor.advance()
}
madeProgress = true
}
}

when (val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, CODEC_TIMEOUT_US)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
outputFormat = decoder.outputFormat
madeProgress = true
}
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
else -> if (outputIndex >= 0) {
if (bufferInfo.size > 0) {
val outputBuffer = decoder.getOutputBuffer(outputIndex) ?: return null
val view = outputBuffer.duplicate().order(ByteOrder.nativeOrder())
view.position(bufferInfo.offset)
view.limit(bufferInfo.offset + bufferInfo.size)
accumulator.add(
buffer = view.slice().order(ByteOrder.nativeOrder()),
presentationTimeUs = bufferInfo.presentationTimeUs,
sampleRate = outputFormat.intValue(MediaFormat.KEY_SAMPLE_RATE, DEFAULT_SAMPLE_RATE),
channelCount = outputFormat.intValue(MediaFormat.KEY_CHANNEL_COUNT, 1),
encoding = outputFormat.intValue(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT),
)
}
outputEnded = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
decoder.releaseOutputBuffer(outputIndex, false)
madeProgress = true
}
}
idleIterations = if (madeProgress) 0 else idleIterations + 1
}
return if (outputEnded) accumulator.levels() else null
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}

private fun MediaFormat.intValue(key: String, fallback: Int): Int =
if (containsKey(key)) getInteger(key) else fallback

private companion object {
const val WAVEFORM_BARS = 32
const val DEFAULT_SAMPLE_RATE = 48_000
const val MAX_DURATION_MS = 30 * 60 * 1_000
const val CODEC_TIMEOUT_US = 10_000L
const val MAX_IDLE_ITERATIONS = 500
}
}

internal class PcmWaveformAccumulator(
private val durationMs: Int,
private val barCount: Int,
) {
private val energy = DoubleArray(barCount)
private val sampleCounts = LongArray(barCount)

@Suppress("ReturnCount")
fun add(
buffer: ByteBuffer,
presentationTimeUs: Long,
sampleRate: Int,
channelCount: Int,
encoding: Int,
) {
if (sampleRate <= 0 || channelCount <= 0) return
val bytesPerSample = encoding.bytesPerSample() ?: return
val frameCount = buffer.remaining() / (bytesPerSample * channelCount)
if (frameCount <= 0) return
val sampleStride = (frameCount / MAX_SAMPLES_PER_BUFFER).coerceAtLeast(1)
var frame = 0
while (frame < frameCount) {
var frameEnergy = 0.0
for (channel in 0 until channelCount) {
val sampleOffset = (frame * channelCount + channel) * bytesPerSample
val sample = buffer.normalizedSample(sampleOffset, encoding) ?: return
frameEnergy += sample * sample
}
val timeUs = presentationTimeUs + frame.toLong() * 1_000_000L / sampleRate
val bucket = ((timeUs * barCount) / (durationMs * 1_000L)).toInt().coerceIn(0, barCount - 1)
energy[bucket] += frameEnergy / channelCount
sampleCounts[bucket] += 1
frame += sampleStride
}
}

fun levels(): List<Float>? {
val rms =
energy.indices.map { index ->
if (sampleCounts[index] == 0L) 0.0 else sqrt(energy[index] / sampleCounts[index])
}
val peak = rms.maxOrNull()?.takeIf { it > 0.0 } ?: return null
return rms.map { value ->
if (value <= 0.0) {
MIN_LEVEL
} else {
(MIN_LEVEL + (1f - MIN_LEVEL) * (value / peak).pow(0.7).toFloat()).coerceIn(MIN_LEVEL, 1f)
}
}
}

private fun Int.bytesPerSample(): Int? =
when (this) {
AudioFormat.ENCODING_PCM_8BIT -> 1
AudioFormat.ENCODING_PCM_16BIT -> 2
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 3
AudioFormat.ENCODING_PCM_32BIT, AudioFormat.ENCODING_PCM_FLOAT -> 4
else -> null
}

private fun ByteBuffer.normalizedSample(offset: Int, encoding: Int): Double? =
when (encoding) {
AudioFormat.ENCODING_PCM_8BIT -> ((get(offset).toInt() and 0xff) - 128) / 128.0
AudioFormat.ENCODING_PCM_16BIT -> getShort(offset) / 32_768.0
AudioFormat.ENCODING_PCM_24BIT_PACKED -> {
val raw =
(get(offset).toInt() and 0xff) or
((get(offset + 1).toInt() and 0xff) shl 8) or
(get(offset + 2).toInt() shl 16)
raw / 8_388_608.0
}
AudioFormat.ENCODING_PCM_32BIT -> getInt(offset) / 2_147_483_648.0
AudioFormat.ENCODING_PCM_FLOAT -> getFloat(offset).toDouble().coerceIn(-1.0, 1.0)
else -> null
}

private companion object {
const val MAX_SAMPLES_PER_BUFFER = 4_096
const val MIN_LEVEL = 0.12f
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package network.columba.app.audio

import network.columba.app.di.ApplicationScope
import network.columba.app.rns.api.RnsTelephony
import network.columba.app.rns.api.model.CallState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class CallMicrophoneAdmissionCoordinator
@Inject
constructor(
private val microphoneArbiter: MicrophoneAdmissionArbiter,
private val telephony: RnsTelephony,
@ApplicationScope applicationScope: CoroutineScope,
) {
private val lock = Any()
private var callLease: MicrophoneAdmissionArbiter.Lease? = null
private var outgoingAdmissionPending = false

init {
applicationScope.launch {
telephony.callState.collect(::reconcile)
}
}

fun tryAcquireForOutgoing(): Boolean =
synchronized(lock) {
if (callLease != null) return@synchronized false
val lease = microphoneArbiter.tryAcquire(MicrophoneAdmissionArbiter.Owner.CALL)
?: return@synchronized false
callLease = lease
outgoingAdmissionPending = true
true
}

fun markOutgoingStarted() {
synchronized(lock) {
outgoingAdmissionPending = false
}
}

fun releaseFailedOutgoing() {
synchronized(lock) {
outgoingAdmissionPending = false
if (!callUsesMicrophone(telephony.callState.value)) releaseLocked()
}
}

fun ensureCallAdmission(): Boolean =
synchronized(lock) {
callLease != null ||
microphoneArbiter.adoptOrAcquire(MicrophoneAdmissionArbiter.Owner.CALL)
?.also { callLease = it } != null
}

private fun reconcile(state: CallState) {
synchronized(lock) {
if (callUsesMicrophone(state)) {
if (callLease == null) {
callLease = microphoneArbiter.adoptOrAcquire(MicrophoneAdmissionArbiter.Owner.CALL)
}
} else if (!outgoingAdmissionPending && !callUsesMicrophone(telephony.callState.value)) {
releaseLocked()
}
}
}

private fun releaseLocked() {
callLease?.let(microphoneArbiter::release)
callLease = null
}

private fun callUsesMicrophone(state: CallState): Boolean =
state is CallState.Connecting ||
state is CallState.Ringing ||
state is CallState.Incoming ||
state is CallState.Active
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package network.columba.app.audio

import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MicrophoneAdmissionArbiter
@Inject
constructor() {
enum class Owner {
CALL,
VOICE_RECORDING,
}

class Lease internal constructor(
val owner: Owner,
)

private var activeLease: Lease? = null

@Synchronized
fun tryAcquire(owner: Owner): Lease? {
if (activeLease != null) return null
return Lease(owner).also { activeLease = it }
}

@Synchronized
fun adoptOrAcquire(owner: Owner): Lease? {
val current = activeLease
if (current != null) return current.takeIf { it.owner == owner }
return Lease(owner).also { activeLease = it }
}

@Synchronized
fun release(lease: Lease) {
if (activeLease === lease) activeLease = null
}

@Synchronized
fun isActive(lease: Lease): Boolean = activeLease === lease

@Synchronized
fun currentOwner(): Owner? = activeLease?.owner
}
Loading
Loading