diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioPttHolds.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioPttHolds.kt index 9c6306f8..f617974e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioPttHolds.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioPttHolds.kt @@ -1,16 +1,25 @@ package com.utun.chat.data -/** Независимые владельцы ручной передачи. Все операции выполняются в главном потоке. */ +/** Источник ручной передачи: кнопка чата, плавающая кнопка либо общий владелец volume-клавиш. */ internal enum class RadioPttSource { BUTTON, OVERLAY, VOLUME } +/** + * Независимые удержания PTT без управления звуком и таймерами; все вызовы сериализует main-поток владельца. + * Каждый источник занимает одно место: повтор press не увеличивает счётчик. Несколько физических клавиш + * одного источника вызывающий агрегирует сам и делает release только после отпускания последней. + * RadioAudioEngine использует release для хвоста передачи, cancel — для немедленной отмены без хвоста. + */ internal class RadioPttHolds { - private val held = mutableSetOf() + private val held = mutableSetOf() // Только ручные владельцы; VAD/автоматика сюда не входят. + /** Добавить source идемпотентно; остальные владельцы сохраняются. */ fun press(source: RadioPttSource) { held.add(source) } - /** Завершать передачу можно только после отпускания последнего владельца. */ + /** Удалить source; true только при удалении последнего владельца. Неизвестный/повторный release даёт false. */ fun release(source: RadioPttSource): Boolean = held.remove(source) && held.isEmpty() + /** Удалить source либо всех при null; вернуть отсутствие владельцев, даже если набор уже был пуст. + * Поэтому cancel разрешает снять отложенный хвост передачи также после предыдущего release. */ fun cancel(source: RadioPttSource? = null): Boolean { if (source == null) held.clear() else held.remove(source) return held.isEmpty() 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 index 96b2602c..8ac7d8d3 100644 --- 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 @@ -3,17 +3,29 @@ package com.utun.chat.data import java.nio.ByteBuffer import java.nio.ByteOrder -/** Эфемерные говорящие одной активной рации; снимки участников и локальный mute не меняют этот набор. */ +/** + * Снимок говорящих одной рации: groupId=0 отключает обработку событий, speakers содержит node ID. + * При включении/смене канала вызывающий создаёт новый RadioTalkState(group), при выключении — RadioTalkState(). + * Снимки участников и локальный mute независимы от индикации речи; переданный speakers нельзя мутировать извне. + * C-ядро хранит одного talker на узел: смена stream даёт новый START без STOP прежнего stream. + * Поэтому набор учитывает nodeId, а stream служит диагностике и не создаёт отдельного говорящего. + * ID — ненулевые 64-битные значения; отрицательный Long сохраняет unsigned-биты протокольного ID. + */ data class RadioTalkState(val groupId: Long = 0, val speakers: Set = emptySet()) { + /** Применить валидное событие парсера; чужая/выключенная группа и повтор состояния возвращают this. + * START добавляет узел, STOP удаляет только его; прежний снимок остаётся неизменным. */ 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) } } +/** Одно событие речи: ненулевые ID группы/узла, stream=0..65535, on=true для START и false для STOP. */ 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. */ +/** Разобрать ровно 19 байтов CHAT_EVT_RADIO_TALK: group(8), source(8), stream(2), on(1), little endian. + * Только on=0/1 и ненулевые group/source; stream допускает весь uint16, включая 0. + * При null, неверной длине/флаге/нулевом ID вернуть null; диагностику отказа пишет вызывающий ChatViewModel. */ 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)