Browse Source

radio: PTT double-tap latch + single-tap off (in-chat & overlay); compressor initial gain = mid-range

master
evgeny 2 days ago
parent
commit
61bf235ef4
  1. 11
      lib/audio_compressor.c
  2. 45
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  3. 61
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

11
lib/audio_compressor.c

@ -43,6 +43,11 @@ struct audio_compressor {
int dbg_counter; 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* audio_compressor_create(void) {
struct audio_compressor* ac = u_calloc(1, sizeof(*ac)); struct audio_compressor* ac = u_calloc(1, sizeof(*ac));
if (!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->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->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->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, 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.sample_rate, ac->cfg.channels, ac->cfg.block_duration_ms,
ac->cfg.lookback_ms, ac->cfg.lookahead_ms, ac->cfg.max_gain_db, 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)); 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->block_levels_count = 0;
ac->accum_count = 0; ac->accum_count = 0;
ac->output_size = 0; ac->output_size = 0;
ac->gain_smoothed = 1.0f; ac->gain_smoothed = initial_gain(ac->max_gain);
ac->clip_count = 0; ac->clip_count = 0;
ac->dbg_counter = 0; ac->dbg_counter = 0;
} }

45
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.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import android.provider.Settings import android.provider.Settings
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
@ -29,9 +30,10 @@ import kotlinx.coroutines.flow.StateFlow
/** /**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
* Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание — * Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd),
* завершает. Работает пока включена опция «PTT поверх приложений» и разрешено * отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания,
* «поверх других приложений» (SYSTEM_ALERT_WINDOW). * повторный двойной тап или drag — отбой. Работает пока включена опция
* «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW).
*/ */
class RadioOverlayService : Service() { class RadioOverlayService : Service() {
@ -41,6 +43,7 @@ class RadioOverlayService : Service() {
const val COLOR_IDLE = 0xFF81C784.toInt() const val COLOR_IDLE = 0xFF81C784.toInt()
const val COLOR_TALKING = 0xFFE53935.toInt() const val COLOR_TALKING = 0xFFE53935.toInt()
const val DRAG_LOCK_MS = 300L const val DRAG_LOCK_MS = 300L
const val PTT_HOLD_MS = 250L
private val _isShown = MutableStateFlow(false) private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown val isShown: StateFlow<Boolean> = _isShown
@ -135,7 +138,7 @@ class RadioOverlayService : Service() {
val wm = windowManager ?: return val wm = windowManager ?: return
val v = TextView(this).apply { val v = TextView(this).apply {
text = "\uD83C\uDF99" text = "\uD83C\uDF99"
textSize = 30f textSize = 21f
gravity = android.view.Gravity.CENTER gravity = android.view.Gravity.CENTER
background = GradientDrawable().apply { background = GradientDrawable().apply {
shape = GradientDrawable.OVAL shape = GradientDrawable.OVAL
@ -143,6 +146,7 @@ class RadioOverlayService : Service() {
} }
} }
val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt() val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt()
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
val lp = WindowManager.LayoutParams( val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
@ -153,7 +157,7 @@ class RadioOverlayService : Service() {
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
width = dp(96); height = dp(96) width = dp(67); height = dp(67)
gravity = Gravity.TOP or Gravity.START gravity = Gravity.TOP or Gravity.START
x = 16; y = 200 x = 16; y = 200
} }
@ -162,6 +166,9 @@ class RadioOverlayService : Service() {
private var touchX = 0f; private var touchY = 0f private var touchX = 0f; private var touchY = 0f
private var dragging = false private var dragging = false
private var dragAllowed = true 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 { override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) { when (event.action) {
@ -172,7 +179,8 @@ class RadioOverlayService : Service() {
touchY = event.rawY touchY = event.rawY
dragging = false dragging = false
dragAllowed = true dragAllowed = true
beginTalk() view.removeCallbacks(talkBeginRunnable)
view.postDelayed(talkBeginRunnable, PTT_HOLD_MS)
return true return true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
@ -185,6 +193,8 @@ class RadioOverlayService : Service() {
dragAllowed = false dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true dragging = true
view.removeCallbacks(talkBeginRunnable)
latched = false
endTalkImmediate() endTalkImmediate()
lp.x = startX + (event.rawX - touchX).toInt() lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt() lp.y = startY + (event.rawY - touchY).toInt()
@ -193,8 +203,27 @@ class RadioOverlayService : Service() {
} }
return true return true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP -> {
endTalk() 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 return true
} }
} }

61
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.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.os.SystemClock
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.view.ViewConfiguration
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable 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.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -42,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -276,7 +279,10 @@ fun ChatScreen(
} }
} }
/* Круглая PTT-кнопка рации: держим — говорим. */ /* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап —
* фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */
private const val PTT_HOLD_MS = 250L
@Composable @Composable
private fun RadioPttButton( private fun RadioPttButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -284,17 +290,60 @@ private fun RadioPttButton(
onTalkBegin: () -> Unit, onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit onTalkEnd: () -> Unit
) { ) {
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
var latched by remember { mutableStateOf(false) }
var lastTapUp by remember { mutableStateOf(0L) }
Surface( Surface(
modifier = modifier modifier = modifier
.size(96.dp) .size(96.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( awaitPointerEventScope {
onPress = { 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() onTalkBegin()
tryAwaitRelease() while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) break
}
onTalkEnd() onTalkEnd()
} }
) }
}, },
shape = CircleShape, shape = CircleShape,
color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784), color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),

Loading…
Cancel
Save