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 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 currentPlayPath: String? = null
fun playVoiceFile(filePath: String, onProgress: (Float) -> Unit = {}, onComplete: () -> Unit = {}) {
if (playing) stopPlayback()
playing = true
fun playVoiceFile(filePath: String, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) {
playFromPosition(filePath, 0, onProgress, onComplete)
}
fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) {
stopPlayback()
isPaused = false; elapsedMs = startMs; playing = true; currentPlayPath = filePath
playThread = Thread {
try {
@ -193,10 +201,21 @@ class AudioRecorderManager {
val handle = NativeLib.voiceDecodeOpen(filePath, info)
if (handle == 0L) {
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]
/* 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(
sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
@ -204,16 +223,13 @@ class AudioRecorderManager {
)
val track = AudioTrack(
AudioManager.STREAM_MUSIC,
sampleRate,
AudioManager.STREAM_MUSIC, sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufSize,
AudioTrack.MODE_STREAM
AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM
)
track.play()
val pcm = ShortArray(FRAME_SAMPLES * channels)
val pcm = ShortArray(frameSamples)
var readTotal = 0L
var read: Int
while (playing) {
@ -221,22 +237,28 @@ class AudioRecorderManager {
if (read <= 0) break
track.write(pcm, 0, 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.release()
track.stop(); track.release()
NativeLib.voiceDecodeClose(handle)
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
}
playing = false
playing = false; isPaused = false; currentPlayPath = null
onComplete()
}.apply { start() }
}
fun pausePlayback() {
pausedElapsedMs = elapsedMs
isPaused = true; playing = false
playThread?.join(500); playThread = null
}
fun stopPlayback() {
playing = false
playThread?.join(500)
playThread = null
playing = false; isPaused = false; pausedElapsedMs = 0
playThread?.join(500); 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.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -31,7 +32,8 @@ import com.utun.chat.data.NativeLib
import kotlinx.coroutines.delay
@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 textColor = if (message.isOutgoing) Color.White else Color.Black
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))
when {
isVoice -> VoiceBubble(message, textColor, onPlayVoice)
isVoice -> VoiceBubble(message, textColor, onPlayVoice, onSeekVoice)
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
else -> {
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
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 wfCount = wf.size
val durMs = message.voiceDurationMs
var playing by remember { mutableStateOf(false) }
var paused by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0) }
var progress by remember { mutableStateOf(0f) }
/* auto-reset on complete */
val onDone = remember { { playing = false; elapsedMs = 0; progress = 0f } }
val onDone = remember { { playing = false; paused = false; elapsedMs = 0; progress = 0f } }
val onPaused = remember { { paused = true; playing = false } }
/* elapsed timer while playing */
LaunchedEffect(playing) {
if (playing) {
val start = System.currentTimeMillis()
while (playing) {
elapsedMs = (System.currentTimeMillis() - start).toInt()
if (playing && !paused) {
val start = System.currentTimeMillis() - elapsedMs
while (playing && !paused) {
elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs)
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f)
delay(80)
}
@ -108,47 +113,52 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
modifier = Modifier.size(32.dp).background(Color.White, CircleShape)
.clickable {
if (message.filePath.isNotEmpty()) {
playing = true; elapsedMs = 0; progress = 0f
onPlay(message.filePath, onDone)
if (playing && !paused) {
onPlay(message.filePath, onDone, onPaused)
} else {
playing = true; paused = false
onPlay(message.filePath, onDone, onPaused)
}
}
},
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))
/* Waveform */
/* Waveform with seek on tap */
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 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) {
val level = wf[i].coerceIn(0f, 1f)
val barH = (2f + level * (ph - 2f)).coerceAtMost(ph)
val x = i.toFloat() * barW
val y = ph - barH
val played = playing && i < playedIdx
val played = showCursor && i < playedIdx
drawRect(
color = when {
played -> Color.White
playing -> Color(0xFFCCCCD4).copy(alpha = 0.8f)
else -> Color(0xFFCCCCD4).copy(alpha = 0.8f)
},
color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) },
topLeft = Offset(x, y),
size = Size(barW * 0.75f, barH)
)
}
/* yellow cursor line */
if (playing && progress > 0f && progress < 1f) {
if (showCursor && progress > 0f && progress < 1f) {
val cursorX = progress * size.width
drawLine(
color = Color(0xFFFFD600),
start = Offset(cursorX, 0f),
end = Offset(cursorX, ph),
strokeWidth = 2.5f
)
drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f)
}
}
}
@ -163,7 +173,7 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
verticalAlignment = Alignment.Bottom
) {
Text(
if (playing) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
if (playing && !paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
else formatDurationMs(durMs),
fontSize = 12.sp,
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
) {
items(messages.size) { idx ->
MessageBubble(messages[idx], onPlayVoice = { path, onDone ->
viewModel.playVoiceMessage(path, onDone)
})
MessageBubble(messages[idx],
onPlayVoice = { path, onDone, onPaused ->
viewModel.playVoiceMessage(path, onDone, onPaused)
},
onSeekVoice = { path, seekMs, onDone ->
viewModel.seekVoice(path, seekMs, onDone)
})
}
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
fun playVoiceMessage(filePath: String, onComplete: () -> Unit) {
fun playVoiceMessage(filePath: String, onDone: () -> Unit, onPaused: () -> Unit) {
if (_currentPlayingPath == filePath) {
audioRecorder.stopPlayback()
_currentPlayingPath = null
onComplete()
if (audioRecorder.isPaused) {
audioRecorder.playFromPosition(filePath, audioRecorder.pausedElapsedMs, onComplete = onDone)
} else {
audioRecorder.pausePlayback()
onPaused()
}
return
}
audioRecorder.stopPlayback()
_currentPlayingPath = filePath
LogManager.addLog("INFO", "VM", "play voice: $filePath")
audioRecorder.playVoiceFile(filePath, onComplete = {
_currentPlayingPath = null
onComplete()
})
audioRecorder.playVoiceFile(filePath, onComplete = { _currentPlayingPath = null; onDone() })
}
fun seekVoice(filePath: String, seekMs: Int, onDone: () -> Unit) {
audioRecorder.stopPlayback()
_currentPlayingPath = filePath
audioRecorder.playFromPosition(filePath, seekMs, onComplete = onDone)
}
fun clearState() {

Loading…
Cancel
Save