Browse Source

voice: real pause (saves position), waveform tap seek, resume from position

- AudioRecorderManager: playFromPosition(startMs), pausePlayback, elapsedMs tracking
  playVoiceFile now delegates to playFromPosition. fade skipped PCM frames on seek.
- ViewModel: playVoiceMessage with pause toggle, seekVoice method
- VoiceBubble: click tap on waveform → seek, pause state (⏸ keeps cursor visible)
  Webform canvas with detectTapGestures for seek input
topo_upd
evgeny 2 months ago
parent
commit
6980f34df9
  1. 56
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  2. 70
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  3. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  4. 22
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

56
tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt

@ -181,11 +181,19 @@ class AudioRecorderManager {
} }
@Volatile private var playing = false @Volatile private var playing = false
@Volatile var isPaused = false; private set
@Volatile var elapsedMs: Int = 0; private set
var pausedElapsedMs: Int = 0; private set
private var playThread: Thread? = null private var playThread: Thread? = null
private var currentPlayPath: String? = null
fun playVoiceFile(filePath: String, onProgress: (Float) -> Unit = {}, onComplete: () -> Unit = {}) { fun playVoiceFile(filePath: String, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) {
if (playing) stopPlayback() playFromPosition(filePath, 0, onProgress, onComplete)
playing = true }
fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) {
stopPlayback()
isPaused = false; elapsedMs = startMs; playing = true; currentPlayPath = filePath
playThread = Thread { playThread = Thread {
try { try {
@ -193,10 +201,21 @@ class AudioRecorderManager {
val handle = NativeLib.voiceDecodeOpen(filePath, info) val handle = NativeLib.voiceDecodeOpen(filePath, info)
if (handle == 0L) { if (handle == 0L) {
LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath")
playing = false; return@Thread playing = false; onComplete(); return@Thread
} }
val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2] val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2]
/* skip to startMs by decoding and discarding frames */
val frameSamples = FRAME_SAMPLES * channels
val skipFrames = startMs / FRAME_MS
val skipBuf = ShortArray(frameSamples)
var skippedSamples = 0L
for (f in 0 until skipFrames) {
val dec = NativeLib.voiceDecodeRead(handle, skipBuf)
if (dec <= 0) break
skippedSamples += dec
}
val bufSize = AudioTrack.getMinBufferSize( val bufSize = AudioTrack.getMinBufferSize(
sampleRate, sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
@ -204,16 +223,13 @@ class AudioRecorderManager {
) )
val track = AudioTrack( val track = AudioTrack(
AudioManager.STREAM_MUSIC, AudioManager.STREAM_MUSIC, sampleRate,
sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM
bufSize,
AudioTrack.MODE_STREAM
) )
track.play() track.play()
val pcm = ShortArray(FRAME_SAMPLES * channels) val pcm = ShortArray(frameSamples)
var readTotal = 0L var readTotal = 0L
var read: Int var read: Int
while (playing) { while (playing) {
@ -221,22 +237,28 @@ class AudioRecorderManager {
if (read <= 0) break if (read <= 0) break
track.write(pcm, 0, read) track.write(pcm, 0, read)
readTotal += read readTotal += read
if (totalSamples > 0) onProgress(readTotal.toFloat() / totalSamples.toFloat()) val total = (skippedSamples + readTotal).toFloat() / (totalSamples * channels).coerceAtLeast(1).toFloat()
elapsedMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt()
onProgress?.invoke(total)
} }
track.stop() track.stop(); track.release()
track.release()
NativeLib.voiceDecodeClose(handle) NativeLib.voiceDecodeClose(handle)
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
} }
playing = false playing = false; isPaused = false; currentPlayPath = null
onComplete() onComplete()
}.apply { start() } }.apply { start() }
} }
fun pausePlayback() {
pausedElapsedMs = elapsedMs
isPaused = true; playing = false
playThread?.join(500); playThread = null
}
fun stopPlayback() { fun stopPlayback() {
playing = false playing = false; isPaused = false; pausedElapsedMs = 0
playThread?.join(500) playThread?.join(500); playThread = null
playThread = null
} }
} }

