diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index 6f077eba..686a6d6c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -90,6 +90,9 @@ class RadioAudioEngine { private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow + /** Фактический TX (PTT/VAD), привязанный к каналу. Capture публикует, io сбрасывает после join. */ + private val _transmittingGroup = MutableStateFlow(null) + val transmittingGroup: StateFlow = _transmittingGroup /* Сериализованный поток для setup/teardown аудио (не блокирует main). */ private val io = Executors.newSingleThreadExecutor { r -> @@ -510,6 +513,10 @@ class RadioAudioEngine { } vadMode = false talking = false + if (_transmittingGroup.value != null) { + LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") + _transmittingGroup.value = null + } _talkingFlow.value = requestedTalk _vadActiveFlow.value = false } @@ -654,6 +661,11 @@ class RadioAudioEngine { } if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting() } else skippedFrames++ + val transmitting = if (captureRunning && active && groupId == group && NativeLib.radioTransmitting()) group else null + if (_transmittingGroup.value != transmitting) { + _transmittingGroup.value = transmitting + LogManager.addLog("DEBUG", "radio", "TX state group=$group on=${transmitting != null} manual=$requestedTalk vad=$vadMode") + } val nowMs = SystemClock.elapsedRealtime() if (nowMs - statsMs >= 1000L) { LogManager.addLog("DEBUG", "debug", "VAD capture summary group=$group spanMs=${nowMs - statsMs} " + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioTalkState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioTalkState.kt new file mode 100644 index 00000000..96b2602c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioTalkState.kt @@ -0,0 +1,24 @@ +package com.utun.chat.data + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Эфемерные говорящие одной активной рации; снимки участников и локальный mute не меняют этот набор. */ +data class RadioTalkState(val groupId: Long = 0, val speakers: Set = emptySet()) { + fun onTalk(event: RadioTalkEvent): RadioTalkState { + if (groupId == 0L || event.groupId != groupId || speakers.contains(event.nodeId) == event.on) return this + return copy(speakers = if (event.on) speakers + event.nodeId else speakers - event.nodeId) + } +} + +data class RadioTalkEvent(val groupId: Long, val nodeId: Long, val stream: Int, val on: Boolean) + +/** Строгий формат CHAT_EVT_RADIO_TALK: group(8), source(8), stream(2), on(1), little endian. */ +fun parseRadioTalkEvent(data: ByteArray?): RadioTalkEvent? { + if (data == null || data.size != 19 || data[18].toInt() !in 0..1) return null + val bytes = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val group = bytes.long + val node = bytes.long + if (group == 0L || node == 0L) return null + return RadioTalkEvent(group, node, bytes.short.toInt() and 0xffff, data[18].toInt() == 1) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 1b1b4aab..48acc26b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -75,6 +75,8 @@ class ChatViewModel : ViewModel() { private val _radioTalking = MutableStateFlow(null) val radioTalking: StateFlow = _radioTalking + private val _radioTalkState = MutableStateFlow(RadioTalkState()) + val radioTalkState: StateFlow = _radioTalkState private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER) val radioRoute: StateFlow = _radioRoute @@ -402,6 +404,11 @@ class ChatViewModel : ViewModel() { 4 -> { if (repo != null) { LogManager.addLog("INFO", "VM", "CHANNEL_UPDATED — refreshChannels"); refreshChannels() } else { LogManager.addLog("WARN", "VM", "CHANNEL_UPDATED skipped — repo is NULL") } } 12 -> LogManager.addLog("INFO", "VM", "service started") 13 -> { + _radioTalkState.value = RadioTalkState() + _radioTalking.value = null + _radioActive.value = false + radioChannelId = null + radioAudio.stop() memberRadios.clear() memberRadioMutes.clear() _members.value = _members.value.map { it.copy(radioActive = false) } @@ -567,6 +574,10 @@ class ChatViewModel : ViewModel() { val chId = String(data, 1, cl) memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId memberRadioMutes[chId] = memberRadioMutes[chId].orEmpty() - nodeId + if (_radioTalkState.value.groupId == chId.toULongOrNull()?.toLong()) { + _radioTalkState.value = _radioTalkState.value.copy(speakers = _radioTalkState.value.speakers - nodeId) + _radioTalking.value = if (_radioActive.value) _radioTalkState.value.speakers.firstOrNull() else null + } val cur = _currentChannel.value if (cur != null && cur.id == String(data, 1, cl)) _members.value = _members.value.filter { it.nodeId != nodeId } @@ -736,17 +747,19 @@ class ChatViewModel : ViewModel() { preparationState(target, job, state) } 43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */ - if (data == null || data.size < 19) return - val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN) - val groupId = bb.getLong() - val src = bb.getLong() - bb.getShort() /* stream */ - val on = bb.get() - val cur = _currentChannel.value - if (cur != null && cur.id.toULongOrNull()?.toLong() == groupId) { - _radioTalking.value = if (on.toInt() != 0) src else null - LogManager.addLog("INFO", "VM", "RADIO_TALK ${if (on.toInt() != 0) "start" else "stop"} src=0x%016x".format(src)) + val event = parseRadioTalkEvent(data) + if (event == null) { LogManager.addLog("ERROR", "radio", "invalid RADIO_TALK bytes=${data?.size ?: 0}"); return } + val previous = _radioTalkState.value + val next = previous.onTalk(event) + if (event.groupId != previous.groupId) { + LogManager.addLog("DEBUG", "radio", "ignored talk group=${event.groupId} active=${previous.groupId} node=${event.nodeId}") + return } + if (next == previous) return + _radioTalkState.value = next + _radioTalking.value = if (_radioActive.value) next.speakers.firstOrNull() else null + LogManager.addLog("DEBUG", "radio", "talk group=${event.groupId} node=${event.nodeId} stream=${event.stream} " + + "on=${event.on} speakers=${next.speakers.size}") } } } @@ -776,6 +789,8 @@ class ChatViewModel : ViewModel() { radioChannelId?.takeIf { it != ch.id }?.toULongOrNull()?.toLong() ?.let { NativeLib.radioSetActive(it, false) } /* выключаем старый канал */ radioChannelId = ch.id + _radioTalkState.value = RadioTalkState(groupId) + _radioTalking.value = null NativeLib.radioSetActive(groupId, true) radioAudio.start(groupId) radioAudio.router.onChanged = { r, headset -> @@ -797,6 +812,7 @@ class ChatViewModel : ViewModel() { NativeLib.radioSetActive(groupId, false) radioAudio.stop() _radioTalking.value = null + _radioTalkState.value = RadioTalkState() _radioRoute.value = AudioRoute.SPEAKER _radioHeadsetAvailable.value = false _wiredInputAvailable.value = false @@ -908,6 +924,7 @@ class ChatViewModel : ViewModel() { radioChannelId = null _radioActive.value = false _radioTalking.value = null + _radioTalkState.value = RadioTalkState() _radioRoute.value = AudioRoute.SPEAKER _radioHeadsetAvailable.value = false _wiredInputAvailable.value = false @@ -1031,7 +1048,7 @@ class ChatViewModel : ViewModel() { } _currentChannel.value = channel _radioActive.value = (radioChannelId == channel.id) - if (radioChannelId != channel.id) _radioTalking.value = null + _radioTalking.value = if (_radioActive.value) _radioTalkState.value.speakers.firstOrNull() else null _soundOn.value = NativeLib.getSoundOnMessage(channel.id) NotificationSound.setMuted(channel.id, !_soundOn.value) r.connectChannel(channel.id)