|
|
|
|
@ -1,5 +1,8 @@
|
|
|
|
|
package com.utun.chat.data |
|
|
|
|
|
|
|
|
|
import android.content.BroadcastReceiver |
|
|
|
|
import android.content.Intent |
|
|
|
|
import android.content.IntentFilter |
|
|
|
|
import android.content.Context |
|
|
|
|
import android.media.AudioAttributes |
|
|
|
|
import android.media.AudioDeviceInfo |
|
|
|
|
@ -11,6 +14,8 @@ import android.media.MediaRecorder
|
|
|
|
|
import android.os.Build |
|
|
|
|
import android.os.Handler |
|
|
|
|
import android.os.Looper |
|
|
|
|
import android.os.PowerManager |
|
|
|
|
import android.os.SystemClock |
|
|
|
|
import com.utun.chat.ChatApplication |
|
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
|
|
|
import kotlinx.coroutines.flow.StateFlow |
|
|
|
|
@ -20,7 +25,7 @@ import java.util.concurrent.Executors
|
|
|
|
|
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). |
|
|
|
|
* |
|
|
|
|
* Потоки: |
|
|
|
|
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT) |
|
|
|
|
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT либо активный VAD) |
|
|
|
|
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write |
|
|
|
|
* |
|
|
|
|
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека |
|
|
|
|
@ -36,8 +41,8 @@ class RadioAudioEngine {
|
|
|
|
|
/** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */ |
|
|
|
|
const val PLAY_CHANNELS = 2 |
|
|
|
|
|
|
|
|
|
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */ |
|
|
|
|
const val RELEASE_HOLD_MS = 200L |
|
|
|
|
const val RELEASE_HOLD_MS = 250L |
|
|
|
|
const val VAD_ACTIVE_MS = 15 * 60 * 1000L |
|
|
|
|
|
|
|
|
|
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ |
|
|
|
|
@Volatile private var inst: RadioAudioEngine? = null |
|
|
|
|
@ -58,13 +63,26 @@ class RadioAudioEngine {
|
|
|
|
|
private var playThread: Thread? = null |
|
|
|
|
private var prevMode: Int = AudioManager.MODE_NORMAL |
|
|
|
|
private var prevSpeaker: Boolean = false |
|
|
|
|
private var audioModeOwned = false |
|
|
|
|
|
|
|
|
|
@Volatile private var active = false |
|
|
|
|
@Volatile private var groupId = 0L |
|
|
|
|
@Volatile private var talking = false |
|
|
|
|
@Volatile private var micDead = false |
|
|
|
|
@Volatile private var captureRunning = false |
|
|
|
|
@Volatile private var playbackRunning = false |
|
|
|
|
@Volatile private var requestedTalk = false |
|
|
|
|
@Volatile private var talkRequestNs = 0L |
|
|
|
|
@Volatile private var generation = 0L |
|
|
|
|
private var audioGroup = 0L |
|
|
|
|
@Volatile private var vadDeadline = 0L |
|
|
|
|
private var vadConfigured = false |
|
|
|
|
private var screenReceiver: BroadcastReceiver? = null |
|
|
|
|
@Volatile private var screenInteractive = false |
|
|
|
|
private var configuredInput = INPUT_AUTO |
|
|
|
|
private var configuredMicSource = 0 |
|
|
|
|
private var configuredStereo = false |
|
|
|
|
private var configuredVad = false |
|
|
|
|
@Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */ |
|
|
|
|
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */ |
|
|
|
|
|
|
|
|
|
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ |
|
|
|
|
val router = CallAudioRouter() |
|
|
|
|
@ -77,13 +95,23 @@ class RadioAudioEngine {
|
|
|
|
|
Thread(r, "radio-audio-io").apply { isDaemon = true } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */ |
|
|
|
|
/* Таймер окна VAD; управление ресурсами всегда на io-потоке. */ |
|
|
|
|
private val mainHandler = Handler(Looper.getMainLooper()) |
|
|
|
|
private val releaseRunnable = Runnable { finishTalk() } |
|
|
|
|
private val releaseTalk = Runnable { finishTalk() } |
|
|
|
|
private val vadTimeout = Runnable { |
|
|
|
|
io.execute { |
|
|
|
|
if (active && SystemClock.elapsedRealtime() >= vadDeadline) { |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "VAD window expired (15 minutes)") |
|
|
|
|
updateCapture() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
private val _vadActiveFlow = MutableStateFlow(false) |
|
|
|
|
val vadActiveFlow: StateFlow<Boolean> = _vadActiveFlow |
|
|
|
|
|
|
|
|
|
fun isActive(): Boolean = active |
|
|
|
|
|
|
|
|
|
fun isTalking(): Boolean = talking |
|
|
|
|
fun isTalking(): Boolean = _talkingFlow.value |
|
|
|
|
|
|
|
|
|
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ |
|
|
|
|
fun route(): AudioRoute = router.route |
|
|
|
|
@ -95,61 +123,63 @@ class RadioAudioEngine {
|
|
|
|
|
fun headsetAvailable(): Boolean = router.headsetAvailable |
|
|
|
|
|
|
|
|
|
/** Ручной выбор выхода (отключает автоследование за гарнитурой). */ |
|
|
|
|
fun setRoute(r: AudioRoute) = router.setRoute(r) |
|
|
|
|
fun setRoute(r: AudioRoute, preferWired: Boolean = false) = router.setRoute(r, preferWired) |
|
|
|
|
|
|
|
|
|
/** Начать прослушивание канала (переключатель рации вкл). |
|
|
|
|
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ |
|
|
|
|
fun start(group: Long) { |
|
|
|
|
if (active && groupId == group) return |
|
|
|
|
mainHandler.removeCallbacks(releaseTalk) |
|
|
|
|
val version = ++generation |
|
|
|
|
active = true |
|
|
|
|
groupId = group |
|
|
|
|
talking = false |
|
|
|
|
io.execute { startBlocking(group) } |
|
|
|
|
requestedTalk = false |
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
io.execute { |
|
|
|
|
if (!active || generation != version) return@execute |
|
|
|
|
releaseLocked() |
|
|
|
|
audioGroup = group |
|
|
|
|
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS |
|
|
|
|
router.setAutoHeadset(true) |
|
|
|
|
if (active && generation == version) { |
|
|
|
|
try { setupAudio() } catch (e: Exception) { |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}") |
|
|
|
|
releaseLocked() |
|
|
|
|
if (generation == version) active = false |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fun stop() { |
|
|
|
|
if (!active) return |
|
|
|
|
mainHandler.removeCallbacks(releaseTalk) |
|
|
|
|
active = false |
|
|
|
|
talking = false |
|
|
|
|
requestedTalk = false |
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
mainHandler.removeCallbacks(releaseRunnable) |
|
|
|
|
io.execute { stopBlocking() } |
|
|
|
|
++generation |
|
|
|
|
io.execute { releaseLocked() } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* ── io-поток (сериализовано): setup/teardown аудио ── */ |
|
|
|
|
|
|
|
|
|
private fun startBlocking(group: Long) { |
|
|
|
|
if (!active || groupId != group) return |
|
|
|
|
|
|
|
|
|
/* Переключение группы: гасим старое аудио (active=false даёт старым |
|
|
|
|
* capture/play-потокам выйти до release, иначе UAF AudioTrack в playLoop). */ |
|
|
|
|
active = false |
|
|
|
|
releaseLocked(false) |
|
|
|
|
active = true |
|
|
|
|
router.setAutoHeadset(true) /* дефолт «авто» только на свежий старт/смену группы */ |
|
|
|
|
setupAudio() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */ |
|
|
|
|
/** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */ |
|
|
|
|
fun reconfigure() { |
|
|
|
|
val g = groupId |
|
|
|
|
if (g == 0L) return |
|
|
|
|
val version = generation |
|
|
|
|
io.execute { |
|
|
|
|
if (groupId != g) return@execute |
|
|
|
|
if (talking) finishTalk() |
|
|
|
|
if (active) { active = false; releaseLocked(false) } |
|
|
|
|
active = true |
|
|
|
|
setupAudio() |
|
|
|
|
if (!active || generation != version || audioTrack == null) return@execute |
|
|
|
|
stopCapture() |
|
|
|
|
loadCaptureSettings() |
|
|
|
|
updateCapture() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */ |
|
|
|
|
/* Приём создаётся независимо от микрофона (только io-поток). */ |
|
|
|
|
private fun setupAudio() { |
|
|
|
|
val group = groupId |
|
|
|
|
loadCaptureSettings() |
|
|
|
|
val group = audioGroup |
|
|
|
|
val ctx = ChatApplication.instance |
|
|
|
|
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
|
|
|
prevMode = am.mode |
|
|
|
|
prevSpeaker = am.isSpeakerphoneOn |
|
|
|
|
audioModeOwned = true |
|
|
|
|
try { |
|
|
|
|
am.mode = AudioManager.MODE_IN_COMMUNICATION |
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
@ -160,24 +190,12 @@ class RadioAudioEngine {
|
|
|
|
|
router.fallbackRoute = AudioRoute.SPEAKER |
|
|
|
|
router.start() |
|
|
|
|
|
|
|
|
|
// ── AudioRecord (захват) с откатом стерео→моно ── |
|
|
|
|
val (rec, capCh) = createAudioRecord() |
|
|
|
|
if (rec == null) { |
|
|
|
|
router.stop(); restoreAudioMode(); active = false; return |
|
|
|
|
} |
|
|
|
|
captureChannels = capCh |
|
|
|
|
|
|
|
|
|
if (!NativeLib.radioAudioStart(group, capCh)) { |
|
|
|
|
rec.release() |
|
|
|
|
if (!NativeLib.radioAudioStart(group, 0)) { |
|
|
|
|
router.stop(); restoreAudioMode(); active = false |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh") |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* VAD авто-PTT: сервисный слой решил, что режим включён — фидим PCM постоянно. */ |
|
|
|
|
vadMode = NativeLib.radioVadMode() |
|
|
|
|
if (vadMode) LogManager.addLog("INFO", "RadioAudio", "VAD auto-PTT enabled group=$group") |
|
|
|
|
|
|
|
|
|
// ── AudioTrack (воспроизведение, всегда стерео) ── |
|
|
|
|
val trackMin = AudioTrack.getMinBufferSize( |
|
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
|
@ -193,13 +211,10 @@ class RadioAudioEngine {
|
|
|
|
|
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) |
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") |
|
|
|
|
rec.release() |
|
|
|
|
NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
audioRecord = rec |
|
|
|
|
audioTrack = track |
|
|
|
|
micDead = false |
|
|
|
|
|
|
|
|
|
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной |
|
|
|
|
* маршрутизации), как в CallAudioEngine. */ |
|
|
|
|
@ -211,27 +226,33 @@ class RadioAudioEngine {
|
|
|
|
|
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
rec.startRecording() |
|
|
|
|
track.play() |
|
|
|
|
|
|
|
|
|
captureThread = Thread { captureLoop() }.apply { |
|
|
|
|
priority = Thread.MAX_PRIORITY; start() |
|
|
|
|
} |
|
|
|
|
playbackRunning = true |
|
|
|
|
playThread = Thread { playLoop() }.apply { |
|
|
|
|
priority = Thread.MAX_PRIORITY; start() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf") |
|
|
|
|
registerScreenReceiver() |
|
|
|
|
updateCapture() |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "listening group=$group trackBuf=$trackBuf") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */ |
|
|
|
|
private fun loadCaptureSettings() { |
|
|
|
|
val provider = ChatApplication.instance.configProvider |
|
|
|
|
configuredInput = provider.getRadioInputSource() |
|
|
|
|
configuredMicSource = provider.getRadioMicSource() |
|
|
|
|
configuredStereo = provider.getRadioCaptureStereo() |
|
|
|
|
configuredVad = provider.getInt("radio.vad_enabled") != 0 |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон |
|
|
|
|
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), |
|
|
|
|
* авто — системный дефолт. Возвращает (record, каналы). */ |
|
|
|
|
private fun createAudioRecord(): Pair<AudioRecord?, Int> { |
|
|
|
|
val ctx = ChatApplication.instance |
|
|
|
|
val inputSource = ctx.configProvider.getRadioInputSource() |
|
|
|
|
val pref = ctx.configProvider.getRadioMicSource() |
|
|
|
|
val wantStereo = ctx.configProvider.getRadioCaptureStereo() |
|
|
|
|
val inputSource = configuredInput |
|
|
|
|
val pref = configuredMicSource |
|
|
|
|
val wantStereo = configuredStereo |
|
|
|
|
|
|
|
|
|
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» |
|
|
|
|
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за |
|
|
|
|
@ -272,6 +293,10 @@ class RadioAudioEngine {
|
|
|
|
|
for (mask in masks) { |
|
|
|
|
val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 |
|
|
|
|
val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
|
if (recMin <= 0) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin") |
|
|
|
|
continue |
|
|
|
|
} |
|
|
|
|
val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4) |
|
|
|
|
for (src in sources) { |
|
|
|
|
val rec = tryCreateRecord(src, bufSize, mask) |
|
|
|
|
@ -298,7 +323,7 @@ class RadioAudioEngine {
|
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") |
|
|
|
|
false |
|
|
|
|
} |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") |
|
|
|
|
LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { |
|
|
|
|
@ -316,89 +341,224 @@ class RadioAudioEngine {
|
|
|
|
|
return rec |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun stopBlocking() { |
|
|
|
|
releaseLocked(true) |
|
|
|
|
private fun screenOn(): Boolean = |
|
|
|
|
(ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive |
|
|
|
|
|
|
|
|
|
private fun registerScreenReceiver() { |
|
|
|
|
val receiver = object : BroadcastReceiver() { |
|
|
|
|
override fun onReceive(context: Context, intent: Intent) { |
|
|
|
|
screenInteractive = intent.action == Intent.ACTION_SCREEN_ON |
|
|
|
|
val version = generation |
|
|
|
|
io.execute { |
|
|
|
|
if (!active || generation != version) return@execute |
|
|
|
|
if (intent.action == Intent.ACTION_SCREEN_OFF) { |
|
|
|
|
vadDeadline = 0L |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "screen off: VAD window closed") |
|
|
|
|
} |
|
|
|
|
if (intent.action == Intent.ACTION_SCREEN_ON) { |
|
|
|
|
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)") |
|
|
|
|
} |
|
|
|
|
updateCapture() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
val filter = IntentFilter().apply { |
|
|
|
|
addAction(Intent.ACTION_SCREEN_ON) |
|
|
|
|
addAction(Intent.ACTION_SCREEN_OFF) |
|
|
|
|
} |
|
|
|
|
val ctx = ChatApplication.instance |
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) |
|
|
|
|
ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) |
|
|
|
|
else ctx.registerReceiver(receiver, filter) |
|
|
|
|
screenReceiver = receiver |
|
|
|
|
screenInteractive = screenOn() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */ |
|
|
|
|
private fun updateCapture() { |
|
|
|
|
mainHandler.removeCallbacks(vadTimeout) |
|
|
|
|
val configured = configuredVad |
|
|
|
|
if (configured && !vadConfigured) vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS |
|
|
|
|
vadConfigured = configured |
|
|
|
|
val wantVad = active && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline && configured |
|
|
|
|
if (wantVad) mainHandler.postDelayed(vadTimeout, (vadDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(1L)) |
|
|
|
|
if (!active || (!requestedTalk && !wantVad)) { |
|
|
|
|
stopCapture() |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
if (audioRecord != null && vadMode != wantVad) stopCapture() |
|
|
|
|
if (audioTrack == null) return |
|
|
|
|
if (audioRecord == null && !startCapture(wantVad)) { |
|
|
|
|
requestedTalk = false |
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
if (requestedTalk && !talking) { |
|
|
|
|
NativeLib.radioTalkBegin(audioGroup) |
|
|
|
|
talking = true |
|
|
|
|
_talkingFlow.value = true |
|
|
|
|
} else if (!requestedTalk && talking) { |
|
|
|
|
NativeLib.radioTalkEnd(audioGroup) |
|
|
|
|
talking = false |
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */ |
|
|
|
|
private fun releaseLocked(resetGroup: Boolean) { |
|
|
|
|
captureThread?.join(1000); captureThread = null |
|
|
|
|
playThread?.join(1000); playThread = null |
|
|
|
|
private fun startCapture(vad: Boolean): Boolean { |
|
|
|
|
val startNs = SystemClock.elapsedRealtimeNanos() |
|
|
|
|
val (rec, channels) = createAudioRecord() |
|
|
|
|
if (rec == null) return false |
|
|
|
|
val createdNs = SystemClock.elapsedRealtimeNanos() |
|
|
|
|
if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) { |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad") |
|
|
|
|
rec.release() |
|
|
|
|
return false |
|
|
|
|
} |
|
|
|
|
vadMode = NativeLib.radioVadMode() |
|
|
|
|
if (vad && !vadMode) { |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled") |
|
|
|
|
NativeLib.radioCaptureStop(); rec.release() |
|
|
|
|
return false |
|
|
|
|
} |
|
|
|
|
val preparedNs = SystemClock.elapsedRealtimeNanos() |
|
|
|
|
try { |
|
|
|
|
rec.startRecording() |
|
|
|
|
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) error("recording did not start") |
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}") |
|
|
|
|
NativeLib.radioCaptureStop(); rec.release() |
|
|
|
|
vadMode = false |
|
|
|
|
return false |
|
|
|
|
} |
|
|
|
|
val recordingNs = SystemClock.elapsedRealtimeNanos() |
|
|
|
|
audioRecord = rec |
|
|
|
|
captureRunning = true |
|
|
|
|
_vadActiveFlow.value = vadMode |
|
|
|
|
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи. |
|
|
|
|
if (requestedTalk) { |
|
|
|
|
NativeLib.radioTalkBegin(audioGroup) |
|
|
|
|
talking = true |
|
|
|
|
_talkingFlow.value = true |
|
|
|
|
} |
|
|
|
|
val captureGroup = audioGroup |
|
|
|
|
captureThread = Thread { |
|
|
|
|
try { captureLoop(rec, captureGroup, channels) } |
|
|
|
|
catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") } |
|
|
|
|
finally { |
|
|
|
|
if (captureRunning) io.execute { |
|
|
|
|
if (audioRecord === rec) { |
|
|
|
|
requestedTalk = false |
|
|
|
|
stopCapture() |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released") |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}.apply { |
|
|
|
|
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() |
|
|
|
|
} |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", |
|
|
|
|
"capture started group=$audioGroup ch=$channels vad=$vadMode " + |
|
|
|
|
"createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " + |
|
|
|
|
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}") |
|
|
|
|
return true |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() } |
|
|
|
|
private fun stopCapture() { |
|
|
|
|
captureRunning = false |
|
|
|
|
val rec = audioRecord |
|
|
|
|
if (rec != null) { |
|
|
|
|
try { rec.stop() } catch (e: Exception) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}") |
|
|
|
|
} |
|
|
|
|
// stop разблокирует read; release только после выхода потока. |
|
|
|
|
captureThread?.join() |
|
|
|
|
captureThread = null |
|
|
|
|
rec.release() |
|
|
|
|
audioRecord = null |
|
|
|
|
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } |
|
|
|
|
audioTrack = null |
|
|
|
|
NativeLib.radioCaptureStop() |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup") |
|
|
|
|
} |
|
|
|
|
vadMode = false |
|
|
|
|
talking = false |
|
|
|
|
_talkingFlow.value = requestedTalk |
|
|
|
|
_vadActiveFlow.value = false |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun releaseLocked() { |
|
|
|
|
mainHandler.removeCallbacks(vadTimeout) |
|
|
|
|
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) } |
|
|
|
|
screenReceiver = null |
|
|
|
|
stopCapture() |
|
|
|
|
playbackRunning = false |
|
|
|
|
audioTrack?.let { track -> |
|
|
|
|
try { track.stop() } catch (e: Exception) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "playback stop failed: ${e.message}") |
|
|
|
|
} |
|
|
|
|
playThread?.interrupt() |
|
|
|
|
playThread?.join() |
|
|
|
|
playThread = null |
|
|
|
|
track.release() |
|
|
|
|
} |
|
|
|
|
audioTrack = null |
|
|
|
|
NativeLib.radioAudioStop() |
|
|
|
|
router.onOutputDevice = null |
|
|
|
|
router.onInputDevice = null |
|
|
|
|
router.stop() |
|
|
|
|
restoreAudioMode() |
|
|
|
|
|
|
|
|
|
if (resetGroup) { |
|
|
|
|
groupId = 0L |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "stopped") |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** PTT зажата — начинаем передачу. */ |
|
|
|
|
fun talkBegin() { |
|
|
|
|
if (!active) return |
|
|
|
|
mainHandler.removeCallbacks(releaseRunnable) |
|
|
|
|
if (talking) return |
|
|
|
|
if (micDead) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)") |
|
|
|
|
} |
|
|
|
|
talking = true |
|
|
|
|
mainHandler.removeCallbacks(releaseTalk) |
|
|
|
|
if (requestedTalk) return |
|
|
|
|
talkRequestNs = SystemClock.elapsedRealtimeNanos() |
|
|
|
|
requestedTalk = true |
|
|
|
|
_talkingFlow.value = true |
|
|
|
|
NativeLib.radioTalkBegin(groupId) |
|
|
|
|
val version = generation |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "PTT requested group=$groupId") |
|
|
|
|
io.execute { |
|
|
|
|
if (active && generation == version) { |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "PTT io queueMs=${(SystemClock.elapsedRealtimeNanos() - talkRequestNs) / 1_000_000}") |
|
|
|
|
updateCapture() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */ |
|
|
|
|
fun talkEnd() { |
|
|
|
|
if (!active || !talking) return |
|
|
|
|
mainHandler.removeCallbacks(releaseRunnable) |
|
|
|
|
mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS) |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** Немедленное завершение передачи без хвоста (drag кнопки рации). */ |
|
|
|
|
fun talkEndImmediate() { |
|
|
|
|
if (!active || !talking) return |
|
|
|
|
mainHandler.removeCallbacks(releaseRunnable) |
|
|
|
|
finishTalk() |
|
|
|
|
/** Отпускание: 250мс передачи, повторное касание отменяет завершение. */ |
|
|
|
|
fun talkEnd() { |
|
|
|
|
if (!active || !requestedTalk) return |
|
|
|
|
mainHandler.removeCallbacks(releaseTalk) |
|
|
|
|
mainHandler.postDelayed(releaseTalk, RELEASE_HOLD_MS) |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun finishTalk() { |
|
|
|
|
if (!talking) return |
|
|
|
|
talking = false |
|
|
|
|
if (!requestedTalk) return |
|
|
|
|
requestedTalk = false |
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
NativeLib.radioTalkEnd(groupId) |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)") |
|
|
|
|
val version = generation |
|
|
|
|
io.execute { if (active && generation == version) updateCapture() } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun captureLoop() { |
|
|
|
|
val ch = captureChannels |
|
|
|
|
private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) { |
|
|
|
|
val frame = FRAME_SAMPLES * ch |
|
|
|
|
val buf = ShortArray(frame) |
|
|
|
|
var frameN = 0L |
|
|
|
|
while (active) { |
|
|
|
|
val rec = audioRecord ?: break |
|
|
|
|
var reportedRequestNs = 0L |
|
|
|
|
while (captureRunning) { |
|
|
|
|
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { |
|
|
|
|
if (active) { |
|
|
|
|
micDead = true |
|
|
|
|
if (captureRunning) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped") |
|
|
|
|
} |
|
|
|
|
break |
|
|
|
|
} |
|
|
|
|
var read = 0 |
|
|
|
|
while (read < frame) { |
|
|
|
|
if (!active) return |
|
|
|
|
if (!captureRunning) return |
|
|
|
|
val n = rec.read(buf, read, frame - read) |
|
|
|
|
if (n <= 0) { |
|
|
|
|
if (active) { |
|
|
|
|
micDead = true |
|
|
|
|
if (captureRunning) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost") |
|
|
|
|
} |
|
|
|
|
return |
|
|
|
|
@ -408,11 +568,19 @@ class RadioAudioEngine {
|
|
|
|
|
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */ |
|
|
|
|
if (frameN < 10 || frameN % 100L == 0L) { |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", |
|
|
|
|
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=${inputSource()} route=${route()}") |
|
|
|
|
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}") |
|
|
|
|
} |
|
|
|
|
frameN++ |
|
|
|
|
if ((talking || vadMode) && !NativeLib.radioAudioFeed(groupId, buf)) { |
|
|
|
|
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") |
|
|
|
|
val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline |
|
|
|
|
if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) { |
|
|
|
|
val requestNs = talkRequestNs |
|
|
|
|
val fed = NativeLib.radioAudioFeed(group, buf) |
|
|
|
|
if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") |
|
|
|
|
if (fed && requestedTalk && requestNs != reportedRequestNs) { |
|
|
|
|
reportedRequestNs = requestNs |
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "PTT first frame queued elapsedMs=${(SystemClock.elapsedRealtimeNanos() - requestNs) / 1_000_000}") |
|
|
|
|
} |
|
|
|
|
if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done") |
|
|
|
|
@ -432,9 +600,9 @@ class RadioAudioEngine {
|
|
|
|
|
val silence = ShortArray(frame) |
|
|
|
|
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ |
|
|
|
|
var nextNs = System.nanoTime() |
|
|
|
|
while (active) { |
|
|
|
|
while (playbackRunning) { |
|
|
|
|
val track = audioTrack ?: return |
|
|
|
|
val n = NativeLib.radioAudioPull(groupId, buf) |
|
|
|
|
val n = NativeLib.radioAudioPull(audioGroup, buf) |
|
|
|
|
if (n > 0) track.write(buf, 0, n) |
|
|
|
|
if (n < frame) track.write(silence, 0, frame - n) |
|
|
|
|
|
|
|
|
|
@ -452,6 +620,8 @@ class RadioAudioEngine {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun restoreAudioMode() { |
|
|
|
|
if (!audioModeOwned) return |
|
|
|
|
audioModeOwned = false |
|
|
|
|
try { |
|
|
|
|
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
|
|
|
am.isSpeakerphoneOn = prevSpeaker |
|
|
|
|
|