radio: AGC release-rate (без кликов), jitter dead-zone, TX/RX-статистика, FGS-гонка и кэш настроек
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user