From 61bf235ef45ea15a28fca4481787c621c24885b0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 29 Sep 2026 20:00:28 +0300 Subject: [PATCH] radio: PTT double-tap latch + single-tap off (in-chat & overlay); compressor initial gain = mid-range --- lib/audio_compressor.c | 11 +++- .../com/utun/chat/data/RadioOverlayService.kt | 45 +++++++++++--- .../com/utun/chat/ui/screens/ChatScreen.kt | 61 +++++++++++++++++-- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/lib/audio_compressor.c b/lib/audio_compressor.c index 135093ac..3ff49a00 100644 --- a/lib/audio_compressor.c +++ b/lib/audio_compressor.c @@ -43,6 +43,11 @@ struct audio_compressor { int dbg_counter; }; +/* Начальный gain — середина диапазона [0, max_gain] в дБ (геометрическое среднее). */ +static float initial_gain(float max_gain) { + return max_gain > 0.0f ? sqrtf(max_gain) : 1.0f; +} + struct audio_compressor* audio_compressor_create(void) { struct audio_compressor* ac = u_calloc(1, sizeof(*ac)); if (!ac) { @@ -85,11 +90,13 @@ void audio_compressor_configure(struct audio_compressor* ac, const audio_compres ac->cfg.release_rate_per_sec = ac->cfg.rise_rate_per_sec * 5.0f; ac->release_factor_per_block = powf(ac->cfg.release_rate_per_sec, (float)ac->cfg.block_duration_ms / 1000.0f); ac->max_gain = powf(10.0f, ac->cfg.max_gain_db / 20.0f); + ac->gain_smoothed = initial_gain(ac->max_gain); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, - "audio_compressor_configure: rate=%d ch=%d block=%dms lookback=%dms lookahead=%dms maxGain=%.0fdB riseRate=%.1f/sec releaseRate=%.1f/sec target=%.0fdBFS", + "audio_compressor_configure: rate=%d ch=%d block=%dms lookback=%dms lookahead=%dms maxGain=%.0fdB initGain=%.1fdB riseRate=%.1f/sec releaseRate=%.1f/sec target=%.0fdBFS", ac->cfg.sample_rate, ac->cfg.channels, ac->cfg.block_duration_ms, ac->cfg.lookback_ms, ac->cfg.lookahead_ms, ac->cfg.max_gain_db, + 20.0f * log10f(ac->gain_smoothed), ac->cfg.rise_rate_per_sec, ac->cfg.release_rate_per_sec, 20.0f * log10f(ac->cfg.target_level)); } @@ -101,7 +108,7 @@ void audio_compressor_reset(struct audio_compressor* ac) { ac->block_levels_count = 0; ac->accum_count = 0; ac->output_size = 0; - ac->gain_smoothed = 1.0f; + ac->gain_smoothed = initial_gain(ac->max_gain); ac->clip_count = 0; ac->dbg_counter = 0; } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index 01cd1062..5b1cfbfa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -12,6 +12,7 @@ import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.IBinder +import android.os.SystemClock import android.provider.Settings import android.view.Gravity import android.view.LayoutInflater @@ -29,9 +30,10 @@ import kotlinx.coroutines.flow.StateFlow /** * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. - * Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание — - * завершает. Работает пока включена опция «PTT поверх приложений» и разрешено - * «поверх других приложений» (SYSTEM_ALERT_WINDOW). + * Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd), + * отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания, + * повторный двойной тап или drag — отбой. Работает пока включена опция + * «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW). */ class RadioOverlayService : Service() { @@ -41,6 +43,7 @@ class RadioOverlayService : Service() { const val COLOR_IDLE = 0xFF81C784.toInt() const val COLOR_TALKING = 0xFFE53935.toInt() const val DRAG_LOCK_MS = 300L + const val PTT_HOLD_MS = 250L private val _isShown = MutableStateFlow(false) val isShown: StateFlow = _isShown @@ -135,7 +138,7 @@ class RadioOverlayService : Service() { val wm = windowManager ?: return val v = TextView(this).apply { text = "\uD83C\uDF99" - textSize = 30f + textSize = 21f gravity = android.view.Gravity.CENTER background = GradientDrawable().apply { shape = GradientDrawable.OVAL @@ -143,6 +146,7 @@ class RadioOverlayService : Service() { } } val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt() + val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong() val lp = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, @@ -153,7 +157,7 @@ class RadioOverlayService : Service() { WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { - width = dp(96); height = dp(96) + width = dp(67); height = dp(67) gravity = Gravity.TOP or Gravity.START x = 16; y = 200 } @@ -162,6 +166,9 @@ class RadioOverlayService : Service() { private var touchX = 0f; private var touchY = 0f private var dragging = false private var dragAllowed = true + private var latched = false + private var lastTapUpTime = 0L + private val talkBeginRunnable = Runnable { beginTalk() } override fun onTouch(view: View, event: MotionEvent): Boolean { when (event.action) { @@ -172,7 +179,8 @@ class RadioOverlayService : Service() { touchY = event.rawY dragging = false dragAllowed = true - beginTalk() + view.removeCallbacks(talkBeginRunnable) + view.postDelayed(talkBeginRunnable, PTT_HOLD_MS) return true } MotionEvent.ACTION_MOVE -> { @@ -185,6 +193,8 @@ class RadioOverlayService : Service() { dragAllowed = false } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { dragging = true + view.removeCallbacks(talkBeginRunnable) + latched = false endTalkImmediate() lp.x = startX + (event.rawX - touchX).toInt() lp.y = startY + (event.rawY - touchY).toInt() @@ -193,8 +203,27 @@ class RadioOverlayService : Service() { } return true } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - endTalk() + MotionEvent.ACTION_UP -> { + view.removeCallbacks(talkBeginRunnable) + val now = SystemClock.uptimeMillis() + val isDouble = lastTapUpTime != 0L && (now - lastTapUpTime) <= doubleTapTimeout + if (latched) { + latched = false + lastTapUpTime = 0L + endTalk() + } else if (isDouble) { + latched = true + lastTapUpTime = 0L + beginTalk() + } else { + lastTapUpTime = now + endTalk() + } + return true + } + MotionEvent.ACTION_CANCEL -> { + view.removeCallbacks(talkBeginRunnable) + if (!latched) endTalk() return true } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 6c9863be..2525f957 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -2,11 +2,13 @@ package com.utun.chat.ui.screens import android.content.ContentResolver import android.net.Uri +import android.os.SystemClock import android.provider.OpenableColumns +import android.view.ViewConfiguration import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -42,6 +44,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -276,7 +279,10 @@ fun ChatScreen( } } -/* Круглая PTT-кнопка рации: держим — говорим. */ +/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап — + * фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */ +private const val PTT_HOLD_MS = 250L + @Composable private fun RadioPttButton( modifier: Modifier = Modifier, @@ -284,17 +290,60 @@ private fun RadioPttButton( onTalkBegin: () -> Unit, onTalkEnd: () -> Unit ) { + val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong() + var latched by remember { mutableStateOf(false) } + var lastTapUp by remember { mutableStateOf(0L) } + Surface( modifier = modifier .size(96.dp) .pointerInput(Unit) { - detectTapGestures( - onPress = { + awaitPointerEventScope { + while (true) { + val down = awaitFirstDown(requireUnconsumed = false) + val downId = down.id + down.consume() + + val now = SystemClock.uptimeMillis() + val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout + + /* быстрое отпускание (до PTT_HOLD_MS) — это тап */ + var tapped = false + withTimeoutOrNull(PTT_HOLD_MS) { + while (true) { + val ev = awaitPointerEvent() + val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull + if (!c.pressed) { tapped = true; return@withTimeoutOrNull } + c.consume() + } + } + + if (tapped) { + if (latched) { + latched = false + lastTapUp = 0L + onTalkEnd() + } else if (isSecondTap) { + latched = true + lastTapUp = 0L + onTalkBegin() + } else { + lastTapUp = SystemClock.uptimeMillis() + } + continue + } + + /* удержание — говорим, пока не отпустят */ onTalkBegin() - tryAwaitRelease() + while (true) { + val ev = awaitPointerEvent() + val c = ev.changes.firstOrNull { it.id == downId } ?: break + c.consume() + if (!c.pressed) break + } onTalkEnd() } - ) + } }, shape = CircleShape, color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),