70
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -20,6 +20,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -31,7 +32,8 @@ import com.utun.chat.data.NativeLib
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = { _, _ -> }) { fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit) -> Unit = { _, _, _ -> },
onSeekVoice: (String, Int, () -> Unit) -> Unit = { _, _, _ -> }) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@ -56,7 +58,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit =
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
when { when {
isVoice -> VoiceBubble(message, textColor, onPlayVoice) isVoice -> VoiceBubble(message, textColor, onPlayVoice, onSeekVoice)
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor) isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
else -> { else -> {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
@ -78,23 +80,26 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit =
} }
} }
@Composable @Composable
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit) -> Unit) { private fun VoiceBubble(message: Message, textColor: Color,
onPlay: (String, () -> Unit, () -> Unit) -> Unit,
onSeek: (String, Int, () -> Unit) -> Unit) {
val wf = message.waveform // List<Float> 0..1, 100 elements val wf = message.waveform // List<Float> 0..1, 100 elements
val wfCount = wf.size val wfCount = wf.size
val durMs = message.voiceDurationMs val durMs = message.voiceDurationMs
var playing by remember { mutableStateOf(false) } var playing by remember { mutableStateOf(false) }
var paused by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0) } var elapsedMs by remember { mutableStateOf(0) }
var progress by remember { mutableStateOf(0f) } var progress by remember { mutableStateOf(0f) }
/* auto-reset on complete */ val onDone = remember { { playing = false; paused = false; elapsedMs = 0; progress = 0f } }
val onDone = remember { { playing = false; elapsedMs = 0; progress = 0f } } val onPaused = remember { { paused = true; playing = false } }
/* elapsed timer while playing */ /* elapsed timer while playing */
LaunchedEffect(playing) { LaunchedEffect(playing) {
if (playing) { if (playing && !paused) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis() - elapsedMs
while (playing) { while (playing && !paused) {
elapsedMs = (System.currentTimeMillis() - start).toInt() elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs)
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f)
delay(80) delay(80)
} }
@ -108,47 +113,52 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
modifier = Modifier.size(32.dp).background(Color.White, CircleShape) modifier = Modifier.size(32.dp).background(Color.White, CircleShape)
.clickable { .clickable {
if (message.filePath.isNotEmpty()) { if (message.filePath.isNotEmpty()) {
playing = true; elapsedMs = 0; progress = 0f if (playing && !paused) {
onPlay(message.filePath, onDone) onPlay(message.filePath, onDone, onPaused)
} else {
playing = true; paused = false
onPlay(message.filePath, onDone, onPaused)
}
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(if (playing) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) Text(if (playing && !paused) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
/* Waveform */ /* Waveform with seek on tap */
if (wfCount >= 2) { if (wfCount >= 2) {
Canvas(modifier = Modifier.weight(1f).height(28.dp)) { Canvas(modifier = Modifier.weight(1f).height(28.dp)
.pointerInput(message.filePath) {
detectTapGestures { offset ->
if (message.filePath.isEmpty()) return@detectTapGestures
val seekFrac = (offset.x / size.width).coerceIn(0f, 1f)
val seekMs = (seekFrac * durMs).toInt()
playing = true; paused = false; elapsedMs = seekMs; progress = seekFrac
onSeek(message.filePath, seekMs, onDone)
}
}
) {
val barW = size.width / wfCount val barW = size.width / wfCount
val ph = size.height val ph = size.height
val playedIdx = if (playing && progress > 0f) (progress * wfCount).toInt() else 0 val showCursor = playing && !paused
val playedIdx = if (showCursor && progress > 0f) (progress * wfCount).toInt() else 0
for (i in 0 until wfCount) { for (i in 0 until wfCount) {
val level = wf[i].coerceIn(0f, 1f) val level = wf[i].coerceIn(0f, 1f)
val barH = (2f + level * (ph - 2f)).coerceAtMost(ph) val barH = (2f + level * (ph - 2f)).coerceAtMost(ph)
val x = i.toFloat() * barW val x = i.toFloat() * barW
val y = ph - barH val y = ph - barH
val played = playing && i < playedIdx val played = showCursor && i < playedIdx
drawRect( drawRect(
color = when { color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) },
played -> Color.White
playing -> Color(0xFFCCCCD4).copy(alpha = 0.8f)
else -> Color(0xFFCCCCD4).copy(alpha = 0.8f)
},
topLeft = Offset(x, y), topLeft = Offset(x, y),
size = Size(barW * 0.75f, barH) size = Size(barW * 0.75f, barH)
) )
} }
/* yellow cursor line */ if (showCursor && progress > 0f && progress < 1f) {
if (playing && progress > 0f && progress < 1f) {
val cursorX = progress * size.width val cursorX = progress * size.width
drawLine( drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f)
color = Color(0xFFFFD600),
start = Offset(cursorX, 0f),
end = Offset(cursorX, ph),
strokeWidth = 2.5f
)
} }
} }
} }
@ -163,7 +173,7 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
Text( Text(
if (playing) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" if (playing && !paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
else formatDurationMs(durMs), else formatDurationMs(durMs),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor.copy(alpha = 0.55f) color = textColor.copy(alpha = 0.55f)

10
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -63,9 +63,13 @@ fun ChatScreen(
state = listState state = listState
) { ) {
items(messages.size) { idx -> items(messages.size) { idx ->
MessageBubble(messages[idx], onPlayVoice = { path, onDone -> MessageBubble(messages[idx],
viewModel.playVoiceMessage(path, onDone) onPlayVoice = { path, onDone, onPaused ->
}) viewModel.playVoiceMessage(path, onDone, onPaused)
},
onSeekVoice = { path, seekMs, onDone ->
viewModel.seekVoice(path, seekMs, onDone)
})
} }
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
} }

22
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -266,20 +266,26 @@ class ChatViewModel : ViewModel() {
private var _currentPlayingPath: String? = null private var _currentPlayingPath: String? = null
fun playVoiceMessage(filePath: String, onComplete: () -> Unit) { fun playVoiceMessage(filePath: String, onDone: () -> Unit, onPaused: () -> Unit) {
if (_currentPlayingPath == filePath) { if (_currentPlayingPath == filePath) {
audioRecorder.stopPlayback() if (audioRecorder.isPaused) {
_currentPlayingPath = null audioRecorder.playFromPosition(filePath, audioRecorder.pausedElapsedMs, onComplete = onDone)
onComplete() } else {
audioRecorder.pausePlayback()
onPaused()
}
return return
} }
audioRecorder.stopPlayback() audioRecorder.stopPlayback()
_currentPlayingPath = filePath _currentPlayingPath = filePath
LogManager.addLog("INFO", "VM", "play voice: $filePath") LogManager.addLog("INFO", "VM", "play voice: $filePath")
audioRecorder.playVoiceFile(filePath, onComplete = { audioRecorder.playVoiceFile(filePath, onComplete = { _currentPlayingPath = null; onDone() })
_currentPlayingPath = null }
onComplete()
}) fun seekVoice(filePath: String, seekMs: Int, onDone: () -> Unit) {
audioRecorder.stopPlayback()
_currentPlayingPath = filePath
audioRecorder.playFromPosition(filePath, seekMs, onComplete = onDone)
} }
fun clearState() { fun clearState() {

Loading…
Cancel
Save