radio: AGC release-rate (без кликов), jitter dead-zone, TX/RX-статистика, FGS-гонка и кэш настроек

This commit is contained in:
evgeny committed 2026-09-20 20:07:48 +03:00
1 parent ba1c2d85d3
commit a7a3666d85
10 files changed
+231 -83

No files matched your search

@@ -263,19 +263,39 @@ class ConfigProvider(private val context: Context) {
/* ── Рация: volume-кнопки PTT и оверлей ── */
fun getRadioVolumePtt(): Boolean = runBlocking {
context.dataStore.data.first()[radioVolumePttKey] ?: false
/* Кэш быстрых настроек рации: чтение не блокирует главный поток.
* Ленивая загрузка один раз, дальше — из памяти; set обновляет кэш сразу. */
@Volatile private var radioVolumePttCached = false
@Volatile private var radioVolumePttLoaded = false
fun getRadioVolumePtt(): Boolean {
if (!radioVolumePttLoaded) {
radioVolumePttCached = runBlocking { context.dataStore.data.first()[radioVolumePttKey] ?: false }
radioVolumePttLoaded = true
}
return radioVolumePttCached
}
suspend fun setRadioVolumePtt(enabled: Boolean) {
radioVolumePttCached = enabled
radioVolumePttLoaded = true
context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
}
fun getRadioOverlay(): Boolean = runBlocking {
context.dataStore.data.first()[radioOverlayKey] ?: false
@Volatile private var radioOverlayCached = false
@Volatile private var radioOverlayLoaded = false
fun getRadioOverlay(): Boolean {
if (!radioOverlayLoaded) {
radioOverlayCached = runBlocking { context.dataStore.data.first()[radioOverlayKey] ?: false }
radioOverlayLoaded = true
}
return radioOverlayCached
}
suspend fun setRadioOverlay(enabled: Boolean) {
radioOverlayCached = enabled
radioOverlayLoaded = true
context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled }
}
@@ -10,6 +10,7 @@ import android.media.MediaRecorder
import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.Executors
/**
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
@@ -35,9 +36,9 @@ class RadioAudioEngine {
}
}
private var audioRecord: AudioRecord? = null
@Volatile private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null
private var audioTrack: AudioTrack? = null
@Volatile private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false
@@ -50,21 +51,44 @@ class RadioAudioEngine {
private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow
/* Сериализованный поток для setup/teardown аудио (не блокирует main). */
private val io = Executors.newSingleThreadExecutor { r ->
Thread(r, "radio-audio-io").apply { isDaemon = true }
}
fun isActive(): Boolean = active
fun isTalking(): Boolean = talking
/** Начать прослушивание канала (переключатель рации вкл). */
fun start(group: Long): Boolean {
stop()
if (!NativeLib.radioAudioStart(group)) {
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return false
}
/** Начать прослушивание канала (переключатель рации вкл).
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
fun start(group: Long) {
if (active && groupId == group) return
active = true
groupId = group
talking = false
io.execute { startBlocking(group) }
}
fun stop() {
if (!active) return
active = false
talking = false
_talkingFlow.value = false
io.execute { stopBlocking() }
}
/* ── io-поток (сериализовано): setup/teardown аудио ── */
private fun startBlocking(group: Long) {
if (!active || groupId != group) return
releaseLocked(false)
if (!NativeLib.radioAudioStart(group)) {
active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return
}
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -80,26 +104,26 @@ class RadioAudioEngine {
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
audioRecord = try {
val rec = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}")
NativeLib.radioAudioStop(); restoreAudioMode(); return false
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null
NativeLib.radioAudioStop(); restoreAudioMode(); return false
rec.release()
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
}
// ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
audioTrack = try {
val track = try {
AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@@ -110,15 +134,15 @@ class RadioAudioEngine {
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null
audioTrack = null
NativeLib.radioAudioStop(); restoreAudioMode(); return false
rec.release()
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
}
active = true
audioRecord = rec
audioTrack = track
micDead = false
audioRecord?.startRecording()
audioTrack?.play()
rec.startRecording()
track.play()
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
@@ -128,15 +152,14 @@ class RadioAudioEngine {
}
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
return true
}
fun stop() {
if (!active) return
active = false
talking = false
_talkingFlow.value = false
private fun stopBlocking() {
releaseLocked(true)
}
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */
private fun releaseLocked(resetGroup: Boolean) {
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
@@ -148,8 +171,10 @@ class RadioAudioEngine {
NativeLib.radioAudioStop()
restoreAudioMode()
groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
if (resetGroup) {
groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
}
}
/** PTT зажата — начинаем передачу. */
@@ -205,16 +230,22 @@ class RadioAudioEngine {
private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
var nextNs = System.nanoTime()
while (active) {
val t0 = System.nanoTime()
val track = audioTrack ?: return
val n = NativeLib.radioAudioPull(groupId, buf)
if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
val elapsedMs = (System.nanoTime() - t0) / 1_000_000
val sleepMs = FRAME_MS - elapsedMs
if (sleepMs > 0) {
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return }
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */
nextNs += frameNs
val waitNs = nextNs - System.nanoTime()
if (waitNs > 0) {
try { Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt()) }
catch (_: InterruptedException) { return }
} else {
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
}
}
LogManager.addLog("DEBUG", "RadioAudio", "play loop done")
@@ -45,16 +45,26 @@ class RadioOverlayService : Service() {
private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown
/* Гонка start/stop: stop() до вызова startForeground() роняет FGS.
* Пока сервис стартует (starting) — взводим pendingStop, а onCreate
* после startForeground() сам вызовет stopSelf(). */
@Volatile private var starting = false
@Volatile private var pendingStop = false
fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun start(ctx: Context) {
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}")
pendingStop = false
if (Build.VERSION.SDK_INT >= 34 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) !=
PackageManager.PERMISSION_GRANTED) {
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted")
return
}
if (starting || _isShown.value) return
starting = true
val i = Intent(ctx, RadioOverlayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(i)
@@ -63,6 +73,11 @@ class RadioOverlayService : Service() {
}
fun stop(ctx: Context) {
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.stop isShown=${_isShown.value} t=${System.currentTimeMillis()}")
if (starting) {
pendingStop = true
return
}
ctx.stopService(Intent(ctx, RadioOverlayService::class.java))
}
}
@@ -76,6 +91,7 @@ class RadioOverlayService : Service() {
override fun onCreate() {
super.onCreate()
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}")
createChannel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(),
@@ -87,10 +103,16 @@ class RadioOverlayService : Service() {
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
if (!canDrawOverlays(this)) {
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping")
starting = false
stopSelf()
return
}
addOverlay()
starting = false
if (pendingStop) {
pendingStop = false
stopSelf()
}
}
private fun createChannel() {
@@ -212,6 +234,7 @@ class RadioOverlayService : Service() {
endTalk()
overlayView?.let { windowManager?.removeView(it) }
overlayView = null
starting = false
_isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy()
@@ -65,6 +65,8 @@ class ChatViewModel : ViewModel() {
private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _radioTalking
private var radioToggleCount = 0
private var callStartedMs = 0L
private var callIncomingAtMs = 0L
@@ -732,6 +734,12 @@ class ChatViewModel : ViewModel() {
val ch = _currentChannel.value ?: return
val groupId = ch.id.toULongOrNull()?.toLong() ?: return
val next = !_radioActive.value
radioToggleCount++
val caller = Throwable().stackTrace.drop(1).take(8)
.joinToString(" <- ") { "${it.className.substringAfterLast('.')}.${it.methodName}:${it.lineNumber}" }
LogManager.addLog("DEBUG", "RADIO_DBG",
"toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " +
"thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller")
NativeLib.radioSetActive(groupId, next)
_radioActive.value = next
if (next) {
@@ -766,7 +774,11 @@ class ChatViewModel : ViewModel() {
private fun syncRadioOverlay() {
val ctx = ChatApplication.instance
if (_radioActive.value && ctx.configProvider.getRadioOverlay())
val overlayEnabled = ctx.configProvider.getRadioOverlay()
LogManager.addLog("DEBUG", "RADIO_DBG",
"syncRadioOverlay active=${_radioActive.value} overlayEnabled=$overlayEnabled " +
"isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}")
if (_radioActive.value && overlayEnabled)
RadioOverlayService.start(ctx)
else
RadioOverlayService.stop(ctx)