Skip to content

Commit b462e15

Browse files
torlando-techclaude
andcommitted
feat(08-03): add voice message recording UI, preview with playback, and bubble rendering
- Fix mic button gesture: replace IconButton with Box so pointerInput receives press/release events (IconButton's internal clickable was swallowing them) - Fix recomposition killing gesture: keep mic button in composition during recording so tryAwaitRelease() completes - Add recording indicator: pulsing red dot + duration timer replaces text field during active recording - Add preview bar after recording: play/pause, waveform with progress, discard (trash), and send buttons - Add VoiceMessagePlayer: decodes length-prefixed Opus frames via LXST-kt Opus decoder, plays through AudioTrack MODE_STATIC - Add VoiceMessageBubble: play/pause, waveform, duration in message bubbles for sent/received voice messages - Extract shared WaveformBar composable for preview and bubble reuse - Hide text content for voice-only messages (content is " " for Sideband compatibility) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2c3003 commit b462e15

2 files changed

Lines changed: 649 additions & 95 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package com.lxmf.messenger.audio
2+
3+
import android.media.AudioAttributes
4+
import android.media.AudioFormat
5+
import android.media.AudioTrack
6+
import android.util.Log
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.flow.asStateFlow
11+
import kotlinx.coroutines.isActive
12+
import kotlinx.coroutines.withContext
13+
import tech.torlando.lxst.codec.Opus
14+
import java.nio.ByteBuffer
15+
16+
/**
17+
* Plays back voice messages encoded as 2-byte big-endian length-prefixed Opus frames.
18+
*
19+
* This is the inverse of [VoiceMessageRecorder]'s encoding format:
20+
* each frame is `[2-byte length][opus bytes]`, decoded with [Opus.decode], and
21+
* written to [AudioTrack] for speaker output.
22+
*
23+
* Thread-safe: [play] and [stop] dispatch blocking AudioTrack calls to [Dispatchers.IO].
24+
*/
25+
class VoiceMessagePlayer {
26+
companion object {
27+
private const val TAG = "Columba:VoicePlayer"
28+
}
29+
30+
private val _state = MutableStateFlow(PlaybackUiState())
31+
val state: StateFlow<PlaybackUiState> = _state.asStateFlow()
32+
33+
@Volatile
34+
private var audioTrack: AudioTrack? = null
35+
36+
@Volatile
37+
private var isPlaying = false
38+
39+
/**
40+
* Play the given Opus-encoded audio bytes (length-prefixed frame format).
41+
* If already playing, stops the current playback first.
42+
*
43+
* This is a suspend function that blocks until playback completes or [stop] is called.
44+
*/
45+
suspend fun play(
46+
audioBytes: ByteArray,
47+
durationMs: Long,
48+
) {
49+
stop()
50+
51+
withContext(Dispatchers.IO) {
52+
val opus = Opus(Opus.PROFILE_VOICE_MEDIUM)
53+
try {
54+
// Decode all frames to PCM
55+
val pcmSamples = decodeFrames(audioBytes, opus)
56+
if (pcmSamples.isEmpty()) {
57+
Log.w(TAG, "No frames decoded")
58+
return@withContext
59+
}
60+
61+
// Convert float32 to int16 for AudioTrack
62+
val shortSamples =
63+
ShortArray(pcmSamples.size) { i ->
64+
(pcmSamples[i] * 32767f).toInt().coerceIn(-32768, 32767).toShort()
65+
}
66+
67+
val bufSize =
68+
AudioTrack.getMinBufferSize(
69+
VoiceMessageRecorder.SAMPLE_RATE,
70+
AudioFormat.CHANNEL_OUT_MONO,
71+
AudioFormat.ENCODING_PCM_16BIT,
72+
)
73+
74+
val track =
75+
AudioTrack
76+
.Builder()
77+
.setAudioAttributes(
78+
AudioAttributes
79+
.Builder()
80+
.setUsage(AudioAttributes.USAGE_MEDIA)
81+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
82+
.build(),
83+
).setAudioFormat(
84+
AudioFormat
85+
.Builder()
86+
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
87+
.setSampleRate(VoiceMessageRecorder.SAMPLE_RATE)
88+
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
89+
.build(),
90+
).setBufferSizeInBytes(maxOf(bufSize, shortSamples.size * 2))
91+
.setTransferMode(AudioTrack.MODE_STATIC)
92+
.build()
93+
94+
track.write(shortSamples, 0, shortSamples.size)
95+
audioTrack = track
96+
isPlaying = true
97+
_state.value = PlaybackUiState(isPlaying = true, progressFraction = 0f)
98+
99+
track.play()
100+
101+
// Update progress while playing
102+
val totalFrames = shortSamples.size
103+
while (isActive && isPlaying) {
104+
val head = track.playbackHeadPosition
105+
if (head >= totalFrames) break
106+
val fraction = head.toFloat() / totalFrames
107+
_state.value = _state.value.copy(progressFraction = fraction)
108+
kotlinx.coroutines.delay(50)
109+
}
110+
111+
// Playback complete
112+
isPlaying = false
113+
track.stop()
114+
track.release()
115+
audioTrack = null
116+
_state.value = PlaybackUiState()
117+
} catch (e: Exception) {
118+
Log.e(TAG, "Playback failed", e)
119+
isPlaying = false
120+
_state.value = PlaybackUiState(error = e.message)
121+
} finally {
122+
opus.release()
123+
}
124+
}
125+
}
126+
127+
/**
128+
* Stop playback immediately.
129+
*/
130+
fun stop() {
131+
isPlaying = false
132+
audioTrack?.let { track ->
133+
try {
134+
track.pause()
135+
track.flush()
136+
track.release()
137+
} catch (e: Exception) {
138+
Log.w(TAG, "Error stopping playback", e)
139+
}
140+
}
141+
audioTrack = null
142+
_state.value = PlaybackUiState()
143+
}
144+
145+
/**
146+
* Decode length-prefixed Opus frames to a single float32 PCM array.
147+
*/
148+
private fun decodeFrames(
149+
audioBytes: ByteArray,
150+
opus: Opus,
151+
): FloatArray {
152+
val buf = ByteBuffer.wrap(audioBytes)
153+
val allSamples = mutableListOf<FloatArray>()
154+
155+
while (buf.remaining() >= 2) {
156+
val frameLen = ((buf.get().toInt() and 0xFF) shl 8) or (buf.get().toInt() and 0xFF)
157+
if (frameLen <= 0 || frameLen > buf.remaining()) break
158+
159+
val frameBytes = ByteArray(frameLen)
160+
buf.get(frameBytes)
161+
try {
162+
allSamples.add(opus.decode(frameBytes))
163+
} catch (e: Exception) {
164+
Log.w(TAG, "Failed to decode frame (${frameBytes.size} bytes)", e)
165+
}
166+
}
167+
168+
// Concatenate all decoded frames
169+
val totalSize = allSamples.sumOf { it.size }
170+
val result = FloatArray(totalSize)
171+
var offset = 0
172+
for (samples in allSamples) {
173+
samples.copyInto(result, offset)
174+
offset += samples.size
175+
}
176+
return result
177+
}
178+
}
179+
180+
/**
181+
* Observable playback state for the UI layer.
182+
*/
183+
data class PlaybackUiState(
184+
val isPlaying: Boolean = false,
185+
val progressFraction: Float = 0f,
186+
val error: String? = null,
187+
)

0 commit comments

Comments
 (0)