Browse Source

Track Android radio speakers and actual transmission by group

master
evgeny 4 days ago
parent
commit
bcda9b82f4
  1. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  2. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioTalkState.kt
  3. 39
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

12
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<Boolean> = _talkingFlow
/** Фактический TX (PTT/VAD), привязанный к каналу. Capture публикует, io сбрасывает после join. */
private val _transmittingGroup = MutableStateFlow<Long?>(null)
val transmittingGroup: StateFlow<Long?> = _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} " +

24
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<Long> = 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)
}

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

@ -75,6 +75,8 @@ class ChatViewModel : ViewModel() {
private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _radioTalking
private val _radioTalkState = MutableStateFlow(RadioTalkState())
val radioTalkState: StateFlow<RadioTalkState> = _radioTalkState
private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val radioRoute: StateFlow<AudioRoute> = _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)

Loading…
Cancel
Save